diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4eaf8dedc..e34285fed4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -189,7 +189,8 @@ jobs: fi test_args=() [ -z "$skip" ] || test_args=(-skip "^(${skip})$") - go test "${test_args[@]}" \ + # Bash 3.2 treats an empty array as unbound under nounset, so expand it conditionally. + go test ${test_args[@]+"${test_args[@]}"} \ ./cmd/codeaf \ ./internal/config \ ./internal/exec/... \ @@ -255,16 +256,81 @@ jobs: path: dist/* if-no-files-found: error + # EACH CHANNEL DEPLOYS ITS OWN RELAY BEFORE ITS BINARIES PUBLISH. dev updates + # caf-fabric-dev, staging updates caf-fabric-staging, and main (rc) or a + # stable dispatch updates caf-fabric once RELAY_DEPLOY_PRODUCTION is true. A + # failed deploy or post-deploy proof rolls the Worker back when a prior + # version existed and stops publish; a rollback cannot undo a one-way storage + # upgrade, which is why every relay change reaches dev first and staging a + # week later. + relay: + needs: prepare + permissions: + contents: read + concurrency: + group: relay-${{ (needs.prepare.outputs.channel == 'stable' || needs.prepare.outputs.channel == 'rc') && 'production' || needs.prepare.outputs.channel }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check production relay deploy gate + id: gate + env: + CHANNEL: ${{ needs.prepare.outputs.channel }} + RELAY_DEPLOY_PRODUCTION: ${{ vars.RELAY_DEPLOY_PRODUCTION }} + run: | + set -euo pipefail + if [ "$CHANNEL" = "stable" ] || [ "$CHANNEL" = "rc" ]; then + if [ "$RELAY_DEPLOY_PRODUCTION" != "true" ]; then + echo "Production relay deploys are off; set repository variable RELAY_DEPLOY_PRODUCTION=true to enable caf-fabric deploys." + echo "deploy=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "deploy=true" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + if: steps.gate.outputs.deploy == 'true' + with: + fetch-depth: 0 + ref: ${{ needs.prepare.outputs.sha }} + - uses: actions/setup-go@v5 + if: steps.gate.outputs.deploy == 'true' + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + if: steps.gate.outputs.deploy == 'true' + with: + node-version: "22" + cache: npm + cache-dependency-path: relay/hosted/package-lock.json + - name: Install hosted relay dependencies + if: steps.gate.outputs.deploy == 'true' + working-directory: relay/hosted + run: npm ci + - name: Deploy and verify channel relay + if: steps.gate.outputs.deploy == 'true' + env: + CHANNEL: ${{ needs.prepare.outputs.channel }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + RELAY_DEPLOY_PRODUCTION: ${{ vars.RELAY_DEPLOY_PRODUCTION }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CODEAF_TELEMETRY: "off" + DO_NOT_TRACK: "1" + run: bash scripts/relay-deploy.sh + # PUBLISH SAYS always() FOR THE SAME REASON build DOES. GitHub skips a job # whose dependency chain contains a skipped job unless the job's own condition # says otherwise, and the chain here always contains one: `test` is skipped on # every dev build by design. The first live run on 2026-09-11 (dev, ef36c3770) # proved it — prepare and build green, eight minutes of binaries uploaded, and - # publish skipped without a word, so no dev release existed. The two explicit - # results below are the whole gate: a red build still publishes nothing. + # publish skipped without a word, so no dev release existed. The three + # explicit results below are the whole gate: a red build or relay deploy still + # publishes nothing. publish: - needs: [prepare, build] - if: always() && needs.prepare.result == 'success' && needs.build.result == 'success' + needs: [prepare, build, relay] + if: always() && needs.prepare.result == 'success' && needs.build.result == 'success' && needs.relay.result == 'success' permissions: contents: write runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 1a84b669a7..4f750993c2 100644 --- a/Makefile +++ b/Makefile @@ -217,6 +217,9 @@ test-tooling: bash scripts/touched-verdict_test.sh bash scripts/one-suite_test.sh bash scripts/shard-test_test.sh + bash scripts/relay-fingerprint_test.sh + bash scripts/relay-wrangler-config_test.sh + bash scripts/relay-deploy_test.sh bash bench/canary/lib/repo_test.sh manual-gates: diff --git a/PERF.md b/PERF.md index e75a7fd619..73c6251f99 100644 --- a/PERF.md +++ b/PERF.md @@ -191,6 +191,31 @@ work. Preserve those assertions when changing a hot path. Paint must not perform filesystem or network reads. Document any cap change with its rationale in the same commit; derive repeated values from constants. +## Paired team history + +These sharing budgets apply only when **share teams** is on in Settings → Devices. +Sharing is off by default on each computer; off rounds run no team carriers, +identity discovery, mirror writes or ownership lookups and retain existing polling. + +`teams.TrafficSyncLines` limits each writing computer's window to 32 lines per +team, and `teams.TrafficSyncBytes` limits encoded lines to 16 KiB per team. +`teams.TrafficSyncTextBytes` shortens text beyond 2 KiB at a rune boundary. +`teams.TrafficSyncDeviceBytes` caps each device's encoded Traffic share at 64 KiB +across teams, newest local activity first. `teams.TrafficSyncEvery` permits a +publication at most once every 30 seconds; team record edits are not delayed. +Capture reads the bounded backward tail across the current and rotated logs. Received windows replace a +separate display mirror and are never republished. An unchanged round on 500 +chats and 5 teams performs zero world scans and opens zero cell metadata files; +member identities cache by metadata stamp, unchanged authored tails are reused, +and unresolved incoming identities share at most one discovery pass per round. Tests count scans and encoded bytes, +compare deterministic values, and verify quiet rounds leave the vault unchanged. +Mirror filtering makes zero directory requests per vault round, using only an +already-held local roster, or every other writer when none is available. A send +shares one ownership reading across its recipients, with a 3-second timeout and +a 5-second process cache (including failures). Cursor saves stat identity files +but decode them only after a stamp changes. Mirror refresh never acknowledges +local news and each hosted window carries its own mirror version. + ## Binary size `SIZE-BUDGET` is the stripped binary's byte cap. `make size` builds through diff --git a/cmd/codeaf-release/main.go b/cmd/codeaf-release/main.go index 91ca7415ab..d39e2c1447 100644 --- a/cmd/codeaf-release/main.go +++ b/cmd/codeaf-release/main.go @@ -11,6 +11,7 @@ // codeaf-release next --channel dev|staging --sha --date YYYYMMDD // codeaf-release kind // codeaf-release prune --channel dev|staging +// codeaf-release service [--url ] package main import ( @@ -46,6 +47,10 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 case "prune": err = runPrune(args[1:], stdin, stdout) + case "service": + err = runService(args[1:], stdout) + case "relay-url": + err = runRelayURL(args[1:], stdout) case "promotion-plan": err = runPromotionPlan(args[1:], stdout) case "promotion-message": @@ -90,6 +95,8 @@ func usage(w io.Writer) { Print stable, rc, dev, staging, or other. prune --channel dev|staging Read lines and print expired tags. + service [--url ] | relay-url --channel stable|rc|staging|dev + Check a live sync service's capabilities, or print a channel's hosted URL. promotion-plan|promotion-decision|promotion-recheck|promotion-release|promotion-published|promotion-jobs|promotion-push-error|promotion-message Plan, check, and report the staging promotion. See docs/rules/promotion.md. `) diff --git a/cmd/codeaf-release/service.go b/cmd/codeaf-release/service.go new file mode 100644 index 0000000000..098e60912f --- /dev/null +++ b/cmd/codeaf-release/service.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// serviceProbeWithin bounds the live check, so a silent service fails the +// release instead of holding the workflow. +const serviceProbeWithin = 60 * time.Second + +// probeService is the live check; tests replace it. +var probeService = syncsetup.ProbeService + +// runService fails when the sync service a build will talk to does not promise +// what the build needs. A client never ships ahead of its service (#2072). +func runService(args []string, stdout io.Writer) error { + url := syncsetup.HostedRelayURL + switch { + case len(args) == 2 && args[0] == "--url": + url = args[1] + case len(args) != 0: + return usageErr("service takes only --url ") + } + ctx, cancel := context.WithTimeout(context.Background(), serviceProbeWithin) + defer cancel() + missing, err := probeService(ctx, url) + if err != nil { + return fmt.Errorf("could not check the sync service at %s: %w", url, err) + } + if len(missing) > 0 { + return fmt.Errorf("the sync service at %s must be updated before this build ships; it does not promise %s", url, describe(missing)) + } + fmt.Fprintf(stdout, "the sync service at %s promises everything this build needs\n", url) + return nil +} + +func describe(missing []directory.Capability) string { + parts := make([]string, len(missing)) + for i, c := range missing { + parts[i] = fmt.Sprintf("%s (needed for %s)", c.Header, c.Feature) + } + return strings.Join(parts, ", ") +} + +// runRelayURL prints the hosted sync service address for one release channel so +// CI scripts read internal/syncsetup/relayurl.go instead of spelling URLs twice. +func runRelayURL(args []string, stdout io.Writer) error { + if len(args) != 2 || args[0] != "--channel" { + return usageErr("relay-url takes --channel ") + } + switch args[1] { + case "stable", "rc": + fmt.Fprintln(stdout, syncsetup.ProductionRelayURL) + case "staging": + fmt.Fprintln(stdout, syncsetup.StagingRelayURL) + case "dev": + fmt.Fprintln(stdout, syncsetup.DevRelayURL) + default: + return usageErr("unknown channel %q (expected stable, rc, staging, or dev)", args[1]) + } + return nil +} diff --git a/cmd/codeaf-release/service_test.go b/cmd/codeaf-release/service_test.go new file mode 100644 index 0000000000..3ca9927df2 --- /dev/null +++ b/cmd/codeaf-release/service_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// TestServiceGateFailsAheadOfTheService pins #2072's release gate: a build +// whose service lacks a promise does not ship, and the refusal names it. +func TestServiceGateFailsAheadOfTheService(t *testing.T) { + defer func(was func(context.Context, string) ([]directory.Capability, error)) { probeService = was }(probeService) + probeService = func(context.Context, string) ([]directory.Capability, error) { + return directory.Capabilities, nil + } + var out, errs bytes.Buffer + if code := run([]string{"service", "--url", "https://sync.example"}, nil, &out, &errs); code != 1 { + t.Fatalf("exit %d, stderr %q", code, errs.String()) + } + if !strings.Contains(errs.String(), directory.ConditionalTakeHeader) { + t.Fatalf("the refusal does not name the promise: %q", errs.String()) + } + probeService = func(context.Context, string) ([]directory.Capability, error) { return nil, nil } + if code := run([]string{"service"}, nil, &out, &errs); code != 0 { + t.Fatalf("a complete service failed the gate: %q", errs.String()) + } +} + +func TestRelayURLPrintsEachChannelConstant(t *testing.T) { + cases := map[string]string{ + "stable": syncsetup.ProductionRelayURL, + "rc": syncsetup.ProductionRelayURL, + "staging": syncsetup.StagingRelayURL, + "dev": syncsetup.DevRelayURL, + } + for ch, want := range cases { + var out, errs bytes.Buffer + if code := run([]string{"relay-url", "--channel", ch}, nil, &out, &errs); code != 0 { + t.Fatalf("relay-url --channel %s exited %d: %s", ch, code, errs.String()) + } + if got := strings.TrimSpace(out.String()); got != want { + t.Fatalf("relay-url --channel %s = %q, want %q", ch, got, want) + } + } + var out, errs bytes.Buffer + if code := run([]string{"relay-url", "--channel", "unknown"}, nil, &out, &errs); code != 2 { + t.Fatalf("unknown channel exited %d, want 2", code) + } +} diff --git a/cmd/codeaf/app_chat_rows_test.go b/cmd/codeaf/app_chat_rows_test.go index d3d0020789..ea601019bf 100644 --- a/cmd/codeaf/app_chat_rows_test.go +++ b/cmd/codeaf/app_chat_rows_test.go @@ -99,9 +99,12 @@ func TestAppChatRowsR1P2PresenceIsIndependentOfClosedChat(t *testing.T) { for _, row := range machineCall[appwire.ChatsResult](t, wire, "chats.list", struct{}{}).Chats { rows[row.Cell] = row } - for _, id := range ids[:2] { + // A lapsed lease of this computer's own is free rather than off (#2072), + // so the chat this backend made reads idle; presence is reported either way. + for i, id := range ids[:2] { row := rows[id] - if row.Status != "off" || row.Online == nil || !*row.Online || row.Line != "" || row.TakeoverLine != "" { + want := map[int]string{0: "off", 1: "idle"}[i] + if row.Status != want || row.Online == nil || !*row.Online || row.Line != "" || row.TakeoverLine != "" { t.Errorf("R1 online holder with closed chat: %+v", row) } } diff --git a/cmd/codeaf/appkeylate_test.go b/cmd/codeaf/appkeylate_test.go index 5d7a16d3eb..589bc91e85 100644 --- a/cmd/codeaf/appkeylate_test.go +++ b/cmd/codeaf/appkeylate_test.go @@ -57,10 +57,10 @@ func TestAppAProcessThatOpenedWithoutAKeyOpensOnceOneArrives(t *testing.T) { } } -// EVERY OTHER DOOR KEEPS ITS FIRST ANSWER. A pipe or a host reads its key -// before it serves anything, so the memo hands every later conversation the -// open it already made, failed or not, and only the app's door opens again. -func TestOnlyTheAppsDoorOpensAFailedProcessHalfAgain(t *testing.T) { +// A FAILED OPEN IS TRIED AGAIN ON EVERY DOOR. A host's first connection can +// precede its first vault pull and the app is handed its key after it starts, +// so a failure is never the memo's answer; a success is, and is shared. +func TestEveryDoorOpensAFailedProcessHalfAgain(t *testing.T) { root := resolvedTempDir(t) wakeEnvironment(t, root) t.Setenv("OPENROUTER_API_KEY", "") @@ -69,9 +69,6 @@ func TestOnlyTheAppsDoorOpensAFailedProcessHalfAgain(t *testing.T) { t.Fatal("the process half opened with no key anywhere") } t.Setenv("OPENROUTER_API_KEY", "test-key") - if _, err := openEngineProcess(); err == nil { - t.Fatal("a door that is not the app opened its process half a second time") - } // Callers racing to open it again build ONE process half between them. opened := make([]*v3Process, 8) var wg sync.WaitGroup diff --git a/cmd/codeaf/approvals.go b/cmd/codeaf/approvals.go index 267f558a4a..00baeb1eb0 100644 --- a/cmd/codeaf/approvals.go +++ b/cmd/codeaf/approvals.go @@ -8,12 +8,14 @@ package main import ( "context" "errors" + "runtime" "sort" "strings" "sync" "time" "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/identity" @@ -54,7 +56,11 @@ func (e toldLine) Unwrap() error { return e.err } // Anything that is not a refusal is returned as it came. func told(err error) error { if refusal, ok := cellsync.RefusalOf(err); ok { - return toldLine{line: refusal.Say(err), err: err} + line := refusal.Say(err) + if line == chatlist.Removed { + line = chatlist.RemovedChat + } + return toldLine{line: line, err: err} } return err } @@ -80,11 +86,11 @@ func (d *approvalsDoor) Pending(ctx context.Context, typed string) (tui3.Pending if err != nil { return tui3.PendingDevice{}, err } - // A pasted self-hosted link must never send its code to the current - // service when the two differ. The terminal's printed command names it. - if via := pair.LinkRelay(typed); via != "" && strings.TrimRight(via, "/") != strings.TrimRight(route.URL, "/") { - return tui3.PendingDevice{}, errors.New("this pairing link uses another sync address — approve it in a terminal with the command shown on the new device") + // Refuse before a code or its key can reach another sync service. + if err := pair.CheckLinkService(typed, route.URL); err != nil { + return tui3.PendingDevice{}, err } + who, err := d.approver(route) if err != nil { return tui3.PendingDevice{}, err @@ -115,7 +121,7 @@ func (d *approvalsDoor) recall(p tui3.PendingDevice) (pair.Asking, error) { defer d.mu.Unlock() as, ok := d.asking[p.Code] if !ok { - return pair.Asking{}, pair.ErrLinkGone + return pair.Asking{}, errors.New("open that pairing link again before approving") } return as, nil } @@ -172,7 +178,7 @@ func (d *approvalsDoor) home() (directory.Client, identity.Identity, string, err func (d *approvalsDoor) Devices(ctx context.Context) ([]tui3.DeviceRow, error) { if syncsetup.Quiet(d.dir) { - return nil, nil + return []tui3.DeviceRow{d.ownRow()}, nil } client, id, self, err := d.home() if err != nil { @@ -183,6 +189,13 @@ func (d *approvalsDoor) Devices(ctx context.Context) ([]tui3.DeviceRow, error) { return nil, told(err) } rows := rowsOf(listing, directory.MetadataKey(id.CellKey()), self) + foundSelf := false + for _, row := range rows { + foundSelf = foundSelf || row.Self + } + if !foundSelf { + rows = append(rows, d.ownRow()) + } if own, named := devname.Chosen(d.dir); named { rows = withOwnName(rows, own) } @@ -250,3 +263,16 @@ func (d *approvalsDoor) DeviceName(sealed string) string { func (d *approvalsDoor) Rename(ctx context.Context, typed string) (string, error) { return renameThisDevice(ctx, d.dir, typed) } + +// ownRow keeps an unpaired computer nameable without waking the sync service. +func (d *approvalsDoor) ownRow() tui3.DeviceRow { + dev, err := identity.LoadDevice(d.dir) + id := "" + if err == nil { + id = dev.ID() + } + return tui3.DeviceRow{ID: id, Name: devname.Name(d.dir), Platform: runtime.GOOS, Self: true} +} + +// SyncService names the local pairing service even when the chat runs elsewhere. +func (d *approvalsDoor) SyncService() string { return syncsetup.Resolve(d.dir).URL } diff --git a/cmd/codeaf/approvals_test.go b/cmd/codeaf/approvals_test.go index 83fbc0419a..f486ad4097 100644 --- a/cmd/codeaf/approvals_test.go +++ b/cmd/codeaf/approvals_test.go @@ -155,10 +155,10 @@ func TestApprovalsDoorTellsAStoppedComputerItWasStopped(t *testing.T) { } stopped := rig.approvalsAt(rig.homeB) - if _, err := stopped.Devices(ctx); err == nil || err.Error() != chatlist.Removed { - t.Fatalf("the stopped computer's device list said %v, want %q", err, chatlist.Removed) + if _, err := stopped.Devices(ctx); err == nil || err.Error() != chatlist.RemovedChat { + t.Fatalf("the stopped computer's device list said %v, want %q", err, chatlist.RemovedChat) } - if err := stopped.Revoke(ctx, p.Device); err == nil || err.Error() != chatlist.Removed { - t.Fatalf("the stopped computer's revoke said %v, want %q", err, chatlist.Removed) + if err := stopped.Revoke(ctx, p.Device); err == nil || err.Error() != chatlist.RemovedChat { + t.Fatalf("the stopped computer's revoke said %v, want %q", err, chatlist.RemovedChat) } } diff --git a/cmd/codeaf/beltdoors_test.go b/cmd/codeaf/beltdoors_test.go index b578080c1b..66d0705ce4 100644 --- a/cmd/codeaf/beltdoors_test.go +++ b/cmd/codeaf/beltdoors_test.go @@ -28,7 +28,8 @@ import ( // Part A's terminal connect door adds its two required forms and descriptions. // The cap moves by exactly those seven lines; unrelated help growth still fails. // The pairing door adds its two forms and their descriptions, four lines more. -const helpLineCap = 121 +// The devbox door adds its form and description, two lines more. +const helpLineCap = 123 // doorsOffThePage are the command words main.go dispatches that `codeaf --help` // deliberately does NOT name, so the check below does not demand a line for diff --git a/cmd/codeaf/c10_selfhost_pair_test.go b/cmd/codeaf/c10_selfhost_pair_test.go index 3c6ed7db89..369dcb4849 100644 --- a/cmd/codeaf/c10_selfhost_pair_test.go +++ b/cmd/codeaf/c10_selfhost_pair_test.go @@ -68,7 +68,7 @@ func TestC10ChatDoesNotSendCustomPairingCodeToAnotherService(t *testing.T) { return pair.Approver{}, nil } _, err := door.Pending(t.Context(), "https://chosen.example/p/k7m2q9xd#AAAAAAAAAAAAAAAAAAAAAA") - if err == nil || !strings.Contains(err.Error(), "approve it in a terminal") { + if err == nil || !strings.Contains(err.Error(), "that link is waiting on chosen.example, but this computer syncs through configured.example — on the new device run: codeaf pair --via https://configured.example") { t.Fatalf("different service link was not explained: %v", err) } } diff --git a/cmd/codeaf/c10_sync_unnamed_test.go b/cmd/codeaf/c10_sync_unnamed_test.go new file mode 100644 index 0000000000..b9c12dcb3d --- /dev/null +++ b/cmd/codeaf/c10_sync_unnamed_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// `codeaf chat --host` runs `codeaf engine` under a non-interactive ssh shell, +// which rarely carries the CODEAF_SYNC_URL the person exported in their own +// terminal. That launch named no relay, so it must join the host the terminal +// started — idle or busy — rather than retiring it or being refused by it; +// otherwise the two launches take the workspace from each other in turn. +func TestC10ALaunchThatNamesNoRelayJoinsTheHostATerminalStarted(t *testing.T) { + for _, busy := range []bool{false, true} { + t.Run(fmt.Sprintf("busy=%t", busy), func(t *testing.T) { + workspace, _ := environmentTestHome(t) + t.Setenv(syncsetup.URLVar, "http://127.0.0.1:41001") + child := environmentChild(t, workspace, os.Getenv(config.APIKeyEnv)) + if busy { + environmentWindow(t, workspace, "") + } + waitEnvironmentHost(t, workspace, func(self remote.HostSelf) bool { return self.Busy == busy }) + t.Setenv(syncsetup.URLVar, "") + if enginehost.SyncDiffers(workspace) || enginehost.EnvironmentDiffers(workspace) { + t.Fatal("a launch that named no relay reads the host's own variable as a different setting") + } + note, err := clearStaleEngineHost(workspace) + if err != nil || note != "" { + t.Fatalf("a launch that named no relay displaced the host: note=%q err=%v", note, err) + } + self, err := enginehost.Ask(workspace, remote.WhoIs{}) + if err != nil || self.PID != child.Process.Pid { + t.Fatalf("the host started by the terminal was replaced: %+v %v", self, err) + } + }) + } +} + +// Naming no relay is not a way to miss a real change: when the address a +// pairing saved moves, both sides read it from the same home, and an idle +// host still restarts to pick it up. +func TestC10AChangedSavedRelayStillRetiresAnIdleHost(t *testing.T) { + workspace, _ := environmentTestHome(t) + t.Setenv(syncsetup.URLVar, "") + if err := syncsetup.SaveRelayURL(os.Getenv("CODEAF_HOME"), "http://127.0.0.1:41001"); err != nil { + t.Fatal(err) + } + environmentChild(t, workspace, os.Getenv(config.APIKeyEnv)) + waitEnvironmentHost(t, workspace, func(self remote.HostSelf) bool { return !self.Busy }) + if err := syncsetup.SaveRelayURL(os.Getenv("CODEAF_HOME"), "http://127.0.0.1:41002"); err != nil { + t.Fatal(err) + } + note, err := clearStaleEngineHost(workspace) + if err != nil || !strings.Contains(note, "restarted") || !nothingHolds(workspace) { + t.Fatalf("an idle host kept a relay the pairing replaced: note=%q err=%v", note, err) + } +} diff --git a/cmd/codeaf/cell_list_test.go b/cmd/codeaf/cell_list_test.go index f4bfb33307..a11e676cc5 100644 --- a/cmd/codeaf/cell_list_test.go +++ b/cmd/codeaf/cell_list_test.go @@ -32,7 +32,7 @@ func TestCellListAll(t *testing.T) { t.Fatal(err) } want := []string{ - "01AAA Nightly index rebuild host-a running on host-a 2m", + "01AAA Nightly index rebuild host-a held on host-a 2m", "01BBB Port the picker host-a host-a offline 3h", "01CCC Fix the flaky test laptop 2 turns from laptop: discard 5h", "01DDD Notes laptop - 1d 2h", diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index e16da7fd95..68a7d8e2a1 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1027,6 +1027,7 @@ func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place sessio } cfg := session.Config{ + TeamComputers: teamComputers, Workspace: workspace, Model: chosen, APIKey: settings.APIKey, diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 8dc3daa123..224ce15d0a 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -40,14 +40,20 @@ import ( "errors" "fmt" "io" + "log" "os" "os/exec" "os/signal" + "path/filepath" "strings" "syscall" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/taskdoor" codeupdate "github.com/Agent-Field/codeaf/internal/update" ) @@ -145,13 +151,15 @@ func openChatV3At(launch atLaunch) error { return err } reach := pair.Reach{ - Name: name, - Device: device, - Machines: pair.MachineBook(), - Label: pair.ThisMachineLabel(), + WeightOnApprover: true, + Name: name, + Device: device, + Machines: pair.MachineBook(), + Label: pair.ThisMachineLabel(), // EVERY LINE OF THE PAIRING GOES TO STDERR, so that `--once` piped into // a file is the reply and nothing else. - Say: func(line string) { fmt.Fprintln(os.Stderr, line) }, + Say: func(line string) { fmt.Fprintln(os.Stderr, line) }, + Sync: syncRelayURL(), } if stdinIsTerminal(os.Stdin) { reach.AskCode = askPairingCode @@ -200,6 +208,10 @@ func openChatV3At(launch atLaunch) error { // first one is open, so the second reach is the same reach with nobody to // question (a prompt raised behind a running surface would be a question on a // terminal the surface owns). + if shown := client.Welcome().Machine; shown != "" { + name = devname.Shown(shown) + client.SetDisplayName(name) + } quiet := reach quiet.AskCode = nil fleet := newEngineFleet(name, client, shutBoot, func(ask engineAsk) (*engineConn, error) { @@ -260,24 +272,25 @@ func askPairingCode(name string) (string, error) { // walks out to the relay and stays there. func runServe(args []string) error { flags := commandFlags("serve") + takeTasks := flags.Bool(taskdoor.TakeTasksFlag, false, "take tasks from your paired computers") workspace := flags.String("workspace", "", "directory a connection works in when it does not name one; empty is the directory this command was run in") - relayAddress := flags.String("via", "", "the sync address to be reachable through; empty reads "+pair.RelayEnv) + relayAddress := flags.String("via", "", "the sync service to be reachable through; empty reads "+pair.RelayEnv+", ~/.codeaf/v3/remote/relay, then the address this machine syncs through") renamedFlag(flags, "relay", "via") if err := parseCommandFlags(flags, args); err != nil { return err } if flags.NArg() != 0 { - return errors.New("usage: codeaf serve [--workspace path] [--via https://…]") + return errors.New("usage: codeaf serve [--workspace path] [--via https://…] [--" + taskdoor.TakeTasksFlag + "]") } service := strings.TrimSpace(*relayAddress) if service == "" { - service = pair.Relay() + service = pair.Relay(syncRelayURL()) } if service == "" { - // THE HONEST STATE, AND THE ONE MOST PEOPLE WILL MEET. The relay is a - // service and this build does not assume one exists. - return errors.New("no sync address is set up on this machine, so there is nowhere to be reachable from — set " + pair.RelayEnv + " to your sync address, or let people in over ssh with `codeaf chat --host` from their side") + // Only a machine whose person turned sync off and named no relay + // gets here; every other machine has sync's address. + return errors.New("no sync service is set up on this machine, so there is nowhere to be reachable from — set " + pair.RelayEnv + " to your sync service") } here := strings.TrimSpace(*workspace) @@ -298,14 +311,23 @@ func runServe(args []string) error { // out instead of leaving the relay to notice. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + defer startServeKeeping(ctx, service)() + if *takeTasks { + defer serveTasks(ctx, service)() + } + shown := "" + if _, err := identity.Load(home.Dir()); err == nil && !identity.Solo(home.Dir()) { + shown = devname.Name(home.Dir()) + } host := &pair.Host{ - Service: service, - Device: device, - Devices: pair.DeviceBook(), - Desk: &pair.Desk{}, - Approve: serveApprover(ctx, os.Stdin, os.Stdout), - Say: func(line string) { fmt.Fprintln(os.Stdout, line) }, - Open: func(tunnel io.ReadWriteCloser) { serveOneConnection(tunnel, here) }, + DisplayName: shown, + Service: service, + Device: device, + Devices: pair.DeviceBook(), + Desk: &pair.Desk{}, + Approve: serveApprover(ctx, os.Stdin, os.Stdout), + Say: func(line string) { fmt.Fprintln(os.Stdout, line) }, + OpenNamed: func(_ string, tunnel io.ReadWriteCloser) error { return serveOneConnection(tunnel, here) }, } if err := host.Run(ctx); err != nil { return err @@ -324,20 +346,28 @@ func runServe(args []string) error { // per process is also the lifetime the engine was written for — it chdirs into // its workspace and opens one session — so two connections are two processes // and never two conversations fighting over one. -func serveOneConnection(tunnel io.ReadWriteCloser, workspace string) { +func serveOneConnection(tunnel io.ReadWriteCloser, workspace string) error { self, err := os.Executable() if err != nil { - fmt.Fprintln(os.Stderr, "could not find this program on disk:", err) - return + return errors.New("this computer could not start the chat") } engine := exec.Command(self, "engine", "--workspace", workspace) engine.Stdin = tunnel engine.Stdout = tunnel - // The engine's own stderr is this machine's, where a person running - // `codeaf serve` can see it. It is never put on the tunnel: stdout is the - // protocol and one stray line there is a frame the surface cannot parse. - engine.Stderr = os.Stderr + // Child diagnostics belong to a local log, never the serving terminal or + // the tunnel, whose stdout carries only protocol frames. + logPath := filepath.Join(home.Dir(), "logs", "serve.log") + var diagnostics io.Writer = io.Discard + if err := os.MkdirAll(filepath.Dir(logPath), 0700); err == nil { + if file, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600); err == nil { + defer file.Close() + diagnostics = file + } + } + engine.Stderr = diagnostics if err := engine.Run(); err != nil && !errors.Is(err, context.Canceled) { - fmt.Fprintln(os.Stderr, "that connection ended:", err) + log.New(diagnostics, "", log.LstdFlags).Print(err) + return err } + return nil } diff --git a/cmd/codeaf/chatv3_at_test.go b/cmd/codeaf/chatv3_at_test.go index 68837d13dd..97f59902ce 100644 --- a/cmd/codeaf/chatv3_at_test.go +++ b/cmd/codeaf/chatv3_at_test.go @@ -1,8 +1,15 @@ package main import ( + "bytes" + "os" + "path/filepath" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // The target is read the way the ssh door reads its own, because it is the same @@ -58,19 +65,20 @@ func TestTheFlagsThatBuildASessionCannotTravelOverAt(t *testing.T) { } // WITH NO RELAY SET UP, BOTH DOORS SAY SO IN ONE SENTENCE AND NAME THE ROAD -// THAT DOES WORK. The relay is a service, this build does not assume one is -// running, and a person who meets this must not be left guessing whether their -// network is broken. +// THAT DOES WORK. Sync falls back to the hosted relay, so the only machine +// with no relay is one whose person turned sync off, and they must not be +// left guessing whether their network is broken. func TestWithNoRelayBothDoorsSayWhatIsWrongAndWhatToDoInstead(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) t.Setenv("CODEAF_RELAY", "") + t.Setenv(syncsetup.URLVar, "off") err := openChatV3At(atLaunch{target: "otter-lamp-42"}) if err == nil { t.Fatal("--at opened a connection with no relay set up") } said := err.Error() - for _, want := range []string{"no service address is set up", "CODEAF_RELAY", "--host over ssh", "otter-lamp-42"} { + for _, want := range []string{"no sync service is set up on this machine", "CODEAF_RELAY", "--host over ssh", "otter-lamp-42"} { if !strings.Contains(said, want) { t.Fatalf("--at with no relay said %q, which does not mention %q", said, want) } @@ -84,11 +92,47 @@ func TestWithNoRelayBothDoorsSayWhatIsWrongAndWhatToDoInstead(t *testing.T) { t.Fatal("codeaf serve started with no relay set up") } said = err.Error() - for _, want := range []string{"no sync address is set up", "CODEAF_RELAY", "--host"} { + for _, want := range []string{"no sync service is set up on this machine", "CODEAF_RELAY", "set " + pair.RelayEnv + " to your sync service"} { if !strings.Contains(said, want) { t.Fatalf("codeaf serve with no relay said %q, which does not mention %q", said, want) } } + if strings.Contains(said, "--host") { + t.Fatalf("codeaf serve told this computer to reach itself: %q", said) + } +} + +// SERVE, --at, PAIRING AND SYNC AGREE ABOUT THE RELAY. A machine whose chats +// sync is reachable through that same relay with nothing else set up, whether +// sync's address came from the variable, a saved pairing or the hosted default. +func TestServeAtPairingAndSyncResolveOneRelay(t *testing.T) { + cases := []struct{ name, variable, saved, want string }{ + {"the hosted default", "", "", syncsetup.HostedRelayURL}, + {"the relay a pairing saved", "", "https://saved.example/fabric", "https://saved.example/fabric"}, + {"the variable", "https://variable.example", "https://saved.example/fabric", "https://variable.example"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv(pair.RelayEnv, "") + t.Setenv(syncsetup.URLVar, c.variable) + if c.saved != "" { + if err := syncsetup.SaveRelayURL(home.Dir(), c.saved); err != nil { + t.Fatal(err) + } + } + mailbox, err := pairMailbox("") + if err != nil { + t.Fatal(err) + } + sync := syncsetup.Resolve(home.Dir()).URL + serve := pair.Relay(syncRelayURL()) + at := pair.RelayFor(pair.Known{}, false, syncRelayURL()) + if sync != c.want || mailbox.URL != c.want || serve != c.want || at != c.want { + t.Fatalf("sync %q, pairing %q, serve %q, --at %q; want all %q", sync, mailbox.URL, serve, at, c.want) + } + }) + } } // `codeaf devices` on a machine nothing has paired with is one sentence and an @@ -108,3 +152,49 @@ func TestDevicesOnAFreshMachineSaysNothingIsPaired(t *testing.T) { t.Fatal("an unknown word was accepted") } } + +// Contract item 7 (Finding 10): `codeaf serve --help` names the full relay +// precedence — CODEAF_RELAY, ~/.codeaf/v3/remote/relay, then sync — matching +// what pair.Relay and pair.RelayFor actually resolve. +func TestServeHelpAndRelayPrecedenceIncludeSavedRemoteRelayFile(t *testing.T) { + var buf bytes.Buffer + previous := usageOut + usageOut = &buf + t.Cleanup(func() { usageOut = previous }) + + if err := runServe([]string{"--help"}); err != exitHelped { + t.Fatalf("runServe(--help) returned %v, want exitHelped", err) + } + help := strings.Join(strings.Fields(buf.String()), " ") + envPos := strings.Index(help, pair.RelayEnv) + filePos := strings.Index(help, "~/.codeaf/v3/remote/relay") + syncPos := strings.Index(help, "syncs through") + if envPos < 0 || filePos < 0 || syncPos < 0 || !(envPos < filePos && filePos < syncPos) { + t.Fatalf("serve --help did not list %s, ~/.codeaf/v3/remote/relay, then sync in order: %q", pair.RelayEnv, help) + } + + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv(pair.RelayEnv, "") + t.Setenv(syncsetup.URLVar, "https://sync.example") + if err := os.MkdirAll(filepath.Dir(pair.RelayFile()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pair.RelayFile(), []byte("https://file.example/fabric\n"), 0o600); err != nil { + t.Fatal(err) + } + + if got := pair.Relay(syncRelayURL()); got != "https://file.example/fabric" { + t.Fatalf("serve precedence with v3/remote/relay gave %q, want file override", got) + } + if got := pair.RelayFor(pair.Known{}, false, syncRelayURL()); got != "https://file.example/fabric" { + t.Fatalf("unpaired --at precedence with v3/remote/relay gave %q, want file override", got) + } + paired := pair.Known{Service: "https://paired.example/fabric"} + if got := pair.RelayFor(paired, true, syncRelayURL()); got != "https://paired.example/fabric" { + t.Fatalf("paired --at precedence gave %q, want paired machine service over file", got) + } + t.Setenv(pair.RelayEnv, "https://env.example/fabric") + if got := pair.RelayFor(paired, true, syncRelayURL()); got != "https://env.example/fabric" { + t.Fatalf("CODEAF_RELAY precedence gave %q, want env override", got) + } +} diff --git a/cmd/codeaf/chatv3_beside.go b/cmd/codeaf/chatv3_beside.go index 202c1f273e..733a93e16f 100644 --- a/cmd/codeaf/chatv3_beside.go +++ b/cmd/codeaf/chatv3_beside.go @@ -454,6 +454,7 @@ func (b *besideAgent) Detach() error { func hostLink(seams hostSeams) tui3.LinkSeam { return tui3.LinkSeam{ Note: seams.Link, + SendRefusal: seams.SendRefusal, EngineBuild: seams.EngineBuild, Ping: seams.Ping, Notice: seams.Notice, diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 78a432970e..320795313d 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -726,10 +726,11 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op // the surface holds it to that (chatv3_host_shared_test.go). counted := countedAgent(agent) options := tui3.Options{ - Agent: counted, - Build: welcome.Build, - Host: dest, - Workspace: welcome.Workspace, + Agent: counted, + Build: welcome.Build, + Host: dest, + SyncService: welcome.SyncService, + Workspace: welcome.Workspace, // The engine's own journal path, shown with its machine in front of it // wherever the surface shows it (internal/tui3's host.go). SessionFile: welcome.SessionFile, @@ -1056,7 +1057,8 @@ type hostSeams struct { // whenever there is nothing to say, which is what a status-line segment // draws as nothing at all. It reads `reconnecting to devbox — trying for up // to 5 minutes` while a dropped link is being redialled. - Link func() string + Link func() string + SendRefusal func() string // Ping measures one empty call and back. The surface owns its cadence and // rolling estimate; this door only hands over the wire's typed method. Ping func() (time.Duration, error) @@ -1090,7 +1092,7 @@ func newHostSeams(client *remote.Client) hostSeams { initial := client.Held() var readInitial atomic.Bool return hostSeams{ - Link: client.LinkNote, Ping: client.Ping, Notice: client.TakeNotice, + Link: client.LinkNote, SendRefusal: client.SendRefusal, Ping: client.Ping, Notice: client.TakeNotice, EngineBuild: func() string { return client.Welcome().Build }, Held: func() ([]remote.HeldQuestion, error) { // Arrival and replay share one snapshot. Re-querying before drawing diff --git a/cmd/codeaf/chatv3_host_teams.go b/cmd/codeaf/chatv3_host_teams.go index bc89028646..89136c57ad 100644 --- a/cmd/codeaf/chatv3_host_teams.go +++ b/cmd/codeaf/chatv3_host_teams.go @@ -19,8 +19,8 @@ import ( // and the surface asks [tui3.TeamsSeam.ReadSince] off its loop, once, which // fills what is held. Every other door here is called from a command and may // take a round trip: the Traffic clock's turn, and the write an edit queued. -// There is no clock of its own: the surface's Traffic clock is the only thing -// that asks again, and it runs only while a managed team is held. +// There is no clock of its own: the surface asks again through its Traffic +// clock and its read-only teams refresh clock, even with no managed chat open. // // NOTHING HERE READS THIS LAPTOP'S PROFILE. The teams a window over --host // draws are the far machine's, or none: an engine without the doors gets no @@ -32,6 +32,7 @@ type hostTeams struct { read func(stamp string, reserved []float64) (remote.TeamsReading, error) write func(base string, teams []teamstore.Team) (remote.TeamsReading, error) traffic func(team, after string, limit int) (remote.TeamsTraffic, error) + history func(team, after, version string, limit int) (remote.TeamsTraffic, error) // The delegation doors, nil when the engine does not answer them // ([remote.Welcome.Delegation]); the seam then hands the surface none. @@ -74,6 +75,7 @@ func newHostTeams(far hostFar, delegation, wrapUp, settings bool) *hostTeams { read: far.client.TeamsRead, write: far.client.TeamsUpdate, traffic: far.client.TeamsTraffic, + history: far.client.TeamsHistory, } if delegation { c := far.client @@ -113,6 +115,12 @@ func hostTeamsSeam(far hostFar, welcome remote.Welcome) tui3.TeamsSeam { // delegation doors are nil, which the surface says rather than guesses at. func (h *hostTeams) seam() tui3.TeamsSeam { s := tui3.TeamsSeam{Load: h.load, ReadSince: h.readSince, Update: h.update, Traffic: h.readTraffic} + if h.history != nil { + s.DisplayTraffic = func(team, after, version string, limit int) ([]teamstore.Entry, string, string, bool, error) { + got, err := h.history(team, after, version, limit) + return got.Entries, got.Cursor, got.Mirror, got.Reset, err + } + } if h.applyDefault != nil { s.ApplyDefault = h.applyDefault } diff --git a/cmd/codeaf/chatv3_lanes.go b/cmd/codeaf/chatv3_lanes.go index 99676727af..4a70453234 100644 --- a/cmd/codeaf/chatv3_lanes.go +++ b/cmd/codeaf/chatv3_lanes.go @@ -75,6 +75,11 @@ func v3LanesOverWire() v3Lanes { // are one fact. A door that took the lanes and then picked its own builder could // turn the verb on with nothing behind it. func v3Shape(cfg session.Config, lanes v3Lanes) (session.Config, func(session.Config) (*session.Agent, error)) { + // Task placement works across the connection independently of adaptive + // run lanes, so disabling those lanes must not hide paired computers. + if cfg.TaskComputers == nil { + cfg.TaskComputers = v3TaskComputers(cfg.ProfileDir) + } if !lanes.Designs { // Nil is the honest way to say the designer is not among the things this // conversation can do, so the model says as much instead of starting two diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index e5b720f31f..d447661ee1 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -544,7 +544,7 @@ func v3NextSessionFrom(current session.Place, workspace, launchDir string) (sess // cannot be inherited: it is minted with the folder, below. root = workspace } - return v3MintSession(filepath.Dir(dir), root, + return v3MintSession(session.BucketOf(current.Transcript()), root, v3StampLaunchDir(launchDir, root), current.Owned) } bucket, err := v3ProjectDir(workspace) @@ -659,15 +659,11 @@ type v3Folder struct { // deliverables is never empty whatever its journal says — those are somebody's // content, and no groom of a bookkeeping file may take them. func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { - entries, err := os.ReadDir(bucket) - if err != nil { + sessionDirs := session.BucketSessionDirs(bucket) + if len(sessionDirs) == 0 { return nil, nil } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - dir := cell.Copied(filepath.Join(bucket, entry.Name())) + for _, dir := range sessionDirs { place := session.Place{Dir: dir} if _, err := os.Stat(place.Transcript()); err != nil { continue @@ -676,7 +672,7 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { folder.meta, _ = session.LoadMeta(dir) folder.at = folder.meta.LastUserAt if folder.at.IsZero() { - if info, err := entry.Info(); err == nil { + if info, err := os.Stat(dir); err == nil { folder.at = info.ModTime() } } @@ -843,9 +839,9 @@ func v3SeatPending(place session.Place, say func(string), drives *driveBook) (ex class = executor.HostBound } machine, err := preflight.OpenMachine(c.Root, place.Workspace) - watch := &cellstore.SealWatch{Say: say} + watch := &cellstore.SealWatch{Say: say, SyncAddress: chatSyncService()} failed(watch, err) - drive, claim := drives.pendingDriveOf(c, cellstore.EngineFor(place.Workspace), watch.Report) + drive, claim := drives.pendingDriveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport) if drive != nil { watch.OnTurnEnd = drive.Idle // the agent waits for the person: upload what is sealed now } diff --git a/cmd/codeaf/chatv3_orchestrate.go b/cmd/codeaf/chatv3_orchestrate.go index 473f001edb..e8b9b4bf72 100644 --- a/cmd/codeaf/chatv3_orchestrate.go +++ b/cmd/codeaf/chatv3_orchestrate.go @@ -66,6 +66,9 @@ func v3OpenSession(cfg session.Config) (*session.Agent, error) { if cfg.ExternalTasks == nil { cfg.ExternalTasks = workGraphs.externalTasks() } + if cfg.TaskComputers == nil { + cfg.TaskComputers = v3TaskComputers(cfg.ProfileDir) + } cfg, runs := v3Adaptive(cfg) agent, err := session.New(cfg) if err != nil { diff --git a/cmd/codeaf/chatv3_pair.go b/cmd/codeaf/chatv3_pair.go index 40c9263d5b..c7acf07aea 100644 --- a/cmd/codeaf/chatv3_pair.go +++ b/cmd/codeaf/chatv3_pair.go @@ -33,7 +33,10 @@ func (chatPairDoor) Offer(ctx context.Context, ui pair.OfferUI) (string, error) if err != nil { return "", err } - label, err := pair.Offer(ctx, route, grant, ui) + if err := ownDeviceRecord(ctx, home.Dir(), route); err != nil { + return "", explainSyncUnreachable(err, route.URL) + } + label, err := pair.Offer(ctx, route, grant, chatOfferUI{OfferUI: ui, service: route.Shown}) return label, explainSyncUnreachable(err, route.URL) } @@ -45,6 +48,11 @@ func (chatPairDoor) Join(ctx context.Context, typed string, ui pair.JoinUI) (pai return pair.Joined{}, err } joined, err := pair.Join(ctx, route, pairJoining(false), typed, ui) + if err == nil { + if f, e := pullFleet(home.Dir(), route)(ctx); e == nil { + joined.With = f.With + } + } return joined, explainSyncUnreachable(err, route.URL) } @@ -58,6 +66,9 @@ func (chatPairDoor) JoinReplacing(ctx context.Context, typed string, ui pair.Joi return pair.Joined{}, err } joined, err := pair.Join(ctx, route, pairJoining(true), typed, ui) + if err == nil { + err = ownDeviceRecord(ctx, home.Dir(), route) + } return joined, explainSyncUnreachable(err, route.URL) } @@ -67,3 +78,17 @@ func (chatPairDoor) HasOwnChats() bool { _, err := identity.Load(home.Dir()) return err == nil } + +// PairCommand is read when instructions are drawn, so a saved --via is reflected immediately. +func (chatPairDoor) PairCommand() string { return pair.Command(pairingRelay(home.Dir()).URL, "") } + +// chatOfferUI gives the chat's link field its own directions while preserving +// every approval and cancellation operation of the ordinary offer. +type chatOfferUI struct { + pair.OfferUI + service string +} + +func (u chatOfferUI) Show(code *pair.Code, _ string) { + u.OfferUI.Show(code, pair.ChatCardLines(code, u.service)) +} diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 527869deef..d3c4c7f858 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -48,7 +48,25 @@ var ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { + if options.SyncService == "" && options.Host == "" { + options.SyncService = chatSyncService() + } forced := armFinalSaving() + setFinalSyncService(options.SyncService) + previousCompatibility := options.SavingCompatibility + options.SavingCompatibility = func(check func() bool) { + retainSavingCompatibility(check) + if previousCompatibility != nil { + previousCompatibility(check) + } + } + previousDurability := options.SavingDurability + options.SavingDurability = func(check func() bool) { + retainLocalDurability(check) + if previousDurability != nil { + previousDurability(check) + } + } previousSaveFailure := options.SaveFailure options.SaveFailure = func(err error) { recordFinalSaveFailure(err) @@ -82,7 +100,10 @@ func runSurface(ctx context.Context, options tui3.Options) error { options.Restart = restart } options.CellsUnavailable = cell.UnavailableReason() - if options.CellsUnavailable == "" { + if options.TaskComputers == nil && options.Host == "" { + options.TaskComputers = v3TaskComputers(options.ProfileDir) + } + if options.CellsUnavailable == "" && options.Host == "" { wireSync(&options) options.WaitForRelease = func() bool { s, err := syncOf() @@ -185,10 +206,14 @@ func runSurface(ctx context.Context, options tui3.Options) error { options.Output = wire return withSurfaceLogger(options.ProfileDir, func() error { err := runSurfaceProgram(ctx, options) + startFinalSaveProgress() select { case <-forced: - // The screen is restored before the forced exit skips stuck cleanup. + // A second quit interrupt keeps the baseline escape after screen restoration. + stopFinalSaveProgress() surfaceForceExit(130) + return err + default: } awaitFinalCloses(ctx) diff --git a/cmd/codeaf/chatv3_taskcomputers.go b/cmd/codeaf/chatv3_taskcomputers.go new file mode 100644 index 0000000000..2e7712f137 --- /dev/null +++ b/cmd/codeaf/chatv3_taskcomputers.go @@ -0,0 +1,53 @@ +package main + +import ( + "net/http" + "path/filepath" + "runtime" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +// v3TaskComputers builds no client while this home is quiet, unpaired or on an +// unsupported platform. A nil door also keeps the proposal argument absent. +func v3TaskComputers(profile string) session.TaskComputers { + // Identity storage takes a concrete home, while an unset profile means + // the ordinary state root to the rest of the conversation configuration. + if profile == "" { + profile = home.Dir() + } + if runtime.GOOS == "windows" || syncsetup.Quiet(profile) { + return nil + } + route := syncsetup.Resolve(profile) + if !route.On() { + return nil + } + id, err := identity.Load(profile) + if err != nil { + return nil + } + device, err := identity.LoadDevice(profile) + if err != nil || device.Cert.Verify(id.PublicKey()) != nil { + return nil + } + // This factory is called for every conversation. Only existing keys are + // read here; directory requests belong to an explicit placement choice. + client := directory.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: linkTimeout}) + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(profile, "v3", "stores") + engine.Guard.Home = profile + store := blobstore.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: time.Minute}) + copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: route.URL, Identity: id, Device: device, Engine: engine, Store: store} + return &taskplace.Computers{Directory: client, Copies: copies, Home: profile} +} diff --git a/cmd/codeaf/chatv3_taskcomputers_default_test.go b/cmd/codeaf/chatv3_taskcomputers_default_test.go new file mode 100644 index 0000000000..2461ad1a2f --- /dev/null +++ b/cmd/codeaf/chatv3_taskcomputers_default_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" +) + +// DESIGN 1 requires the ordinary chat launch to find the paired identity in +// the same home that serve uses, without a separate profile override. +func TestConversationTaskComputersUseTheDefaultHomeWhenTheProfileIsUnset(t *testing.T) { + _ = pairedTaskDefaultHome(t) + computers := v3TaskComputers("") + if computers == nil || computers.ThisComputer() != "chat computer" { + t.Fatal("the normal profile hides paired task computers") + } +} + +// DESIGN 1 also applies over the ordinary engine connection; disabling an +// unrelated run lane must not remove the task placement that /task --on uses. +func TestConnectedConversationsKeepPairedTaskPlacement(t *testing.T) { + profile := pairedTaskDefaultHome(t) + cfg, _ := v3Shape(session.Config{ProfileDir: profile}, v3LanesOverWire()) + if cfg.TaskComputers == nil || cfg.TaskComputers.ThisComputer() != "chat computer" { + t.Fatal("the connected conversation hides paired task placement") + } +} + +func pairedTaskDefaultHome(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("paired tasks require Linux or macOS") + } + profile := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROFILE_DIR", "") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + t.Setenv("CODEAF_SYNC_URL", server.URL) + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(profile, "chat computer"); err != nil { + t.Fatal(err) + } + return profile +} diff --git a/cmd/codeaf/chatv3_taskcomputers_test.go b/cmd/codeaf/chatv3_taskcomputers_test.go new file mode 100644 index 0000000000..f824f129b7 --- /dev/null +++ b/cmd/codeaf/chatv3_taskcomputers_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "net/http/httptest" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +// The absence law is applied at the actual conversation configuration door. +func TestConversationTaskComputersAreAbsentInUnpairedSoloAndSyncOffHomes(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + for _, kind := range []string{"unpaired", "solo", "off"} { + t.Run(kind, func(t *testing.T) { + profile := t.TempDir() + if kind == "solo" { + if _, err := identity.EnsureSolo(profile); err != nil { + t.Fatal(err) + } + } + if kind == "off" { + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_SYNC_URL", "off") + } + if got := v3TaskComputers(profile); got != nil { + t.Fatalf("%s offered task computers", kind) + } + }) + } +} + +// Contract 6 selects from the signed pairing roster, including unsupported +// Windows rows and excluding this computer and revoked device certificates. +func TestConversationTaskComputersUsePairingNamesAndRevocation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + profile := t.TempDir() + id, err := identity.Ensure(profile) + if err != nil { + t.Fatal(err) + } + self, err := identity.LoadDevice(profile) + if err != nil { + t.Fatal(err) + } + state := directory.NewMemory(time.Now) + client := state.For(self.ID()) + key := directory.MetadataKey(id.CellKey()) + var revoked string + for _, item := range []struct { + name, platform string + revoke bool + }{{"chat computer", "linux", false}, {"work computer", "darwin", false}, {"Windows computer", "windows", false}, {"removed computer", "linux", true}} { + dev := self + if item.name != "chat computer" { + dev, err = identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + } + name, err := directory.SealName(key, item.name) + if err != nil { + t.Fatal(err) + } + if err := client.PutDevice(context.Background(), dev.ID(), directory.Device{V: 1, Name: name, Platform: item.platform}); err != nil { + t.Fatal(err) + } + if item.revoke { + revoked = dev.ID() + if err := client.Revoke(context.Background(), dev.ID()); err != nil { + t.Fatal(err) + } + } + } + server := httptest.NewServer(directory.Handler(reqsign.AuthenticateAt(time.Now), func(string) (directory.Directory, error) { return state, nil })) + defer server.Close() + t.Setenv("CODEAF_SYNC_URL", server.URL) + computers := v3TaskComputers(profile) + if runtime.GOOS == "windows" { + if computers != nil { + t.Fatal("Windows origin offered remote tasks") + } + return + } + if computers == nil { + t.Fatal("paired home has no task computers") + } + rows, err := computers.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("paired computers = %+v", rows) + } + for _, row := range rows { + if row.Device == self.ID() || row.Device == revoked { + t.Fatalf("non-target listed: %+v", row) + } + if row.Name == "Windows computer" && row.CanTakeTasks { + t.Fatal("Windows target can take tasks") + } + } +} diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index a44d6a2c32..478899a117 100644 --- a/cmd/codeaf/cluster.go +++ b/cmd/codeaf/cluster.go @@ -7,20 +7,26 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/clusterctl" cs "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" "io" "os" + "slices" + "strings" "time" ) -const clusterHelp = `codeaf cluster status - Show this joined computer, learners, voters and the current coordinator. -codeaf cluster lead - Ask this joined computer to coordinate through a hosted or self-hosted relay. +const clusterVerboseFlag = "verbose" + +const clusterHelp = `codeaf cluster status [--` + clusterVerboseFlag + `] + Show the computers coordinating, deciding changes and keeping copies. +codeaf cluster lead [--` + clusterVerboseFlag + `] + Ask this joined computer to coordinate through the sync service. Persistent storage is required; keep codeaf open while this computer leads. - Three voters tolerate one unavailable member. + Three deciding computers tolerate one unavailable computer. ` type clusterDoor interface { @@ -35,6 +41,12 @@ func runCluster(args []string) error { fmt.Fprint(usageOut, clusterHelp) return exitHelped } + flags := commandFlags("cluster") + verbose := flags.Bool(clusterVerboseFlag, false, "show coordination details for debugging") + if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { + return err + } + args = flags.Args() if len(args) != 1 || args[0] != "status" && args[0] != "lead" { return errors.New("usage: codeaf cluster status | lead") } @@ -50,31 +62,61 @@ func runCluster(args []string) error { } ctx, cancel := context.WithTimeout(context.Background(), clusterctl.ForwardTimeout) defer cancel() - return explainSyncUnreachable(clusterCommand(ctx, s, args[0], os.Stdout), s.Relay) + defer terminalSyncProgress(s.Relay)() + return clusterCommandMode(ctx, s, args[0], os.Stdout, *verbose, s.Relay) +} + +// clusterCommandOptions carries the terminal's explicit mode and selected +// service. Calls without options keep the existing detailed output. +type clusterCommandOptions struct { + verbose bool + service string +} + +func clusterCommandMode(ctx context.Context, c clusterDoor, verb string, out io.Writer, verbose bool, service ...string) error { + options := clusterCommandOptions{verbose: verbose} + if len(service) > 0 { + options.service = service[0] + } + return clusterCommand(ctx, c, verb, out, options) } // clusterCommand answers `codeaf cluster status` and `codeaf cluster lead`. The // whole answer is written into a buffer by work bounded by one ceiling, so a // store open that ignores its context cannot hold the terminal past it, and an -// answer that arrives late never reaches the terminal at all. -func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer) error { +// answer that arrives late never reaches the terminal at all. Errors are explained +// once, with the service this command used, after either work or its ceiling ends. +func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer, options ...clusterCommandOptions) error { + selected := clusterCommandOptions{verbose: true} + if len(options) > 0 { + selected = options[0] + } + verbose, address := selected.verbose, selected.service if verb == "status" { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, clusterStatusTimeout) defer cancel() } - return boundedClusterOutput(ctx, out, func(ctx context.Context, out io.Writer) error { + err := boundedClusterOutput(ctx, out, func(ctx context.Context, out io.Writer) error { if verb == "lead" { r, err := c.LeadCluster(ctx, 0) if err != nil { - return explainSyncUnreachable(explainRemovedDevice(err), "") + return explainRemovedDevice(err) + } + if !verbose { + names := clusterDeviceNames(ctx, c, clusterctl.Status{Self: r.Node}) + _, err = fmt.Fprintln(out, names[r.Node]+" now coordinates your devices") + return err } _, err = fmt.Fprintf(out, "computer %d is coordinating · term %d · revision %d · read barrier %d\n", r.Node, r.Term, r.Revision, r.Barrier) return err } s, err := c.ClusterStatus(ctx) if err != nil { - return explainSyncUnreachable(explainRemovedDevice(err), "") + return explainRemovedDevice(err) + } + if !verbose { + return printClusterStatus(out, s, clusterDeviceNames(ctx, c, s)) } if s.Leader != 0 { fmt.Fprintf(out, "coordinator %d\n", s.Leader) @@ -96,6 +138,7 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ } return nil }) + return explainSyncUnreachable(err, address) } // boundedClusterOutput runs one cluster answer under the forwarding ceiling and @@ -122,6 +165,78 @@ func boundedClusterOutput(ctx context.Context, out io.Writer, work func(context. _, err := io.WriteString(out, got.text) return err case <-ctx.Done(): - return explainSyncUnreachable(ctx.Err(), "") + return ctx.Err() + } +} + +// clusterNamer resolves protocol membership through the encrypted device roster. +type clusterNamer interface { + ClusterNames(context.Context, clusterctl.Status) map[uint64]string +} + +func clusterDeviceNames(ctx context.Context, c clusterDoor, status clusterctl.Status) map[uint64]string { + names := map[uint64]string{} + if n, ok := c.(clusterNamer); ok { + names = n.ClusterNames(ctx, status) + } + if names == nil { + names = map[uint64]string{} + } + if names[status.Self] == "" && status.Self != 0 { + names[status.Self] = devname.Name(home.Dir()) + } + return names +} + +const clusterOtherComputer = "another computer" + +func clusterName(names map[uint64]string, node uint64, status clusterctl.Status) string { + if name := names[node]; name != "" { + return name + } + if _, absent := names[node]; absent && slices.ContainsFunc(status.Members, func(member cs.MemberLabel) bool { + return member.Node == node && member.Device != "" && !member.Anchor + }) { + return "a computer no longer in your devices" + } + return clusterOtherComputer +} + +func printClusterStatus(out io.Writer, s clusterctl.Status, names map[uint64]string) error { + if s.Leader != 0 { + leader := clusterName(names, s.Leader, s) + if s.Leader == s.Self { + leader += " (this computer)" + } + fmt.Fprintf(out, "coordinating %s\n", leader) + } + join := func(nodes []uint64) string { + var shown []string + unknown := 0 + for _, node := range nodes { + name := clusterName(names, node, s) + if name == clusterOtherComputer && names[node] == "" { + unknown++ + continue + } + if !slices.Contains(shown, name) { + shown = append(shown, name) + } + } + switch unknown { + case 0: + case 1: + shown = append(shown, clusterOtherComputer) + default: + shown = append(shown, fmt.Sprintf("%d other computers", unknown)) + } + return strings.Join(shown, " "+tokens.Plain.Glyph(tokens.GSeparator)+" ") + } + if len(s.Configuration.Voters) > 0 { + fmt.Fprintf(out, "deciding %s %d of %d needed\n", join(s.Configuration.Voters), len(s.Configuration.Voters)/2+1, len(s.Configuration.Voters)) + } + if len(s.Configuration.Learners) > 0 { + fmt.Fprintf(out, "keeping copies %s\n", join(s.Configuration.Learners)) } + return nil } diff --git a/cmd/codeaf/cluster_deadline_test.go b/cmd/codeaf/cluster_deadline_test.go index 7bc09d143b..0f400960b9 100644 --- a/cmd/codeaf/cluster_deadline_test.go +++ b/cmd/codeaf/cluster_deadline_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/clusterctl" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // Contract E6 distinguishes an unanswered request from a network that has no route. @@ -21,9 +22,9 @@ func TestClusterStatusDistinguishesDeadlinesFromUnreachableNetworks(t *testing.T want string }{ {"deadline", context.DeadlineExceeded, "the sync service at http://relay.invalid did not answer in time — try again"}, - {"refused", syscall.ECONNREFUSED, "the sync service at http://relay.invalid cannot be reached from here — check this computer's network"}, - {"route", syscall.ENETUNREACH, "the sync service at http://relay.invalid cannot be reached from here — check this computer's network"}, - {"dns", &net.DNSError{Err: "no such host", Name: "relay.invalid", IsNotFound: true}, "the sync service at http://relay.invalid cannot be reached from here — check this computer's network"}, + {"refused", syscall.ECONNREFUSED, syncwords.Unreachable("http://relay.invalid")}, + {"route", syscall.ENETUNREACH, syncwords.Unreachable("http://relay.invalid")}, + {"dns", &net.DNSError{Err: "no such host", Name: "relay.invalid", IsNotFound: true}, syncwords.Unreachable("http://relay.invalid")}, } { t.Run(item.name, func(t *testing.T) { failure := &url.Error{Op: "Get", URL: "http://relay.invalid/v2/cluster/control", Err: item.cause} diff --git a/cmd/codeaf/cluster_test.go b/cmd/codeaf/cluster_test.go index 3b5df588c9..d2fc09d1ad 100644 --- a/cmd/codeaf/cluster_test.go +++ b/cmd/codeaf/cluster_test.go @@ -27,7 +27,7 @@ func (c clusterFixture) LeadCluster(context.Context, uint64) (cs.LeadershipRecei func TestR6R8R30ClusterCommandReceiptAndRefusal(t *testing.T) { var out bytes.Buffer c := clusterFixture{receipt: cs.LeadershipReceipt{Node: 4, Term: 8, Revision: 3, Barrier: 19, Voters: []uint64{1, 2, 4}}} - if e := clusterCommand(context.Background(), c, "lead", &out); e != nil { + if e := clusterCommandMode(context.Background(), c, "lead", &out, true); e != nil { t.Fatal(e) } if got := out.String(); got != "computer 4 is coordinating · term 8 · revision 3 · read barrier 19\n" { @@ -35,11 +35,11 @@ func TestR6R8R30ClusterCommandReceiptAndRefusal(t *testing.T) { } out.Reset() c.err = clusterctl.ErrUnavailable - if e := clusterCommand(context.Background(), c, "lead", &out); !errors.Is(e, clusterctl.ErrUnavailable) || out.Len() != 0 { + if e := clusterCommandMode(context.Background(), c, "lead", &out, true); !errors.Is(e, clusterctl.ErrUnavailable) || out.Len() != 0 { t.Fatal(e, out.String()) } c.err = nil - if e := clusterCommand(context.Background(), c, "status", &out); e != nil { + if e := clusterCommandMode(context.Background(), c, "status", &out, true); e != nil { t.Fatal(e) } if !strings.Contains(out.String(), "voting computers [1 2 4]") { @@ -54,7 +54,7 @@ func TestR6R8R30ClusterCommandReceiptAndRefusal(t *testing.T) { // R6/R30 require command help to offer the leadership that both relays serve. func TestR6R30ClusterHelpOffersHostedLeadership(t *testing.T) { - for _, phrase := range []string{"hosted or self-hosted relay", "Persistent storage is required", "Three voters tolerate one unavailable member"} { + for _, phrase := range []string{"through the sync service", "Persistent storage is required", "Three deciding computers tolerate one unavailable computer"} { if !strings.Contains(clusterHelp, phrase) { t.Errorf("cluster help omits %q", phrase) } diff --git a/cmd/codeaf/cluster_unreachable_test.go b/cmd/codeaf/cluster_unreachable_test.go index 60dc81182a..8d93151460 100644 --- a/cmd/codeaf/cluster_unreachable_test.go +++ b/cmd/codeaf/cluster_unreachable_test.go @@ -7,6 +7,8 @@ import ( "strings" "syscall" "testing" + + "github.com/Agent-Field/codeaf/internal/syncwords" ) // B5 gives an unreachable relay the same words at both command doors. @@ -14,7 +16,7 @@ func TestClusterStatusUsesTheDevicesUnreachableSentence(t *testing.T) { var out bytes.Buffer failure := &url.Error{Op: "Get", URL: "http://relay.invalid/v2/cluster/control", Err: syscall.ECONNREFUSED} err := clusterCommand(context.Background(), clusterFixture{err: failure}, "status", &out) - want := "the sync service at http://relay.invalid cannot be reached from here — check this computer's network" + want := syncwords.Unreachable("http://relay.invalid") if err == nil || err.Error() != want || out.Len() != 0 { t.Fatalf("status: %v output=%q", err, out.String()) } diff --git a/cmd/codeaf/devbox.go b/cmd/codeaf/devbox.go new file mode 100644 index 0000000000..340dff1931 --- /dev/null +++ b/cmd/codeaf/devbox.go @@ -0,0 +1,332 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/devbox" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func runDevbox(args []string) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return devboxCommand(ctx, args, newDevboxFlow()) +} + +func devboxCommand(ctx context.Context, args []string, flow devbox.Flow) error { + if len(args) == 0 { + return wrongCall("usage: codeaf devbox up | status [] | down ") + } + if askedForHelp(args[:1]) { + return commandHelp("devbox") + } + action := args[0] + if action != "up" && action != "status" && action != "down" { + return wrongCall("usage: codeaf devbox up | status [] | down ") + } + var o devbox.Options + flags := devboxFlags("devbox "+action, action == "up", &o) + if err := parseCommandFlags(flags, reorder(flags, args[1:])); err != nil { + return err + } + if flags.NArg() > 1 || (action != "status" && flags.NArg() != 1) { + return wrongCall("usage: codeaf devbox " + action + " ") + } + if flags.NArg() == 1 { + o.Name = flags.Arg(0) + } + if action == "up" { + o.Version = devboxVersion(buildinfo.Revision(), o.Channel) + } + err := flow.Run(ctx, action, o) + if err != nil && (err.Error() == devbox.TokenNeeded || err.Error() == devbox.TerraformNeeded) { + fmt.Fprintln(usageErr, err) + return exitStatus(1) + } + return err +} + +// The root row stays compact; this page carries the full action and flag reference. +const devboxUsage = ` codeaf devbox up [--yes] [--dir D] [--region R] + [--workspace W] [--channel C] + codeaf devbox down [--yes] [--dir D] + codeaf devbox status [] [--dir D]` + +func devboxFlags(name string, up bool, o *devbox.Options) *flag.FlagSet { + flags := commandFlags(name) + flags.StringVar(&o.Dir, "dir", "", "folder holding this box's Terraform; default is /devbox/") + flags.BoolVar(&o.Yes, "yes", false, "skip the create or destroy confirmation") + if up { + flags.StringVar(&o.Region, "region", "", "the region the box runs in; empty picks one for you") + flags.StringVar(&o.Workspace, "workspace", "", "the workspace on your account, by name or id; needed when you have several") + flags.StringVar(&o.Channel, "channel", devboxChannel(buildinfo.Revision()), "stable, staging or dev; follows this binary, with dev for a source build") + } + return flags +} + +func devboxChannel(revision string) string { + switch channel := buildinfo.Channel(revision); channel { + case "stable", "staging", "dev": + return channel + case "rc": + return "stable" + default: + return "dev" + } +} + +func devboxVersion(revision, channel string) string { + if buildinfo.Channel(revision) == channel { + if _, _, err := devbox.Channel(channel); err == nil { + return revision + } + } + return "" +} + +func newDevboxFlow() devbox.Flow { + door := newPairDoor(os.Stdin, os.Stdout) + return devbox.Flow{ + Home: home.Dir(), In: os.Stdin, Out: os.Stdout, Err: os.Stderr, Interactive: stdinIsTerminal(os.Stdin), StdoutTerminal: stdinIsTerminal(os.Stdout), + Unavailable: func() string { + if reason := cell.UnavailableReason(); reason != "" { + return reason + } + _, err := door.mailbox("") + if err != nil { + return err.Error() + } + return "" + }, + Prepare: func(ctx context.Context, name string) (devbox.Offer, error) { return prepareDevbox(door, name) }, + Pair: func(ctx context.Context, name string, ready func(devbox.Offer)) error { + return offerDevbox(ctx, door, name, ready) + }, + Devices: devboxDevices, + Probe: probeDevbox, + Remove: devboxRemoval(openChatsKind, pair.MachineBook()), + ShareState: shareDevboxState, + } +} + +// devboxShareWithin bounds the one home-state round up runs for a new box, so +// a slow relay cannot hold the command after the box already answers. +const devboxShareWithin = 30 * time.Second + +// shareDevboxState runs the round an open terminal runs: it pushes this +// computer's saved keys and settings to the identity the box just joined, and +// the box's serve pulls them on its next round. +func shareDevboxState(ctx context.Context) error { + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + return err + } + ctx, cancel := context.WithTimeout(ctx, devboxShareWithin) + defer cancel() + return s.SyncState(ctx) +} + +func devboxDevices(ctx context.Context) ([]devbox.Device, error) { + known, err := pair.Machines() + if err != nil { + return nil, err + } + remembered := make([]devbox.Device, 0, len(known)) + for _, one := range known { + name := one.Name + if one.Alias != "" { + name = one.Alias + } + remembered = append(remembered, devbox.Device{Name: name}) + } + if syncsetup.Quiet(home.Dir()) { + return remembered, nil + } + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil { + return nil, err + } + if !ok { + return remembered, nil + } + kind := chatsKindOf(s.Dir, s.Identity.CellKey(), s.Device.ID(), s.Relay) + ctx, cancel := context.WithTimeout(ctx, listRequestWithin) + defer cancel() + listing, err := kind.dir.List(ctx) + if err != nil { + return nil, kind.explain(err) + } + presence, presenceErr := kind.dir.Presence(ctx) + result := make([]devbox.Device, 0, len(listing.Devices)) + for id, record := range listing.Devices { + name, err := directory.OpenName(kind.key, record.Name) + if err != nil { + if record.Revoked { + continue + } + return nil, errors.New("could not read a device name from this identity's list") + } + d := devbox.Device{ID: id, Name: name, Removed: record.Revoked} + seen := record.LastSeen + if presenceErr == nil { + if p, ok := presence.Devices[id]; ok { + online := p.Online + d.Online = &online + seen = p.LastSeen + } + } + if seen > 0 { + d.LastSeen = time.UnixMilli(seen) + } + result = append(result, d) + } + return append(result, remembered...), nil +} + +// Pin before offering the first code so even a lost acknowledgement can be probed. +func prepareDevbox(door pairDoor, name string) (devbox.Offer, error) { + route, err := door.mailbox("") + if err != nil { + return devbox.Offer{}, err + } + client, err := pair.ThisDevice(pair.OpenKeeper()) + if err != nil { + return devbox.Offer{}, err + } + key, devices, known, err := pair.DevboxRemote(name, route.URL, client, pair.ThisMachineLabel()) + if err != nil { + return devbox.Offer{}, err + } + if err := pair.MachineBook().Remember(known); err != nil { + return devbox.Offer{}, err + } + return devbox.Offer{Via: route.URL, RemoteKey: base64.StdEncoding.EncodeToString(key), RemoteDevices: base64.StdEncoding.EncodeToString(devices)}, nil +} + +// Select once, ignoring revoked names; a saved id continues to identify a renamed box. +func devboxRemoval(open func() (chatsKind, bool), book *pair.Book) func(context.Context, string, string) (bool, error) { + return func(ctx context.Context, name, id string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + kind, ok := open() + if !ok { + return false, errors.New("could not open this identity's device list") + } + rows, err := kind.rows() + if err != nil { + return false, err + } + var selected *chatsRow + if id != "" { + for _, row := range rows { + if !row.Stopped && row.id == id { + copy := row + selected = © + break + } + } + } + if selected == nil { + for _, row := range rows { + if !row.Stopped && strings.EqualFold(row.Name, name) { + if selected != nil { + return false, fmt.Errorf("more than one active device is called %q", name) + } + copy := row + selected = © + } + } + } + if selected != nil { + if _, err := kind.revoke(*selected); err != nil { + return false, err + } + } + if err := book.ForgetAlias(name); err != nil { + return false, err + } + return selected != nil, nil + } +} + +func offerDevbox(ctx context.Context, door pairDoor, name string, ready func(devbox.Offer)) error { + route, err := door.mailbox("") + if err != nil { + return err + } + grant, err := door.grant(route) + if err != nil { + return err + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + ui := devboxOfferUI{name: name, offer: devbox.Offer{Via: route.URL}, ready: ready, cancel: cancel} + label, err := pair.Offer(ctx, route, grant, &ui) + if err != nil { + if errors.Is(err, pair.ErrDidNotFinish) || errors.Is(err, context.DeadlineExceeded) { + return devbox.ErrJoinInterrupted + } + return err + } + if label != name { + return fmt.Errorf("a different device joined: %s", label) + } + door.recordOwnDeviceOrSay(ctx, route) + return nil +} + +// The command authorizes this one named box to receive the full identity. +// A burned code cancels rather than silently changing the code already in a plan. +type devboxOfferUI struct { + name string + offer devbox.Offer + ready func(devbox.Offer) + cancel context.CancelFunc + shown bool +} + +func (u *devboxOfferUI) Show(code *pair.Code, _ string) { + if !u.shown { + u.shown = true + u.offer.Code = code.Shown() + u.ready(u.offer) + } +} +func (u *devboxOfferUI) Burned() { u.cancel() } +func (u *devboxOfferUI) Ask(ctx context.Context, label, _ string) bool { + return ctx.Err() == nil && label == u.name +} + +// The Noise handshake proves the configured key answered, including on released boxes without a watch. +func probeDevbox(ctx context.Context, name string) bool { + book := pair.MachineBook() + known, found, err := book.Machine(name) + if err != nil || !found || known.Alias != name { + return false + } + client, err := pair.ThisDevice(pair.OpenKeeper()) + if err != nil { + return false + } + tunnel, err := (pair.Reach{Name: name, Device: client, Machines: book, Label: pair.ThisMachineLabel()}).Open(ctx) + if err != nil { + return false + } + _ = tunnel.Close() + return true +} diff --git a/cmd/codeaf/devbox_remove_test.go b/cmd/codeaf/devbox_remove_test.go new file mode 100644 index 0000000000..9bd3b10cca --- /dev/null +++ b/cmd/codeaf/devbox_remove_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devbox" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// R5.6: the production removal wiring revokes by saved id across name reuse and renames. +func TestDevboxR56UpDownNameReuseRenameAndMissingDevice(t *testing.T) { + for _, mode := range []string{"reuse", "renamed", "no-match"} { + t.Run(mode, func(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("CODEAF_HOME", stateHome) + dir := filepath.Join(t.TempDir(), "box") + memory := directory.NewMemory(time.Now) + key := bytes.Repeat([]byte{7}, 32) + kind := chatsKindOf(memory.For(thisComputer), key, thisComputer, "https://relay.example") + var out bytes.Buffer + var reachable atomic.Bool + joined := 0 + f := devbox.Flow{Home: stateHome, Out: &out, Getenv: func(string) string { return "fixture-token" }, LookPath: func(string) (string, error) { return "fake-terraform", nil }, Workspaces: func(context.Context, string) ([]devbox.Workspace, error) { + return []devbox.Workspace{{ID: "workspace"}}, nil + }, Settle: func(context.Context, string, string, string) error { return nil }, Probe: func(context.Context, string) bool { return reachable.Load() }, + Prepare: func(_ context.Context, name string) (devbox.Offer, error) { + err := pair.MachineBook().Remember(pair.Known{Name: "box-remote", Alias: name, Key: "test-key", Service: "https://relay.example"}) + return devbox.Offer{Via: "https://relay.example", RemoteKey: "fixture-key", RemoteDevices: "fixture-devices"}, err + }, + Devices: func(context.Context) ([]devbox.Device, error) { + rows, err := kind.rows() + var devices []devbox.Device + for _, r := range rows { + devices = append(devices, devbox.Device{ID: r.id, Name: r.Name, Removed: r.Stopped}) + } + return devices, err + }, + Remove: devboxRemoval(func() (chatsKind, bool) { return kind, true }, pair.MachineBook()), + } + f.Pair = func(ctx context.Context, name string, ready func(devbox.Offer)) error { + joined++ + id := fmt.Sprintf("dev_%032d", joined) + sealed, err := directory.SealName(kind.key, name) + if err != nil { + return err + } + if err := memory.For(id).PutDevice(ctx, id, directory.Device{V: 1, Name: sealed}); err != nil { + return err + } + ready(devbox.Offer{Code: "42-123-456", Via: "https://relay.example"}) + <-ctx.Done() + return ctx.Err() + } + f.RunTerraform = func(_ context.Context, _, folder string, args, env []string, stdout, stderr io.Writer) error { + switch args[0] { + case "output": + _, err := io.WriteString(stdout, `{"service_id":{"value":"service"},"environment_id":{"value":"environment"}}`) + return err + case "show": + _, err := io.WriteString(stdout, `{"resource_changes":[]}`) + return err + case "apply": + if strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + reachable.Store(true) + } + return os.WriteFile(filepath.Join(folder, "terraform.tfstate"), []byte(`{"resources":[{"type":"railway_project","instances":[{}]}]}`), 0o600) + case "destroy": + reachable.Store(false) + return os.WriteFile(filepath.Join(folder, "terraform.tfstate"), []byte(`{"resources":[]}`), 0o600) + } + return nil + } + rounds := 1 + if mode == "reuse" { + rounds = 2 + } + for n := 1; n <= rounds; n++ { + opts := devbox.Options{Name: "box", Dir: dir, Yes: true} + if err := f.Run(t.Context(), "up", opts); err != nil { + t.Fatal(err) + } + expected := fmt.Sprintf("dev_%032d", n) + raw, err := os.ReadFile(filepath.Join(dir, "codeaf.devbox.json")) + if err != nil || !strings.Contains(string(raw), expected) { + t.Fatalf("R5.6 missing joined id: %s %v", raw, err) + } + info, err := os.Stat(filepath.Join(dir, "codeaf.devbox.json")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatal("R5.6 receipt is not 0600") + } + if mode == "renamed" { + sealed, err := directory.SealName(kind.key, "new-box-name") + if err != nil { + t.Fatal(err) + } + if err := memory.For(expected).PutDevice(t.Context(), expected, directory.Device{V: 1, Name: sealed}); err != nil { + t.Fatal(err) + } + } + // The second removal also proves the active-name fallback ignores old revoked rows. + if mode == "reuse" && n == 2 { + if err := os.Remove(filepath.Join(dir, "codeaf.devbox.json")); err != nil { + t.Fatal(err) + } + } + if mode == "no-match" { + if err := kind.dir.Revoke(t.Context(), expected); err != nil { + t.Fatal(err) + } + } + if err := f.Run(t.Context(), "down", opts); err != nil { + t.Fatalf("R5.6 down %d: %v", n, err) + } + listing, err := kind.dir.List(t.Context()) + if err != nil || !listing.Devices[expected].Revoked { + t.Fatalf("R5.6 did not revoke %s: %v", expected, err) + } + if _, found, err := pair.MachineBook().Machine("box"); err != nil || found { + t.Fatal("R5.6 alias retained") + } + } + if mode == "no-match" && !strings.Contains(out.String(), "No active device matches box; no device was removed.") { + t.Fatalf("R5.6 no-match hidden: %s", &out) + } + }) + } +} diff --git a/cmd/codeaf/devbox_test.go b/cmd/codeaf/devbox_test.go new file mode 100644 index 0000000000..1bc7a30416 --- /dev/null +++ b/cmd/codeaf/devbox_test.go @@ -0,0 +1,410 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devbox" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/pairbox" +) + +// R6.1: only a release on the chosen stable, staging or dev channel pins the first install. +func TestDevboxR61FirstInstallVersionMatchesChannel(t *testing.T) { + for _, row := range []struct{ name, revision, channel, want string }{ + {"stable", "v1.2.3", "stable", "v1.2.3"}, + {"staging", "staging-20261030-aaaaaaaaaaaa", "staging", "staging-20261030-aaaaaaaaaaaa"}, + {"dev", "dev-20261030-aaaaaaaaaaaa", "dev", "dev-20261030-aaaaaaaaaaaa"}, + {"stable-to-staging", "v1.2.3", "staging", ""}, + {"stable-to-dev", "v1.2.3", "dev", ""}, + {"staging-to-stable", "staging-20261030-aaaaaaaaaaaa", "stable", ""}, + {"staging-to-dev", "staging-20261030-aaaaaaaaaaaa", "dev", ""}, + {"dev-to-stable", "dev-20261030-aaaaaaaaaaaa", "stable", ""}, + {"dev-to-staging", "dev-20261030-aaaaaaaaaaaa", "staging", ""}, + {"release-candidate", "v1.2.3-rc.1", "stable", ""}, + {"rc-is-not-offered", "v1.2.3-rc.1", "rc", ""}, + {"source-commit", "abcdef12", "dev", ""}, + {"source-unstamped", "", "dev", ""}, + {"source-word", "dev", "dev", ""}, + {"other", "custom-build", "other", ""}, + } { + t.Run(row.name, func(t *testing.T) { + if got := devboxVersion(row.revision, row.channel); got != row.want { + t.Fatalf("R6.1 version = %q, want %q", got, row.want) + } + }) + } +} + +// Contracts 1 and 7: each command exposes its actual flags without touching a provider. +func TestDevboxHelpAndChannelDefaults(t *testing.T) { + for _, action := range []string{"up", "status", "down"} { + t.Run(action, func(t *testing.T) { + out, errs := captureUsage(t) + err := devboxCommand(context.Background(), []string{action, "--help"}, devbox.Flow{}) + if exitCodeOf(err) != 0 || errs.Len() != 0 { + t.Fatalf("help: %v %s", err, errs) + } + for _, flag := range []string{"codeaf devbox " + action, "--dir", "--yes"} { + if !strings.Contains(out.String(), flag) { + t.Fatalf("missing %s: %s", flag, out) + } + } + if (action == "up") != strings.Contains(out.String(), "--channel") { + t.Fatalf("wrong flags: %s", out) + } + }) + } + for _, row := range []struct{ revision, want string }{{"source", "dev"}, {"dev-20261001-aaaaaaaaaaaa", "dev"}, {"staging-20261001-aaaaaaaaaaaa", "staging"}, {"v1.2.3", "stable"}, {"v1.2.3-rc.1", "stable"}} { + if got := devboxChannel(row.revision); got != row.want { + t.Errorf("%s: %s != %s", row.revision, got, row.want) + } + } +} + +// Contract 3: only the requested name may receive the unattended identity offer. +func TestDevboxOfferDoesNotPrintItsCodeOrAdmitAnotherName(t *testing.T) { + var code, via string + cancelled := false + ui := devboxOfferUI{name: "build-box", offer: devbox.Offer{Via: "https://relay.example"}, ready: func(o devbox.Offer) { code, via = o.Code, o.Via }, cancel: func() { cancelled = true }} + fresh, err := pair.NewCode(time.Now()) + if err != nil { + t.Fatal(err) + } + ui.Show(fresh.WithPlate("42"), "") + if code != "42-"+strings.ReplaceAll(fresh.Shown(), " ", "-") || via != "https://relay.example" { + t.Fatalf("ready %q %q", code, via) + } + if !ui.Ask(context.Background(), "build-box", "") || ui.Ask(context.Background(), "stranger", "") { + t.Fatal("wrong admission") + } + ui.Burned() + if !cancelled { + t.Fatal("burned code was left live") + } +} + +// Contract 6: an idle serving computer refreshes its own directory record until stopped. +func TestDevboxServeRefreshesItsOwnRecord(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ticks := make(chan time.Time) + calls := make(chan struct{}, 4) + done := make(chan struct{}) + go func() { + defer close(done) + refreshServeRecord(ctx, ticks, func(context.Context) error { calls <- struct{}{}; return nil }) + }() + await := func() { + t.Helper() + select { + case <-calls: + case <-time.After(time.Second): + t.Fatal("no refresh") + } + } + await() + ticks <- time.Now() + await() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("refresh survived cancellation") + } + if len(calls) != 0 { + t.Fatal("refresh after stop") + } +} + +// Contract 1: the command prints exactly one prerequisite sentence and exits nonzero. +func TestDevboxPrerequisiteSentenceIsPrintedOnce(t *testing.T) { + for _, action := range []string{"up", "down"} { + out, errs := captureUsage(t) + f := devbox.Flow{Getenv: func(string) string { return "" }, LookPath: func(string) (string, error) { t.Fatal("terraform before token"); return "", nil }} + err := devboxCommand(context.Background(), []string{action, "box"}, f) + if exitCodeOf(err) == 0 || out.Len() != 0 || errs.String() != devbox.TokenNeeded+"\n" { + t.Fatalf("exit %v, stdout %q, stderr %q", err, out.String(), errs.String()) + } + } +} + +// R5.2: serving shares one gated follower across refreshes and closes it synchronously. +func TestDevboxR52ServePresenceWatchLifetimeAndBoundedRefresh(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ticks := make(chan time.Time, 32) + refreshes := make(chan struct{}, 32) + stream := &devboxPresenceStream{closed: make(chan struct{}), readerDone: make(chan struct{}), started: make(chan struct{})} + var dials atomic.Int32 + dial := func(context.Context) (dirwatch.Stream, error) { dials.Add(1); return stream, nil } + done := make(chan struct{}) + go func() { + defer close(done) + servePresence(ctx, dirwatch.Follow(t.Name(), dial), ticks, func(context.Context) error { refreshes <- struct{}{}; return nil }) + }() + await := func(ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(time.Second): + t.Fatal("presence did not advance") + } + } + await(refreshes) + await(stream.started) + // A second follower must share this process's socket, including while records refresh. + other := dirwatch.Follow(t.Name(), dial) + defer other.Close() + for range 20 { + ticks <- time.Now() + await(refreshes) + } + select { + case <-refreshes: + t.Fatal("refresh spins without a tick") + default: + } + cancel() + await(done) + select { + case <-stream.closed: + t.Fatal("closed a shared watch") + default: + } + other.Close() + await(stream.closed) + await(stream.readerDone) + if dials.Load() != 1 { + t.Fatalf("watch opened %d times", dials.Load()) + } + + // Buffered ticks cannot create workers while the current refresh is blocked. + blockedCtx, stop := context.WithCancel(context.Background()) + defer stop() + var calls atomic.Int32 + entered := make(chan struct{}) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + refreshServeRecord(blockedCtx, ticks, func(ctx context.Context) error { + if calls.Add(1) == 1 { + close(entered) + } + <-ctx.Done() + return ctx.Err() + }) + }() + await(entered) + for range 20 { + ticks <- time.Now() + } + stop() + await(stopped) + if calls.Load() != 1 { + t.Fatalf("refresh workers survived cancellation: %d", calls.Load()) + } + // A closed ticker also ends the loop instead of spinning. + for len(ticks) > 0 { + <-ticks + } + close(ticks) + refreshServeRecord(context.Background(), ticks, func(context.Context) error { + if calls.Add(1) > 3 { + t.Fatal("closed ticker spins") + } + return nil + }) +} + +type devboxPresenceStream struct{ closed, readerDone, started chan struct{} } + +func (s *devboxPresenceStream) Next(ctx context.Context) (dirwatch.Frame, error) { + defer close(s.readerDone) + close(s.started) + <-ctx.Done() + return dirwatch.Frame{}, ctx.Err() +} +func (*devboxPresenceStream) Ping(context.Context) error { return nil } +func (*devboxPresenceStream) Vouching() bool { return false } +func (s *devboxPresenceStream) Close() { close(s.closed) } + +// R5.3: remote configuration and its alias are prepared before offering an unchanged identity grant. +func TestDevboxR53OfferKeepsRemoteConfigurationOutsideIdentityGrant(t *testing.T) { + for _, mode := range []string{"joined", "other-name", "cancelled"} { + t.Run(mode, func(t *testing.T) { + laptopHome := t.TempDir() + t.Setenv("CODEAF_HOME", laptopHome) + id, err := identity.Ensure(laptopHome) + if err != nil { + t.Fatal(err) + } + route := pair.Mailbox{URL: "https://relay.example", Host: "relay.example", Box: pairbox.NewMemory(pairbox.DefaultLimits, nil)} + door := pairDoor{term: newTerminal(strings.NewReader(""), io.Discard), mailbox: func(string) (pair.Mailbox, error) { return route, nil }, grant: func(pair.Mailbox) (pair.Grant, error) { return pair.Grant{Identity: id, SyncURL: route.URL}, nil }} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + remote, err := prepareDevbox(door, "build-box") + if err != nil { + t.Fatal(err) + } + ready := make(chan devbox.Offer, 1) + done := make(chan error, 1) + go func() { done <- offerDevbox(ctx, door, "build-box", func(o devbox.Offer) { ready <- o }) }() + var offer devbox.Offer + select { + case offer = <-ready: + case err := <-done: + t.Fatalf("offer ended: %v", err) + case <-ctx.Done(): + t.Fatal("no offer") + } + if offer.Code == "" || offer.Via != route.URL { + t.Fatal("missing identity offer") + } + for _, blob := range []string{remote.RemoteKey, remote.RemoteDevices} { + if raw, err := base64.StdEncoding.DecodeString(blob); err != nil || len(raw) == 0 { + t.Fatal("missing remote configuration") + } + } + if _, found, err := pair.MachineBook().Machine("build-box"); err != nil || !found { + t.Fatal("R5.3 alias not pinned before join") + } + if mode == "cancelled" { + cancel() + } else { + name := "build-box" + if mode == "other-name" { + name = "other" + } + boxHome := t.TempDir() + _, err := pair.Join(ctx, route, pair.Joining{Home: boxHome, Label: name}, offer.Code, devboxJoinScreen{}) + if (err == nil) != (mode == "joined") { + t.Fatalf("join: %v", err) + } + if _, err := os.Stat(filepath.Join(boxHome, "v3", "remote", "device.key")); !os.IsNotExist(err) { + t.Fatal("identity join installed remote access") + } + } + select { + case err := <-done: + if (err == nil) != (mode == "joined") { + t.Fatalf("offer: %v", err) + } + case <-time.After(time.Second): + t.Fatal("offer did not finish") + } + known, found, err := pair.MachineBook().Machine("build-box") + if err != nil || !found { + t.Fatalf("alias after offer: %v %v", found, err) + } + if found && (known.Alias != "build-box" || known.Name == known.Alias || known.Service != route.URL) { + t.Fatal("alias does not pin the box") + } + }) + } +} + +type devboxJoinScreen struct{} + +func (devboxJoinScreen) Waiting(string) {} + +// R5.8: compact root usage and full command usage expose their applicable flags. +func TestDevboxR58UsageShowsYesAndDir(t *testing.T) { + for _, args := range [][]string{nil, {"--help"}} { + out, errs := captureUsage(t) + var err error + if args == nil { + err = usage(nil) + } else { + err = devboxCommand(context.Background(), args, devbox.Flow{}) + } + if err != nil && exitCodeOf(err) != 0 || errs.Len() != 0 { + t.Fatalf("help: %v %s", err, errs) + } + var rows []string + for _, row := range strings.Split(out.String(), "\n") { + if strings.Contains(row, "codeaf devbox") { + rows = append(rows, row) + } + } + if args == nil { + if len(rows) != 1 || !strings.Contains(rows[0], "up | down · status []") || !strings.Contains(rows[0], "--yes") || !strings.Contains(rows[0], "--dir") { + t.Fatalf("R5.8 root usage: %s", out) + } + continue + } + for _, flag := range []string{"--region", "--workspace", "--channel"} { + if !strings.Contains(out.String(), flag) { + t.Errorf("R5.8 full devbox help omits %s", flag) + } + } + for _, action := range []string{"up", "status", "down"} { + var shape string + for _, row := range rows { + if strings.Contains(row, "codeaf devbox "+action) { + shape = row + } + } + if !strings.Contains(shape, "--dir") || action != "status" && !strings.Contains(shape, "--yes") { + t.Errorf("devbox %s usage omits its flags: %s", action, out) + } + } + } +} + +// R3.3: expired or vanished identity joins map to the retry sentinel without saving an alias. +func TestDevboxR33OfferMapsInterruptedJoin(t *testing.T) { + for _, cause := range []error{pairbox.ErrGone, context.DeadlineExceeded, pairbox.ErrForbidden} { + t.Run(cause.Error(), func(t *testing.T) { + laptopHome := t.TempDir() + t.Setenv("CODEAF_HOME", laptopHome) + id, err := identity.Ensure(laptopHome) + if err != nil { + t.Fatal(err) + } + box := devboxInterruptedMailbox{Box: pairbox.NewMemory(pairbox.DefaultLimits, nil), err: cause} + route := pair.Mailbox{URL: "https://relay.example", Host: "relay.example", Box: box} + door := pairDoor{term: newTerminal(strings.NewReader(""), io.Discard), mailbox: func(string) (pair.Mailbox, error) { return route, nil }, grant: func(pair.Mailbox) (pair.Grant, error) { return pair.Grant{Identity: id, SyncURL: route.URL}, nil }} + err = offerDevbox(context.Background(), door, "build-box", func(devbox.Offer) {}) + if errors.Is(err, devbox.ErrJoinInterrupted) != (cause != pairbox.ErrForbidden) { + t.Fatalf("mapped result: %v", err) + } + if _, found, err := pair.MachineBook().Machine("build-box"); err != nil || found { + t.Fatal("failed offer remembered alias") + } + }) + } +} + +type devboxInterruptedMailbox struct { + pairbox.Box + err error +} + +func (b devboxInterruptedMailbox) Poll(context.Context, string, pairbox.Side, int, time.Duration) (pairbox.Batch, error) { + return pairbox.Batch{}, b.err +} + +// R5.8: root help preserves section separators and the two-line command shape within existing caps. +func TestDevboxR58RootHelpKeepsSeparatorsAndDescription(t *testing.T) { + out, _ := captureUsage(t) + if err := usage(nil); err != nil { + t.Fatal(err) + } + text := out.String() + for _, want := range []string{"\n\nLook at what happened", "\n\nExamples:", "\n codeaf devbox up | down · status [] [--yes] [--dir D]\n an always-on computer of yours on your own Railway account\n"} { + if !strings.Contains(text, want) { + t.Errorf("R5.8 help lacks %q", want) + } + } +} diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go index ca96b4bcdb..c49878e0da 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -10,27 +10,30 @@ package main // was. Each kind is a [deviceKind]; the door only knows the interface, so a // third kind is one more type and not one more branch. // -// THE EMPTINESS LAW: a kind with nothing in it has no heading, and a machine -// with nothing of either kind says one sentence ([pair.NoDevices]). +// A kind with no remote devices has no heading. Before pairing, the local +// computer's own row is followed by the pairing next step. import ( "cmp" "context" "errors" "fmt" - "net" "net/url" + "os" + "runtime" "slices" "strings" - "syscall" "time" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/Agent-Field/codeaf/internal/wireauth" ) @@ -60,8 +63,19 @@ type allStopper interface { // person's, because the person owns both. func runDevices(args []string) error { kinds := openDeviceKinds() + if len(args) == 0 { + defer terminalSyncProgress(syncsetup.Resolve(home.Dir()).URL)() + if r := syncsetup.Resolve(home.Dir()); r.On() { + fmt.Println(syncwords.ServiceLine(r.URL)) + } + } switch { case len(args) > 0 && args[0] == "revoke": + if !askedForHelp(args[1:]) { + if err := checkRevokeRosters(kinds); err != nil { + return err + } + } return stopDevice(kinds, args[1:]) case len(args) > 0 && args[0] == "rename": return runDevicesRename(args[1:]) @@ -71,11 +85,12 @@ func runDevices(args []string) error { return fmt.Errorf("usage: codeaf devices [revoke [--all] | rename ]") } if !cell.Available() { - device, err := pair.ThisDevice(pair.OpenKeeper()) + paired, err := pair.DeviceBook().Devices() if err != nil { return err } - paired, err := pair.DeviceBook().Devices() + fmt.Print(localDeviceTable(time.Now(), false)) + device, err := pair.ThisDevice(pair.OpenKeeper()) if err != nil { return err } @@ -87,7 +102,7 @@ func runDevices(args []string) error { fmt.Print(pair.MachinesList(known, time.Now())) return nil } - return listDevices(kinds, time.Now()) + return listDevices(kinds, time.Now(), true) } // revokeDevice stops one remote-access device, or every one answering to one @@ -109,10 +124,59 @@ func openDeviceKinds() []deviceKind { } // listDevices prints every kind's table, or the one sentence when none has rows. -func listDevices(kinds []deviceKind, now time.Time) error { +// The public command opts into local facts without changing roster-only callers. +func listDevices(kinds []deviceKind, now time.Time, includeLocal ...bool) error { + // Read each roster once before rendering so duplicate names across the two + // permissions get selectors too, without adding another service request. + chats := map[int][]chatsRow{} + books := map[int][]pair.Paired{} + counts := map[string]int{} + for i, kind := range kinds { + switch c := kind.(type) { + case chatsKind: + rows, err := c.rows() + if err != nil { + return err + } + chats[i] = rows + for _, row := range rows { + counts[strings.ToLower(row.baseName)]++ + } + case bookKind: + rows, err := c.book.Devices() + if err != nil { + return err + } + books[i] = rows + for _, row := range rows { + counts[strings.ToLower(row.Label)]++ + } + } + } + duplicates := map[string]bool{} + for name, count := range counts { + duplicates[name] = count > 1 + } var tables []string - for _, kind := range kinds { - table, err := kind.table(now) + if len(includeLocal) > 0 && includeLocal[0] && len(chats) == 0 { + tables = append(tables, localDeviceTable(now, true)) + } + for i, kind := range kinds { + var table string + var err error + switch c := kind.(type) { + case chatsKind: + table = chatsRowsTable(chats[i], now, duplicates) + case bookKind: + var known []pair.Known + known, err = pair.Machines() + if err == nil { + table, err = c.letIn(books[i], now, duplicates) + table = strings.TrimLeft(table+pair.MachinesList(known, now), "\n") + } + default: + table, err = kind.table(now) + } if err != nil { return err } @@ -140,7 +204,7 @@ func listDevices(kinds []deviceKind, now time.Time) error { // `codeaf devices revoke --help`, and named in the one usage table. func stopDevice(kinds []deviceKind, args []string) error { flags := commandFlags("devices revoke") - all := flags.Bool("all", false, "remove every device of that name that can use this machine, not just the one") + all := flags.Bool("all", false, "remove every device of that name, computers that sync your chats included, not just the one") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err } @@ -148,16 +212,16 @@ func stopDevice(kinds []deviceKind, args []string) error { return errors.New("usage: codeaf devices revoke [--all] — `codeaf devices` lists the names") } name := flags.Arg(0) + // A partial --all still says what it did stop before what it could not. said, err := stopByName(kinds, name, *all) - if err != nil { - return err + if said != "" { + fmt.Println(said) } - fmt.Println(said) - return nil + return err } // stopByName finds the one kind that has the name and stops it there. With -// --all the name is taken to mean the kind that can stop several at once. +// --all it stops every device of that name, in every kind that has one. func stopByName(kinds []deviceKind, name string, all bool) (string, error) { if all { return stopAllByName(kinds, name) @@ -169,13 +233,32 @@ func stopByName(kinds []deviceKind, name string, all bool) (string, error) { return kind.stop(name) } +// stopAllByName stops every device called name. A kind that can stop several +// stops them all; any other kind stops its one, so --all never misses a device +// that `codeaf devices` lists (#2072). func stopAllByName(kinds []deviceKind, name string) (string, error) { + var said []string + var failed error for _, kind := range kinds { + n, err := kind.find(name) + failed = cmp.Or(failed, err) + if n == 0 { + continue + } + stop := kind.stop if many, ok := kind.(allStopper); ok { - return many.stopAll(name) + stop = many.stopAll } + line, err := stop(name) + if err != nil { + return strings.Join(said, "\n"), err + } + said = append(said, line) + } + if len(said) == 0 { + return "", cmp.Or(failed, unknownDevice(name)) } - return "", unknownDevice(name) + return strings.Join(said, "\n"), nil } // kindOf is the one kind that has a device called name. A name in two kinds is @@ -186,15 +269,43 @@ func kindOf(kinds []deviceKind, name string) (deviceKind, error) { var failed error for _, kind := range kinds { n, err := kind.find(name) - failed = cmp.Or(failed, err) + if err != nil { + line := "could not read the device list; nothing was removed — try again" + switch kind.(type) { + case chatsKind: + line = chatsRevokeReadFailure + case bookKind: + line = accessRevokeReadFailure + } + failed = cmp.Or(failed, error(toldLine{line: line, err: err})) + } if n > 0 { hits = append(hits, kind) } } + // Both lists must be known before a selector can authorize a removal. + if failed != nil { + return nil, failed + } switch { case len(hits) == 1: return hits[0], nil case len(hits) > 1: + var choices []string + for _, kind := range hits { + if selector, ok := kind.(interface { + selectors(string) ([]string, error) + }); ok { + picked, err := selector.selectors(name) + if err != nil { + return nil, err + } + choices = append(choices, picked...) + } + } + if len(choices) > 0 { + return nil, devname.Ambiguous(name, choices) + } return nil, fmt.Errorf("more than one device is called %q, so the name cannot pick one — `codeaf devices` lists them", name) case failed != nil: return nil, failed @@ -228,7 +339,7 @@ func (b bookKind) table(now time.Time) (string, error) { // letIn is the table of devices let in, or "" when there are none. The machine's // own name is only worth saying when there is a device it matters to. -func (bookKind) letIn(paired []pair.Paired, now time.Time) (string, error) { +func (bookKind) letIn(paired []pair.Paired, now time.Time, duplicates ...map[string]bool) (string, error) { if len(paired) == 0 { return "", nil } @@ -236,12 +347,18 @@ func (bookKind) letIn(paired []pair.Paired, now time.Time) (string, error) { if err != nil { return "", err } - return pair.DevicesList(device.Name(), paired, pair.OpenKeeper(), now), nil + return pair.DevicesList(device.Name(), paired, pair.OpenKeeper(), now, duplicates...), nil } func (b bookKind) find(name string) (int, error) { paired, err := b.book.Devices() - return countCalled(paired, name, func(p pair.Paired) string { return p.Label }), err + count := 0 + for _, p := range paired { + if devname.MatchesDevice(name, p.Label, p.ID()) { + count++ + } + } + return count, err } func (b bookKind) stop(name string) (string, error) { @@ -310,7 +427,8 @@ func chatsKindOf(dir directory.Client, cellKey []byte, self, relay string) chats // it by. type chatsRow struct { pair.ChatsDevice - id string + id string + baseName string } // listRequestWithin bounds one request to the relay so a silent one cannot hang @@ -324,14 +442,23 @@ func (c chatsKind) rows() ([]chatsRow, error) { if err != nil { return nil, c.explain(err) } + presence, _ := c.dir.Presence(ctx) open := func(sealed string) (string, error) { return directory.OpenName(c.key, sealed) } rows := make([]chatsRow, 0, len(l.Devices)) for id, d := range l.Devices { + name := chatlist.DeviceName(l.Devices, id, open) rows = append(rows, chatsRow{ - ChatsDevice: pair.ChatsDevice{Name: chatlist.DeviceName(l.Devices, id, open), This: id == c.self, Stopped: d.Revoked}, + ChatsDevice: pair.ChatsDevice{Name: name, This: id == c.self, Stopped: d.Revoked, Platform: d.Platform}, id: id, + baseName: name, }) } + for i := range rows { + rows[i].Online = presence.Devices[rows[i].id].Online + if seen := l.Devices[rows[i].id].LastSeen; seen > 0 { + rows[i].LastSeen = time.UnixMilli(seen) + } + } // This computer first, removed ones last, then by name: the order the chat's list uses. slices.SortFunc(rows, func(a, b chatsRow) int { return cmp.Or(cmp.Compare(rank(a.Stopped), rank(b.Stopped)), -cmp.Compare(rank(a.This), rank(b.This)), cmp.Compare(a.Name, b.Name), cmp.Compare(a.id, b.id)) @@ -347,25 +474,60 @@ func rank(b bool) int { return 0 } -func (c chatsKind) table(time.Time) (string, error) { +func (c chatsKind) table(now time.Time) (string, error) { rows, err := c.rows() if err != nil { return "", err } - // A list of only this computer says nothing a person did not know. - if len(rows) == 0 || (len(rows) == 1 && rows[0].This) { - return "", nil + return chatsRowsTable(rows, now, nil), nil +} + +// chatsRowsTable renders the same snapshot used to decide duplicate selectors. +func chatsRowsTable(rows []chatsRow, now time.Time, duplicates map[string]bool) string { + if len(rows) == 0 { + return "" + } + // Selectors belong to presentation. Roster consumers need the original + // name even when a removed computer once used the same name. + if duplicates == nil { + duplicates = chatNameDuplicates(rows) } shown := make([]pair.ChatsDevice, len(rows)) for i, row := range rows { shown[i] = row.ChatsDevice + shown[i].Name = devname.DeviceLabel(row.baseName, row.id, duplicates[strings.ToLower(row.baseName)]) + } + slices.SortStableFunc(shown, func(a, b pair.ChatsDevice) int { + return cmp.Or(cmp.Compare(rank(a.Stopped), rank(b.Stopped)), -cmp.Compare(rank(a.This), rank(b.This)), cmp.Compare(a.Name, b.Name)) + }) + text := pair.ChatsDevicesListAt(shown, now) + if stdinIsTerminal(os.Stdout) { + text = strings.Replace(text, "\nremoved\n", "\n\x1b[2mremoved\x1b[0m\n", 1) } - return pair.ChatsDevicesList(shown), nil + return text } func (c chatsKind) called(name string) ([]chatsRow, error) { rows, err := c.rows() - return slices.DeleteFunc(rows, func(r chatsRow) bool { return !strings.EqualFold(r.Name, name) }), err + duplicates := chatNameDuplicates(rows) + for i := range rows { + rows[i].Name = devname.DeviceLabel(rows[i].baseName, rows[i].id, duplicates[strings.ToLower(rows[i].baseName)]) + } + return slices.DeleteFunc(rows, func(r chatsRow) bool { return !devname.MatchesDevice(name, r.baseName, r.id) }), err +} + +// chatNameDuplicates keeps list labels and removal receipts on the same selectors. +func chatNameDuplicates(rows []chatsRow) map[string]bool { + duplicates := map[string]bool{} + for i, row := range rows { + for _, other := range rows[i+1:] { + if strings.EqualFold(row.baseName, other.baseName) { + duplicates[strings.ToLower(row.baseName)] = true + duplicates[strings.ToLower(other.baseName)] = true + } + } + } + return duplicates } func (c chatsKind) find(name string) (int, error) { @@ -379,7 +541,7 @@ func (c chatsKind) stop(name string) (string, error) { case err != nil: return "", err case len(rows) > 1: - return "", fmt.Errorf("more than one of your devices is called %q, so the name cannot pick one — `codeaf devices` lists them", name) + return "", ambiguousChatDevices(name, rows) case len(rows) == 0: return "", unknownDevice(name) case rows[0].Stopped: @@ -412,38 +574,83 @@ func (c chatsKind) explain(err error) error { case errors.Is(err, directory.ErrNotFound): return errors.New(chatlist.DeviceMissing) } - return err + return explainSyncUnreachable(err, c.relay) } // explainSyncUnreachable keeps network failures in the same words at every // command door while preserving the cause for exit handling. func explainSyncUnreachable(err error, relay string) error { - if errors.Is(err, directory.ErrRevoked) { - return err - } + // A failed request knows the service it used, even if the configured + // default changed. Let syncwords read that origin before filling a default. var request *url.Error - var network net.Error - errors.As(err, &request) - errors.As(err, &network) - if relay == "" && request != nil { - if address, parseErr := url.Parse(request.URL); parseErr == nil { - relay = address.Scheme + "://" + address.Host - } - } - if errors.Is(err, context.DeadlineExceeded) || network != nil && network.Timeout() { - return pair.ServiceTimeout(err, relay) + if relay == "" && !errors.As(err, &request) { + relay = syncsetup.Resolve(home.Dir()).URL } - var dns *net.DNSError - if errors.Is(err, directory.ErrUnreachable) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH) || errors.As(err, &dns) { - return toldLine{line: fmt.Sprintf("the sync service at %s cannot be reached from here — check this computer's network", relay), err: err} - } - return err + return syncwords.Explain(err, relay) } // explainRemovedDevice gives every command the same removal and recovery words. func explainRemovedDevice(err error) error { if errors.Is(err, directory.ErrRevoked) { - return toldLine{line: "this device was removed by another of your devices, so it can no longer reach your chats — run `codeaf pair` to bring it back", err: err} + return toldLine{line: chatlist.Removed, err: err} } return err } + +// ambiguousChatDevices lists usable selectors, so the next command can choose one. +func ambiguousChatDevices(name string, rows []chatsRow) error { + choices := make([]string, len(rows)) + for i, row := range rows { + choices[i] = devname.DeviceLabel(row.baseName, row.id, true) + } + return devname.Ambiguous(name, choices) +} + +func (c chatsKind) selectors(name string) ([]string, error) { + rows, err := c.called(name) + choices := make([]string, len(rows)) + for i, row := range rows { + choices[i] = devname.DeviceLabel(row.baseName, row.id, true) + } + return choices, err +} + +func (b bookKind) selectors(name string) ([]string, error) { + rows, err := b.book.Devices() + var choices []string + for _, row := range rows { + if devname.MatchesDevice(name, row.Label, row.ID()) { + choices = append(choices, devname.DeviceLabel(row.Label, row.ID(), true)) + } + } + return choices, err +} + +// localDeviceTable uses only this computer's facts before a shared roster exists. +func localDeviceTable(now time.Time, canPair bool) string { + return pair.ChatsDevicesListAt([]pair.ChatsDevice{{Name: devname.Name(home.Dir()), This: true, Platform: runtime.GOOS}}, now, canPair) +} + +const chatsRevokeReadFailure = "could not read the devices that sync your chats; nothing was removed — try again" +const accessRevokeReadFailure = "could not read the devices that can use this machine; nothing was removed — try again" + +// checkRevokeRosters distinguishes a home with no sync roster from one whose +// roster setup failed. Quiet display fallbacks cannot authorize a removal. +func checkRevokeRosters(kinds []deviceKind) error { + _, err := identity.Load(home.Dir()) + if errors.Is(err, identity.ErrNone) { + return nil + } + if err == nil && identity.Solo(home.Dir()) { + return nil + } + if err == nil { + for _, kind := range kinds { + if _, ok := kind.(chatsKind); ok { + return nil + } + } + err = errors.New("sync devices are unavailable") + } + return toldLine{line: chatsRevokeReadFailure, err: err} +} diff --git a/cmd/codeaf/devices_outage_test.go b/cmd/codeaf/devices_outage_test.go index 3f97efbcec..2ccbad43d0 100644 --- a/cmd/codeaf/devices_outage_test.go +++ b/cmd/codeaf/devices_outage_test.go @@ -8,6 +8,7 @@ import ( "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // Contract G3/J19 refuses an unavailable listing without claiming nothing is paired. @@ -19,10 +20,10 @@ func TestDevicesOutageDoesNotClaimNothingIsPaired(t *testing.T) { relay.Close() t.Setenv(syncsetup.URLVar, relay.URL) printed, err := captureStdout(t, func() error { return runDevices(nil) }) - if err == nil || !strings.Contains(err.Error(), "cannot be reached from here") { + if err == nil || !strings.Contains(err.Error(), syncwords.Unreachable(relay.URL)) { t.Errorf("listing must fail with the network sentence: output=%q error=%v", printed, err) } - if strings.TrimSpace(printed) != "" || strings.Contains(printed, pair.NoDevices) { + if strings.TrimSpace(printed) != syncwords.ServiceLine(relay.URL) || strings.Contains(printed, pair.NoDevices) { t.Errorf("unavailable listing printed an empty-device claim: %q", printed) } } diff --git a/cmd/codeaf/devices_test.go b/cmd/codeaf/devices_test.go index ded1f0f2e3..b4e3f3d5e1 100644 --- a/cmd/codeaf/devices_test.go +++ b/cmd/codeaf/devices_test.go @@ -123,12 +123,35 @@ func TestDevicesRevokeNamesThatCannotPickOne(t *testing.T) { if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked { t.Fatalf("a refused name still stopped a computer: %+v", l.Devices) } - // --all is the remote-access kind's word, so the name means that kind. + // --all means every device of that name, whichever kind it is (#2072). if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"phone", "--all"}) }); err != nil { t.Fatal(err) } - if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked { - t.Fatal("--all stopped a computer that holds the chats") + if l, _ := dir.List(devicesCtx); !l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked { + t.Fatalf("--all did not stop the computer of that name: %+v", l.Devices) + } + if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 0 { + t.Fatalf("--all left the remote device of that name: %v", left) + } +} + +// TestDevicesRevokeAllFindsAComputerThatSyncsChats pins #2072: a computer that +// `codeaf devices` lists is removed by `revoke --all` as by +// `revoke `, though no device of that name can use this machine. +func TestDevicesRevokeAllFindsAComputerThatSyncsChats(t *testing.T) { + chats, dir := chatsWith(t, "laptop") + kinds := []deviceKind{chats, bookKind{bookWith(t, "phone")}} + if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"laptop", "--all"}) }); err != nil { + t.Fatalf("revoke laptop --all: %v", err) + } + if l, _ := dir.List(devicesCtx); !l.Devices[otherComputer].Revoked { + t.Fatal("--all missed the computer that syncs the chats") + } + if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 1 { + t.Fatalf("--all stopped a device of another name: %v", left) + } + if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"ghost", "--all"}) }); err == nil || !strings.Contains(err.Error(), "no device is called") { + t.Fatalf("revoke ghost --all: %v", err) } } @@ -173,9 +196,8 @@ func TestChatsTableListsThisComputerFirst(t *testing.T) { } } -// A list holding only this computer says nothing a person did not know, so it -// is not drawn and the one sentence takes its place. -func TestChatsTableOfOnlyThisComputerIsAbsent(t *testing.T) { +// M10a lists this computer even before pairing; its next step adds a peer. +func TestChatsTableOfOnlyThisComputerOffersPairing(t *testing.T) { cellKey := bytes.Repeat([]byte{7}, 32) dir := directory.NewMemory(time.Now) sealed, err := directory.SealName(directory.MetadataKey(cellKey), "desk") @@ -186,7 +208,7 @@ func TestChatsTableOfOnlyThisComputerIsAbsent(t *testing.T) { t.Fatal(err) } kind := chatsKindOf(dir.For(thisComputer), cellKey, thisComputer, "https://relay.example") - if table, err := kind.table(time.Now()); table != "" || err != nil { - t.Fatalf("a lone computer drew %q (%v), want nothing", table, err) + if table, err := kind.table(time.Now()); err != nil || !strings.Contains(table, "desk") || !strings.Contains(table, "this device") || !strings.Contains(table, pair.NextPairing) || strings.Contains(table, "remove one with") { + t.Fatalf("a lone computer drew %q (%v), want this device and the pairing next step", table, err) } } diff --git a/cmd/codeaf/displaced_watch_test.go b/cmd/codeaf/displaced_watch_test.go index 2864408446..c62a50d4a6 100644 --- a/cmd/codeaf/displaced_watch_test.go +++ b/cmd/codeaf/displaced_watch_test.go @@ -141,7 +141,7 @@ func takeLiveChatFromHome(t *testing.T, h *hostedMoveRig) (cell.Cell, error) { if _, err := io.WriteString(typed, " "); err != nil { return cell.Cell{}, err } - h.wait("running chat on Home", func() bool { return strings.Contains(screen.String(), "running on original computer") }) + h.wait("running chat on Home", func() bool { return strings.Contains(screen.String(), "held on original computer") }) if _, err := io.WriteString(typed, "\r"); err != nil { return cell.Cell{}, err } diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 1c20ab1712..35c5a8b7f1 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -24,21 +24,25 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/calllog" + "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/ctxbudget" "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/head" homepkg "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/lease" + "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/resident" "github.com/Agent-Field/codeaf/internal/revision" "github.com/Agent-Field/codeaf/internal/router" runengine "github.com/Agent-Field/codeaf/internal/run" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/store" + "github.com/Agent-Field/codeaf/internal/taskdoor" "github.com/Agent-Field/codeaf/internal/trace" ) @@ -327,6 +331,9 @@ func runDo(args []string) error { "(older engine only; the run engine refuses it)") keep := flags.Bool("keep", false, "keep the run's store instead of deleting it on the way out, "+ "and say where it is") + // This private flag is the task door's process boundary, hidden from help + // and the manual because only serve supplies an owned attempt directory. + runStore := flags.String(taskdoor.TaskStoreFlag, "", hiddenShorthand+taskdoor.TaskStoreFlag) workspace := flags.String("dir", "", "the directory to work in, edited in place (default: the current directory)") shorthandFlag(flags, "w", "dir") wall := wallFlag{wall: defaultDoWall} @@ -364,6 +371,9 @@ func runDo(args []string) error { if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err } + if err := waitTaskLaunch(*runStore); err != nil { + return refuseDoBeforeRun(*asJSON, "", err) + } noteRenamedFlags(flags) slots, err := parseSlots(*slotsRaw) if err != nil { @@ -400,7 +410,7 @@ func runDo(args []string) error { } return doErrand(doRequest{ effort: effort, pins: pins.pins, - task: next.brief, assignment: next.assignment, cellRoot: next.cellRoot, run: run, database: *database, keep: *keep, workspace: *workspace, + task: next.brief, assignment: next.assignment, cellRoot: next.cellRoot, run: run, database: *database, keep: *keep, workspace: *workspace, runStore: *runStore, timeout: wall.wall, asJSON: *asJSON, yesSpend: *yesSpend, model: *model, planModel: *planModel, checkModel: *checkModel, contextFill: *contextFill, completionReserve: *completionReserve, slots: slots, @@ -439,6 +449,7 @@ type doRequest struct { run string database string keep bool + runStore string workspace string timeout time.Duration asJSON bool @@ -769,6 +780,9 @@ func errandRun(request doRequest, seats config.Seats, started time.Time) (outcom if session.BashBeltAsked() { return runErrand(request, seats) } + if request.runStore != "" { + return headlessOutcome{}, errors.New("this computer's task belt cannot take tasks from another computer") + } path, home, ephemeral, err := headlessStore(request.database) if err != nil { return headlessOutcome{}, err @@ -3638,6 +3652,16 @@ func parseSlots(raw string) (*int, error) { // door was handed is the whole assignment, which is the same verbatim contract // the resident road keeps. func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, runErr error) { + if request.runStore != "" { + defer runengine.RecordTaskTrajectoryOrder()() + // The task door reads per-call ledger rows after this process exits; + // queued final charges must reach disk before its receipt is printed. + defer session.CloseUsage() + } + remote, rung, err := doTaskControls(request.runStore) + if err != nil { + return headlessOutcome{}, err + } // A FLAG THIS ROAD CANNOT HONOUR IS REFUSED IN WORDS, NEVER DROPPED. `--db` // names a store the older engine works in; a run keeps its plan in its own // private folder instead, and a run that quietly worked somewhere @@ -3658,6 +3682,9 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, if err != nil { return headlessOutcome{}, err } + if remote.Limits.USD > 0 && (bound.usd <= 0 || remote.Limits.USD < bound.usd) { + bound.usd, bound.stop, bound.words = remote.Limits.USD, stopBudget, "the task reached the spending limit set on the other computer" + } if bound.refused { // A CEILING OF NOTHING IS A RUN THAT MAY SPEND NOTHING. Refused here, // before anything is opened or built, because a limit of zero is not a @@ -3697,7 +3724,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // run's business ([session.RunTreeSnapshot]). before := session.SnapshotRunTree(workspace) title := topicTitle(request.headline()) - _, recordDir, _, err := headlessStore("") + recordDir, err := doRunStore(request.runStore) if err != nil { return headlessOutcome{}, err } @@ -3705,6 +3732,10 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // The record is private and unique per invocation. The existing headless // home owns both its location and the rule for when it survives a run. defer func() { + if request.runStore != "" { + outcome.recordKept = recordDir + return + } if !admitted { _ = os.RemoveAll(recordDir) return @@ -3735,6 +3766,22 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, if newClient == nil { newClient = newLiveClient } + if request.runStore != "" { + // Placement opts this child into chat's published capability rules. + // Only existing rows are read: another service's cache cannot answer + // for this model, and a task must not wait for catalog discovery. + build := newClient + newClient = func(settings config.Config, model string) (*liveClient, error) { + settings.DropRefusedEffort = true + service, _ := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL).For(model) + if strings.EqualFold(service.Source.ID, modelsource.DefaultID) { + settings.Models = sharedCatalog(settings).SnapshotNow() + } else { + settings.Models = catalog.Recall(config.CatalogOptionsFor(service, settings.ProfileDir)) + } + return build(settings, model) + } + } completerFor = crewCompleters(settings, newClient) } // EVERY SEAT CALL IS PRICED BEFORE IT IS MADE (internal/session's @@ -3747,7 +3794,11 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // THE REVIEW ROUND IS ON for every `do` run: a leaf that lands done is // checked against its acceptance, and a check that does not hold becomes a // fix task under the leaf's parent the run waits on. - limits := runengine.Limits{ReviewRound: true, CostUSD: bound.usd} + limits := runengine.Limits{ReviewRound: true, CostUSD: bound.usd, StepsPerTask: remote.MaxSteps} + workerOptions := inCell.workerOptions() + if rung != effort.None || remote.NoProgress > 0 { + workerOptions = append(workerOptions, runengine.WithTaskControls(rung, remote.NoProgress)) + } // AN INTERRUPT MUST LAND THE RUN, NOT VANISH IT, the same way it must on the // resident road: routed through the context, the supervisor stops launching, // drains what is in flight, and what it reached is composed and printed. @@ -3781,7 +3832,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, Work: seats.Work.Model, Plan: seats.Plan.Model, Check: seats.Check.Model, - }, memorySection, spendPreauthorized(request.yesSpend, env.Value), completerFor, inCell.workerOptions(), settings.Sources), + }, memorySection, spendPreauthorized(request.yesSpend, env.Value), completerFor, workerOptions, settings.Sources), }) errand := runErrandOutcome(summary, workspace, bound) // THE RUN'S OWN CLOCK, read off the context because the summary's word is diff --git a/cmd/codeaf/do_reasoning_test.go b/cmd/codeaf/do_reasoning_test.go new file mode 100644 index 0000000000..1f12e317e6 --- /dev/null +++ b/cmd/codeaf/do_reasoning_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/router" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// R9.2 exercises the real task child settings, worker and provider adapter. +// A fresh service-owned cache must suffice without another discovery request. +func TestATaskChildSendsNoEffortToAModelWithoutReasoning(t *testing.T) { + for _, direct := range []bool{false, true} { + t.Run(map[bool]string{false: "default service", true: "connected service"}[direct], func(t *testing.T) { + for _, present := range noReasoningDoRequests(t, true, direct) { + for _, key := range []string{"reasoning", "reasoning_effort", "thinking"} { + if raw, ok := present[key]; ok { + t.Errorf("task child sent unsupported %s: %s", key, raw) + } + } + } + }) + } +} + +// R9's stability rule leaves plain do's explicit effort on the wire, even for +// the same model and cached catalog used by the task-child regression above. +func TestPlainCodeafDoStillSendsItsEffort(t *testing.T) { + for _, present := range noReasoningDoRequests(t, false, false) { + var reasoning struct { + Effort string `json:"effort"` + } + if err := json.Unmarshal(present["reasoning"], &reasoning); err != nil || reasoning.Effort != "low" { + t.Errorf("plain do changed its low effort: %s (%v)", present["reasoning"], err) + } + } +} + +func noReasoningDoRequests(t *testing.T, taskChild, direct bool) []map[string]json.RawMessage { + t.Helper() + home := beltRunEnv(t) + t.Setenv("HOME", home) + model := "fixture/no-thinking" + var mu sync.Mutex + var calls []map[string]json.RawMessage + listings := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + mu.Lock() + listings++ + mu.Unlock() + w.Write([]byte(`{"data":[{"id":"fixture/no-thinking","name":"No thinking","context_length":32768,"supported_parameters":["tools","max_tokens"],"pricing":{"prompt":"0.000001","completion":"0.000001"}},{"id":"no-thinking","supported_parameters":["tools","max_tokens"]}]}`)) + case "/chat/completions": + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + mu.Lock() + calls = append(calls, body) + mu.Unlock() + // Stop after the first real worker call; tool execution and task outcome + // are independent of the request shape this endpoint is recording. + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":{"message":"fixture stops after recording the request","code":403}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + t.Setenv("CODEAF_BASE_URL", server.URL) + profile := config.ProfileDir() + options := catalog.Options{BaseURL: server.URL, APIKey: "test-key", Dir: profile} + if direct { + if err := config.WriteSources(profile, []config.PersistedSource{{ID: "custom-fixture", Written: "local", Key: "test-key", Address: server.URL}}); err != nil { + t.Fatal(err) + } + model = "local/no-thinking" + settings, err := config.Load() + if err != nil { + t.Fatal(err) + } + service, _ := settings.Sources.For(model) + options = config.CatalogOptionsFor(service, profile) + } + models := catalog.Load(context.Background(), options) + wireModel := model + if direct { + wireModel = "no-thinking" + } + if supported, known := models.SupportsParameter(wireModel, "reasoning"); supported || !known { + t.Fatal("fixture did not publish a non-reasoning model") + } + // An unrelated default cache deliberately disagrees about this bare id. + // The child must read the selected service's compartment, as chat does. + if direct { + if err := catalog.Remember(catalog.Options{BaseURL: server.URL, Dir: profile}, []catalog.Model{{ID: "no-thinking", Parameters: []string{"reasoning"}}}); err != nil { + t.Fatal(err) + } + } + // The provider panel owns a separate price cache. Prime it too, so any + // discovery during do itself is observable instead of hidden by the fixture. + router.LoadCatalog(profile, server.URL, "test-key", server.Client()) + before := listings + oldCatalog := sharedCatalog + sharedCatalog = newSharedCatalog() + t.Cleanup(func() { sharedCatalog = oldCatalog }) + var stdout, stderr strings.Builder + request := doRequest{task: "write out.txt", workspace: t.TempDir(), asJSON: true, model: model, planModel: model, checkModel: model, + timeout: 30 * time.Second, slots: bound(1), stdout: &stdout, stderr: &stderr} + request.newClient = func(settings config.Config, model string) (*liveClient, error) { + if got := settings.ClientConfig(model).DropRefusedEffort; got != taskChild { + t.Errorf("refused-effort repair = %v, task child = %v", got, taskChild) + } + return newLiveClient(settings, model) + } + if taskChild { + attempt := t.TempDir() + request.runStore = filepath.Join(attempt, "run") + raw, err := json.Marshal(taskdoor.Start{Effort: "low"}) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(attempt, "request.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + if err := doErrand(request); exitCodeOf(err) != 2 { + t.Fatalf("expected fixture refusal, got %v\n%s\n%s", err, &stdout, &stderr) + } + // The accessor already belongs to do startup. Closing it joins any existing + // warm-up before restoring the test seam and inspecting the request count. + settings, err := config.Load() + if err != nil { + t.Fatal(err) + } + sharedCatalog(settings).Close() + mu.Lock() + defer mu.Unlock() + if len(calls) != 1 { + t.Fatalf("provider calls=%d; stdout=%s stderr=%s", len(calls), &stdout, &stderr) + } + if listings != before { + t.Errorf("do fetched its catalog at startup: before %d, after %d", before, listings) + } + return calls +} diff --git a/cmd/codeaf/do_task_launch_test.go b/cmd/codeaf/do_task_launch_test.go new file mode 100644 index 0000000000..8a18c45915 --- /dev/null +++ b/cmd/codeaf/do_task_launch_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +func TestTaskLaunchGateHelper(t *testing.T) { + if os.Getenv("CODEAF_TASK_LAUNCH_HELPER") != "1" { + return + } + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + var args []string + for n, arg := range os.Args { + if arg == "--" { + args = os.Args[n+1:] + break + } + } + _ = os.WriteFile(filepath.Join(os.Getenv("CODEAF_HOME"), "entered"), nil, 0600) + os.Exit(exitCodeOf(runDo(args))) +} + +// Contract 12: the actual hidden command flag prevents work before its pid +// is durable. A launch abandoned during that window exits without tools. +func TestDoWaitsForDurableTaskAdmissionBeforeStarting(t *testing.T) { + for _, mode := range []string{"ended", "registered"} { + t.Run(mode, func(t *testing.T) { + home, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(home) + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + attempt := filepath.Join(home, "attempt") + record := filepath.Join(attempt, "run") + if err := os.MkdirAll(record, 0700); err != nil { + t.Fatal(err) + } + statePath := filepath.Join(attempt, "state.json") + writeState := func(state taskdoor.State) { + t.Helper() + raw, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + staged := statePath + ".new" + if err := os.WriteFile(staged, raw, 0600); err != nil { + t.Fatal(err) + } + if err := os.Rename(staged, statePath); err != nil { + t.Fatal(err) + } + } + writeState(taskdoor.State{Phase: "running", LaunchStartedAt: time.Now()}) + out, err := os.Create(filepath.Join(attempt, "do.json")) + if err != nil { + t.Fatal(err) + } + defer out.Close() + log, err := os.Create(filepath.Join(attempt, "do.log")) + if err != nil { + t.Fatal(err) + } + defer log.Close() + // Omitting the brief makes even a broken gate fail before constructing a + // provider, so this regression can never contact a real model endpoint. + cmd := exec.Command(os.Args[0], "-test.run=^TestTaskLaunchGateHelper$", "--", "--task-store", record, "--json") + cmd.Env = append(os.Environ(), "CODEAF_TASK_LAUNCH_HELPER=1") + cmd.Stdout, cmd.Stderr = out, log + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(filepath.Join(home, "entered")); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("child did not reach launch gate") + } + time.Sleep(5 * time.Millisecond) + } + time.Sleep(20 * time.Millisecond) + if info, err := out.Stat(); err != nil || info.Size() != 0 { + t.Fatalf("child passed an undurable admission: %v, %v", info, err) + } + next := taskdoor.State{Phase: "interrupted"} + want := "this task attempt has already ended" + if mode == "registered" { + next = taskdoor.State{Phase: "running", PID: cmd.Process.Pid, ProcessStart: "fixture-start"} + want = "no goal given" + } + writeState(next) + if err := cmd.Wait(); err == nil { + t.Fatal("abandoned launch succeeded") + } + raw, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), want) { + t.Fatalf("wrong launch refusal: %s", raw) + } + if entries, err := os.ReadDir(record); err != nil || len(entries) != 0 { + t.Fatalf("abandoned launch started a run: %v, %v", entries, err) + } + }) + } +} diff --git a/cmd/codeaf/do_task_store.go b/cmd/codeaf/do_task_store.go new file mode 100644 index 0000000000..84af088414 --- /dev/null +++ b/cmd/codeaf/do_task_store.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// doRunStore keeps the task door's record outside the copy and refuses reuse: +// a kept attempt can be followed after a crash but must never run twice. +func doRunStore(owned string) (string, error) { + if owned == "" { + _, dir, _, err := headlessStore("") + return dir, err + } + if !filepath.IsAbs(owned) { + return "", errors.New("the task's private record needs an absolute folder") + } + entries, err := os.ReadDir(owned) + if err == nil && len(entries) > 0 { + return "", errors.New("the task's private record already contains a run") + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err := os.MkdirAll(owned, 0700); err != nil { + return "", err + } + return owned, nil +} + +// waitTaskLaunch lets a surviving child be found after a serve crash before +// any model call or tool can run. The door releases it by saving its pid. +func waitTaskLaunch(record string) error { + if record == "" { + return nil + } + for { + raw, err := os.ReadFile(filepath.Join(filepath.Dir(record), "state.json")) + if err != nil { + return err + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + return err + } + if state.Phase != "running" || state.Abandoned { + return errors.New("this task attempt has already ended") + } + if state.PID == os.Getpid() && state.ProcessStart != "" { + return nil + } + time.Sleep(10 * time.Millisecond) + } +} + +func doTaskControls(record string) (taskdoor.Start, effort.Rung, error) { + var request taskdoor.Start + if record == "" { + return request, effort.None, nil + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(record), "request.json")) + // Direct in-process command tests can name an owned store without a door. + // A real serve admission always wrote request.json before the child ran. + if errors.Is(err, os.ErrNotExist) { + return request, effort.None, nil + } + if err != nil { + return request, effort.None, err + } + if err := json.Unmarshal(raw, &request); err != nil { + return request, effort.None, err + } + if choice, ok := crewroute.ParseEffort(request.Effort); ok && choice != crewroute.EffortKnee { + return request, effort.None, nil + } + rung, ok := effort.Parse(request.Effort) + if !ok { + return request, effort.None, errors.New("the task's reasoning effort is not readable") + } + return request, rung, nil +} diff --git a/cmd/codeaf/do_task_store_test.go b/cmd/codeaf/do_task_store_test.go new file mode 100644 index 0000000000..1f9d3f31e7 --- /dev/null +++ b/cmd/codeaf/do_task_store_test.go @@ -0,0 +1,251 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type taskRunJournal struct { + spend []taskdoor.Spend + steps []taskdoor.Step + end taskdoor.Finished +} + +type taskBillingProvider struct { + session.Completer + callsFile string +} + +func (p taskBillingProvider) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + file, err := os.OpenFile(p.callsFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + _, writeErr := file.WriteString("call\n") + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + return nil, err + } + return p.Completer.CompleteWithMessages(ctx, messages, options...) +} + +func (j *taskRunJournal) Append(event any) error { + switch event := event.(type) { + case taskdoor.Spend: + j.spend = append(j.spend, event) + case taskdoor.Step: + j.steps = append(j.steps, event) + case taskdoor.Finished: + j.end = event + } + return nil +} + +// The subprocess exits immediately after the real do road returns, so the +// parent cannot accidentally flush its queued usage rows on the child's behalf. +func TestTaskDoBillHelper(t *testing.T) { + if os.Getenv("CODEAF_TASK_DO_BILL_HELPER") != "1" { + return + } + // This package's TestMain deliberately clears credentials/profile and + // selects the older belt. Reapply only this fixture's disposable inputs. + t.Setenv("HOME", os.Getenv("CODEAF_TEST_TASK_RUN_HOME")) + t.Setenv("CODEAF_HOME", os.Getenv("CODEAF_TEST_TASK_RUN_HOME")) + t.Setenv("CODEAF_PROFILE_DIR", os.Getenv("CODEAF_TEST_TASK_RUN_PROFILE")) + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("OPENROUTER_API_KEY", "test-key") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + value := func(flag string) string { + for n, arg := range os.Args { + if arg == flag && n+1 < len(os.Args) { + return os.Args[n+1] + } + } + return "" + } + err := doErrand(doRequest{task: "write out.txt", workspace: value("--dir"), runStore: value("--task-store"), asJSON: true, timeout: 45 * time.Second, + stdout: os.Stdout, stderr: os.Stderr, newBeltCompleter: func(string) session.Completer { + return taskBillingProvider{finishingSeat(0.01), filepath.Join(filepath.Dir(value("--task-store")), "provider-calls")} + }}) + os.Exit(exitCodeOf(err)) +} + +// Contracts 5 and 12: the actual do run keeps the requested private record, +// and the executor's per-call ledger source sums to that run's own receipt. +func TestTheTaskDoorKeepsTheRealDoRecordAndItsWholeBill(t *testing.T) { + beltRunEnv(t) + home, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(home) + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t)) + attempt := filepath.Join(home, "att_0000000000000081") + record := filepath.Join(attempt, "run") + work := filepath.Join(attempt, "work") + if err := os.MkdirAll(attempt, 0700); err != nil { + t.Fatal(err) + } + // A received task has its own Git history. The do receipt reads changed + // files from that history, so a plain directory would not exercise it. + if err := os.Rename(beltRepoWorkspace(t), work); err != nil { + t.Fatal(err) + } + var stdout, stderr strings.Builder + jsonFile, err := os.Create(filepath.Join(attempt, "do.json")) + if err != nil { + t.Fatal(err) + } + defer jsonFile.Close() + logFile, err := os.Create(filepath.Join(attempt, "do.log")) + if err != nil { + t.Fatal(err) + } + defer logFile.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestTaskDoBillHelper$", "--", "--dir", work, "--task-store", record) + cmd.Env = append(os.Environ(), "CODEAF_TASK_DO_BILL_HELPER=1", "CODEAF_HOME="+filepath.Join(home, "bootstrap"), "CODEAF_TEST_TASK_RUN_HOME="+home, "CODEAF_TEST_TASK_RUN_PROFILE="+os.Getenv("CODEAF_PROFILE_DIR")) + cmd.Stdout, cmd.Stderr = jsonFile, logFile + err = cmd.Run() + jsonRaw, _ := os.ReadFile(jsonFile.Name()) + logRaw, _ := os.ReadFile(logFile.Name()) + stdout.Write(jsonRaw) + stderr.Write(logRaw) + if err != nil { + t.Fatalf("run: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(record, "plandb.db")); err != nil { + t.Fatal("private run was not kept", err) + } + if stores, _ := filepath.Glob(filepath.Join(home, "runs", "codeaf-do-*")); len(stores) != 0 { + t.Fatalf("record escaped attempt: %v", stores) + } + if err := os.WriteFile(filepath.Join(attempt, "events.jsonl"), nil, 0600); err != nil { + t.Fatal(err) + } + journal := &taskRunJournal{} + a := &taskdoor.Attempt{ID: filepath.Base(attempt), Dir: attempt} + if err := taskdoor.NewExecutor(taskdoor.ExecutorConfig{Ledger: session.UsageLedgerPath()}).Adopt(t.Context(), a, journal); err != nil { + t.Fatal(err) + } + var bill struct { + Spend float64 `json:"spend_usd"` + } + if err := json.Unmarshal([]byte(stdout.String()), &bill); err != nil { + t.Fatal(err) + } + var sum float64 + for _, row := range journal.spend { + sum += row.Line.USD + } + providerCalls, err := os.ReadFile(filepath.Join(attempt, "provider-calls")) + if err != nil { + t.Fatal(err) + } + if len(journal.spend) != len(strings.Fields(string(providerCalls))) { + t.Fatalf("journal charges=%d provider calls=%q", len(journal.spend), providerCalls) + } + if journal.end.Outcome != "done" { + t.Fatalf("completed child did not finish normally: %+v", journal.end) + } + if len(journal.end.Changed) != 1 || journal.end.Changed[0] != "out.txt" { + t.Fatalf("changed files are not portable: %v", journal.end.Changed) + } + var order uint64 + for _, line := range journal.steps { + if line.Step.RecordedOrder <= order { + t.Fatalf("steps lost write order: %+v", journal.steps) + } + order = line.Step.RecordedOrder + } + if len(journal.spend) < 3 || bill.Spend <= 0 || math.Abs(sum-bill.Spend) > 1e-9 { + t.Fatalf("journal=%+v sum=%g actual bill=%g", journal.spend, sum, bill.Spend) + } +} + +// The task-store flag is internal serve plumbing, so the ordinary do help +// must not promise a user-facing option for it. +func TestTheTaskStoreFlagIsAbsentFromDoHelp(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + out, _ := captureUsage(t) + if code := exitCodeOf(runDo([]string{"--help"})); code != 0 { + t.Fatalf("help exit=%d", code) + } + text := out.String() + if strings.Contains(text, "--"+taskdoor.TaskStoreFlag) { + t.Fatalf("private flag appeared in help: %s", text) + } +} + +// The task's settled limits and effort travel to the real run, while this +// computer's own spending bound continues to constrain it. +func TestTheTaskDoorHonorsTheSettledTaskControls(t *testing.T) { + for _, kind := range []string{"steps", "spend", "no progress"} { + t.Run(kind, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + beltRunEnv(t) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + attempt := t.TempDir() + record := filepath.Join(attempt, "run") + work := beltRepoWorkspace(t) + settled := taskdoor.Start{Effort: "high"} + switch kind { + case "steps": + settled.MaxSteps = 2 + case "spend": + settled.Limits.USD = 0.02 + case "no progress": + settled.NoProgress = 2 + } + raw, err := json.Marshal(settled) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(attempt, "request.json"), raw, 0600); err != nil { + t.Fatal(err) + } + calls := 0 + seat := &beltSeat{ever: func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + calls++ + if rung := provider.ReasoningEffortFrom(ctx); rung != provider.EffortHigh { + t.Errorf("effort=%s", rung) + } + reply := beltToolReply("printf 'same answer'") + cost := 0.01 + reply.Usage.Cost = &cost + return reply, nil + }} + out, err := runErrand(doRequest{task: "work", workspace: work, runStore: record, timeout: 30 * time.Second, stderr: &strings.Builder{}, newBeltCompleter: func(string) session.Completer { return seat }}, stubErrandSeats()) + if err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("settled %s allowed %d provider calls, outcome=%+v", kind, calls, out) + } + if kind == "spend" && out.status() != exitLimit { + t.Fatalf("spending bound did not stop run: %+v", out) + } + }) + } +} diff --git a/cmd/codeaf/doctor.go b/cmd/codeaf/doctor.go index 0a1993de43..9302638ad9 100644 --- a/cmd/codeaf/doctor.go +++ b/cmd/codeaf/doctor.go @@ -43,7 +43,10 @@ type doctorSnapshot struct { Key keyReport // Cells is what the cells' working files hold against the disk budget, "" when // they hold nothing. - Cells string + Cells string + EngineStore []string + SyncIssues []string + Fixes []string } // fallbackKeyEnv is the compatibility variable [config.Load] accepts for an @@ -87,11 +90,12 @@ func runDoctor(args []string) error { func runDoctorWith(args []string, output io.Writer, dailyBudget float64) error { flags := commandFlags("doctor") database := flags.String("db", defaultChatDB(), storeFlagHelp) + fix := flags.Bool("fix", false, "repair safe engine store and sync state") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err } if flags.NArg() != 0 { - return fmt.Errorf("usage: codeaf doctor [--db path]") + return fmt.Errorf("usage: codeaf doctor [--db path] [--fix]") } path, err := expandHome(strings.TrimSpace(*database)) if err != nil { @@ -128,6 +132,7 @@ func runDoctorWith(args []string, output io.Writer, dailyBudget float64) error { // moves the store and moves nothing about who this machine can talk to. snapshot.Key = readKeyReport(strings.TrimSpace(env.Get(config.ProfileDirEnv))) snapshot.Cells = cellsUsage() + snapshot.EngineStore, snapshot.SyncIssues, snapshot.Fixes = inspectAndRepairDoctor(*fix) _, err = io.WriteString(output, formatDoctor(snapshot)) return err } @@ -250,6 +255,15 @@ func formatDoctor(snapshot doctorSnapshot) string { if line := formatCallLog(snapshot.CallLog); line != "" { block += fmt.Sprintf("%-16s %s\n", "model calls", line) } + for _, line := range snapshot.EngineStore { + block += fmt.Sprintf("%-16s %s\n", "engine store", line) + } + for _, line := range snapshot.SyncIssues { + block += fmt.Sprintf("%-16s %s\n", "sync", line) + } + for _, line := range snapshot.Fixes { + block += line + "\n" + } return block } diff --git a/cmd/codeaf/doctor_repair.go b/cmd/codeaf/doctor_repair.go new file mode 100644 index 0000000000..447deb9019 --- /dev/null +++ b/cmd/codeaf/doctor_repair.go @@ -0,0 +1,481 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +const doctorSyncTimeout = 3 * time.Second + +// inspectAndRepairDoctor collects engine-store and cross-computer sync health +// diagnostics and, when fix is true, repairs only what cannot touch another +// computer's live hold or discard a unique turn. +func inspectAndRepairDoctor(fix bool) (engineStore []string, syncIssues []string, fixes []string) { + storeLines, storeFixes := inspectDoctorStores(fix) + engineStore = append(engineStore, storeLines...) + fixes = append(fixes, storeFixes...) + + syncLines, syncFixes := inspectDoctorSync(fix) + syncIssues = append(syncIssues, syncLines...) + fixes = append(fixes, syncFixes...) + return engineStore, syncIssues, fixes +} + +func doctorStoreDirs() []string { + seen := map[string]bool{} + var out []string + add := func(dir string) { + clean := filepath.Clean(strings.TrimSpace(dir)) + if clean == "" || clean == "." || strings.HasSuffix(clean, ".lock") || seen[clean] { + return + } + seen[clean] = true + out = append(out, clean) + } + add(cellstore.SharedStoreDir()) + matches, _ := filepath.Glob(filepath.Join(home.Join("v3", "stores"), "*")) + slices.Sort(matches) + for _, m := range matches { + info, err := os.Stat(m) + if err == nil && info.IsDir() { + add(m) + } + } + return out +} + +func inspectDoctorStores(fix bool) (issues []string, fixes []string) { + dirs := doctorStoreDirs() + seenBusy := map[string]bool{} + for _, dir := range dirs { + if holder, busy := cellstore.InspectStoreBusy(dir, ""); busy { + key := filepath.Clean(holder.Lock) + if key == "." || key == "" { + key = dir + } + if !seenBusy[key] { + seenBusy[key] = true + if who := holder.Describe(); who != "" { + issues = append(issues, "busy · held by "+who) + } else { + issues = append(issues, "busy") + } + } + } + } + return issues, fixes +} + +type localChatInventory struct { + foldersByID map[string][]string + inUseByID map[string]bool + orphanStaging map[string]string +} + +func scanLocalChatInventory() localChatInventory { + inv := localChatInventory{ + foldersByID: map[string][]string{}, + inUseByID: map[string]bool{}, + orphanStaging: map[string]string{}, + } + seenFolder := map[string]bool{} + recordFolder := func(id, dir string) { + id = strings.TrimSpace(id) + dir = filepath.Clean(strings.TrimSpace(dir)) + if id == "" || dir == "" || dir == "." { + return + } + transcript := session.Place{Dir: dir}.Transcript() + if session.InUse(transcript) || handoff.Arriving(dir) { + inv.inUseByID[id] = true + } + key := id + "\x00" + dir + if seenFolder[key] { + return + } + seenFolder[key] = true + inv.foldersByID[id] = append(inv.foldersByID[id], dir) + } + + for _, row := range session.ReadHome().Sessions() { + id := strings.TrimSpace(row.ID) + if id == "" { + if dir, ok := session.FolderOf(row.Transcript); ok { + id = filepath.Base(dir) + } + } + dir := session.DirOf(row.Transcript) + if row.Open { + inv.inUseByID[id] = true + } + recordFolder(id, dir) + } + + var candidates []string + for _, pattern := range []string{ + filepath.Join(home.Join("v3", "projects"), "*", "*"), + filepath.Join(home.Join("v3", "projects"), "*", ".moving", "*"), + filepath.Join(cell.Dir(home.Dir()), "*"), + } { + matches, _ := filepath.Glob(pattern) + candidates = append(candidates, matches...) + } + slices.Sort(candidates) + for _, entry := range candidates { + base := filepath.Base(entry) + if base == ".moving" || strings.HasSuffix(base, ".arriving") || strings.HasSuffix(base, ".lock") { + continue + } + info, err := os.Stat(entry) + if err != nil || !info.IsDir() { + continue + } + if strings.HasSuffix(base, handoff.StagingSuffix) { + baseRoot := strings.TrimSuffix(entry, handoff.StagingSuffix) + id := strings.TrimSpace(filepath.Base(baseRoot)) + if id == "" { + continue + } + if handoff.Arriving(baseRoot) || session.InUse(session.Place{Dir: baseRoot}.Transcript()) { + inv.inUseByID[id] = true + continue + } + if _, exists := inv.orphanStaging[id]; !exists { + inv.orphanStaging[id] = filepath.Clean(entry) + } + continue + } + if strings.HasSuffix(base, ".replaced") { + continue + } + if filepath.Base(filepath.Dir(entry)) == ".moving" && fileExists(entry+".lock") && !fileExists(filepath.Join(entry, filepath.FromSlash(cell.CopyReadyPath))) { + continue + } + hasTranscript := fileExists(session.Place{Dir: entry}.Transcript()) + hasCell := fileExists(filepath.Join(entry, filepath.FromSlash(cell.MetaPath))) + hasMeta := fileExists(filepath.Join(entry, "meta.json")) + if hasTranscript || hasCell || hasMeta { + id := base + if meta, err := session.LoadMeta(entry); err == nil && strings.TrimSpace(meta.ID) != "" { + id = strings.TrimSpace(meta.ID) + } + recordFolder(id, entry) + } + } + return inv +} + +func inspectDoctorSync(fix bool) (issues []string, fixes []string) { + inv := scanLocalChatInventory() + handledStaging := map[string]bool{} + + if syncsetup.MayTalk(home.Dir()) { + if s, ok, err := syncsetup.Open(home.Dir()); err == nil && ok { + ctx, cancel := context.WithTimeout(context.Background(), doctorSyncTimeout) + defer cancel() + if l, listErr := s.Dir.List(ctx); listErr == nil { + self := s.Device.ID() + ids := slices.Sorted(maps.Keys(l.Cells)) + reportedUnfinished := map[string]bool{} + releasedTake := map[string]bool{} + + // 1. Unfinished continues started by this computer. + for _, id := range ids { + rec := l.Cells[id] + if rec.Archived || inv.inUseByID[id] || handoff.Arriving(takeRootFor(id)) { + continue + } + takingDir := inv.orphanStaging[id] + heldLiveBySelf := rec.Lease.Device == self && rec.Lease.Expires > l.Now + heldLiveByOther := rec.Lease.Device != "" && rec.Lease.Device != self && rec.Lease.Expires > l.Now + if heldLiveByOther { + // Never touch another computer's live hold. + continue + } + _, hasLocal := openCellByID(id) + unfinished := false + if heldLiveBySelf && (takingDir != "" || hasLocal != nil || !sendLeaseMatches(s, id, rec.Lease.Fence)) { + unfinished = true + } else if takingDir != "" { + unfinished = true + } + if !unfinished { + continue + } + handledStaging[id] = true + + if fix { + safeStaging := takingDir == "" || !stagingHasUniqueTurns(takingDir, id, rec.Head) + released := false + if heldLiveBySelf { + relErr := directory.ReleaseQuiet(ctx, s.Dir, id, rec.Lease.Fence) + if errors.Is(relErr, directory.ErrUnauthorized) { + relErr = s.Dir.Release(ctx, id, rec.Lease.Fence) + } + if relErr == nil { + released = true + rec.Lease.Expires, rec.Lease.Pending = 0, 0 + l.Cells[id] = rec + } + } + removedStaging := false + if (!heldLiveBySelf || released) && takingDir != "" && safeStaging { + if rmErr := os.RemoveAll(takingDir); rmErr == nil { + removedStaging = true + delete(inv.orphanStaging, id) + } + } + if (heldLiveBySelf && released) || (!heldLiveBySelf && removedStaging) { + fixes = append(fixes, fmt.Sprintf("fixed: released unfinished continue for chat %s", id)) + releasedTake[id] = true + if takingDir == "" || removedStaging { + continue + } + } + } + issues = append(issues, fmt.Sprintf("unfinished continue · chat %s", id)) + reportedUnfinished[id] = true + } + + // 2. Duplicate Home rows for one chat id: local duplicate folders and sync branch copies. + localIDs := slices.Sorted(maps.Keys(inv.foldersByID)) + for _, id := range localIDs { + if len(inv.foldersByID[id]) > 1 { + issues = append(issues, fmt.Sprintf("duplicate rows · chat %s (%d local folders)", id, len(inv.foldersByID[id]))) + } + } + for _, branchID := range ids { + branchRec := l.Cells[branchID] + if branchRec.Archived || branchRec.ParentCell == "" || branchRec.OrphanTurns == 0 { + continue + } + mainID := resolveRootCellID(l.Cells, branchRec.ParentCell) + mainRec, hasMain := l.Cells[mainID] + allInMain := hasMain && !mainRec.Archived && branchTurnsAllInMain(mainID, mainRec, branchID, branchRec) + heldLiveByOther := branchRec.Lease.Expires > l.Now && branchRec.Lease.Device != "" && branchRec.Lease.Device != self + if fix && allInMain && !heldLiveByOther { + if discErr := s.Discard(ctx, branchID); discErr == nil { + fixes = append(fixes, fmt.Sprintf("fixed: retired duplicate branch copy %s of chat %s", branchID, mainID)) + branchRec.Archived = true + l.Cells[branchID] = branchRec + continue + } + } + if allInMain { + issues = append(issues, fmt.Sprintf("duplicate rows · chat %s (branch copy %s, all turns in main chat)", mainID, branchID)) + } else { + turnsWord := fmt.Sprintf("%d turns", branchRec.OrphanTurns) + if branchRec.OrphanTurns == 1 { + turnsWord = "1 turn" + } + issues = append(issues, fmt.Sprintf("duplicate rows · chat %s (branch copy %s, %s not in main chat)", mainID, branchID, turnsWord)) + } + } + + // 3. This computer's own lapsed holds that would ask a question on open. + for _, id := range ids { + rec := l.Cells[id] + if rec.Archived || rec.OrphanTurns > 0 || reportedUnfinished[id] || releasedTake[id] || inv.inUseByID[id] { + continue + } + if rec.Lease.Device == self && rec.Lease.Expires > 0 && rec.Lease.Expires <= l.Now { + if lapsedHoldWouldAskOnOpen(s, id, rec) { + issues = append(issues, fmt.Sprintf("lapsed hold on this computer · chat %s", id)) + } + } + } + + // Any local orphan .taking folder for a chat id absent from the directory listing. + issues, fixes = finishOrphanStaging(inv.orphanStaging, handledStaging, fix, issues, fixes) + return issues, fixes + } + } + } + + // Sync is off or unreachable: still report local duplicate Home rows and orphaned .taking folders. + localIDs := slices.Sorted(maps.Keys(inv.foldersByID)) + for _, id := range localIDs { + if len(inv.foldersByID[id]) > 1 { + issues = append(issues, fmt.Sprintf("duplicate rows · chat %s (%d local folders)", id, len(inv.foldersByID[id]))) + } + } + issues, fixes = finishOrphanStaging(inv.orphanStaging, handledStaging, fix, issues, fixes) + return issues, fixes +} + +func finishOrphanStaging(orphanStaging map[string]string, handled map[string]bool, fix bool, issues, fixes []string) ([]string, []string) { + ids := slices.Sorted(maps.Keys(orphanStaging)) + for _, id := range ids { + if handled[id] { + continue + } + takingDir := orphanStaging[id] + if fix && !stagingHasUniqueTurns(takingDir, id, "") { + if err := os.RemoveAll(takingDir); err == nil { + fixes = append(fixes, fmt.Sprintf("fixed: released unfinished continue for chat %s", id)) + continue + } + } + issues = append(issues, fmt.Sprintf("unfinished continue · chat %s", id)) + } + return issues, fixes +} + +func sendLeaseMatches(s *syncsetup.Sync, id string, fence uint64) bool { + heldCell, heldFence, err := s.SendLease(id) + return err == nil && heldCell == id && heldFence == fence && fence != 0 +} + +func resolveRootCellID(cells map[string]directory.Cell, parent string) string { + cur := strings.TrimSpace(parent) + for hops := 0; cur != "" && hops < 16; hops++ { + rec, ok := cells[cur] + if !ok || rec.OrphanTurns == 0 || strings.TrimSpace(rec.ParentCell) == "" || rec.ParentCell == cur { + break + } + cur = strings.TrimSpace(rec.ParentCell) + } + return cur +} + +func branchTurnsAllInMain(mainID string, mainRec directory.Cell, branchID string, branchRec directory.Cell) bool { + if strings.TrimSpace(branchRec.Head) == "" { + return false + } + if mainRec.Head != "" && branchRec.Head == mainRec.Head { + return true + } + mainLocal, err := openCellByID(mainID) + if err != nil { + return false + } + if mainRec.Head != "" { + if mainKnown, err := cellstore.KnowsHead(mainLocal, mainRec.Head); err != nil || !mainKnown { + return false + } + } + if branchLocal, err := openCellByID(branchID); err == nil { + branchTurns, err := cellstore.Turns(branchLocal) + if err != nil || len(branchTurns) == 0 || branchTurns[len(branchTurns)-1].ID != branchRec.Head { + return false + } + for _, turn := range branchTurns { + if known, err := cellstore.KnowsHead(mainLocal, turn.ID); err != nil || !known { + return false + } + } + return true + } + known, err := cellstore.KnowsHead(mainLocal, branchRec.Head) + return err == nil && known +} + +func lapsedHoldWouldAskOnOpen(s *syncsetup.Sync, id string, rec directory.Cell) bool { + localCell, err := openCellByID(id) + if err != nil { + return true + } + if known, err := cellstore.KnowsHead(localCell, rec.Head); err != nil || !known { + return true + } + if extends, err := cellstore.Extends(localCell, rec.Head); err != nil || !extends { + return true + } + if head, err := cellstore.Head(localCell); err != nil || head == nil || head.Turn.ID != rec.Head { + return true + } + if rec.Lease.Pending > 0 { + return true + } + if heldCell, heldFence, err := s.SendLease(id); err == nil && heldFence != 0 && (heldCell != id || heldFence != rec.Lease.Fence) { + return true + } + return false +} + +// stagingHasUniqueTurns fails closed whenever a .taking scratch folder holds any +// sealed turn or unsealed transcript content that does not already exist in the +// local main chat or the directory's durable head. +func stagingHasUniqueTurns(takingDir, id, durableHead string) bool { + entries, err := os.ReadDir(takingDir) + if err != nil { + return !os.IsNotExist(err) + } + if len(entries) == 0 { + return false + } + + mainCell, mainErr := openCellByID(id) + stagedCell := cell.Cell{ID: id, Root: takingDir} + hasStagedCell := fileExists(filepath.Join(takingDir, filepath.FromSlash(cell.MetaPath))) + if hasStagedCell { + if _, busy := cellstore.InspectStoreBusy(cellstore.SharedStoreDir(), ""); busy { + return true + } + stagedTurns, turnsErr := cellstore.Turns(stagedCell) + if turnsErr != nil && !os.IsNotExist(turnsErr) { + return true + } + for _, turn := range stagedTurns { + if durableHead != "" && len(stagedTurns) > 0 && stagedTurns[len(stagedTurns)-1].ID == durableHead { + break + } + if mainErr != nil { + return true + } + if known, err := cellstore.KnowsHead(mainCell, turn.ID); err != nil || !known { + return true + } + } + } + + stagedTranscript := session.Place{Dir: takingDir}.Transcript() + stagedRaw, readErr := os.ReadFile(stagedTranscript) + if readErr != nil { + return !os.IsNotExist(readErr) + } + if len(bytes.TrimSpace(stagedRaw)) == 0 { + return false + } + if hasStagedCell { + if head, err := cellstore.Head(stagedCell); err == nil && head != nil && int64(len(stagedRaw)) <= head.Receipt.Transcript.End { + if durableHead != "" && head.Turn.ID == durableHead { + return false + } + if mainErr == nil { + if known, err := cellstore.KnowsHead(mainCell, head.Turn.ID); err == nil && known { + return false + } + } + } + } + if mainErr != nil { + return true + } + mainRaw, err := os.ReadFile(session.Place{Dir: mainCell.Root}.Transcript()) + if err != nil { + return true + } + return !bytes.HasPrefix(mainRaw, stagedRaw) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/cmd/codeaf/doctor_repair_test.go b/cmd/codeaf/doctor_repair_test.go new file mode 100644 index 0000000000..dcf83e00aa --- /dev/null +++ b/cmd/codeaf/doctor_repair_test.go @@ -0,0 +1,488 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestDoctorHoldStoreHelperProcess(t *testing.T) { + storeDir := os.Getenv("CODEAF_TEST_DOCTOR_HOLD_STORE") + if storeDir == "" { + return + } + release, err := cellstore.HoldStoreWriter(storeDir, "codeaf chat") + if err != nil { + os.Exit(2) + } + _, _ = os.Stdout.WriteString("LOCKED\n") + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + _ = release() + os.Exit(0) +} + +func startDoctorStoreHolder(t *testing.T, storeDir string) func() { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(exe, "-test.run=^TestDoctorHoldStoreHelperProcess$") //codeaf:plumbing spawns this test binary to hold an engine store lock in another process + cmd.Env = append(os.Environ(), "CODEAF_TEST_DOCTOR_HOLD_STORE="+storeDir) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + line, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil || strings.TrimSpace(line) != "LOCKED" { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("helper failed to hold store %s: line=%q err=%v", storeDir, line, err) + } + var stopped bool + stop := func() { + if stopped { + return + } + stopped = true + _, _ = stdin.Write([]byte("release\n")) + _ = stdin.Close() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = cmd.Process.Kill() + <-done + } + } + t.Cleanup(stop) + return stop +} + +type doctorTestClock struct { + mu sync.Mutex + offset time.Duration +} + +func (c *doctorTestClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return time.Now().Add(c.offset) +} + +func (c *doctorTestClock) SetOffset(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.offset = d +} + +func writeLocalChatFolder(t *testing.T, dir, id, title, transcriptBody string) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(dir, session.Meta{ + ID: id, + Title: title, + Workspace: dir, + }); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(session.Place{Dir: dir}.Transcript(), []byte(transcriptBody), 0o600); err != nil { + t.Fatal(err) + } +} + +// Contract 5: `codeaf doctor` reports a busy engine store and its holder, an +// unfinished take, duplicate Home rows for one chat id, and this computer's own +// lapsed holds that would ask a question on open; without `--fix` it mutates +// nothing; with `--fix` it repairs only what is safe, prints one line per +// change, never touches another computer's live hold, and never deletes a turn +// that exists only in the thing it removes. +func TestDoctorFindsAndRepairsSyncAndEngineStoreSafely(t *testing.T) { + celltest.Available(t) + clock := &doctorTestClock{offset: -2 * time.Minute} + svc := relayserve.New(relayserve.Config{Store: t.TempDir(), Now: clock.Now}) + srv := httptest.NewServer(svc.Handler) + t.Cleanup(func() { + srv.Close() + svc.Close() + }) + + homeA := t.TempDir() + homeB := t.TempDir() + t.Setenv(home.EnvVar, homeA) + t.Setenv(syncsetup.URLVar, srv.URL) + + id, err := identity.Ensure(homeA) + if err != nil { + t.Fatal(err) + } + if err := identity.Save(homeB, id); err != nil { + t.Fatal(err) + } + + syncA, ok, err := syncsetup.Open(homeA) + if err != nil || !ok { + t.Fatalf("syncsetup.Open(homeA) = (%v, %v)", ok, err) + } + syncB, ok, err := syncsetup.Open(homeB) + if err != nil || !ok { + t.Fatalf("syncsetup.Open(homeB) = (%v, %v)", ok, err) + } + ctx := context.Background() + if err := syncsetup.PutOwnDevice(ctx, syncA.Dir, syncA.Identity, syncA.Device, "desk"); err != nil { + t.Fatal(err) + } + if err := syncsetup.PutOwnDevice(ctx, syncB.Dir, syncB.Identity, syncB.Device, "laptop"); err != nil { + t.Fatal(err) + } + + const ( + unfinishedID = "01J00000000000000000000101" + uniqueStagingID = "01J00000000000000000000102" + otherLiveID = "01J00000000000000000000103" + mainChatID = "01J00000000000000000000104" + redundantBranchID = "01J00000000000000000000105" + uniqueBranchID = "01J00000000000000000000106" + lapsedSelfID = "01J00000000000000000000107" + localDupID = "01J00000000000000000000108" + + headMain = "1111111111111111111111111111111111111111111111111111111111111111" + headUnique = "2222222222222222222222222222222222222222222222222222222222222222" + ) + + makeInit := func(head, parent string, orphans uint32) directory.CellInit { + return directory.CellInit{ + Head: head, + Class: string(cell.HostBound), + ParentCell: parent, + OrphanTurns: orphans, + Keys: map[string]map[string]string{id.CellKeyID(): {id.ID(): ""}}, + } + } + + // 1. Lapsed hold on this computer (created 2 minutes ago on the relay clock, > 90s LeaseTTL, within 5m skew). + if _, err := syncA.Dir.Create(ctx, lapsedSelfID, makeInit(headMain, "", 0)); err != nil { + t.Fatal(err) + } + clock.SetOffset(0) + + // 2. Unfinished take on this computer: live lease on syncA with no SendLease receipt, plus empty .taking folder. + unfinishedView, err := syncA.Dir.Create(ctx, unfinishedID, makeInit(headMain, "", 0)) + if err != nil { + t.Fatal(err) + } + unfinishedTaking := filepath.Join(homeA, "v3", "projects", "copies", unfinishedID+handoff.StagingSuffix) + if err := os.MkdirAll(unfinishedTaking, 0o700); err != nil { + t.Fatal(err) + } + + // 3. Unfinished take whose .taking folder contains a unique unsealed turn absent from main chat (must NOT be deleted!). + uniqueStagingDir := filepath.Join(homeA, "v3", "projects", "copies", uniqueStagingID+handoff.StagingSuffix) + writeLocalChatFolder(t, uniqueStagingDir, uniqueStagingID, "Unique Staged Turn", "{\"type\":\"session\",\"id\":\""+uniqueStagingID+"\"}\n{\"role\":\"user\",\"content\":\"only in staging\"}\n") + + // 4. Live hold on another computer (syncB) — must NEVER be touched by doctor --fix! + otherView, err := syncB.Dir.Create(ctx, otherLiveID, makeInit(headMain, "", 0)) + if err != nil { + t.Fatal(err) + } + + // 5. Main chat + two branch copies: + // - redundantBranchID has Head == headMain (all turns in main chat -> safe to retire) + // - uniqueBranchID has Head == headUnique (turns NOT in main chat -> must NEVER be retired!) + mainView, err := syncB.Dir.Create(ctx, mainChatID, makeInit(headMain, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := syncB.Dir.Release(ctx, mainChatID, mainView.Cell.Lease.Fence); err != nil { + t.Fatal(err) + } + redBranchView, err := syncA.Dir.Create(ctx, redundantBranchID, makeInit(headMain, mainChatID, 2)) + if err != nil { + t.Fatal(err) + } + if err := syncA.Dir.Release(ctx, redundantBranchID, redBranchView.Cell.Lease.Fence); err != nil { + t.Fatal(err) + } + uniqBranchView, err := syncB.Dir.Create(ctx, uniqueBranchID, makeInit(headUnique, mainChatID, 3)) + if err != nil { + t.Fatal(err) + } + if err := syncB.Dir.Release(ctx, uniqueBranchID, uniqBranchView.Cell.Lease.Fence); err != nil { + t.Fatal(err) + } + + // 6. Duplicate local Home folders for one chat id on disk. + header := "{\"type\":\"session\",\"id\":\"" + localDupID + "\"}\n" + writeLocalChatFolder(t, filepath.Join(homeA, "v3", "projects", "-work-proj", localDupID), localDupID, "Dup Local", header) + writeLocalChatFolder(t, filepath.Join(homeA, "v3", "projects", "copies", localDupID), localDupID, "Dup Local", header) + + // 7. Busy engine store (held by live foreign process) AND a lock file in another store + // whose recorded holder is dead. No live process holds that lock, so it is free: doctor + // neither reports it nor deletes it. + sharedStore := cellstore.SharedStoreDir() + stopHolder := startDoctorStoreHolder(t, sharedStore) + defer stopHolder() + + otherStore := filepath.Join(homeA, "v3", "stores", " stale-check") + otherStore = strings.ReplaceAll(otherStore, " ", "") + if err := os.MkdirAll(otherStore, 0o700); err != nil { + t.Fatal(err) + } + staleLockPath := filepath.Join(otherStore, "writer.lock") + stalePayload, _ := json.Marshal(cellstore.StoreHolder{PID: 99999999, Command: "codeaf chat"}) + if err := os.WriteFile(staleLockPath, append(stalePayload, '\n'), 0o600); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(t.TempDir(), "graph.db") + + // ── STEP 1: `codeaf doctor` WITHOUT `--fix` reports everything and mutates NOTHING ── + var reportOut bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath}, &reportOut, 0); err != nil { + t.Fatal(err) + } + report := reportOut.String() + exeBase := filepath.Base(os.Args[0]) + for _, want := range []string{ + "engine store", + "busy · held by " + exeBase + ", pid ", + "unfinished continue · chat " + unfinishedID, + "unfinished continue · chat " + uniqueStagingID, + "duplicate rows · chat " + localDupID + " (2 local folders)", + "duplicate rows · chat " + mainChatID + " (branch copy " + redundantBranchID + ", all turns in main chat)", + "duplicate rows · chat " + mainChatID + " (branch copy " + uniqueBranchID + ", 3 turns not in main chat)", + "lapsed hold on this computer · chat " + lapsedSelfID, + } { + if !strings.Contains(report, want) { + t.Fatalf("doctor report missing %q:\n%s", want, report) + } + } + if strings.Contains(report, "fixed:") { + t.Fatalf("doctor without --fix printed fix lines:\n%s", report) + } + for _, forbidden := range []string{"daemon", "launchd", "systemd", "service", "brain", "standing watch", "auditor", "verdict", "verified", "refuted"} { + if strings.Contains(strings.ToLower(report), forbidden) { + t.Fatalf("doctor output contains forbidden word %q:\n%s", forbidden, report) + } + } + + if strings.Contains(report, staleLockPath) { + t.Fatalf("doctor reported a lock no live process holds:\n%s", report) + } + + // Verify zero state mutations occurred without --fix! + if _, err := os.Stat(unfinishedTaking); err != nil { + t.Fatalf("doctor without --fix deleted .taking dir: %v", err) + } + checkUnfinished, err := syncA.Dir.Cell(ctx, unfinishedID) + if err != nil || checkUnfinished.Cell.Lease.Expires <= checkUnfinished.Now || checkUnfinished.Cell.Lease.Fence != unfinishedView.Cell.Lease.Fence { + t.Fatalf("doctor without --fix altered unfinished take lease: %+v (err=%v)", checkUnfinished, err) + } + checkRedBranch, err := syncA.Dir.Cell(ctx, redundantBranchID) + if err != nil || checkRedBranch.Cell.Archived { + t.Fatalf("doctor without --fix archived redundant branch: %+v (err=%v)", checkRedBranch, err) + } + + // ── STEP 2: `codeaf doctor --fix` repairs what is safe and preserves every safety invariant ── + var fixOut bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath, "--fix"}, &fixOut, 0); err != nil { + t.Fatal(err) + } + fixedText := fixOut.String() + for _, wantFix := range []string{ + "fixed: released unfinished continue for chat " + unfinishedID, + "fixed: retired duplicate branch copy " + redundantBranchID + " of chat " + mainChatID, + } { + if !strings.Contains(fixedText, wantFix) { + t.Fatalf("doctor --fix missing %q:\n%s", wantFix, fixedText) + } + } + for _, forbidden := range []string{"daemon", "launchd", "systemd", "service", "brain", "standing watch", "auditor", "verdict", "verified", "refuted"} { + if strings.Contains(strings.ToLower(fixedText), forbidden) { + t.Fatalf("doctor --fix output contains forbidden word %q:\n%s", forbidden, fixedText) + } + } + + // Safe repairs happened: + if _, err := os.Stat(staleLockPath); err != nil { + t.Fatalf("doctor --fix deleted a free lock file: %v", err) + } + if _, err := os.Stat(unfinishedTaking); !os.IsNotExist(err) { + t.Fatalf("doctor --fix did not remove empty .taking dir: %v", err) + } + afterUnfinished, err := syncA.Dir.Cell(ctx, unfinishedID) + if err != nil || afterUnfinished.Cell.Lease.Expires != 0 { + t.Fatalf("doctor --fix did not release this computer's stale take: %+v (err=%v)", afterUnfinished, err) + } + afterRedBranch, err := syncA.Dir.Cell(ctx, redundantBranchID) + if err != nil || !afterRedBranch.Cell.Archived { + t.Fatalf("doctor --fix did not retire redundant branch copy: %+v (err=%v)", afterRedBranch, err) + } + + // Safety Invariant 1: NEVER touches another computer's live hold! + afterOther, err := syncB.Dir.Cell(ctx, otherLiveID) + if err != nil { + t.Fatal(err) + } + if afterOther.Cell.Lease.Device != syncB.Device.ID() || afterOther.Cell.Lease.Fence != otherView.Cell.Lease.Fence || afterOther.Cell.Lease.Expires <= afterOther.Now { + t.Fatalf("doctor --fix touched another computer's live hold: got %+v, want device %s fence %d live", afterOther.Cell.Lease, syncB.Device.ID(), otherView.Cell.Lease.Fence) + } + + // Safety Invariant 2: NEVER touches a live process's store lock! + if _, stillBusy := cellstore.InspectStoreBusy(sharedStore, ""); !stillBusy { + t.Fatal("doctor --fix disturbed a live foreign process's store lock") + } + + // Safety Invariant 3: NEVER deletes or retires a branch copy or .taking folder with unique turns! + afterUniqBranch, err := syncB.Dir.Cell(ctx, uniqueBranchID) + if err != nil || afterUniqBranch.Cell.Archived { + t.Fatalf("doctor --fix retired branch copy whose turns are NOT in main chat: %+v (err=%v)", afterUniqBranch, err) + } + if _, err := os.Stat(session.Place{Dir: uniqueStagingDir}.Transcript()); err != nil { + t.Fatalf("doctor --fix deleted .taking folder containing unique turns: %v", err) + } +} + +// TestDoctorDoesNotCreateRecordsOnSyncService verifies that read-only +// `codeaf doctor` makes only a read-only listing call under the person's own +// identity and creates no namespaces, devices, cells, or Raft stores on the +// sync service (and makes zero requests when sync is off or unpaired). +func TestDoctorDoesNotCreateRecordsOnSyncService(t *testing.T) { + celltest.Available(t) + storeDir := t.TempDir() + svc := relayserve.New(relayserve.Config{Store: storeDir}) + t.Cleanup(func() { _ = svc.Close() }) + + var mu sync.Mutex + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + svc.Handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + + homeA := t.TempDir() + t.Setenv(home.EnvVar, homeA) + t.Setenv(syncsetup.URLVar, srv.URL) + + if _, err := identity.Ensure(homeA); err != nil { + t.Fatal(err) + } + syncA, ok, err := syncsetup.Open(homeA) + if err != nil || !ok { + t.Fatalf("syncsetup.Open(homeA) = (%v, %v)", ok, err) + } + if err := syncsetup.PutOwnDevice(context.Background(), syncA.Dir, syncA.Identity, syncA.Device, "desk"); err != nil { + t.Fatal(err) + } + + readNames := func(dir string) []string { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + slices.Sort(names) + return names + } + + dirNamesBefore := readNames(filepath.Join(storeDir, "directory")) + raftNamesBefore := readNames(filepath.Join(storeDir, "raft")) + + mu.Lock() + requests = nil + mu.Unlock() + + dbPath := filepath.Join(t.TempDir(), "graph.db") + var out bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath}, &out, 0); err != nil { + t.Fatal(err) + } + + mu.Lock() + gotRequests := slices.Clone(requests) + requests = nil + mu.Unlock() + + if !slices.Equal(gotRequests, []string{"GET /v1/dir/list"}) { + t.Fatalf("doctor HTTP requests = %v, want only [GET /v1/dir/list]", gotRequests) + } + if got := readNames(filepath.Join(storeDir, "directory")); !slices.Equal(got, dirNamesBefore) { + t.Fatalf("relay directory files changed after doctor: got %v, want %v", got, dirNamesBefore) + } + if got := readNames(filepath.Join(storeDir, "raft")); !slices.Equal(got, raftNamesBefore) { + t.Fatalf("relay raft files changed after doctor: got %v, want %v", got, raftNamesBefore) + } + + // Sync off: zero HTTP requests. + t.Setenv(syncsetup.URLVar, "off") + var offOut bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath}, &offOut, 0); err != nil { + t.Fatal(err) + } + mu.Lock() + offRequests := slices.Clone(requests) + requests = nil + mu.Unlock() + if len(offRequests) != 0 { + t.Fatalf("doctor sent %v with CODEAF_SYNC_URL=off, want none", offRequests) + } + + // Unpaired solo home: zero HTTP requests. + soloHome := t.TempDir() + if _, err := identity.EnsureSolo(soloHome); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, soloHome) + t.Setenv(syncsetup.URLVar, srv.URL) + var soloOut bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath}, &soloOut, 0); err != nil { + t.Fatal(err) + } + mu.Lock() + soloRequests := slices.Clone(requests) + mu.Unlock() + if len(soloRequests) != 0 { + t.Fatalf("doctor sent %v on unpaired solo home, want none", soloRequests) + } +} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index c13500edcf..5e126b436c 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -156,6 +156,7 @@ func runRemoteEngine(args []string) error { } } err := remote.Serve(os.Stdin, os.Stdout, remote.Options{ + SyncService: chatSyncService(), Boot: func(hello remote.Hello) (*remote.Engine, error) { return bootEngine(hello, *workspace, *file) }, @@ -429,9 +430,11 @@ func nothingHolds(workspace string) bool { // differentEngineEnvironmentSentence names the host's workspace so the command // stops the folder actually holding the old environment, including from Home. +// It offers --no-host first: that opens this window with this terminal's +// environment and never interrupts the other window (#2072). func differentEngineEnvironmentSentence(workspace string) string { stop := engineStopWords(workspace) - return "the background engine for this folder started with a different environment than this terminal and is still in use — after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run " + stop + return "the background engine for this folder started with a different environment than this terminal and is still in use — codeaf chat --no-host opens this window on its own and leaves the other one running; or, after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run " + stop } // settleCellsMode checks the capability of an already-running host before @@ -943,7 +946,8 @@ func runEngineHost(workspaceFlag, sessionFlag string) error { return fmt.Errorf("open %s: %w", workspace, err) } err = enginehost.Run(workspace, enginehost.Options{ - Cells: cell.Available(), + HomeRemoved: func() { os.Exit(0) }, + Cells: cell.Available(), Boot: func(hello remote.Hello) (*remote.Engine, error) { return bootEngine(hello, workspace, sessionFlag) }, // usage writer owner: see CloseUsage after the host returns @@ -1239,6 +1243,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string proc.warmModels("engine/models", agent, launch.Model) return &remote.Engine{ + SyncService: chatSyncService(), ReleaseOnLeave: func(current remote.WrappedAgent) bool { built, ok := current.(*session.Agent) if !ok { @@ -1510,14 +1515,12 @@ func engineLaunchOptions(hello remote.Hello, workspace, sessionFlag string) v3Op // For the pipe engine this changes nothing at all: one conversation calls it // once, exactly as before. // -// A FAILED OPEN IS FINAL FOR EVERY DOOR BUT THE APP. A pipe or a host reads its -// key from the environment and the profile before it serves anything, so the -// open that failed for the want of one would fail the same way again. The app -// is handed its keys over the wire AFTER it has started ([appBackend.SetKey]), -// so the open that ran before the key arrived is not the answer for the rest of -// the process: [openAppEngineProcess] tries it again. A success is never -// replaced, and mu makes every opener wait for the one in flight, so two -// callers can never build two process halves. +// A FAILED OPEN IS TRIED AGAIN. The app is handed its keys over the wire AFTER +// it has started ([appBackend.SetKey]), and a host's first connection can +// precede its first vault pull, so the open that failed then is not the answer +// for the rest of the process. A success is never replaced, and mu makes every +// opener wait for the one in flight, so two callers can never build two +// process halves. var engineProcess struct { mu sync.Mutex tried bool @@ -1525,23 +1528,23 @@ var engineProcess struct { err error } -func openEngineProcess() (*v3Process, error) { return openEngineProcessRetrying(false) } - -// openAppEngineProcess is [openEngineProcess] for the app, where a process half -// that failed to open is opened again by the next caller rather than handed -// back as the same error for as long as the window stays open. -func openAppEngineProcess() (*v3Process, error) { return openEngineProcessRetrying(true) } - -func openEngineProcessRetrying(retryFailed bool) (*v3Process, error) { +// openEngineProcess opens the process half once and keeps only a success. A +// first connection can precede the serving computer's first vault pull, and the +// app is handed its keys after it has started, so a failed open is tried again +// by the next caller rather than handed back for the rest of the process. +func openEngineProcess() (*v3Process, error) { engineProcess.mu.Lock() defer engineProcess.mu.Unlock() - if !engineProcess.tried || (retryFailed && engineProcess.proc == nil) { + if engineProcess.proc == nil { engineProcess.proc, engineProcess.err = openV3Process("engine") engineProcess.tried = true } return engineProcess.proc, engineProcess.err } +// openAppEngineProcess is [openEngineProcess] for the app's doors. +func openAppEngineProcess() (*v3Process, error) { return openEngineProcess() } + // openedEngineProcess is the process half when it is already open, and nil // when it is not; it never opens one. A key that moves while nothing is open // has nothing to update, because the open that comes later reads the keys as diff --git a/cmd/codeaf/engine_environment_test.go b/cmd/codeaf/engine_environment_test.go index ca027c5f7c..d09d39cf6d 100644 --- a/cmd/codeaf/engine_environment_test.go +++ b/cmd/codeaf/engine_environment_test.go @@ -464,7 +464,7 @@ func TestEnvironmentStopCommandQuotesTheWorkspaceForTheShell(t *testing.T) { t.Run(workspace, func(t *testing.T) { note := differentEngineEnvironmentSentence(workspace) _, command, ok := strings.Cut(note, "), run ") - if !ok || !strings.Contains(note, "after work finishes, close every window for this workspace and reopen") || !strings.Contains(note, "To stop now (interrupts active turns and tasks)") { + if !ok || !strings.Contains(note, "codeaf chat --no-host opens this window on its own") || !strings.Contains(note, "after work finishes, close every window for this workspace and reopen") || !strings.Contains(note, "To stop now (interrupts active turns and tasks)") { t.Fatalf("busy note does not explain the recovery: %s", note) } // Keep the production command's complete shell argument text. Only diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index d36e069582..fda68ce3ad 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -4,9 +4,15 @@ import ( "context" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" "os" "os/signal" + "strings" "sync" + "time" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/session" @@ -14,14 +20,25 @@ import ( const quitSaveFailedWord = "codeaf could not save this chat; unsaved turns stay on this computer and return on the next start if this computer still holds the chat." const quitSaveKeptWord = "codeaf could not save this chat; unsaved turns are kept in %s and return on the next start if this computer still holds the chat." -const quitStillSavingWord = "codeaf is still saving this chat in the background; unsaved turns stay on this computer." +const quitSavingWord = "saving this chat to your other devices…" +const quitStillSavingWord = "still saving — ctrl+c leaves it saved here" +const quitStillLocalWord = "still saving on this computer — ctrl+c leaves the written turns here" +const quitLegacySavingWord = "codeaf is still saving this chat in the background; unsaved turns stay on this computer." +const quitSavedHereWord = "saved here; it reaches your other devices when the sync service at %s answers" // finalSaving keeps one failure until cleanup has returned the terminal. var finalSaving struct { sync.Mutex - failure error - surface bool - finishing bool + failure error + surface bool + finishing bool + address string + durable []func() bool + compatibility []func() bool + reporting bool + reportDone chan struct{} + reportOK bool + interrupted chan struct{} // pending keeps closing replies alive until the launcher can safely shut their links. pending []<-chan struct{} // forced bypasses cleanup after a second interrupt has restored the screen. @@ -35,6 +52,12 @@ func armFinalSaving() chan struct{} { defer finalSaving.Unlock() finalSaving.surface = true finalSaving.finishing = false + finalSaving.address = "" + finalSaving.durable = nil + finalSaving.compatibility = nil + finalSaving.reporting = false + finalSaving.reportDone = nil + finalSaving.interrupted = make(chan struct{}) finalSaving.forced = make(chan struct{}) return finalSaving.forced } @@ -93,9 +116,6 @@ func awaitFinalCloses(ctx context.Context) { if len(pending) == 0 { return } - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt) - defer signal.Stop(interrupt) for _, done := range pending { waiting: for { @@ -104,9 +124,6 @@ func awaitFinalCloses(ctx context.Context) { break waiting case <-forced: return - case <-interrupt: - surfaceForceExit(130) - return case <-ctx.Done(): markFinalSavingFinishing() return @@ -123,6 +140,21 @@ func markFinalSavingFinishing() { finalSaving.finishing = true } +// interruptFinalSaving releases the reporter without waiting for a stalled flush. +func interruptFinalSaving() { + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.finishing = true + if finalSaving.interrupted == nil { + finalSaving.interrupted = make(chan struct{}) + } + select { + case <-finalSaving.interrupted: + default: + close(finalSaving.interrupted) + } +} + // finalSavingFinishing reports whether a save was left finishing in the // background, in which case no second, competing flush is started. func finalSavingFinishing() bool { @@ -141,9 +173,19 @@ func recordFinalSaveFailure(err error) { defer finalSaving.Unlock() if errors.Is(err, session.ErrSavingUnanswered) { finalSaving.finishing = true + } + if syncwords.UploadPending(err) { + finalSaving.finishing = true + if finalSaving.failure == nil { + finalSaving.failure = err + } return } - if finalSaving.failure == nil || (unsavedTranscript(finalSaving.failure) == "" && unsavedTranscript(err) != "") { + previousUnanswered := errors.Is(finalSaving.failure, session.ErrSavingUnanswered) + unanswered := errors.Is(err, session.ErrSavingUnanswered) + if finalSaving.failure == nil || syncwords.UploadPending(finalSaving.failure) || + previousUnanswered && !unanswered || + !unanswered && unsavedTranscript(finalSaving.failure) == "" && unsavedTranscript(err) != "" { finalSaving.failure = err } } @@ -158,26 +200,271 @@ func unsavedTranscript(err error) string { } // finishFinalSaving reports one plain sentence after the screen is restored. -func finishFinalSaving(ctx context.Context) bool { - if !finalSavingFinishing() { - recordFinalSaveFailure(flushFinalSaves(ctx)) +func finishFinalSaving(ctx context.Context) (ok bool) { + defer stopFinalSaveProgress() + done, interrupted, alreadyFinishing, owner := beginFinalReport() + if !owner { + <-done + return finalReportResult() } - err, surface, finishing := takeFinalSaveFailure() + if !alreadyFinishing { + flushCtx, cancel := context.WithCancel(ctx) + defer cancel() + flushed := make(chan error, 1) + flush := flushFinalSaves + // Only this reporter owns the flush result. A late worker cannot change + // a completed receipt or a later command's reporting generation. + guard.Go("quit/flush", func() { flushed <- flush(flushCtx) }) + select { + case err := <-flushed: + recordFinalSaveFailure(err) + case <-interrupted: + select { + case err := <-flushed: + recordFinalSaveFailure(err) + default: + } + case <-ctx.Done(): + markFinalSavingFinishing() + select { + case err := <-flushed: + recordFinalSaveFailure(err) + default: + } + } + } + err, surface, finishing, address, checks, legacy := takeFinalReport() + defer func() { completeFinalReport(done, ok) }() if err == nil && !finishing { return true } + safe := allLocalDurable(checks) + if address == "" { + address = syncsetup.Resolve(home.Dir()).URL + } + if syncwords.UploadPending(err) && surface && !safe { + err = session.ErrSavingUnanswered + } + if finishing && (err == nil || errors.Is(err, session.ErrSavingUnanswered)) && safe { + err = syncwords.UploadFailure(session.ErrSavingUnanswered) + } + stopFinalSaveProgress() + line, ok := finalSaveWords(err, finishing, address) + if legacy && (syncwords.UploadPending(err) || finishing && (err == nil || errors.Is(err, session.ErrSavingUnanswered))) { + line, ok = quitLegacySavingWord, true + } terminal := os.Stderr if surface { terminal = os.Stdout } + if line != "" { + fmt.Fprintln(terminal, line) + } + return ok +} + +// finalSaveWords distinguishes durable local turns from a failed local write. +func finalSaveWords(err error, finishing bool, address string) (string, bool) { + if syncwords.UploadPending(err) { + return fmt.Sprintf(quitSavedHereWord, syncwords.Host(address)), true + } + if (err == nil && finishing) || errors.Is(err, session.ErrSavingUnanswered) { + return "saving on this computer has not finished; its written turns stay here", false + } if err == nil { - fmt.Fprintln(terminal, quitStillSavingWord) - return true + return "", true } - line := quitSaveFailedWord if transcript := unsavedTranscript(err); transcript != "" { - line = fmt.Sprintf(quitSaveKeptWord, transcript) + return fmt.Sprintf(quitSaveKeptWord, transcript), false + } + return quitSaveFailedWord, false +} + +// quitProgress has an injected clock because the three-second line is a UI contract. +type quitProgress struct { + after func(time.Duration) <-chan time.Time + say func(string) + stop <-chan struct{} + safe func() bool +} + +func (q quitProgress) run() { + select { + case <-q.stop: + return + case <-q.after(150 * time.Millisecond): + } + q.say(quitSavingWord) + select { + case <-q.stop: + return + case <-q.after(3 * time.Second): + if q.safe != nil && !q.safe() { + q.say(quitStillLocalWord) + } else { + q.say(quitStillSavingWord) + } + } + <-q.stop +} + +var finalProgress struct { + sync.Mutex + stop chan struct{} + done chan struct{} +} + +// startFinalSaveProgress runs only after the alternate screen has been restored. +// It covers all cleanup, including a closing link and the last local flush. +func startFinalSaveProgress() { + address, checks, legacy := finalProgressState() + if address == "" || strings.EqualFold(address, "off") { + return + } + finalProgress.Lock() + defer finalProgress.Unlock() + if finalProgress.stop != nil { + return + } + stop, done := make(chan struct{}), make(chan struct{}) + finalProgress.stop, finalProgress.done = stop, done + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + guard.Go("quit/interrupt", func() { + defer signal.Stop(interrupt) + select { + case <-stop: + return + case <-interrupt: + if legacy { + surfaceForceExit(130) + return + } + interruptFinalSaving() + ok := finishFinalSaving(context.Background()) + if ok { + surfaceForceExit(0) + } else { + surfaceForceExit(1) + } + } + }) + + guard.Go("quit/progress", func() { + defer close(done) + if legacy { + return + } + q := quitProgress{safe: func() bool { return allLocalDurable(checks) }, after: time.After, say: func(line string) { fmt.Fprintln(os.Stdout, line) }, stop: stop} + q.run() + }) +} + +func stopFinalSaveProgress() { + done := takeFinalProgress() + if done != nil { + <-done + } +} + +func retainLocalDurability(check func() bool) { + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.durable = append(finalSaving.durable, check) +} + +// allLocalDurable requires every captured chat's positive local receipt. +func allLocalDurable(checks []func() bool) bool { + if len(checks) == 0 { + return false + } + for _, check := range checks { + if !check() { + return false + } + } + return true +} + +// prepareFinalReport starts a separate non-surface command without reusing a prior receipt. +func prepareFinalReport() { + finalSaving.Lock() + defer finalSaving.Unlock() + if !finalSaving.surface { + finalSaving.reporting = false + finalSaving.reportDone = nil + finalSaving.interrupted = make(chan struct{}) + } +} + +func retainSavingCompatibility(check func() bool) { + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.compatibility = append(finalSaving.compatibility, check) +} + +// legacySavingLocked preserves the baseline when any participating engine lacks receipts. +func legacySavingLocked() bool { + for _, check := range finalSaving.compatibility { + if !check() { + return true + } } - fmt.Fprintln(terminal, line) return false } + +// beginFinalReport elects one reporter without holding the lock during I/O. +func beginFinalReport() (done, interrupted chan struct{}, finishing, owner bool) { + finalSaving.Lock() + defer finalSaving.Unlock() + if finalSaving.reporting { + return finalSaving.reportDone, nil, false, false + } + finalSaving.reporting = true + finalSaving.reportDone = make(chan struct{}) + if finalSaving.interrupted == nil { + finalSaving.interrupted = make(chan struct{}) + } + return finalSaving.reportDone, finalSaving.interrupted, finalSaving.finishing, true +} +func finalReportResult() bool { + finalSaving.Lock() + defer finalSaving.Unlock() + return finalSaving.reportOK +} +func completeFinalReport(done chan struct{}, ok bool) { + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.reportOK = ok + close(done) +} +func takeFinalReport() (err error, surface, finishing bool, address string, checks []func() bool, legacy bool) { + finalSaving.Lock() + defer finalSaving.Unlock() + err, surface, finishing, address = finalSaving.failure, finalSaving.surface, finalSaving.finishing, finalSaving.address + checks = append([]func() bool(nil), finalSaving.durable...) + legacy = legacySavingLocked() + finalSaving.failure, finalSaving.surface, finalSaving.finishing = nil, false, false + finalSaving.address, finalSaving.durable, finalSaving.compatibility = "", nil, nil + return +} +func finalProgressState() (address string, checks []func() bool, legacy bool) { + finalSaving.Lock() + defer finalSaving.Unlock() + return finalSaving.address, append([]func() bool(nil), finalSaving.durable...), legacySavingLocked() +} +func takeFinalProgress() chan struct{} { + finalProgress.Lock() + defer finalProgress.Unlock() + stop, done := finalProgress.stop, finalProgress.done + finalProgress.stop, finalProgress.done = nil, nil + if stop != nil { + close(stop) + } + return done +} +func setFinalSyncService(address string) { + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.address = address +} diff --git a/cmd/codeaf/helpwidth_test.go b/cmd/codeaf/helpwidth_test.go index 1a9d52d967..67adddb186 100644 --- a/cmd/codeaf/helpwidth_test.go +++ b/cmd/codeaf/helpwidth_test.go @@ -126,6 +126,10 @@ func helpPages(t *testing.T) []helpPage { {"why", runWhy}, {"cache", runCache}, {"devices", runDevices}, + {"devbox", runDevbox}, + {"devbox up", func(args []string) error { return runDevbox(append([]string{"up"}, args...)) }}, + {"devbox status", func(args []string) error { return runDevbox(append([]string{"status"}, args...)) }}, + {"devbox down", func(args []string) error { return runDevbox(append([]string{"down"}, args...)) }}, {"pair", runPair}, {"plan new", func(args []string) error { return runPlanNew("plan new", args) }}, {"plan run", func(args []string) error { return runGraph("plan run", args) }}, diff --git a/cmd/codeaf/hosted_takeback_test.go b/cmd/codeaf/hosted_takeback_test.go index 8567e37395..83d73feffb 100644 --- a/cmd/codeaf/hosted_takeback_test.go +++ b/cmd/codeaf/hosted_takeback_test.go @@ -493,7 +493,11 @@ func TestKilledComputerTakesBackFromHomeAndLocalCard(t *testing.T) { if _, err := io.WriteString(typed, " "); err != nil { t.Fatal(err) } - h.wait("Home continuation offer", func() bool { return strings.Contains(screen.String(), "alt+c") }) + // The question and its continue chord must be visible in the same offer. + h.wait("Home continuation offer", func() bool { + text := screen.String() + return strings.Contains(text, "Continue other computer's latest chat here?") && (strings.Contains(text, "alt+c") || strings.Contains(text, "opt+c")) + }) if _, err := io.WriteString(typed, "\x1bc"); err != nil { t.Fatal(err) } diff --git a/cmd/codeaf/l4_cluster_test.go b/cmd/codeaf/l4_cluster_test.go new file mode 100644 index 0000000000..3aec2eae32 --- /dev/null +++ b/cmd/codeaf/l4_cluster_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/clusterctl" + cs "github.com/Agent-Field/codeaf/internal/clusterstate" +) + +type l4Cluster struct{} + +func (l4Cluster) ClusterStatus(context.Context) (clusterctl.Status, error) { + return clusterctl.Status{Self: 1, Leader: 1, Members: []cs.MemberLabel{{Node: 5, Device: "gone"}}, Configuration: cs.Configuration{Voters: []uint64{1, 2, 3}, Learners: []uint64{4, 5}}}, nil +} +func (l4Cluster) LeadCluster(context.Context, uint64) (cs.LeadershipReceipt, error) { + return cs.LeadershipReceipt{Node: 1}, nil +} +func (l4Cluster) ClusterNames(context.Context, clusterctl.Status) map[uint64]string { + return map[uint64]string{1: "laptop-a", 2: "desk-b", 3: "the sync service", 4: "box-c", 5: ""} +} + +// L4-M14a, L4-M14b, L4-M14d: ordinary status and leadership use names; verbose keeps debugging details. +func TestL4ClusterNames(t *testing.T) { + var out bytes.Buffer + if err := clusterCommandMode(context.Background(), l4Cluster{}, "status", &out, false); err != nil { + t.Fatal(err) + } + for _, want := range []string{"coordinating laptop-a (this computer)", "deciding laptop-a", "2 of 3 needed", "keeping copies box-c", "a computer no longer in your devices"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("%q in %s", want, out.String()) + } + } + for _, bad := range []string{"[1 2 3]", "term", "revision", "read barrier", "voting", "learning"} { + if strings.Contains(out.String(), bad) { + t.Fatal(out.String()) + } + } + out.Reset() + if err := clusterCommandMode(context.Background(), l4Cluster{}, "lead", &out, false); err != nil { + t.Fatal(err) + } + if out.String() != "laptop-a now coordinates your devices\n" { + t.Fatal(out.String()) + } + out.Reset() + if err := clusterCommandMode(context.Background(), l4Cluster{}, "status", &out, true); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "[1 2 3]") { + t.Fatal(out.String()) + } +} diff --git a/cmd/codeaf/l4_devices_test.go b/cmd/codeaf/l4_devices_test.go new file mode 100644 index 0000000000..60cd3ffa4a --- /dev/null +++ b/cmd/codeaf/l4_devices_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/devname" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// L4-M10a, L4-M15a through L4-M15d: rows retain this computer, facts and selectable names. +func TestL4DeviceListAndSelectors(t *testing.T) { + devices := map[string]directory.Device{} + key := []byte("01234567890123456789012345678901") + for _, id := range []string{"dev_8381", "dev_9cf9"} { + sealed, err := directory.SealName(key, "desk-b") + if err != nil { + t.Fatal(err) + } + devices[id] = directory.Device{Name: sealed, Platform: "linux", LastSeen: time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC).UnixMilli(), Revoked: id == "dev_9cf9"} + } + c := chatsKind{dir: &l4DeviceDirectory{listing: directory.Listing{Devices: devices}}, key: key, self: "dev_8381"} + now := time.Date(2026, 10, 30, 12, 1, 0, 0, time.UTC) + table, err := c.table(now) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"desk-b #8381", "desk-b #9cf9", "Linux", "last seen", "this device", "removed"} { + if !strings.Contains(table, want) { + t.Fatalf("missing %q: %s", want, table) + } + } + if strings.Index(table, "removed") > strings.Index(table, "desk-b #9cf9") { + t.Fatal(table) + } + for _, name := range []string{"desk-b#8381", "desk-b #8381"} { + rows, err := c.called(name) + if err != nil || len(rows) != 1 || rows[0].id != "dev_8381" { + t.Fatalf("%v %v", rows, err) + } + } + _, err = c.stop("desk-b") + if err == nil || !strings.Contains(err.Error(), "desk-b #8381, desk-b #9cf9") || !strings.Contains(err.Error(), "codeaf devices revoke desk-b#8381") { + t.Fatal(err) + } + single := pair.ChatsDevicesListAt([]pair.ChatsDevice{{Name: "here", This: true}}, now) + if strings.Contains(single, "last seen") || strings.Contains(single, "0 ") { + t.Fatal(single) + } +} + +type l4DeviceDirectory struct { + directory.Client + listing directory.Listing +} + +func (d *l4DeviceDirectory) List(context.Context) (directory.Listing, error) { return d.listing, nil } + +func (d *l4DeviceDirectory) Presence(context.Context) (directory.PresenceView, error) { + return directory.PresenceView{}, nil +} + +// L4-M15a, L4-M15c: one sync permission and one remote permission with the same name both get selectors. +func TestL4CrossKindDeviceSelectors(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + key := []byte("01234567890123456789012345678901") + sealed, err := directory.SealName(key, "laptop-a") + if err != nil { + t.Fatal(err) + } + c := chatsKind{dir: &l4DeviceDirectory{listing: directory.Listing{Devices: map[string]directory.Device{"dev_8381": {Name: sealed}}}}, key: key} + b := pair.BookAt(filepath.Join(t.TempDir(), "devices.json")) + remote := pair.Paired{Label: "laptop-a", Key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"} + if err := b.Admit(remote); err != nil { + t.Fatal(err) + } + out, err := os.CreateTemp(t.TempDir(), "list") + if err != nil { + t.Fatal(err) + } + defer out.Close() + old := os.Stdout + os.Stdout = out + defer func() { os.Stdout = old }() + if err := listDevices([]deviceKind{c, bookKind{b}}, time.Time{}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"laptop-a #8381", devname.DeviceLabel("laptop-a", remote.ID(), true)} { + if !strings.Contains(string(raw), want) { + t.Fatalf("missing %s: %s", want, raw) + } + } + _, err = kindOf([]deviceKind{c, bookKind{b}}, "laptop-a") + if err == nil || !strings.Contains(err.Error(), "laptop-a #8381") || !strings.Contains(err.Error(), "codeaf devices revoke laptop-a#8381") { + t.Fatal(err) + } +} diff --git a/cmd/codeaf/l4_final_save_test.go b/cmd/codeaf/l4_final_save_test.go new file mode 100644 index 0000000000..a06cc1c147 --- /dev/null +++ b/cmd/codeaf/l4_final_save_test.go @@ -0,0 +1,263 @@ +package main + +import ( + "context" + "errors" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// L4-H4a, L4-H4d: the restored terminal gets immediate and three-second progress. +func TestL4QuitProgressClock(t *testing.T) { + ticks := make(chan time.Time) + waits := make(chan time.Duration) + said := make(chan string) + done := make(chan struct{}) + q := quitProgress{after: func(d time.Duration) <-chan time.Time { waits <- d; return ticks }, say: func(s string) { said <- s }, stop: done} + finished := make(chan struct{}) + go func() { q.run(); close(finished) }() + if d := <-waits; d <= 0 || d > time.Second { + t.Fatal(d) + } + ticks <- time.Time{} + if line := <-said; line != "saving this chat to your other devices…" { + t.Fatal(line) + } + if d := <-waits; d != 3*time.Second { + t.Fatal(d) + } + ticks <- time.Time{} + if line := <-said; line != "still saving — ctrl+c leaves it saved here" { + t.Fatal(line) + } + close(done) + <-finished +} + +// L4-H4b, L4-H4c: uploading failure is success locally; disk failure is not. +func TestL4QuitSaveWords(t *testing.T) { + for _, tc := range []struct { + err error + finishing, ok bool + want string + }{ + {syncwords.UploadFailure(context.DeadlineExceeded), false, true, "saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers"}, + {nil, true, false, "saving on this computer has not finished; its written turns stay here"}, + {errors.New("disk full"), false, false, "codeaf could not save this chat"}, + } { + line, ok := finalSaveWords(tc.err, tc.finishing, "http://127.0.0.1:8901") + if ok != tc.ok || !strings.HasPrefix(line, tc.want) || strings.Contains(line, "background") { + t.Fatalf("%v %q", ok, line) + } + } +} + +// L4-H4e: interruption ends the waiting function without waiting for a save reply. +func TestL4QuitWaitCanBeCanceled(t *testing.T) { + finalSaving.Lock() + finalSaving.pending = nil + finalSaving.finishing = false + finalSaving.Unlock() + retainFinalClose(make(chan struct{})) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + awaitFinalCloses(ctx) + if !finalSavingFinishing() { + t.Fatal("unanswered upload not retained locally") + } + line, ok := finalSaveWords(syncwords.UploadFailure(context.DeadlineExceeded), finalSavingFinishing(), "http://127.0.0.1:8901") + if !ok || line != "saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers" { + t.Fatalf("%v %q", ok, line) + } + finalSaving.Lock() + finalSaving.finishing = false + finalSaving.Unlock() +} + +// L4-H4b/H4c: an upload receipt never masks another chat's local failure. +func TestL4LocalSaveFailureWinsOverUpload(t *testing.T) { + for _, pending := range []error{ + syncwords.UploadFailure(context.DeadlineExceeded), + session.SavingError("/temporary/chat/transcript.jsonl", session.ErrSavingUnanswered), + } { + finalSaving.Lock() + finalSaving.failure = nil + finalSaving.finishing = false + finalSaving.Unlock() + recordFinalSaveFailure(pending) + local := errors.New("local disk full") + recordFinalSaveFailure(local) + err, _, finishing := takeFinalSaveFailure() + line, ok := finalSaveWords(err, finishing, "http://127.0.0.1:8901") + if !errors.Is(err, local) || ok || !strings.HasPrefix(line, "codeaf could not save this chat") { + t.Fatalf("local failure was hidden: %v %v %q", err, ok, line) + } + } +} + +// L4-H4c: an unanswered close with a path cannot erase an earlier local write failure. +func TestL4UnansweredCloseKeepsLocalFailure(t *testing.T) { + finalSaving.Lock() + finalSaving.failure, finalSaving.finishing = nil, false + finalSaving.Unlock() + local := errors.New("local disk full") + recordFinalSaveFailure(local) + recordFinalSaveFailure(session.SavingError("/temporary/chat/transcript.jsonl", session.ErrSavingUnanswered)) + err, _, finishing := takeFinalSaveFailure() + line, ok := finalSaveWords(err, finishing, "http://127.0.0.1:8901") + if !errors.Is(err, local) || ok || !strings.HasPrefix(line, "codeaf could not save this chat") { + t.Fatalf("local failure was replaced: %v %v %q", err, ok, line) + } +} + +// L4-H4c: concurrent final reporters share one local failure and one terminal receipt. +func TestL4ConcurrentFinalSavingKeepsFailure(t *testing.T) { + t.Cleanup(func() { + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.failure = nil + finalSaving.finishing = false + finalSaving.surface = false + finalSaving.reporting = false + finalSaving.reportDone = nil + finalSaving.durable = nil + }) + + oldFlush, oldErr := flushFinalSaves, os.Stderr + out, err := os.CreateTemp(t.TempDir(), "receipt") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stderr = out + defer func() { flushFinalSaves = oldFlush; os.Stderr = oldErr }() + armFinalSaving() + finalSaving.Lock() + finalSaving.surface = false + finalSaving.Unlock() + flushFinalSaves = func(context.Context) error { return errors.New("local disk full") } + results := make(chan bool, 2) + go func() { results <- finishFinalSaving(context.Background()) }() + go func() { results <- finishFinalSaving(context.Background()) }() + first, second := <-results, <-results + if first || second { + t.Fatal("a concurrent local failure was reported successful") + } + raw, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(raw), "codeaf could not save this chat") != 1 { + t.Fatalf("receipts=%s", raw) + } +} + +// L4-H4c: one final reporter owns the flush as well as its failure receipt. +func TestL4FinalSavingFlushHasOneOwner(t *testing.T) { + oldFlush, oldErr := flushFinalSaves, os.Stderr + out, err := os.CreateTemp(t.TempDir(), "receipt") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stderr = out + t.Cleanup(func() { + flushFinalSaves, os.Stderr = oldFlush, oldErr + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.failure, finalSaving.surface, finalSaving.finishing = nil, false, false + finalSaving.reporting, finalSaving.reportDone = false, nil + finalSaving.durable = nil + }) + armFinalSaving() + finalSaving.Lock() + finalSaving.surface = false + finalSaving.Unlock() + var calls atomic.Int32 + entered, release := make(chan struct{}), make(chan struct{}) + flushFinalSaves = func(context.Context) error { + if calls.Add(1) == 1 { + close(entered) + <-release + return errors.New("local disk full") + } + return nil + } + results := make(chan bool, 2) + go func() { results <- finishFinalSaving(context.Background()) }() + <-entered + go func() { results <- finishFinalSaving(context.Background()) }() + close(release) + first, second := <-results, <-results + if first || second || calls.Load() != 1 { + t.Fatalf("results=%v,%v flushes=%d", first, second, calls.Load()) + } +} + +// L4-H4e: an interrupt releases the sole reporter even when its flush is stalled. +func TestL4InterruptedFinalSavingUsesLocalReceipt(t *testing.T) { + for _, safe := range []bool{false, true} { + t.Run(map[bool]string{false: "local unconfirmed", true: "local confirmed"}[safe], func(t *testing.T) { + oldFlush, oldOut := flushFinalSaves, os.Stdout + out, err := os.CreateTemp(t.TempDir(), "receipt") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stdout = out + t.Cleanup(func() { + flushFinalSaves, os.Stdout = oldFlush, oldOut + finalSaving.Lock() + defer finalSaving.Unlock() + finalSaving.failure, finalSaving.surface, finalSaving.finishing = nil, false, false + finalSaving.reporting, finalSaving.reportDone = false, nil + finalSaving.durable = nil + }) + armFinalSaving() + finalSaving.Lock() + finalSaving.address = "http://127.0.0.1:8901" + finalSaving.Unlock() + retainLocalDurability(func() bool { return safe }) + entered, release, workerDone := make(chan struct{}), make(chan struct{}), make(chan struct{}) + defer func() { close(release); <-workerDone }() + flushFinalSaves = func(context.Context) error { + defer close(workerDone) + close(entered) + <-release + return syncwords.UploadFailure(context.DeadlineExceeded) + } + result := make(chan bool, 1) + go func() { result <- finishFinalSaving(context.Background()) }() + <-entered + interruptFinalSaving() + select { + case ok := <-result: + if ok != safe { + t.Fatalf("local=%v result=%v", safe, ok) + } + case <-time.After(time.Second): + t.Fatal("interrupt still waits for the flush") + } + if got := finishFinalSaving(context.Background()); got != safe { + t.Fatalf("follower result=%v local=%v", got, safe) + } + raw, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + want := "saving on this computer has not finished; its written turns stay here\n" + if safe { + want = "saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers\n" + } + if string(raw) != want { + t.Fatalf("receipt=%q", raw) + } + }) + } +} diff --git a/cmd/codeaf/l4_help_test.go b/cmd/codeaf/l4_help_test.go new file mode 100644 index 0000000000..3e17da13fe --- /dev/null +++ b/cmd/codeaf/l4_help_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" +) + +// L4-M2a, L4-M8b: help gives the real pairing code shape and both service +// overrides. The front page's line cap leaves codeaf relay to its own help. +func TestL4PairAndRelayHelp(t *testing.T) { + if strings.Contains(usageText, "six-digit code for your chats") { + t.Fatal("top-level help gives the wrong code shape") + } + for _, name := range []string{"CODEAF_SYNC_URL", "CODEAF_RELAY"} { + if !strings.Contains(environmentText, name) { + t.Fatal(name) + } + } + var out bytes.Buffer + d := newPairDoor(strings.NewReader(""), &out) + old := usageOut + usageOut = &out + defer func() { usageOut = old }() + _ = d.run(context.Background(), []string{"--help"}) + if !strings.Contains(out.String(), "04-063-213") || strings.Contains(out.String(), "six-digit") { + t.Fatal(out.String()) + } +} diff --git a/cmd/codeaf/l4_machine_test.go b/cmd/codeaf/l4_machine_test.go new file mode 100644 index 0000000000..16b7d7cd76 --- /dev/null +++ b/cmd/codeaf/l4_machine_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/pair" +) + +// L4-M3a, L4-M3b, L4-FIX5-b: a label cannot prove a paired device; explain the permission before asking. +func TestL4MachineApprovalForPairedDevice(t *testing.T) { + for _, tc := range []struct { + label, answer string + yes bool + }{ + {label: "laptop-a", answer: "y\n", yes: true}, + {label: "new-device", answer: "n\n"}, + } { + t.Run(tc.label, func(t *testing.T) { + out := &pairScreenText{} + person := newTerminal(strings.NewReader(tc.answer), out) + if got := confirmMachine(person, context.Background(), tc.label, "red green blue"); got != tc.yes { + t.Fatalf("approval = %v; want %v", got, tc.yes) + } + text := out.String() + weight := strings.Index(text, pair.PairingWeight) + question := strings.Index(text, pair.AskMachineLine(tc.label, "red green blue")) + choice := strings.Index(text, pair.AskChoice) + if weight < 0 || question <= weight || choice <= question { + t.Fatalf("expected permission, ordinary question and choice in that order:\n%s", text) + } + if strings.Contains(text, "one of your devices") { + t.Fatalf("the question infers a paired device from an unverified label:\n%s", text) + } + }) + } +} diff --git a/cmd/codeaf/l4_pair_test.go b/cmd/codeaf/l4_pair_test.go new file mode 100644 index 0000000000..c36e984e8a --- /dev/null +++ b/cmd/codeaf/l4_pair_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/devname" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// L4-H2c: already paired is the first explanation, even when --replace was supplied. +func TestL4AlreadyPairedNamesDevices(t *testing.T) { + r := newPairRig(t) + id, err := identity.Load(r.homeA) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"laptop-a", "box-c"} { + peerHome := t.TempDir() + if _, err := identity.Adopt(peerHome, id, true); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(peerHome, name); err != nil { + t.Fatal(err) + } + if err := ownDeviceRecord(context.Background(), peerHome, pair.Mailbox{URL: r.url}); err != nil { + t.Fatal(err) + } + } + d := r.door(r.homeA, strings.NewReader(""), &pairScreenText{}) + for _, replace := range []bool{false, true} { + err := d.pairingStatus(context.Background(), pair.Mailbox{URL: r.url}, replace) + if err == nil || err.Error() != "this computer is already paired with box-c and laptop-a — codeaf devices lists them" { + t.Fatalf("%v", err) + } + } +} + +// L4-H1b, L4-H1c: neither approval road reads a mismatched link from any service. +func TestL4ApprovalMismatchMakesNoRequest(t *testing.T) { + for _, tc := range []struct{ own, link string }{ + {"http://127.0.0.1:8901", "https://codeaf.agentfield.ai/p/k7m2q9xd"}, + {"https://codeaf.agentfield.ai/fabric", "http://127.0.0.1:8901/p/k7m2q9xd"}, + } { + mailbox := func(string) (pair.Mailbox, error) { return pair.Mailbox{URL: tc.own}, nil } + called := false + approver := func(pair.Mailbox) (pair.Approver, error) { called = true; return pair.Approver{}, nil } + d := pairDoor{mailbox: mailbox, approver: approver} + if err := d.approve(context.Background(), "", tc.link); err == nil || strings.Contains(err.Error(), "not waiting") { + t.Fatal(err) + } + ui := &approvalsDoor{mailbox: mailbox, approver: approver} + if _, err := ui.Pending(context.Background(), tc.link); err == nil || strings.Contains(err.Error(), "not waiting") { + t.Fatal(err) + } + if called { + t.Fatal("a mismatched approval reached the approver") + } + } +} + +// L4-H2a, L4-M12a, L4-M12b: a looked-around computer pairs through both real doors. +func TestL4PairEmptyChatThroughBothDoors(t *testing.T) { + for _, code := range []bool{false, true} { + t.Run(map[bool]string{false: "link", true: "code"}[code], func(t *testing.T) { + r := newPairRig(t) + t.Setenv("CODEAF_HOME", r.homeB) + if _, err := devname.Set(r.homeA, "laptop-a"); err != nil { + t.Fatal(err) + } + if _, err := identity.EnsureSolo(r.homeB); err != nil { + t.Fatal(err) + } + dir := filepath.Join(r.homeB, "v3/projects/project/chat/.cell") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "transcript.jsonl"), []byte("{\"type\":\"session\",\"id\":\"chat\"}\n"), 0600); err != nil { + t.Fatal(err) + } + if !code { + screen, done := r.asking(t) + token := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + if _, err := r.approveWith(t, []string{"approve", token}, "a\n"); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if !strings.Contains(screen.String(), pair.PairedWithLine("laptop-a")) { + t.Fatal(screen.String()) + } + return + } + offer := r.show(t) + typed := offer.code(t) + out := &pairScreenText{} + done := make(chan error, 1) + go func() { done <- r.door(r.homeB, strings.NewReader(""), out).run(context.Background(), []string{typed}) }() + offer.out.waitFor(t, `y / n`) + if _, err := io.WriteString(offer.typed, "y\n"); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), pair.PairedWithLine("laptop-a")) { + t.Fatal(out.String()) + } + }) + } +} diff --git a/cmd/codeaf/l4_progress_test.go b/cmd/codeaf/l4_progress_test.go new file mode 100644 index 0000000000..783c3afe78 --- /dev/null +++ b/cmd/codeaf/l4_progress_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "testing" + "time" +) + +// L4-M11a: two seconds of silence produce one stderr line, only on a terminal. +func TestL4CommandProgress(t *testing.T) { + for _, terminal := range []bool{false, true} { + var out l4ProgressOutput + out.wrote = make(chan struct{}) + tick := make(chan time.Time, 1) + armed := make(chan time.Duration, 1) + stop := syncProgress("http://127.0.0.1:8901", terminal, &out, func(d time.Duration) <-chan time.Time { armed <- d; return tick }) + if terminal { + if d := <-armed; d != 2*time.Second { + t.Fatal(d) + } + tick <- time.Time{} + } + // Joining after the fake clock fires gives a deterministic final transcript. + if terminal { + <-out.wrote + } + stop() + if terminal && out.String() != "asking the sync service at 127.0.0.1:8901…\n" { + t.Fatal(out.String()) + } + if !terminal && out.Len() != 0 { + t.Fatal(out.String()) + } + } +} + +type l4ProgressOutput struct { + bytes.Buffer + wrote chan struct{} +} + +func (o *l4ProgressOutput) Write(p []byte) (int, error) { + n, err := o.Buffer.Write(p) + close(o.wrote) + return n, err +} diff --git a/cmd/codeaf/l4_syncdrive_test.go b/cmd/codeaf/l4_syncdrive_test.go new file mode 100644 index 0000000000..94cb684930 --- /dev/null +++ b/cmd/codeaf/l4_syncdrive_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "net/url" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// L4-H3a: an unreachable service cannot trap a tool behind an endless startup retry loop. +func TestL4SyncOutageGateFallsBackLocally(t *testing.T) { + h := t.TempDir() + t.Setenv("CODEAF_HOME", h) + t.Setenv("CODEAF_SYNC_URL", "http://127.0.0.1:8901") + if _, err := identity.Ensure(h); err != nil { + t.Fatal(err) + } + var attempts atomic.Int32 + drive := newLiveDrive(func(func()) (*syncsetup.Drive, error) { + attempts.Add(1) + return nil, &url.Error{Op: "Put", URL: "http://127.0.0.1:8901/device", Err: syscall.ECONNREFUSED} + }) + if ready := drive.pending(); ready != nil { + <-ready + } + done := make(chan error, 1) + go func() { done <- drive.Gate() }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("local tools were trapped by sync startup retries") + } + if n := attempts.Load(); n != 2 { + t.Fatalf("startup attempts=%d, want one initial and one for this call", n) + } +} diff --git a/cmd/codeaf/l4b2_cluster_test.go b/cmd/codeaf/l4b2_cluster_test.go new file mode 100644 index 0000000000..61f1ab0a7b --- /dev/null +++ b/cmd/codeaf/l4b2_cluster_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/clusterctl" + cs "github.com/Agent-Field/codeaf/internal/clusterstate" +) + +// L4-M14e: An older service's unnamed members are unknown computers, while an evidenced removal is named honestly. +func TestL4B2ClusterWithoutLabels(t *testing.T) { + s := clusterctl.Status{Self: 1, Leader: 2, Configuration: cs.Configuration{Voters: []uint64{1, 2, 3}, Learners: []uint64{4, 5}}} + for _, labels := range []bool{false, true} { + names := map[uint64]string{1: "laptop-a"} + if labels { + s.Members = []cs.MemberLabel{{Node: 2, Device: "gone"}} + names[2] = "" + } + var out bytes.Buffer + if err := printClusterStatus(&out, s, names); err != nil { + t.Fatal(err) + } + text := out.String() + if labels { + if !strings.Contains(text, "coordinating a computer no longer in your devices") { + t.Fatal(text) + } + } else { + if strings.Contains(text, "no longer") || !strings.Contains(text, "coordinating another computer") || !strings.Contains(text, "2 other computers") { + t.Fatal(text) + } + } + if strings.Contains(text, "[2 3]") || strings.Contains(text, "computer 2") { + t.Fatal(text) + } + } + // A chosen device name that matches the fallback is still a known name. + s.Members = nil + var out bytes.Buffer + if err := printClusterStatus(&out, s, map[uint64]string{1: "laptop-a", 2: "another computer"}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "deciding laptop-a · 2 other computers") { + t.Fatal("a chosen name was counted as unknown") + } + +} diff --git a/cmd/codeaf/l4b2_devices_test.go b/cmd/codeaf/l4b2_devices_test.go new file mode 100644 index 0000000000..adf63c4ff8 --- /dev/null +++ b/cmd/codeaf/l4b2_devices_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/devname" +) + +// L4-M10b, L4-M10c: An unpaired computer is listed from local facts without contacting any service. +func TestL4B2UnpairedDeviceListsItself(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_SYNC_URL", "off") + door := newApprovalsDoor(dir) + rows, err := door.Devices(context.Background()) + if err != nil || len(rows) != 1 || !rows[0].Self || rows[0].Name != devname.Name(dir) || rows[0].Platform == "" { + t.Fatalf("rows=%+v err=%v", rows, err) + } +} diff --git a/cmd/codeaf/l4b2_pair_test.go b/cmd/codeaf/l4b2_pair_test.go new file mode 100644 index 0000000000..6babf6228f --- /dev/null +++ b/cmd/codeaf/l4b2_pair_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" +) + +type l4B2OfferScreen struct{ lines string } + +func (s *l4B2OfferScreen) Show(_ *pair.Code, lines string) { s.lines = lines } +func (*l4B2OfferScreen) Burned() {} +func (*l4B2OfferScreen) Ask(context.Context, string, string) bool { return false } + +// L4-M5a: The chat offer adapter supplies both directions through the selected service; the terminal stays a code road. +func TestL4B2ChatOfferDirections(t *testing.T) { + code, err := pair.NewCode(time.Unix(1_000_000, 0)) + if err != nil { + t.Fatal(err) + } + screen := &l4B2OfferScreen{} + ui := chatOfferUI{OfferUI: screen, service: "http://127.0.0.1:8901"} + terminal := pair.ChatLines(code, ui.service) + ui.Show(code, terminal) + want := "paste the link the new device shows, or on it run codeaf pair " + code.Shown() + " --via http://127.0.0.1:8901" + if !strings.Contains(screen.lines, want) { + t.Fatal(screen.lines) + } + if strings.Contains(terminal, "paste the link") { + t.Fatal("terminal offered an absent field") + } +} diff --git a/cmd/codeaf/l4fix2_devices_test.go b/cmd/codeaf/l4fix2_devices_test.go new file mode 100644 index 0000000000..d4e5b4c241 --- /dev/null +++ b/cmd/codeaf/l4fix2_devices_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// L4-M10a, L4-B15 stability: The capable public command explicitly includes +// local facts, while the existing roster renderer keeps its base empty result. +func TestL4Fix2PublicUnpairedDevices(t *testing.T) { + celltest.Available(t) + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + t.Setenv("CODEAF_SYNC_URL", "off") + if _, err := devname.Set(dir, "fresh-laptop"); err != nil { + t.Fatal(err) + } + text, err := captureStdout(t, func() error { return runDevices(nil) }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"fresh-laptop", "this device", pair.NextPairing} { + if !strings.Contains(text, want) { + t.Fatalf("missing %q: %s", want, text) + } + } + if strings.Count(text, pair.NextPairing) != 1 || strings.Contains(text, "remove one with") || strings.Contains(text, pair.NoDevices) { + t.Fatal(text) + } +} diff --git a/cmd/codeaf/l4fix2_service_test.go b/cmd/codeaf/l4fix2_service_test.go new file mode 100644 index 0000000000..49df594792 --- /dev/null +++ b/cmd/codeaf/l4fix2_service_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// L4-B8 and stability: the remote/pairing override does not silently move chat +// syncing away from the service saved by pairing. Each command names its input. +func TestL4Fix2RemoteOverrideDoesNotMoveChatSync(t *testing.T) { + local := t.TempDir() + t.Setenv("CODEAF_HOME", local) + t.Setenv("CODEAF_SYNC_URL", "") + t.Setenv("CODEAF_RELAY", "https://remote.example") + if err := syncsetup.SaveRelayURL(local, "https://sync.example"); err != nil { + t.Fatal(err) + } + if got := syncsetup.Resolve(local).URL; got != "https://sync.example" { + t.Errorf("remote override changed chat syncing: %q", got) + } + route, err := pairMailbox("") + if err != nil || route.URL != "https://remote.example" { + t.Fatalf("pairing route=%+v %v", route, err) + } + if command := (chatPairDoor{}).PairCommand(); !strings.Contains(command, "--via https://remote.example") { + t.Fatal(command) + } + if got := newApprovalsDoor(local).SyncService(); got != "https://sync.example" { + t.Errorf("devices named the wrong chat service: %q", got) + } + t.Setenv("CODEAF_SYNC_URL", "https://explicit-sync.example") + route, err = pairMailbox("") + if err != nil || route.URL != "https://explicit-sync.example" { + t.Fatalf("explicit sync route=%+v %v", route, err) + } +} diff --git a/cmd/codeaf/l4fix2_solo_test.go b/cmd/codeaf/l4fix2_solo_test.go new file mode 100644 index 0000000000..8c58517a21 --- /dev/null +++ b/cmd/codeaf/l4fix2_solo_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// L4-B11 and stability: a never-paired computer does not gain sync progress, +// and a second quit interrupt keeps status 130 even with a current engine. +func TestL4Fix2SoloQuitKeepsBaseline(t *testing.T) { + for _, host := range []string{"", "desk-b"} { + t.Run(map[bool]string{false: "solo computer", true: "paired viewer of solo engine"}[host != ""], func(t *testing.T) { + local := t.TempDir() + t.Setenv("CODEAF_HOME", local) + if _, err := identity.EnsureSolo(local); err != nil { + t.Fatal(err) + } + if host != "" { + if err := identity.EndSolo(local); err != nil { + t.Fatal(err) + } + } + t.Cleanup(cell.UseAvailabilityInTests(false)) + oldRun, oldExit := runSurfaceProgram, surfaceForceExit + defer func() { + runSurfaceProgram, surfaceForceExit = oldRun, oldExit + stopFinalSaveProgress() + takeFinalSaveFailure() + }() + restored, code := false, 0 + runSurfaceProgram = func(_ context.Context, o tui3.Options) error { + if o.SyncService != "" { + t.Errorf("solo startup advertised sync: %q", o.SyncService) + } + o.SavingCompatibility(func() bool { return true }) + o.SavingDurability(func() bool { return true }) + o.ForceQuit() + restored = true + return nil + } + surfaceForceExit = func(got int) { + if !restored { + t.Error("exit before terminal restoration") + } + code = got + } + if err := runSurface(context.Background(), tui3.Options{ProfileDir: local, Host: host}); err != nil { + t.Fatal(err) + } + if code != 130 { + t.Fatalf("second interrupt exit=%d", code) + } + }) + } +} diff --git a/cmd/codeaf/l4fix3_cluster_test.go b/cmd/codeaf/l4fix3_cluster_test.go new file mode 100644 index 0000000000..dc87152be5 --- /dev/null +++ b/cmd/codeaf/l4fix3_cluster_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "bytes" + "context" + "errors" + "syscall" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// L4-H6f, fix round 3: A command's selected service survives errors that carry +// no request URL, independently of the default and the presentation mode. +func TestL4Fix3ClusterKeepsSelectedService(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv(syncsetup.URLVar, "https://another.example") + const selected = "http://selected.example/fabric" + for _, verb := range []string{"status", "lead"} { + for _, verbose := range []bool{false, true} { + for _, cause := range []error{context.DeadlineExceeded, syscall.ECONNREFUSED} { + var output bytes.Buffer + err := clusterCommandMode(t.Context(), clusterFixture{err: cause}, verb, &output, verbose, selected) + want := syncwords.Explain(cause, selected).Error() + if err == nil || err.Error() != want || !errors.Is(err, cause) || output.Len() != 0 { + t.Fatalf("%s verbose=%v: error=%v output=%q; want %q", verb, verbose, err, output.String(), want) + } + } + } + } +} diff --git a/cmd/codeaf/l4fix3_devices_test.go b/cmd/codeaf/l4fix3_devices_test.go new file mode 100644 index 0000000000..740c9be488 --- /dev/null +++ b/cmd/codeaf/l4fix3_devices_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// L4-M15a, L4-M15b, fix round 3: Plain roster names remain usable by devbox, +// while duplicate selectors still appear in the list and removal receipts. +func TestL4Fix3RosterNamesAndSelectors(t *testing.T) { + kind, dir := chatsWith(t, "desk") + rows, err := kind.rows() + if err != nil || len(rows) != 2 { + t.Fatalf("roster: %+v, %v", rows, err) + } + for _, row := range rows { + if row.Name != "desk" { + t.Fatalf("a selector replaced the roster name: %+v", row) + } + } + table, err := kind.table(time.Unix(1000, 0)) + if err != nil || !strings.Contains(table, "desk #aaaa") || !strings.Contains(table, "desk #bbbb") { + t.Fatalf("selectors: %q, %v", table, err) + } + line, err := kind.stop("desk#bbbb") + if err != nil || line != pair.ChatsRevokedLine("desk #bbbb") { + t.Fatalf("removal receipt: %q, %v", line, err) + } + listing, err := dir.List(t.Context()) + if err != nil || !listing.Devices[otherComputer].Revoked || listing.Devices[thisComputer].Revoked { + t.Fatalf("removal selected the wrong device: %+v, %v", listing, err) + } + line, err = kind.stop("desk#bbbb") + if err != nil || line != "desk #bbbb is already removed." { + t.Fatalf("repeated removal: %q, %v", line, err) + } + // The displayed selector, rather than the full device id, still decides + // alphabetical order after selectors move out of the roster. + sealed, err := directory.SealName(kind.key, "desk") + if err != nil { + t.Fatal(err) + } + ordered := chatsKind{key: kind.key, dir: &l4DeviceDirectory{listing: directory.Listing{Devices: map[string]directory.Device{ + "dev_1111bbbb": {Name: sealed}, + "dev_2222aaaa": {Name: sealed}, + }}}} + table, err = ordered.table(time.Unix(1000, 0)) + if err != nil || strings.Index(table, "desk #aaaa") < 0 || strings.Index(table, "desk #aaaa") > strings.Index(table, "desk #bbbb") { + t.Fatalf("selector order: %q, %v", table, err) + } +} diff --git a/cmd/codeaf/l4fix4_preidentity_test.go b/cmd/codeaf/l4fix4_preidentity_test.go new file mode 100644 index 0000000000..5881d34036 --- /dev/null +++ b/cmd/codeaf/l4fix4_preidentity_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "errors" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// L4-B: The existing older-chat road makes a pre-identity chat visible and +// readable on another computer in the account, leaving its original untouched. +func TestL4Fix4PreIdentityChatSyncsAfterJoining(t *testing.T) { + celltest.RequireEngine(t) + t.Cleanup(cellstore.StopSpawned) + local, other := newShortOlderChatHome(t), newShortOlderChatHome(t) + t.Setenv(home.EnvVar, local) + t.Setenv("CODEAF_PROFILE_DIR", local) + server := httptest.NewServer(relayserveForMove(t)) + t.Cleanup(server.Close) + t.Setenv(syncsetup.URLVar, server.URL) + work := t.TempDir() + bucket, err := v3ProjectDir(work) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(bucket, "before-identity") + if err := os.MkdirAll(original, 0700); err != nil { + t.Fatal(err) + } + const journal = "{\"type\":\"session\",\"version\":1,\"id\":\"before-identity\"}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"my pre-identity turn\"}\n" + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(journal), 0600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ID: "before-identity", Title: "Before pairing", Workspace: work, LastUserAt: time.Unix(1_700_000_000, 0)}); err != nil { + t.Fatal(err) + } + if _, err := identity.Load(local); !errors.Is(err, identity.ErrNone) { + t.Fatalf("fixture has an identity: %v", err) + } + account, err := identity.Ensure(other) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(local, account, false); err != nil { + t.Fatal(err) + } + if !syncsetup.MayTalk(local) { + t.Fatal("the newly joined account cannot sync") + } + place, err := movablePlace(t.Context(), session.Place{Dir: original, Workspace: work}) + if err != nil { + t.Fatal(err) + } + if place.Dir == original || cell.Copied(original) != place.Dir { + t.Fatal("the pre-identity chat was not exposed as a completed copy") + } + if dirs := session.BucketSessionDirs(bucket); len(dirs) != 1 || dirs[0] != place.Dir { + t.Fatalf("Home's conversations = %v", dirs) + } + copied, err := cell.OpenAt(place.Dir, place.ID()) + if err != nil { + t.Fatal(err) + } + localSync, ok, err := syncsetup.Open(local) + if err != nil || !ok { + t.Fatalf("open joined sync: %v, %v", ok, err) + } + engine := cellstore.EngineFor(work) + if err := localSync.PublishCopy(t.Context(), engine, copied, "new computer", "Before pairing"); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, other) + t.Setenv("CODEAF_PROFILE_DIR", other) + otherSync, ok, err := syncsetup.Open(other) + if err != nil || !ok { + t.Fatalf("open account sync: %v, %v", ok, err) + } + receiver := cellstore.EngineFor("") + receiver.DataRoot = filepath.Join(other, "v3/stores") + continuer := otherSync.Continuer(receiver, syncsetup.TakeOptions{ + DeviceName: "other computer", + RootFor: func(id string) string { return filepath.Join(other, "v3/projects/copied", id) }, + BucketFor: func(string) string { return filepath.Join(other, "v3/projects/copied") }, + }) + opened, err := continuer.Take(t.Context(), copied.ID) + if err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(filepath.Join(opened.Taken.Cell.Root, cell.TranscriptPath)); err != nil || !strings.Contains(string(raw), "my pre-identity turn") { + t.Fatalf("the account lost the pre-identity chat: %s, %v", raw, err) + } + if raw, err := os.ReadFile(filepath.Join(original, "transcript.jsonl")); err != nil || string(raw) != journal { + t.Fatal("sync changed the original", err) + } +} diff --git a/cmd/codeaf/l4fix_at_test.go b/cmd/codeaf/l4fix_at_test.go new file mode 100644 index 0000000000..3580511c37 --- /dev/null +++ b/cmd/codeaf/l4fix_at_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "encoding/base64" + "io" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// L4-G6, L4-FIX5-a: the banner's first name opens --at, whose window keeps the chosen device name. +func TestL4FixAtWindowUsesDeviceName(t *testing.T) { + h := t.TempDir() + t.Setenv("CODEAF_HOME", h) + t.Setenv("CODEAF_PROFILE_DIR", h) + t.Cleanup(cell.UseAvailabilityInTests(false)) + if _, err := devname.Set(h, "desk-b"); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(&relay.Server{}) + defer server.Close() + t.Setenv("CODEAF_RELAY", server.URL) + machine, err := pair.ThisDevice(pair.FileKeeper{Path: filepath.Join(t.TempDir(), "key")}) + if err != nil { + t.Fatal(err) + } + surface, err := pair.ThisDevice(pair.OpenKeeper()) + if err != nil { + t.Fatal(err) + } + devices := pair.BookAt(filepath.Join(t.TempDir(), "devices.json")) + if err := devices.Admit(pair.Paired{Label: "surface", Key: base64.RawURLEncoding.EncodeToString(surface.Public())}); err != nil { + t.Fatal(err) + } + if err := pair.MachineBook().Remember(pair.Known{Name: machine.Name(), Key: base64.RawURLEncoding.EncodeToString(machine.Public()), Service: server.URL}); err != nil { + t.Fatal(err) + } + pipes := make(chan io.ReadWriteCloser, 1) + ready := make(chan struct{}) + var once sync.Once + var target string + host := &pair.Host{Service: server.URL, Device: machine, DisplayName: "desk-b", Devices: devices, Desk: &pair.Desk{}, Say: func(line string) { + if strings.Contains(line, "reachable as") { + once.Do(func() { + first, _, _ := strings.Cut(line, "\n") + target = strings.Fields(first)[5] + close(ready) + }) + } + }, Open: func(pipe io.ReadWriteCloser) { + pipes <- pipe + defer pipe.Close() + _ = remote.Serve(pipe, pipe, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: &quietAgent{}, Workspace: "/work", SessionFile: "/work/chat.jsonl"}, nil + }}) + }} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- host.Run(ctx) }() + defer func() { cancel(); <-done }() + <-ready + if !relay.ValidName(target) || target != machine.Name() { + t.Fatalf("serve's first name %q cannot select the serving machine %q", target, machine.Name()) + } + old := runSurfaceProgram + defer func() { runSurfaceProgram = old }() + visited := false + runSurfaceProgram = func(_ context.Context, options tui3.Options) error { + visited = true + if options.Host != "desk-b" || strings.Contains(options.Notice, machine.Name()) { + t.Errorf("host=%q notice=%q", options.Host, options.Notice) + } + _ = (<-pipes).Close() + compact, ok := options.Agent.(interface{ Compact(context.Context) error }) + if !ok { + t.Fatal("remote agent has no compact door") + } + if err := compact.Compact(t.Context()); err == nil || !strings.Contains(err.Error(), "desk-b") || strings.Contains(err.Error(), machine.Name()) { + t.Errorf("window error uses a different name: %v", err) + } + return nil + } + if err := openChatV3At(atLaunch{target: target}); err != nil { + t.Fatal(err) + } + stopFinalSaveProgress() + takeFinalSaveFailure() + if !visited { + t.Fatal("surface never opened") + } +} diff --git a/cmd/codeaf/l4fix_compat_test.go b/cmd/codeaf/l4fix_compat_test.go new file mode 100644 index 0000000000..7441ca6905 --- /dev/null +++ b/cmd/codeaf/l4fix_compat_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// L4-R4: An interrupted close with a legacy welcome keeps the baseline background-saving success. +func TestL4FixLegacyInterruptWords(t *testing.T) { + oldFlush, oldOut := flushFinalSaves, os.Stdout + out, err := os.CreateTemp(t.TempDir(), "receipt") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stdout = out + armFinalSaving() + t.Cleanup(func() { flushFinalSaves, os.Stdout = oldFlush, oldOut; takeFinalSaveFailure() }) + // The remote client has an old engine's welcome, with no optional capability. + client := &remote.Client{} + agent := client.Agent() + checks := []func() bool{agent.LocalDurable} + for _, check := range checks { + retainLocalDurability(check) + } + // Surface assembly must capture compatibility alongside durability. + // The optional seam is discovered here so this test compiles on the pre-fix tree. + legacy, ok := any(agent).(interface{ SavingReceipts() bool }) + if !ok { + t.Fatal("no negotiated legacy saving mode") + } + if legacy.SavingReceipts() { + t.Fatal("legacy engine advertised receipts") + } + // The launcher captures the engine's negotiated mode through its surface options. + retainSavingCompatibility(legacy.SavingReceipts) + interruptFinalSaving() + if !finishFinalSaving(context.Background()) { + t.Fatal("legacy interrupt changed the baseline exit status") + } + raw, _ := os.ReadFile(out.Name()) + if strings.TrimSpace(string(raw)) != "codeaf is still saving this chat in the background; unsaved turns stay on this computer." { + t.Fatalf("receipt=%s", raw) + } +} + +// L4-R4: A legacy forced interrupt keeps the baseline exit code after restoration. +func TestL4FixLegacyForcedInterrupt(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Cleanup(cell.UseAvailabilityInTests(false)) + oldRun, oldExit := runSurfaceProgram, surfaceForceExit + defer func() { + runSurfaceProgram, surfaceForceExit = oldRun, oldExit + stopFinalSaveProgress() + takeFinalSaveFailure() + }() + restored := false + code := 0 + surfaceForceExit = func(got int) { + if !restored { + t.Error("exit before terminal restoration") + } + code = got + } + runSurfaceProgram = func(_ context.Context, o tui3.Options) error { + o.SavingCompatibility(func() bool { return false }) + o.ForceQuit() + restored = true + return nil + } + if err := runSurface(context.Background(), tui3.Options{ProfileDir: t.TempDir()}); err != nil { + t.Fatal(err) + } + if code != 130 { + t.Fatalf("legacy interrupt exit=%d", code) + } +} + +// L4-R5: The terminal reports the idle chat's pending upload after the other chat detached successfully. +func TestL4FixTwoChatQuitReceipt(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Cleanup(cell.UseAvailabilityInTests(false)) + oldRun, oldFlush, oldOut := runSurfaceProgram, flushFinalSaves, os.Stdout + out, err := os.CreateTemp(t.TempDir(), "receipt") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stdout = out + defer func() { + runSurfaceProgram, flushFinalSaves, os.Stdout = oldRun, oldFlush, oldOut + stopFinalSaveProgress() + takeFinalSaveFailure() + }() + flushFinalSaves = func(context.Context) error { return nil } + runSurfaceProgram = func(_ context.Context, o tui3.Options) error { + // These are the two per-chat outcomes exercised by tui3's two-chat departure regression. + o.SavingDurability(func() bool { return true }) + o.SavingDurability(func() bool { return true }) + o.SaveFailure(syncwords.UploadFailure(context.DeadlineExceeded)) + return nil + } + if err := runSurface(context.Background(), tui3.Options{ProfileDir: t.TempDir(), SyncService: "http://127.0.0.1:8901"}); err != nil { + t.Fatal(err) + } + if !finishFinalSaving(context.Background()) { + t.Fatal("a successful detach poisoned the idle chat's upload receipt") + } + raw, _ := os.ReadFile(out.Name()) + if strings.TrimSpace(string(raw)) != "saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers" { + t.Fatalf("receipt=%s", raw) + } +} diff --git a/cmd/codeaf/l4fix_devices_test.go b/cmd/codeaf/l4fix_devices_test.go new file mode 100644 index 0000000000..3549292abc --- /dev/null +++ b/cmd/codeaf/l4fix_devices_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" +) + +type l4BrokenRoster struct{ directory.Client } + +func (l4BrokenRoster) List(context.Context) (directory.Listing, error) { + return directory.Listing{}, errors.New("unreadable roster") +} + +// L4-R1: A failed roster cannot authorize removing a suffixed selector from the other roster. +func TestL4FixRevokeNeedsBothRosters(t *testing.T) { + t.Run("sync list", func(t *testing.T) { + book := bookWith(t, "laptop") + rows, _ := book.Devices() + selector := "laptop#" + rows[0].ID()[len(rows[0].ID())-4:] + _, err := captureStdout(t, func() error { + return stopDevice([]deviceKind{chatsKind{dir: l4BrokenRoster{}}, bookKind{book}}, []string{selector}) + }) + left, _ := book.Devices() + if err == nil || !strings.Contains(err.Error(), "devices that sync your chats") || !strings.Contains(err.Error(), "nothing was removed") || len(left) != 1 { + t.Fatalf("err=%v left=%+v", err, left) + } + }) + t.Run("remote list", func(t *testing.T) { + chats, dir := chatsWith(t, "laptop") + path := filepath.Join(t.TempDir(), "devices.json") + if err := os.WriteFile(path, []byte("broken"), 0600); err != nil { + t.Fatal(err) + } + _, err := captureStdout(t, func() error { + return stopDevice([]deviceKind{chats, bookKind{pair.BookAt(path)}}, []string{"laptop#bbbb"}) + }) + list, _ := dir.List(devicesCtx) + if err == nil || !strings.Contains(err.Error(), "devices that can use this machine") || !strings.Contains(err.Error(), "nothing was removed") || list.Devices[otherComputer].Revoked { + t.Fatalf("err=%v list=%+v", err, list) + } + }) + t.Run("colliding suffix", func(t *testing.T) { + chats, dir := chatsWith(t, "laptop") + book := bookWith(t, "laptop") + peers, _ := book.Devices() + // Match the real remote key's suffix in the sync roster too. + id := "dev_" + peers[0].ID() + sealed, _ := directory.SealName(chats.key, "laptop") + if err := dir.PutDevice(devicesCtx, id, directory.Device{V: 1, Name: sealed}); err != nil { + t.Fatal(err) + } + selector := "laptop#" + id[len(id)-4:] + _, err := captureStdout(t, func() error { return stopDevice([]deviceKind{chats, bookKind{book}}, []string{selector}) }) + peers, _ = book.Devices() + list, _ := dir.List(devicesCtx) + if err == nil || !strings.Contains(err.Error(), "more than one device") || !strings.Contains(err.Error(), "codeaf devices revoke") || len(peers) != 1 || list.Devices[id].Revoked { + t.Fatalf("err=%v peers=%v list=%+v", err, peers, list) + } + }) +} + +// L4-R1: The public revoke command cannot silently drop a roster whose local setup is unreadable. +func TestL4FixPublicRevokeNeedsReadableSetup(t *testing.T) { + for _, bad := range []string{"identity", "service address"} { + t.Run(bad, func(t *testing.T) { + h := t.TempDir() + t.Setenv("CODEAF_HOME", h) + t.Setenv("CODEAF_SYNC_URL", "http://127.0.0.1:8901") + if _, err := identity.Ensure(h); err != nil { + t.Fatal(err) + } + peer := pair.Paired{Label: "laptop", Key: "remote-key"} + if err := pair.DeviceBook().Admit(peer); err != nil { + t.Fatal(err) + } + if bad == "identity" { + if err := os.WriteFile(filepath.Join(h, "identity.json"), []byte("broken"), 0600); err != nil { + t.Fatal(err) + } + } else { + t.Setenv("CODEAF_SYNC_URL", "://not-a-service") + } + selector := "laptop#" + peer.ID()[len(peer.ID())-4:] + _, err := captureStdout(t, func() error { return runDevices([]string{"revoke", selector}) }) + left, _ := pair.DeviceBook().Devices() + if err == nil || !strings.Contains(err.Error(), "devices that sync your chats") || !strings.Contains(err.Error(), "nothing was removed") || len(left) != 1 { + t.Fatalf("err=%v left=%+v", err, left) + } + }) + } +} diff --git a/cmd/codeaf/l4fix_unpaired_test.go b/cmd/codeaf/l4fix_unpaired_test.go new file mode 100644 index 0000000000..1ee866d9b5 --- /dev/null +++ b/cmd/codeaf/l4fix_unpaired_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "github.com/Agent-Field/codeaf/internal/devname" + "runtime" + "strings" + "testing" +) + +// L4-R8: The public devices command lists this never-paired computer from local facts. +func TestL4FixTerminalUnpairedDevice(t *testing.T) { + h := t.TempDir() + t.Setenv("CODEAF_HOME", h) + t.Setenv("CODEAF_SYNC_URL", "off") + if _, err := devname.Set(h, "fresh-laptop"); err != nil { + t.Fatal(err) + } + text, err := captureStdout(t, func() error { return runDevices(nil) }) + if err != nil { + t.Fatal(err) + } + // The local facts row holds whether or not an engine is available here; the + // pairing next step is pinned on the capable road by TestL4Fix2PublicUnpairedDevices, + // because an unavailable engine keeps the base remote-access route. + for _, want := range []string{"fresh-laptop", "this device"} { + if !strings.Contains(text, want) { + t.Fatalf("missing %q: %s", want, text) + } + } + if strings.Contains(text, "nothing paired") { + t.Fatal(text) + } + if runtime.GOOS == "linux" && !strings.Contains(text, "Linux") { + t.Fatal(text) + } +} diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 9ff141880c..c0cf66a740 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -33,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" lanes "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/plan" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/relayserve" @@ -57,6 +58,7 @@ func main() { // timers installed by the feature that automations replaced. automation.RemoveOldTimers() } + home.Witness(home.Dir()) registerRunningCLI() code := execute() // The teams store folds its WAL back in as it closes (internal/teams). @@ -201,6 +203,7 @@ func surfaceProcs(ncpu int) int { // one calm sentence over a restored terminal instead of spilling a goroutine // dump across the screen the user was working in. func execute() (code int) { + prepareFinalReport() // Every CLI door shares this exit, including verbs with no session to close. // C28 requires their grouped call and turn records to reach disk too. defer func() { @@ -348,6 +351,8 @@ func run() error { return relayserve.Main(os.Args[2:]) case "cluster": return runCluster(os.Args[2:]) + case "devbox": + return runDevbox(os.Args[2:]) case "devices": // Who is allowed to open a conversation here, and the door for taking // that back. REVOKING IS THIS MACHINE'S DECISION AND ONLY THIS @@ -604,7 +609,7 @@ Look at what happened — read-only, no key, nothing spent the models this machine will use, and what each has been measured at codeaf pool [show|status|verify] [--json] [--key key] the Model Pool: what is resolved and cached; verify fetches with a key - codeaf doctor [--db path] + codeaf doctor [--db path] [--fix] is this install healthy, and where does it keep things codeaf manual every page of codeaf's own manual, one per line @@ -632,8 +637,10 @@ Housekeeping — changes state on disk or on the network codeaf pair [--via url] [--name name] add this device to yours: shows a link to approve from one you use codeaf pair approve | [--replace] | --code - approve a new device; or use or show a six-digit code for your chats - codeaf serve [--workspace path] [--via url] + approve a device; or use or show a code such as ` + pair.ExampleChatCode + ` for your chats + codeaf devbox up | down · status [] [--yes] [--dir D] + an always-on computer of yours on your own Railway account + codeaf serve [--workspace path] [--via url] [--take-tasks] be reachable from your other devices without ssh, with a pairing code codeaf devices | codeaf cluster status | lead list your devices and the ones that can use this machine @@ -762,6 +769,8 @@ A variable that pins a ` + "`/settings`" + ` row wins over that row. CODEAF_PREAUTHORIZE_SPEND 1 spends past the day's limit without stopping a headless run to ask + CODEAF_SYNC_URL sync service for your chats; off disables syncing + CODEAF_RELAY sync service override for pairing and remote connections CODEAF_HOME the whole state root — journal, workspace, CAS, craft, profiles, catalog, skills (default ~/.codeaf). Move it to run a disposable store that touches nothing of yours. diff --git a/cmd/codeaf/olderchats.go b/cmd/codeaf/olderchats.go index 57508a9919..a20f0ab4e9 100644 --- a/cmd/codeaf/olderchats.go +++ b/cmd/codeaf/olderchats.go @@ -177,8 +177,11 @@ func olderChatCopyable(meta session.Meta) bool { // movablePlace resolves a completed copy or prepares one when the person opens // an older chat. Selection runs on the existing asynchronous opening door. +// Sync off means not talking to the service, never forgetting a completed copy +// already on this computer, so an existing `.moving` copy is opened even when +// sync is quiet or off. func movablePlace(ctx context.Context, place session.Place) (session.Place, error) { - if place.Dir == "" || !cell.Available() || !syncsetup.MayTalk(home.Dir()) { + if place.Dir == "" { return place, nil } if _, err := cell.OpenAt(place.Dir, place.ID()); err == nil { @@ -187,6 +190,9 @@ func movablePlace(ctx context.Context, place session.Place) (session.Place, erro original := place.Dir copied := cell.Copied(original) if copied == original { + if !cell.Available() || !syncsetup.MayTalk(home.Dir()) { + return place, nil + } if _, err := os.Stat(filepath.Join(original, "transcript.jsonl")); os.IsNotExist(err) { return place, nil } diff --git a/cmd/codeaf/olderchats_test.go b/cmd/codeaf/olderchats_test.go index 6a93aa5d90..387e15b21b 100644 --- a/cmd/codeaf/olderchats_test.go +++ b/cmd/codeaf/olderchats_test.go @@ -2,6 +2,8 @@ package main import ( "context" + "fmt" + "net/http/httptest" "os" "path/filepath" "strings" @@ -12,7 +14,9 @@ import ( "github.com/Agent-Field/codeaf/internal/cell/celltest" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // TestC29FirstSavedTurnIncludesTheEarlierConversation proves C29.1/C29.4 with @@ -70,3 +74,509 @@ func TestC29FirstSavedTurnIncludesTheEarlierConversation(t *testing.T) { // Retire only this fixture's engine routes before TempDir cleanup. _ = cellstore.Await(context.Background(), c) } + +func newShortOlderChatHome(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "cf29-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + sockets, _ := filepath.Glob(filepath.Join(dir, "v3", "engine-*.sock")) + for _, socket := range sockets { + _ = (cellstore.Daemon{Socket: socket}).Stop(context.Background()) + } + _ = os.RemoveAll(dir) + }) + return dir +} + +// TestCopiedLegacyChatKeepsCopyReadyAfterRewind verifies from the person's side +// that copying a legacy chat into `.moving` and rewinding to an earlier turn +// preserves `.cell/copy-ready` (along with local `.cell/` bookkeeping such as +// `copy-offered`, `published.json`, and `adopted.json`) and keeps Home listing +// the `.moving` copy rather than the stale original. +func TestCopiedLegacyChatKeepsCopyReadyAfterRewind(t *testing.T) { + celltest.RequireEngine(t) + t.Cleanup(cellstore.StopSpawned) + + homeDir := newShortOlderChatHome(t) + t.Setenv(home.EnvVar, homeDir) + t.Setenv("CODEAF_PROFILE_DIR", homeDir) + if _, err := identity.Ensure(homeDir); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, "http://127.0.0.1:1") + + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "readme.txt"), []byte("turn one"), 0o600); err != nil { + t.Fatal(err) + } + bucket, err := v3ProjectDir(work) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(bucket, "legacy-rewind-chat") + if err := os.MkdirAll(original, 0o700); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + t1Stamp := now.Add(-10 * time.Minute).Format(time.RFC3339) + t2Stamp := now.Add(-1 * time.Minute).Format(time.RFC3339) + origTranscript := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":\"legacy-rewind-chat\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"turn one before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"reply one before copy\",\"timestamp\":%q}\n", + t1Stamp, t1Stamp, t1Stamp, + ) + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(origTranscript), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ + ID: "legacy-rewind-chat", + Title: "Stale Pre-Copy Original", + Workspace: work, + Created: now.Add(-10 * time.Minute), + LastUserAt: now.Add(-10 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + copied, err := copyOlderChat(t.Context(), original) + if err != nil { + t.Fatalf("copyOlderChat: %v", err) + } + defer func() { _ = cellstore.Await(context.Background(), copied) }() + + firstHead, err := cellstore.Head(copied) + if err != nil || firstHead == nil { + t.Fatalf("Head after copyOlderChat: %v", err) + } + + // Update the `.moving` copy with a second turn and local `.cell/` bookkeeping. + copyTranscriptPath := filepath.Join(copied.Root, filepath.FromSlash(cell.TranscriptPath)) + turnTwoTranscript := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":%q,\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"turn one before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"reply one before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"turn two in copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"reply two in copy\",\"timestamp\":%q}\n", + copied.ID, t1Stamp, t1Stamp, t1Stamp, t2Stamp, t2Stamp, + ) + if err := os.WriteFile(filepath.Join(work, "readme.txt"), []byte("turn two"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(copyTranscriptPath, []byte(turnTwoTranscript), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(copied.Root, session.Meta{ + ID: copied.ID, + Title: "Live Moving Copy", + Workspace: work, + Created: now.Add(-10 * time.Minute), + LastUserAt: now.Add(-1 * time.Minute), + }); err != nil { + t.Fatal(err) + } + engine := cellstore.EngineFor(work) + secondHead, err := engine.Seal(t.Context(), copied, cellstore.TurnInfo{}) + if err != nil { + t.Fatalf("Seal turn 2: %v", err) + } + if err := cellstore.Await(t.Context(), copied); err != nil { + t.Fatalf("Await turn 2: %v", err) + } + if err := cellstore.NotePublished(copied, secondHead.Turn.ID); err != nil { + t.Fatalf("NotePublished: %v", err) + } + if err := os.WriteFile(filepath.Join(copied.Root, filepath.FromSlash(cell.CopyOfferedPath)), nil, 0o600); err != nil { + t.Fatal(err) + } + adoptedPath := filepath.Join(copied.Root, ".cell", "adopted.json") + if err := os.WriteFile(adoptedPath, []byte(fmt.Sprintf(`{"V":1,"head":%q,"turns_size":1,"transcript_end":1,"from":"laptop"}`+"\n", firstHead.Turn.ID)), 0o600); err != nil { + t.Fatal(err) + } + + // Rewind to the first turn (whose snapshot predates `copy-ready`, `copy-offered`, `published.json`, and `adopted.json`). + if _, err := engine.Rewind(t.Context(), copied, firstHead.Turn.ID); err != nil { + t.Fatalf("Rewind: %v", err) + } + + copyReadyFile := filepath.Join(copied.Root, ".cell", "copy-ready") + if _, err := os.Stat(copyReadyFile); err != nil { + t.Fatalf("rewind removed .cell/copy-ready: %v", err) + } + if _, err := os.Stat(filepath.Join(copied.Root, filepath.FromSlash(cell.CopyOfferedPath))); err != nil { + t.Fatalf("rewind removed .cell/copy-offered: %v", err) + } + if shared, err := cellstore.Shared(copied); err != nil || !shared { + t.Fatalf("rewind lost local publication/adoption bookkeeping: shared=%v err=%v", shared, err) + } + rawPublished, err := os.ReadFile(filepath.Join(copied.Root, ".cell", "published.json")) + if err != nil || !strings.Contains(string(rawPublished), secondHead.Turn.ID) { + t.Fatalf("rewind altered .cell/published.json: err=%v body=%q", err, string(rawPublished)) + } + if _, err := os.Stat(adoptedPath); err != nil { + t.Fatalf("rewind removed .cell/adopted.json: %v", err) + } + if got := cell.Copied(original); filepath.Clean(got) != filepath.Clean(copied.Root) { + t.Fatalf("cell.Copied(original) after rewind = %s, want %s", got, copied.Root) + } + rewoundTranscript, err := os.ReadFile(copyTranscriptPath) + if err != nil || !strings.Contains(string(rewoundTranscript), "turn one before copy") || strings.Contains(string(rewoundTranscript), "turn two in copy") { + t.Fatalf("rewound transcript = %q, err = %v", string(rewoundTranscript), err) + } + + t.Setenv(syncsetup.URLVar, "off") + rows := session.ReadHome().Sessions() + if len(rows) != 1 { + t.Fatalf("ReadHome() after rewind returned %d chats, want 1: %+v", len(rows), rows) + } + if rows[0].ID != copied.ID || filepath.Clean(session.DirOf(rows[0].Transcript)) != filepath.Clean(copied.Root) { + t.Fatalf("Home listed stale original instead of rewound copy: got id=%q dir=%q, want id=%q dir=%q", + rows[0].ID, session.DirOf(rows[0].Transcript), copied.ID, copied.Root) + } +} + +// TestCopiedLegacyChatKeepsCopyReadyAfterTakeBack verifies from the person's +// side that copying a legacy chat into `.moving`, continuing it on another +// computer, and taking it back onto this computer's `.moving` copy root keeps +// `.cell/copy-ready` and lists the updated copy (never the stale original) on +// Home — both for borrowed-project (`InPlace`) and owned (`swap`) chats. +func TestCopiedLegacyChatKeepsCopyReadyAfterTakeBack(t *testing.T) { + celltest.RequireEngine(t) + t.Cleanup(cellstore.StopSpawned) + + for _, tc := range []struct { + name string + owned bool + }{ + {name: "borrowed_project", owned: false}, + {name: "owned_workspace", owned: true}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + srv := httptest.NewServer(relayserveForMove(t)) + t.Cleanup(srv.Close) + + home1 := newShortOlderChatHome(t) + home2 := newShortOlderChatHome(t) + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(home.EnvVar, home1) + t.Setenv("CODEAF_PROFILE_DIR", home1) + + id, err := identity.Ensure(home1) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(home2, id, false); err != nil { + t.Fatal(err) + } + sync1, ok, err := syncsetup.Open(home1) + if err != nil || !ok { + t.Fatalf("Open(home1) = (%v, %v)", ok, err) + } + sync2, ok, err := syncsetup.Open(home2) + if err != nil || !ok { + t.Fatalf("Open(home2) = (%v, %v)", ok, err) + } + if err := syncsetup.PutOwnDevice(ctx, sync1.Dir, sync1.Identity, sync1.Device, "desk"); err != nil { + t.Fatal(err) + } + if err := syncsetup.PutOwnDevice(ctx, sync2.Dir, sync2.Identity, sync2.Device, "laptop"); err != nil { + t.Fatal(err) + } + + projectWork := t.TempDir() + bucket, err := v3ProjectDir(projectWork) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(bucket, "legacy-takeback-orig") + origWork := projectWork + if tc.owned { + origWork = filepath.Join(original, "work") + } + if err := os.MkdirAll(origWork, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(original, 0o700); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + deskStamp := now.Add(-15 * time.Minute).Format(time.RFC3339) + laptopStamp := now.Add(-1 * time.Minute).Format(time.RFC3339) + origJournal := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":\"legacy-takeback-orig\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"started on desk before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"desk initial answer\",\"timestamp\":%q}\n", + deskStamp, deskStamp, deskStamp, + ) + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(origJournal), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ + ID: "legacy-takeback-orig", + Title: "Stale Original On Desk", + Workspace: origWork, + Owned: tc.owned, + Created: now.Add(-15 * time.Minute), + LastUserAt: now.Add(-15 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 1. Copy the legacy chat into `.moving` on Computer 1 and publish it. + copied, err := copyOlderChat(ctx, original) + if err != nil { + t.Fatalf("copyOlderChat: %v", err) + } + copiedMeta, err := session.LoadMeta(copied.Root) + if err != nil { + t.Fatal(err) + } + copyWork1 := copiedMeta.Workspace + if copiedMeta.Owned { + copyWork1 = filepath.Join(copied.Root, "work") + } + engine1 := cellstore.EngineFor(copyWork1) + engine1.DataRoot = filepath.Join(home1, "v3", "stores") + if err := sync1.PublishCopy(ctx, engine1, copied, "desk", "Copied Chat From Desk"); err != nil { + t.Fatalf("PublishCopy: %v", err) + } + if err := os.WriteFile(filepath.Join(copied.Root, filepath.FromSlash(cell.CopyOfferedPath)), nil, 0o600); err != nil { + t.Fatal(err) + } + + // 2. Continue the chat on Computer 2 (`home2`), add a turn, and publish it. + t.Setenv(home.EnvVar, home2) + t.Setenv("CODEAF_PROFILE_DIR", home2) + awayPath := projectWork + ".away" + if !tc.owned { + if err := os.Rename(projectWork, awayPath); err != nil { + t.Fatal(err) + } + } + restoreProjectWork := func() { + if !tc.owned { + if _, err := os.Stat(awayPath); err == nil { + _ = os.Rename(awayPath, projectWork) + } + } + } + defer restoreProjectWork() + + engine2 := cellstore.EngineFor("") + engine2.DataRoot = filepath.Join(home2, "v3", "stores") + cont2 := sync2.Continuer(engine2, syncsetup.TakeOptions{ + DeviceName: "laptop", + RootFor: func(cid string) string { return filepath.Join(home2, "v3", "projects", "copies", cid) }, + BucketFor: func(string) string { return filepath.Join(home2, "v3", "projects", "copy--project") }, + }) + on2, err := cont2.Take(ctx, copied.ID) + if err != nil { + t.Fatalf("Computer 2 Take: %v", err) + } + cell2, err := cell.OpenAt(on2.Taken.Cell.Root, copied.ID) + if err != nil { + t.Fatal(err) + } + transcript2 := session.Place{Dir: cell2.Root}.Transcript() + f2, err := os.OpenFile(transcript2, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, err = fmt.Fprintf(f2, + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"question on laptop\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"turn added on laptop\",\"timestamp\":%q}\n", + laptopStamp, laptopStamp, + ) + _ = f2.Close() + if err != nil { + t.Fatal(err) + } + meta2, err := session.LoadMeta(cell2.Root) + if err != nil { + t.Fatal(err) + } + meta2.Title = "Continued On Laptop" + meta2.LastUserAt = now.Add(-1 * time.Minute) + if err := session.SaveMeta(cell2.Root, meta2); err != nil { + t.Fatal(err) + } + work2 := meta2.Workspace + if meta2.Owned { + work2 = filepath.Join(cell2.Root, "work") + } + driveEngine2 := engine2 + driveEngine2.Workspace = work2 + title2 := meta2.Title + drive2, err := sync2.Drive(ctx, driveEngine2, cell2, syncsetup.DriveOptions{ + DeviceName: "laptop", + Title: func() string { return title2 }, + }) + if err != nil { + t.Fatalf("Computer 2 Drive: %v", err) + } + if _, err := drive2.Store(driveEngine2).Seal(ctx, cell2, cellstore.TurnInfo{}); err != nil { + _ = drive2.Close(ctx) + t.Fatalf("Computer 2 Seal: %v", err) + } + drive2.Idle() + if err := drive2.Close(ctx); err != nil { + t.Fatalf("Computer 2 Drive.Close: %v", err) + } + + // 3. Back on Computer 1 (`home1`), take the chat back onto `/.moving/`. + restoreProjectWork() + t.Setenv(home.EnvVar, home1) + t.Setenv("CODEAF_PROFILE_DIR", home1) + if gotRoot := takeRootFor(copied.ID); filepath.Clean(gotRoot) != filepath.Clean(copied.Root) { + t.Fatalf("takeRootFor(%s) = %s, want %s", copied.ID, gotRoot, copied.Root) + } + takeBackEngine1 := cellstore.EngineFor("") + takeBackEngine1.DataRoot = filepath.Join(home1, "v3", "stores") + cont1 := sync1.Continuer(takeBackEngine1, syncsetup.TakeOptions{ + DeviceName: "desk", + RootFor: takeRootFor, + BucketFor: takeCopyBucket, + }) + back1, err := cont1.Take(ctx, copied.ID) + if err != nil { + t.Fatalf("Computer 1 Take back: %v", err) + } + if filepath.Clean(back1.Taken.Cell.Root) != filepath.Clean(copied.Root) { + t.Fatalf("Take back landed at %s, want %s", back1.Taken.Cell.Root, copied.Root) + } + + if _, err := os.Stat(filepath.Join(copied.Root, ".cell", "copy-ready")); err != nil { + t.Fatalf("take-back lost .cell/copy-ready: %v", err) + } + if got := cell.Copied(original); filepath.Clean(got) != filepath.Clean(copied.Root) { + t.Fatalf("cell.Copied(original) after take-back = %s, want %s", got, copied.Root) + } + + // Verify Home (including with sync turned off) lists the updated `.moving` copy once, not the stale original. + t.Setenv(syncsetup.URLVar, "off") + rows := session.ReadHome().Sessions() + if len(rows) != 1 { + t.Fatalf("ReadHome() after take-back returned %d chats, want 1: %+v", len(rows), rows) + } + if rows[0].ID != copied.ID || filepath.Clean(session.DirOf(rows[0].Transcript)) != filepath.Clean(copied.Root) { + t.Fatalf("Home listed stale original instead of taken-back copy: got {ID:%q Transcript:%q}, want {ID:%q Root:%q}", + rows[0].ID, rows[0].Transcript, copied.ID, copied.Root) + } + homeRowBody, err := os.ReadFile(rows[0].Transcript) + if err != nil || !strings.Contains(string(homeRowBody), "turn added on laptop") { + t.Fatalf("Home row transcript missing laptop turn: err=%v body=%s", err, string(homeRowBody)) + } + + // Opening the stale original path resolves to the taken-back copy without losing Computer 2's turn. + opened, err := movablePlace(ctx, session.Place{Dir: original, Workspace: origWork, Owned: tc.owned}) + if err != nil { + t.Fatalf("movablePlace(original) after take-back: %v", err) + } + if filepath.Clean(opened.Dir) != filepath.Clean(copied.Root) { + t.Fatalf("movablePlace(original) = %s, want %s", opened.Dir, copied.Root) + } + rawTranscript, err := os.ReadFile(opened.Transcript()) + if err != nil || !strings.Contains(string(rawTranscript), "turn added on laptop") { + t.Fatalf("taken-back copy transcript lost laptop turn: err=%v body=%s", err, string(rawTranscript)) + } + }) + } +} + +// TestOpeningStaleOriginalNeverDeletesPopulatedCopy verifies the second line of +// defence from the person's side: even if `.cell/copy-ready` was missing on a +// `.moving` copy that already holds sealed turns, opening the stale original +// never wipes the populated copy. +func TestOpeningStaleOriginalNeverDeletesPopulatedCopy(t *testing.T) { + celltest.RequireEngine(t) + t.Cleanup(cellstore.StopSpawned) + + homeDir := newShortOlderChatHome(t) + t.Setenv(home.EnvVar, homeDir) + t.Setenv("CODEAF_PROFILE_DIR", homeDir) + if _, err := identity.Ensure(homeDir); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, "http://127.0.0.1:1") + + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "readme.txt"), []byte("turn one"), 0o600); err != nil { + t.Fatal(err) + } + bucket, err := v3ProjectDir(work) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(bucket, "legacy-never-wipe") + if err := os.MkdirAll(original, 0o700); err != nil { + t.Fatal(err) + } + origTranscript := `{"type":"session","version":1,"id":"legacy-never-wipe"}` + "\n" + + `{"type":"message","role":"user","content":"pre-copy words"}` + "\n" + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(origTranscript), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ + ID: "legacy-never-wipe", + Title: "Stale Original", + Workspace: work, + LastUserAt: time.Now(), + }); err != nil { + t.Fatal(err) + } + + copied, err := copyOlderChat(t.Context(), original) + if err != nil { + t.Fatalf("copyOlderChat: %v", err) + } + defer func() { _ = cellstore.Await(context.Background(), copied) }() + + // Add a precious new turn to the `.moving` copy and seal it. + copyTranscriptPath := filepath.Join(copied.Root, filepath.FromSlash(cell.TranscriptPath)) + preciousTranscript := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"pre-copy words\"}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"precious turn only in moving copy\"}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"precious answer that must never be wiped\"}\n", + copied.ID, + ) + if err := os.WriteFile(copyTranscriptPath, []byte(preciousTranscript), 0o600); err != nil { + t.Fatal(err) + } + engine := cellstore.EngineFor(work) + if _, err := engine.Seal(t.Context(), copied, cellstore.TurnInfo{}); err != nil { + t.Fatalf("Seal precious turn: %v", err) + } + + // Simulate an installation where an earlier build already stripped `.cell/copy-ready`. + copyReadyFile := filepath.Join(copied.Root, ".cell", "copy-ready") + if err := os.Remove(copyReadyFile); err != nil { + t.Fatal(err) + } + if cell.Copied(original) != original { + t.Fatal("expected Copied(original) == original once copy-ready was removed") + } + + // Opening the stale original must NEVER os.RemoveAll(copied.Root) or wipe the precious turn. + opened, err := movablePlace(t.Context(), session.Place{Dir: original, Workspace: work}) + if err != nil { + t.Fatalf("movablePlace(original) failed: %v", err) + } + if filepath.Clean(opened.Dir) != filepath.Clean(copied.Root) { + t.Fatalf("movablePlace(original) = %s, want %s", opened.Dir, copied.Root) + } + afterRaw, err := os.ReadFile(copyTranscriptPath) + if err != nil { + t.Fatalf("copy transcript unreadable: %v", err) + } + if !strings.Contains(string(afterRaw), "precious answer that must never be wiped") { + t.Fatalf("opening stale original wiped the populated copy! transcript:\n%s", string(afterRaw)) + } + if _, err := os.Stat(copyReadyFile); err != nil { + t.Fatalf("expected CopyLegacy to restore .cell/copy-ready on populated copy: %v", err) + } +} diff --git a/cmd/codeaf/pair.go b/cmd/codeaf/pair.go index 83d5ad741a..a7b00550d4 100644 --- a/cmd/codeaf/pair.go +++ b/cmd/codeaf/pair.go @@ -111,9 +111,9 @@ func (d pairDoor) run(ctx context.Context, args []string) error { fmt.Fprintln(usageOut, reason) } flags := commandFlags("pair") - via := flags.String("via", "", "the sync address to pair through; empty is the one this device already uses") + via := flags.String("via", "", "the sync service to pair through; empty is the one this device already uses") renamedFlag(flags, "relay", "via") - code := flags.Bool("code", false, "show a six-digit code that shares this device's chats, instead of asking to join") + code := flags.Bool("code", false, "show a code such as "+pair.ExampleChatCode+" that shares this device's chats, instead of asking to join") replace := flags.Bool("replace", false, "when typing a code, replace this device's own chats with the shared ones") name := flags.String("name", "", "what your devices call this one; empty keeps its current name, which starts as the host name") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { @@ -122,6 +122,12 @@ func (d pairDoor) run(ctx context.Context, args []string) error { if err := d.naming(ctx, *name, flags.Args(), *code); err != nil { return err } + progressAddress := *via + if progressAddress == "" { + progressAddress = syncsetup.Resolve(home.Dir()).URL + } + d.term.waitingStop = terminalSyncProgress(progressAddress) + defer d.term.stopProgress() return explainSyncUnreachable(d.dispatch(ctx, flags.Args(), *via, *replace, *code), *via) } @@ -208,12 +214,17 @@ func (d pairDoor) show(ctx context.Context, relay string) error { if err != nil { return err } + if d.ownRecord != nil { + if err := d.ownRecord(ctx, route); err != nil { + return err + } + } label, err := pair.Offer(ctx, route, grant, offerScreen{d.term}) if err != nil { return d.endedByPerson(ctx, err) } - d.term.say(pair.PairedChatsLine(label)) d.recordOwnDeviceOrSay(ctx, route) + d.term.say(pair.PairedChatsLine(label)) return nil } @@ -227,6 +238,9 @@ func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) e if err != nil { return err } + if err := d.pairingStatus(ctx, route, replace); err != nil { + return err + } joining := d.joining(replace) if d.name != "" { joining.Label = d.name @@ -240,9 +254,14 @@ func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) e return err } } - d.term.say(joinedSentence(joined)) if !joined.Already { + if f, err := pullFleet(joining.Home, route)(ctx); err == nil { + joined.With = f.With + } d.recordOwnDeviceOrSay(ctx, route) + } + d.term.say(joinedSentence(joined)) + if !joined.Already { d.term.say(terminalSharingLine) } return nil diff --git a/cmd/codeaf/pair_status.go b/cmd/codeaf/pair_status.go new file mode 100644 index 0000000000..4bca807f41 --- /dev/null +++ b/cmd/codeaf/pair_status.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "errors" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// pairingStatus distinguishes a live pairing from local chats before showing a +// fresh invitation. Replacement is a remedy only after this device was removed. +func (d pairDoor) pairingStatus(ctx context.Context, route pair.Mailbox, replace bool) error { + j := d.joining(replace) + if _, err := identity.Load(j.Home); errors.Is(err, identity.ErrNone) { + return nil + } else if err != nil { + return err + } + if identity.Solo(j.Home) { + return nil + } + client, id, err := homeDirectory(j.Home, route) + if err != nil { + return err + } + listing, err := client.List(ctx) + if errors.Is(err, directory.ErrRevoked) { + return nil + } + if err != nil { + return err + } + dev, err := identity.Device(j.Home) + if err != nil { + return err + } + if listing.Devices[dev.ID()].Revoked { + return nil + } + var names []string + for key, record := range listing.Devices { + if key != dev.ID() && !record.Revoked { + names = append(names, openName(directory.MetadataKey(id.CellKey()), record.Name)) + } + } + if len(names) == 0 { + return nil + } + sort.Strings(names) + return errors.New("this computer is already paired with " + strings.Join(names, " and ") + " — codeaf devices lists them") +} diff --git a/cmd/codeaf/pair_terminal.go b/cmd/codeaf/pair_terminal.go index 78110b54f0..62ae02b2be 100644 --- a/cmd/codeaf/pair_terminal.go +++ b/cmd/codeaf/pair_terminal.go @@ -71,8 +71,9 @@ func (s *lineSource) finish(line chan string) { // terminal is a person at a plain terminal: what they read and what they type. type terminal struct { - out io.Writer - lines *lineSource + out io.Writer + lines *lineSource + waitingStop func() } func newTerminal(in io.Reader, out io.Writer) *terminal { @@ -80,13 +81,21 @@ func newTerminal(in io.Reader, out io.Writer) *terminal { } // say prints one line. -func (t *terminal) say(line string) { fmt.Fprintln(t.out, line) } +func (t *terminal) say(line string) { t.stopProgress(); fmt.Fprintln(t.out, line) } + +func (t *terminal) stopProgress() { + if t.waitingStop != nil { + t.waitingStop() + t.waitingStop = nil + } +} // confirm puts a question with the pair package's own choice under it and // answers whether the person said yes. THERE IS NO DEFAULT: Enter, the end of // the input, silence and every word but y and yes are a no, because a stray key // must never let a device in. func (t *terminal) confirm(ctx context.Context, question string) bool { + t.stopProgress() fmt.Fprint(t.out, question+" "+pair.AskChoice+" ") typed, ok := t.lines.next(ctx) if !ok { @@ -108,6 +117,7 @@ func saidYes(typed string) bool { // approveLink uses the same deliberate approval key as the chat card. Code // pairing keeps its own yes/no question because its comparison is different. func (t *terminal) approveLink(ctx context.Context, question string) bool { + t.stopProgress() fmt.Fprint(t.out, question+" a / d ") typed, ok := t.lines.next(ctx) if !ok { @@ -120,7 +130,7 @@ func (t *terminal) approveLink(ctx context.Context, question string) bool { // offerScreen is the device that shows the code, as a terminal. type offerScreen struct{ *terminal } -func (s offerScreen) Show(_ *pair.Code, lines string) { fmt.Fprint(s.out, lines) } +func (s offerScreen) Show(_ *pair.Code, lines string) { s.stopProgress(); fmt.Fprint(s.out, lines) } func (s offerScreen) Burned() { s.say(pair.BurnLine) } @@ -147,6 +157,12 @@ func serveApprover(ctx context.Context, in *os.File, out io.Writer) func(label, return func(label, words string) bool { asked, stop := context.WithTimeout(ctx, pair.ConfirmWithin) defer stop() - return person.confirm(asked, pair.AskMachineLine(label, words)) + return confirmMachine(person, asked, label, words) } } + +// confirmMachine puts the permission weight at the point of the serving decision. +func confirmMachine(person *terminal, ctx context.Context, label, words string) bool { + person.say(pair.PairingWeight) + return person.confirm(ctx, pair.AskMachineLine(label, words)) +} diff --git a/cmd/codeaf/pair_test.go b/cmd/codeaf/pair_test.go index 9de0a67ad0..618e729d15 100644 --- a/cmd/codeaf/pair_test.go +++ b/cmd/codeaf/pair_test.go @@ -194,6 +194,7 @@ func (r *pairRig) joinWith(t *testing.T, code string, replace bool) (*pairScreen // The whole of both doors through the real command: one shows a code, the other // types it, both screens show the same three words, a person types y, and the // second computer holds the first one's chats and the relay it came through. +// L4-M12b: code approval uses the same completion sentence as link approval. func TestM1M8PairBothDoorsOnATerminal(t *testing.T) { rig := newPairRig(t) if _, err := identity.EnsureSolo(rig.homeB); err != nil { @@ -204,6 +205,9 @@ func TestM1M8PairBothDoorsOnATerminal(t *testing.T) { t.Fatal(err) } + if _, err := devname.Set(rig.homeA, "laptop-a"); err != nil { + t.Fatal(err) + } shown := rig.show(t) code := shown.code(t) @@ -227,8 +231,8 @@ func TestM1M8PairBothDoorsOnATerminal(t *testing.T) { } wanted := pair.WaitingChatsLine(asked[1]) - if got := joiner.String(); got != wanted+"\n"+pair.JoinedLine+"\n"+terminalSharingLine+"\n" { - t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s\n%s", got, wanted, pair.JoinedLine) + if got, want := joiner.String(), wanted+"\n"+pair.PairedWithLine("laptop-a")+"\n"+terminalSharingLine+"\n"; got != want { + t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s", got, want) } if !strings.HasSuffix(shown.out.String(), pair.PairedChatsLine("laptop")+"\n") { t.Fatalf("the sharing terminal did not end on the paired line:\n%s", shown.out.String()) diff --git a/cmd/codeaf/pairlink.go b/cmd/codeaf/pairlink.go index 6d1d9fbc86..e36677a16b 100644 --- a/cmd/codeaf/pairlink.go +++ b/cmd/codeaf/pairlink.go @@ -15,6 +15,7 @@ import ( "errors" "net/http" "runtime" + "sort" "time" "github.com/Agent-Field/codeaf/internal/devname" @@ -64,12 +65,20 @@ func (s homeSigner) Sign(msg []byte) []byte { return s.dev.Sign(msg) } // pullFleet counts what the identity holds, as this computer now sees it. func pullFleet(dir string, route pair.Mailbox) func(context.Context) (pair.Fleet, error) { return func(ctx context.Context) (pair.Fleet, error) { - client, _, err := homeDirectory(dir, route) + client, id, err := homeDirectory(dir, route) if err != nil { return pair.Fleet{}, err } listing, err := client.List(ctx) - return fleetOf(listing), err + fleet := fleetOf(listing) + if err == nil { + dev, e := identity.Device(dir) + if e != nil { + return fleet, e + } + fleet.With = pairingPeer(listing, directory.MetadataKey(id.CellKey()), dev.ID()) + } + return fleet, err } } @@ -93,7 +102,7 @@ func fleetOf(l directory.Listing) pair.Fleet { func linkJoining(dir string) func(pair.Mailbox, bool) pair.LinkJoining { return func(route pair.Mailbox, replace bool) pair.LinkJoining { return pair.LinkJoining{ - Joining: pairJoiningAt(dir)(replace), Platform: runtime.GOOS, Host: route.Host, Via: syncsetup.Named(route.URL), + Joining: pairJoiningAt(dir)(replace), Platform: runtime.GOOS, Host: route.URL, Via: syncsetup.Named(route.URL), Pull: pullFleet(dir, route), } } @@ -110,6 +119,9 @@ func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) err if err != nil { return err } + if err := d.pairingStatus(ctx, route, replace); err != nil { + return err + } joining := d.linking(route, replace) if d.name != "" { joining.Label = d.name @@ -130,10 +142,8 @@ func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) err if joined.Already { d.term.say(joined.Sentence()) } else { - d.term.say(pair.JoinedFleetLine(joined.Fleet.Workspaces)) - } - if !joined.Already { d.recordOwnDeviceOrSay(ctx, route) + d.term.say(pair.PairedWithLine(joined.Fleet.With)) d.term.say(terminalSharingLine) } return nil @@ -146,13 +156,19 @@ func (d pairDoor) approve(ctx context.Context, relay, typed string) error { if err != nil { return err } - if relay == "" { + // A page already on this computer's service keeps that explicit route. + // A different service must still face the mailbox's configured route below, + // so a pasted link cannot redirect approval to another service. + if relay == "" && pair.CheckLinkService(typed, pairingRelay(home.Dir()).URL) == nil { relay = pair.LinkRelay(typed) } route, err := d.mailbox(relay) if err != nil { return err } + if err := pair.CheckLinkService(typed, route.URL); err != nil { + return err + } who, err := d.approver(route) if err != nil { return err @@ -168,11 +184,11 @@ func (d pairDoor) approve(ctx context.Context, relay, typed string) error { if err := who.Approve(ctx, asking); err != nil { return err } - if err := identity.EndSolo(home.Dir()); err != nil { + if err := identity.EndSolo(d.joining(false).Home); err != nil { return err } - d.term.say(pair.ApprovedLine(asking.Name)) d.recordOwnDeviceOrSay(ctx, route) + d.term.say(pair.ApprovedLine(asking.Name)) return nil } @@ -200,3 +216,18 @@ func linkApprover(dir string) func(pair.Mailbox) (pair.Approver, error) { }, nil } } + +// pairingPeer reads names after joining, so pairing needs no new wire fields. +func pairingPeer(l directory.Listing, key []byte, self string) string { + var names []string + for id, dev := range l.Devices { + if id != self && !dev.Revoked { + names = append(names, openName(key, dev.Name)) + } + } + sort.Strings(names) + if len(names) > 0 { + return names[0] + } + return "" +} diff --git a/cmd/codeaf/pairlink_test.go b/cmd/codeaf/pairlink_test.go index 1392369781..944df50fa2 100644 --- a/cmd/codeaf/pairlink_test.go +++ b/cmd/codeaf/pairlink_test.go @@ -58,7 +58,7 @@ func TestPairByLinkEndToEnd(t *testing.T) { if err := <-done; err != nil { t.Fatal(err) } - screen.waitFor(t, `Paired\.`) + screen.waitFor(t, `Paired with`) if !strings.Contains(screen.String(), terminalSharingLine+"\n") { t.Fatalf("link join omitted deferred sharing: %s", screen.String()) } @@ -157,7 +157,7 @@ func TestPairApproveRefusals(t *testing.T) { rig := newPairRig(t) out := &pairScreenText{} err := rig.door(rig.homeB, strings.NewReader("a\n"), out).run(context.Background(), []string{"approve", "k7m2q9xd.AAAAAAAAAAAAAAAAAAAAAA"}) - if !errors.Is(err, pair.ErrNotJoined) || !strings.Contains(err.Error(), "start codeaf here once") { + if !errors.Is(err, pair.ErrNotJoined) || !strings.Contains(err.Error(), "open codeaf on this computer once") { t.Fatalf("an unpaired computer approving ended with %v", err) } err = rig.door(rig.homeA, strings.NewReader(""), out).run(context.Background(), []string{"approve", "nope"}) diff --git a/cmd/codeaf/pairroute.go b/cmd/codeaf/pairroute.go index 3632a4f713..39862f202b 100644 --- a/cmd/codeaf/pairroute.go +++ b/cmd/codeaf/pairroute.go @@ -14,6 +14,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" @@ -25,6 +26,16 @@ import ( // wait and this client never cuts a healthy one short. const pairPollTimeout = pairbox.MaxWait + 10*time.Second +// syncRelayURL is the relay this computer syncs through, empty when sync is +// off. `codeaf serve` and `--at` fall back to it, so a machine whose chats sync +// is reachable through the same relay with nothing else set up. +func syncRelayURL() string { + if relay := syncsetup.Resolve(home.Dir()); relay.On() { + return relay.URL + } + return "" +} + // pairMailbox is the relay a pairing runs through: the --relay flag, else the // relay this computer syncs through. A relay named by flag is the one the other // device is told to pass, so it is `Shown`; the default needs no telling. @@ -39,7 +50,7 @@ func pairMailboxAt(dir, relayFlag string) (pair.Mailbox, error) { flag := strings.TrimSpace(relayFlag) base := flag if flag == "" { - relay := syncsetup.Resolve(dir) + relay := pairingRelay(dir) if relay.Off { return pair.Mailbox{}, pair.ErrSyncOff } @@ -56,7 +67,7 @@ func pairMailboxAt(dir, relayFlag string) (pair.Mailbox, error) { Box: pairbox.NewHTTP(base, &http.Client{Timeout: pairPollTimeout}), URL: base, Host: u.Host, - Shown: flag, + Shown: syncsetup.Named(base), }, nil } @@ -98,3 +109,16 @@ func pairJoiningAt(dir string) func(replace bool) pair.Joining { } } } + +// pairingRelay keeps the pairing/remote override out of ordinary chat syncing. +// An explicit sync variable wins; otherwise pairing may use CODEAF_RELAY before +// the saved service or hosted default, as its printed instructions promise. +func pairingRelay(dir string) syncsetup.Relay { + selected := syncsetup.Resolve(dir) + if env.Get(syncsetup.URLVar) == "" { + if override := strings.TrimSpace(env.Get(pair.RelayEnv)); override != "" { + return syncsetup.Relay{URL: override} + } + } + return selected +} diff --git a/cmd/codeaf/privacy_test.go b/cmd/codeaf/privacy_test.go index e5a4a4b669..48e41327f9 100644 --- a/cmd/codeaf/privacy_test.go +++ b/cmd/codeaf/privacy_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/syncsetup" @@ -32,7 +33,7 @@ func TestC8DeviceListsOnASoloHomeSendNothing(t *testing.T) { if err := runDevices(nil); err != nil { t.Fatal(err) } - if rows, err := newApprovalsDoor(home.Dir()).Devices(context.Background()); err != nil || len(rows) != 0 { + if rows, err := newApprovalsDoor(home.Dir()).Devices(context.Background()); err != nil || len(rows) != 1 || !rows[0].Self || rows[0].Name != devname.Name(home.Dir()) { t.Fatalf("slash device list: %v, %v", rows, err) } if hits.Load() != 0 || !syncsetup.Quiet(home.Dir()) { diff --git a/cmd/codeaf/quit_handoff_test.go b/cmd/codeaf/quit_handoff_test.go index f579ad4148..d2c1ca8f6c 100644 --- a/cmd/codeaf/quit_handoff_test.go +++ b/cmd/codeaf/quit_handoff_test.go @@ -33,17 +33,19 @@ type savingQuitAgent struct { func (a *savingQuitAgent) Close() error { return a.save() } // Contract E2 follows the actual quit, launcher teardown and bare receiving launch. -func TestHostedSurfaceQuitPublishesReleasesAndBareLaunchMovesTheChat(t *testing.T) { +// L4-H4b preserves publication and release when the final upload succeeds. +func TestL4HostedSurfaceQuitPublishesReleasesAndBareLaunchMovesTheChat(t *testing.T) { runHostedQuitContract(t, false) } -// Contract E2 bounds an offline quit and names the locally retained transcript. -func TestHostedSurfaceQuitWithRelayDownKeepsTheLocalTurns(t *testing.T) { +// L4-H4b reports a successful local save when the final upload cannot finish. +func TestL4HostedSurfaceQuitWithRelayDownKeepsTheLocalTurns(t *testing.T) { runHostedQuitContract(t, true) } func runHostedQuitContract(t *testing.T, relayDown bool) { t.Helper() + t.Cleanup(stopFinalSaveProgress) cfg, c := doorChat(t) short, err := os.MkdirTemp("", "ce-") if err != nil { @@ -235,18 +237,21 @@ func runHostedQuitContract(t *testing.T, relayDown bool) { defer func() { flushFinalSaves, os.Stdout = oldFlush, oldOut }() flushFinalSaves = func(context.Context) error { return nil } os.Stdout = out - if finishFinalSaving(t.Context()) { - t.Fatal("offline quit claimed a complete save") + if !finishFinalSaving(t.Context()) { + t.Fatal("a confirmed local save was reported as a failure") } if _, err := out.Seek(0, io.SeekStart); err != nil { t.Fatal(err) } line, err := io.ReadAll(out) - if err != nil || !strings.Contains(string(line), "unsaved turns are kept in "+transcript) { + if err != nil || !strings.Contains(string(line), "saved here; it reaches your other devices when the sync service at ") || strings.Contains(string(line), transcript) || strings.Contains(string(line), "could not save") { t.Fatalf("offline quit report=%q err=%v", line, err) } return } + if !finishFinalSaving(t.Context()) { + t.Fatal("the completed upload was reported as a failure") + } if releases.Load() != 1 { t.Fatalf("relay releases=%d", releases.Load()) } diff --git a/cmd/codeaf/quit_save_test.go b/cmd/codeaf/quit_save_test.go index cb8112369f..b397b54778 100644 --- a/cmd/codeaf/quit_save_test.go +++ b/cmd/codeaf/quit_save_test.go @@ -21,7 +21,8 @@ func (failedSaveAgent) Close() error { } // B7 puts the failure on the restored screen even when stderr is a log. -func TestQuitSaveFailureUsesTheRestoredSurfaceOutput(t *testing.T) { +// L4-H4c preserves the nonzero receipt for a real local saving failure. +func TestL4QuitSaveFailureUsesTheRestoredSurfaceOutput(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) t.Cleanup(cell.UseAvailabilityInTests(false)) oldRun, oldFlush := runSurfaceProgram, flushFinalSaves @@ -76,7 +77,8 @@ func TestQuitSaveFailureUsesTheRestoredSurfaceOutput(t *testing.T) { } // B7 reports a final save failure once without exposing the engine's error. -func TestQuitSaveFailureIsOnePlainSentence(t *testing.T) { +// L4-H4c keeps local failure words distinct from an upload-only receipt. +func TestL4QuitSaveFailureIsOnePlainSentence(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) finalSaving.Lock() finalSaving.surface = false diff --git a/cmd/codeaf/quit_tail_test.go b/cmd/codeaf/quit_tail_test.go index 74f2b0f058..5bf14a8022 100644 --- a/cmd/codeaf/quit_tail_test.go +++ b/cmd/codeaf/quit_tail_test.go @@ -208,11 +208,12 @@ func (h *hostedTailChat) saved() bool { } func (h *hostedTailChat) quit(g *tailPublishGate) string { - return h.quitAfter(g, 3*time.Second) + return h.quitAfter(g, 0) } func (h *hostedTailChat) quitAfter(g *tailPublishGate, delay time.Duration) string { h.t.Helper() + h.t.Cleanup(stopFinalSaveProgress) oldRun, oldOut, oldFlush := runSurfaceProgram, os.Stdout, flushFinalSaves out, err := os.CreateTemp(h.t.TempDir(), "quit-output") if err != nil { @@ -278,15 +279,18 @@ func quittingTailChat(t *testing.T) (*hostedTailChat, *tailPublishGate, string) case <-time.After(cellstore.SealWithin): t.Fatal("next publish never committed") } - output := h.quit(g) + output := h.quitAfter(g, 0) return h, g, output } // Contract 1 requires quitting after work to save the latest head quietly and release its hold. -func TestHostedQuitAfterWorkSavesNewestHeadQuietly(t *testing.T) { +// L4-H4a allows progress while waiting, without turning a successful save into a failure. +func TestL4HostedQuitAfterWorkSavesNewestHead(t *testing.T) { h, _, output := quittingTailChat(t) - if output != "" { - t.Errorf("successful quit printed %q", output) + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if line != "" && line != quitSavingWord && line != quitStillSavingWord && line != quitStillLocalWord { + t.Errorf("successful quit printed an unexpected receipt: %q", output) + } } if !h.saved() { t.Error("quit left the newest sealed head local") @@ -301,7 +305,8 @@ func TestHostedQuitAfterWorkSavesNewestHeadQuietly(t *testing.T) { } // Contract 2 requires a closing publish to join a committed request without conflicting with itself. -func TestHostedQuitJoinsCommittedPublishWithoutSelfConflict(t *testing.T) { +// L4-H4b preserves the joined publish when its reply is released at the close boundary. +func TestL4HostedQuitJoinsCommittedPublishWithoutSelfConflict(t *testing.T) { _, gate, _ := quittingTailChat(t) if got := gate.conflicts.Load(); got != 0 { t.Fatalf("one computer conflicted with itself %d times", got) @@ -312,7 +317,8 @@ func TestHostedQuitJoinsCommittedPublishWithoutSelfConflict(t *testing.T) { } // Contract 3 requires the publication record to name the head accepted by the relay. -func TestHostedQuitRecordsTheHeadAcceptedByRelay(t *testing.T) { +// L4-H4b keeps the accepted head while the final upload reply joins closing. +func TestL4HostedQuitRecordsTheHeadAcceptedByRelay(t *testing.T) { h, _, _ := quittingTailChat(t) raw, err := os.ReadFile(filepath.Join(h.c.Root, cell.StateDir, "published.json")) if err != nil { @@ -331,7 +337,8 @@ func TestHostedQuitRecordsTheHeadAcceptedByRelay(t *testing.T) { } // Contract 6 requires the first take back to restore the newest history without a kept branch. -func TestHostedQuitThenTakeBackRestoresNewestHistoryFirstTry(t *testing.T) { +// L4-H4b preserves newest-history continuation after the joined final save. +func TestL4HostedQuitThenTakeBackRestoresNewestHistoryFirstTry(t *testing.T) { h, _, _ := quittingTailChat(t) rootB := func(id string) string { return filepath.Join(h.bRoots, id) } got, err := h.taker(h.b, h.eb, rootB, nil).Take(t.Context(), h.c.ID) @@ -439,7 +446,8 @@ func TestHostedLaunchNeverSealsAnEmptyTurn(t *testing.T) { } // Contract 7 requires an auxiliary-only local tail to adopt the remote history without making unsaved edits. -func TestHostedTakeBackNeverBranchesBookkeepingOnlyTurns(t *testing.T) { +// L4-H4b retains local bookkeeping after an upload-only failure and permits later continuation. +func TestL4HostedTakeBackNeverBranchesBookkeepingOnlyTurns(t *testing.T) { for _, kind := range []string{"auxiliary usage", "reconciled cost"} { t.Run(kind, func(t *testing.T) { g := &tailPublishGate{committed: make(chan struct{}), reply: make(chan struct{})} @@ -476,8 +484,8 @@ func TestHostedTakeBackNeverBranchesBookkeepingOnlyTurns(t *testing.T) { func assertBookkeepingTakeBack(t *testing.T, h *hostedTailChat, g *tailPublishGate) { t.Helper() - output := h.quit(g) - if !strings.Contains(output, "could not save") { + output := h.quitAfter(g, 0) + if !strings.Contains(output, "saved here; it reaches your other devices when the sync service at ") || strings.Contains(output, "could not save") { raw, _ := os.ReadFile(session.Place{Dir: h.c.Root}.Transcript()) t.Fatalf("offline fixture did not retain local work: %q\n%s", output, raw) } @@ -517,7 +525,8 @@ func assertBookkeepingTakeBack(t *testing.T, h *hostedTailChat, g *tailPublishGa } // Contract 4 requires a failed quit to retain its hold and retry on restart while it still owns the chat. -func TestHostedFailedQuitRetriesOnNextStartWhileStillHolding(t *testing.T) { +// L4-H4b keeps the hold and retries uploading after a successful local-only quit. +func TestL4HostedLocalSaveRetriesUploadOnNextStartWhileStillHolding(t *testing.T) { g := &tailPublishGate{committed: make(chan struct{}), reply: make(chan struct{}), titleRelease: make(chan struct{})} h := newHostedTailChat(t, g) h.say("Save this answer on the other computer.") @@ -528,9 +537,9 @@ func TestHostedFailedQuitRetriesOnNextStartWhileStillHolding(t *testing.T) { raw, err := os.ReadFile(session.Place{Dir: h.c.Root}.Transcript()) return err == nil && strings.Contains(string(raw), `"title":"a saved conversation title"`) }) - output := h.quit(g) - if !strings.Contains(output, "could not save") || !strings.Contains(output, "if this computer still holds the chat") { - t.Errorf("failed quit made an unconditional recovery promise: %q", output) + output := h.quitAfter(g, 0) + if !strings.Contains(output, "saved here; it reaches your other devices when the sync service at ") || strings.Contains(output, "could not save") { + t.Errorf("an upload-only failure lost its local-save receipt: %q", output) } if _, fence, err := h.a.SendLease(h.c.ID); err != nil || fence == 0 { t.Fatalf("failed quit lost its hold: %v", err) @@ -544,7 +553,8 @@ func TestHostedFailedQuitRetriesOnNextStartWhileStillHolding(t *testing.T) { } // Contract 4b requires a removed computer to finish its local save quietly without a relay save. -func TestHostedRemovedComputerQuitsQuietlyAndKeepsLocalHistory(t *testing.T) { +// L4-H4b leaves a completed local quit quiet when removal has disabled uploads. +func TestL4HostedRemovedComputerQuitsQuietlyAndKeepsLocalHistory(t *testing.T) { g := &tailPublishGate{committed: make(chan struct{}), reply: make(chan struct{})} h := newHostedTailChat(t, g) h.say("Keep this answer on this computer.") diff --git a/cmd/codeaf/quit_wait_test.go b/cmd/codeaf/quit_wait_test.go index b791736eae..7d86d97486 100644 --- a/cmd/codeaf/quit_wait_test.go +++ b/cmd/codeaf/quit_wait_test.go @@ -10,6 +10,7 @@ import ( ) // Contract 1b keeps an unanswered save distinct from a failed one at the saving ceiling. +// L4-H4b requires a new quit generation and a positive local receipt at that ceiling. func TestQuitWaitReportsStillFinishingAtTheSavingCeiling(t *testing.T) { oldOut, oldFlush := os.Stdout, flushFinalSaves flushFinalSaves = func(context.Context) error { return nil } @@ -20,19 +21,21 @@ func TestQuitWaitReportsStillFinishingAtTheSavingCeiling(t *testing.T) { } defer out.Close() os.Stdout = out + armFinalSaving() finalSaving.Lock() - finalSaving.failure, finalSaving.pending, finalSaving.forced = nil, nil, nil - finalSaving.surface = true + finalSaving.failure, finalSaving.pending = nil, nil finalSaving.Unlock() t.Cleanup(func() { os.Stdout = oldOut finalSaving.Lock() finalSaving.failure, finalSaving.pending, finalSaving.forced = nil, nil, nil finalSaving.surface = false + finalSaving.reporting, finalSaving.reportDone, finalSaving.durable = false, nil, nil finalSaving.Unlock() }) retainFinalClose(make(chan struct{})) - ctx, cancel := context.WithTimeout(t.Context(), 1200*time.Millisecond) + retainLocalDurability(func() bool { return true }) + ctx, cancel := context.WithDeadline(t.Context(), time.Unix(0, 0)) defer cancel() awaitFinalCloses(ctx) finishFinalSaving(context.Background()) @@ -43,7 +46,7 @@ func TestQuitWaitReportsStillFinishingAtTheSavingCeiling(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Count(string(raw), "still saving this chat in the background") != 1 || strings.Contains(string(raw), "could not save") { + if strings.Count(string(raw), "saved here; it reaches your other devices when the sync service at") != 1 || strings.Contains(string(raw), "could not save") { t.Fatalf("long wait=%q", raw) } finalSaving.Lock() diff --git a/cmd/codeaf/serve_presence.go b/cmd/codeaf/serve_presence.go new file mode 100644 index 0000000000..0a913f1367 --- /dev/null +++ b/cmd/codeaf/serve_presence.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// A watch socket is the directory's online signal. Merely upserting a record +// does not advance last seen, because those timestamps belong to the relay. +func keepServePresence(ctx context.Context, _ string) { + ticks := time.NewTicker(syncsetup.DefaultInterval) + defer ticks.Stop() + keepServeHomePresence(ctx, home.Dir(), ticks.C) +} + +// Reopening uses the home-sync gates and releases the watch on a changed binding. +// Serving itself can continue while a newly paired identity starts its own lifetime. +func keepServeHomePresence(ctx context.Context, dir string, ticks <-chan time.Time) { + if ctx.Err() != nil || cell.UnavailableReason() != "" || syncsetup.Quiet(dir) { + return + } + current, ok, err := syncsetup.Open(dir) + if err != nil || !ok { + return + } + var refreshed time.Time + servePresence(ctx, current.Follow(), ticks, func(ctx context.Context) error { + if cell.UnavailableReason() != "" || syncsetup.Quiet(dir) { + return directory.ErrRevoked + } + next, ok, err := syncsetup.Open(dir) + if err != nil || !ok { + return directory.ErrRevoked + } + if next.Identity.ID() != current.Identity.ID() || next.Device.ID() != current.Device.ID() || next.Relay != current.Relay { + return directory.ErrRevoked + } + if !refreshed.IsZero() && time.Since(refreshed) < serveRecordEvery { + return nil + } + err = syncsetup.PutOwnDevice(ctx, next.Dir, next.Identity, next.Device, devname.Name(dir)) + if err == nil { + refreshed = time.Now() + } + return err + }) +} + +// Follow shares the one process-wide socket; ticks refresh records synchronously. +func servePresence(ctx context.Context, feed dirwatch.Follower, ticks <-chan time.Time, put func(context.Context) error) { + if feed != nil { + defer feed.Close() + } + if ctx.Err() != nil { + return + } + refreshServeRecord(ctx, ticks, put) +} + +const serveRecordEvery = 2 * time.Minute + +func refreshServeRecord(ctx context.Context, ticks <-chan time.Time, put func(context.Context) error) { + for ctx.Err() == nil { + request, cancel := context.WithTimeout(ctx, listRequestWithin) + err := put(request) + cancel() + if errors.Is(err, directory.ErrRevoked) { + return + } + select { + case <-ctx.Done(): + return + case _, ok := <-ticks: + if !ok { + return + } + } + } +} diff --git a/cmd/codeaf/serve_presence_test.go b/cmd/codeaf/serve_presence_test.go new file mode 100644 index 0000000000..2c836daf2f --- /dev/null +++ b/cmd/codeaf/serve_presence_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/rotate" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// R5.2: an explicit serving relay cannot bypass any home-sync gate. +func TestDevboxR52ServePresenceRespectsEligibility(t *testing.T) { + celltest.Available(t) + for _, mode := range []string{"quiet", "off", "unavailable", "rotating"} { + t.Run(mode, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "unexpected", 404) })) + defer server.Close() + dir := t.TempDir() + t.Setenv(home.EnvVar, dir) + t.Setenv(syncsetup.URLVar, server.URL) + if mode == "quiet" { + if _, err := identity.EnsureSolo(dir); err != nil { + t.Fatal(err) + } + } else { + if _, err := identity.Ensure(dir); err != nil { + t.Fatal(err) + } + } + switch mode { + case "off": + t.Setenv(syncsetup.URLVar, "off") + case "unavailable": + t.Cleanup(cell.UseAvailabilityInTests(false)) + case "rotating": + if err := os.WriteFile(filepath.Join(dir, rotate.File), []byte("pending"), 0o600); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + keepServePresence(ctx, server.URL) + if requests.Load() != 0 { + t.Fatalf("R5.2 %s published %d requests", mode, requests.Load()) + } + }) + } +} + +// R5.2: a serving worker releases its old signer after identity/device/relay rebinding. +func TestDevboxR52ServePresenceStopsOnRebinding(t *testing.T) { + celltest.Available(t) + for _, mode := range []string{"identity", "device", "relay"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + t.Setenv(home.EnvVar, dir) + started := make(chan struct{}, 8) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case started <- struct{}{}: + default: + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{}`)) + })) + defer server.Close() + t.Setenv(syncsetup.URLVar, server.URL) + if _, err := identity.Ensure(dir); err != nil { + t.Fatal(err) + } + ticks := make(chan time.Time) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + go func() { defer close(done); keepServeHomePresence(ctx, dir, ticks) }() + t.Cleanup(func() { cancel(); <-done }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("R5.2 no initial presence") + } + switch mode { + case "identity": + id, err := identity.Ensure(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := identity.Save(dir, id); err != nil { + t.Fatal(err) + } + case "device": + if err := os.Remove(filepath.Join(dir, "device.json")); err != nil { + t.Fatal(err) + } + if _, err := identity.Device(dir); err != nil { + t.Fatal(err) + } + case "relay": + t.Setenv(syncsetup.URLVar, "https://new-relay.example") + } + select { + case ticks <- time.Now(): + case <-done: + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("R5.2 retained old presence binding") + } + }) + } +} diff --git a/cmd/codeaf/serve_state.go b/cmd/codeaf/serve_state.go new file mode 100644 index 0000000000..6d1e9e93da --- /dev/null +++ b/cmd/codeaf/serve_state.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// startServeKeeping starts serve's home-state and presence workers only when +// syncsetup.ServeStateVar asks for them (a dev box sets it), and returns their +// join. +func startServeKeeping(ctx context.Context, service string) func() { + return serveKeeping(ctx, service, syncsetup.ServeKeepsState()) +} + +// serveKeeping is startServeKeeping with the decision made. With keep false +// it starts nothing and the join does nothing, so every serve that did not ask +// behaves exactly as it always has. +func serveKeeping(ctx context.Context, service string, keep bool) func() { + if !keep { + return func() {} + } + joinState := startServeHomeState(ctx, home.Dir(), func(err error) { + fmt.Fprintln(os.Stderr, "could not sync this machine's saved keys and settings:", err) + }) + presenceCtx, stopPresence := context.WithCancel(ctx) + presenceDone := make(chan struct{}) + guard.Go("serve/presence", func() { defer close(presenceDone); keepServePresence(presenceCtx, service) }) + return func() { + stopPresence() + <-presenceDone + joinState() + } +} + +// startServeHomeState gives the serving process one home-state worker and a +// join. Its first round runs asynchronously so a slow relay cannot delay serving. +func startServeHomeState(ctx context.Context, home string, report func(error)) func() { + ctx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + guard.Go("serve/home-state", func() { + defer close(done) + syncsetup.RunHomeState(ctx, home, report) + }) + return func() { cancel(); <-done } +} diff --git a/cmd/codeaf/serve_state_test.go b/cmd/codeaf/serve_state_test.go new file mode 100644 index 0000000000..4c67c9f9cc --- /dev/null +++ b/cmd/codeaf/serve_state_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +func TestR43ServeHomeStateCancelsAndJoinsInFlightRound(t *testing.T) { + celltest.Available(t) + t.Setenv(config.ProfileDirEnv, "") + started := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "cluster") { + t.Error("serve started a cluster member") + } + select { + case started <- struct{}{}: + default: + } + <-r.Context().Done() + })) + defer server.Close() + t.Setenv(syncsetup.URLVar, server.URL) + stateHome := t.TempDir() + if _, err := identity.Ensure(stateHome); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + join := startServeHomeState(ctx, stateHome, func(err error) { t.Errorf("cancelled round printed: %v", err) }) + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("serve did not start its home-state round") + } + cancel() + joined := make(chan struct{}) + go func() { join(); close(joined) }() + select { + case <-joined: + case <-time.After(time.Second): + t.Fatal("serve returned without joining its home-state goroutine") + } +} + +func TestR45DevicesPrintsMachineAliasAndFallsBackToRelayName(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + now := time.Now().Add(-time.Minute) + for _, known := range []pair.Known{ + {Name: "finch-magnolia-60", Alias: "build-box", Key: "aliased-test-key", Since: now}, + {Name: "otter-lamp-42", Key: "plain-test-key", Since: now}, + } { + if err := pair.MachineBook().Remember(known); err != nil { + t.Fatal(err) + } + } + printed := listed(t, bookKind{pair.DeviceBook()}) + for _, want := range []string{"machines this device can reach", "build-box", "otter-lamp-42", "open one with `codeaf chat --at `"} { + if !strings.Contains(printed, want) { + t.Errorf("devices lacks %q: %s", want, printed) + } + } + if strings.Contains(printed, "finch-magnolia-60") { + t.Errorf("alias row uses its relay name: %s", printed) + } +} + +// R4.4 goes through the real engine boot and an HTTP provider request. A daemon +// may already hold its process resources when a serving round writes the key. +func TestR44ServeEngineReadsProviderKeyThatArrivedAfterStartup(t *testing.T) { + server := newV3ClientDoorServer(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv(config.ProfileDirEnv, "") + t.Setenv(config.APIKeyEnv, "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("CODEAF_BASE_URL", server.URL) + workspace := t.TempDir() + t.Chdir(workspace) + freshEngineProcess(t) + if _, err := bootEngine(remote.Hello{Version: remote.Version, Workspace: workspace, Model: "test/model"}, "", ""); !errors.Is(err, config.ErrNoAPIKey) { + t.Fatalf("keyless engine boot = %v", err) + } + if _, err := os.Stat(config.BudgetConfigPath("")); !os.IsNotExist(err) { + t.Fatalf("expected a keyless profile at startup: %v", err) + } + const arrivedKey = "sk-or-v1-FAKE-arrived-from-vault" + if err := config.WriteAPIKey("", arrivedKey); err != nil { + t.Fatal(err) + } + engine, err := bootEngine(remote.Hello{Version: remote.Version, Workspace: workspace, Model: "test/model"}, "", "") + if err != nil { + t.Fatalf("engine ignored the arrived key: %v", err) + } + defer engine.Agent.Close() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + events, err := engine.Agent.Submit(ctx, "say done") + if err != nil { + t.Fatal(err) + } + for range events { + } + if got := server.call(t, 0).authorization; got != "Bearer "+arrivedKey { + t.Fatal("engine did not use the newly saved provider key") + } +} diff --git a/cmd/codeaf/serve_tasks.go b/cmd/codeaf/serve_tasks.go new file mode 100644 index 0000000000..b7e5003d14 --- /dev/null +++ b/cmd/codeaf/serve_tasks.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// serveTasks shares the machine door's lifetime, relay and output. Its own +// cancellation joins followers while deliberately leaving their children alive. +func serveTasks(ctx context.Context, service string) func() { + profile := home.Dir() + if syncsetup.Quiet(profile) { + return func() {} + } + if runtime.GOOS == "windows" { + fmt.Fprintln(os.Stdout, taskdoor.UnsupportedComputerLine) + return func() {} + } + config, ok, err := serveTasksConfig(profile, service) + if err != nil { + fmt.Fprintln(os.Stderr, "could not take tasks from your paired computers:", err) + return func() {} + } + if !ok { + return func() {} + } + config.Say = func(line string) { fmt.Fprintln(os.Stdout, line) } + ctx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + guard.Go("serve/tasks", func() { + defer close(done) + if err := taskdoor.Serve(ctx, config); err != nil && ctx.Err() == nil { + fmt.Fprintln(os.Stderr, "could not take tasks from your paired computers:", err) + } + }) + return func() { cancel(); <-done } +} + +func serveTasksConfig(profile, service string) (taskdoor.Config, bool, error) { + absolute, err := filepath.Abs(profile) + if err != nil { + return taskdoor.Config{}, false, err + } + profile = absolute + if runtime.GOOS == "windows" || syncsetup.Quiet(profile) { + return taskdoor.Config{}, false, nil + } + client, id, err := homeDirectory(profile, pair.Mailbox{URL: service}) + if err != nil { + return taskdoor.Config{}, false, err + } + device, err := identity.LoadDevice(profile) + if err != nil { + return taskdoor.Config{}, false, err + } + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(profile, "v3", "stores") + engine.Guard.Home = profile + store := blobstore.NewHTTP(service, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{}) + copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: service, Identity: id, Device: device, Engine: engine, Store: store} + return taskdoor.Config{Service: service, Identity: id, Device: device, Home: profile, Root: filepath.Join(profile, "v3", "taskdoor"), + Roster: func(ctx context.Context) (map[string]bool, error) { + listing, err := client.List(ctx) + if err != nil { + return nil, err + } + active := make(map[string]bool, len(listing.Devices)) + for id, device := range listing.Devices { + active[id] = !device.Revoked + } + return active, nil + }, Executor: taskdoor.NewExecutor(taskdoor.ExecutorConfig{Copies: copies, Ledger: filepath.Join(profile, "v3", "usage.jsonl")})}, true, nil +} diff --git a/cmd/codeaf/serve_tasks_test.go b/cmd/codeaf/serve_tasks_test.go new file mode 100644 index 0000000000..d5cda23e4a --- /dev/null +++ b/cmd/codeaf/serve_tasks_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +// DESIGN section 2: only a paired, sync-enabled serve opens a task door; its +// roster is the identity directory, independent of machine-access grants. +func TestServeTasksUsesThePairedIdentityRoster(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + profile := t.TempDir() + id, err := identity.Ensure(profile) + if err != nil { + t.Fatal(err) + } + dev, err := identity.LoadDevice(profile) + if err != nil { + t.Fatal(err) + } + state := directory.NewMemory(time.Now) + active, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + removed, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + client := state.For(dev.ID()) + for _, d := range []identity.Dev{dev, active, removed} { + if err := client.PutDevice(context.Background(), d.ID(), directory.Device{V: 1}); err != nil { + t.Fatal(err) + } + } + if err := client.Revoke(context.Background(), removed.ID()); err != nil { + t.Fatal(err) + } + // This fixture needs the actual signed directory wire, including its + // per-device revocation field, but no provider or remote service. + server := httptest.NewServer(directory.Handler(reqsign.AuthenticateAt(time.Now), func(string) (directory.Directory, error) { return state, nil })) + t.Cleanup(server.Close) + config, ok, err := serveTasksConfig(profile, server.URL) + if runtime.GOOS == "windows" { + if ok { + t.Fatal("Windows offered a task door") + } + return + } + if err != nil || !ok { + t.Fatalf("paired: %t, %v", ok, err) + } + if config.Root != filepath.Join(profile, "v3", "taskdoor") || config.Service != server.URL { + t.Fatalf("wrong attempt home: %+v", config) + } + roster, err := config.Roster(context.Background()) + if err != nil { + t.Fatal(err) + } + if !roster[active.ID()] || roster[removed.ID()] { + t.Fatalf("roster ignored revocation: %v", roster) + } + if _, err := os.Stat(filepath.Join(profile, "v3", "remote", "devices.json")); !os.IsNotExist(err) { + t.Fatal("task door consulted machine-access grants") + } +} + +// DESIGN section 2: quiet and unpaired homes do not register or mention tasks. +func TestServeTasksLeavesUnpairedAndQuietHomesAlone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + for _, kind := range []string{"unpaired", "solo", "off"} { + t.Run(kind, func(t *testing.T) { + profile := t.TempDir() + if kind == "solo" { + if _, err := identity.EnsureSolo(profile); err != nil { + t.Fatal(err) + } + } + if kind == "off" { + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_SYNC_URL", "off") + } + _, ok, err := serveTasksConfig(profile, "http://127.0.0.1:8999") + if err != nil || ok { + t.Fatalf("%s opened a task door: %t, %v", kind, ok, err) + } + }) + } +} diff --git a/cmd/codeaf/sync_off_home_test.go b/cmd/codeaf/sync_off_home_test.go new file mode 100644 index 0000000000..488de02fdb --- /dev/null +++ b/cmd/codeaf/sync_off_home_test.go @@ -0,0 +1,494 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +type syncOffFrameCapture struct { + mu sync.Mutex + bytes.Buffer +} + +func (c *syncOffFrameCapture) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.Buffer.Write(p) +} + +func (c *syncOffFrameCapture) Plain() string { + c.mu.Lock() + raw := c.Buffer.String() + c.mu.Unlock() + return ansi.Strip(raw) +} + +// TestSyncOffHomeKeepsEveryChatOpensAndWrites verifies Contract 1 end to end: +// with CODEAF_SYNC_URL=off (or sync otherwise off), Home lists every chat on +// this computer — current ones in `.moving` and legacy ones alike — each once, +// never talks to the sync service, and each chat opens and can be written. +func TestSyncOffHomeKeepsEveryChatOpensAndWrites(t *testing.T) { + celltest.Available(t) + + var modelTurns atomic.Int64 + modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/models") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"test/model"}]}`)) + return + } + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + seq := modelTurns.Add(1) + reply := fmt.Sprintf("sync-off-reply-%d", seq) + var req struct { + Messages []struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"messages"` + } + if json.Unmarshal(body, &req) == nil { + for i := len(req.Messages) - 1; i >= 0; i-- { + if req.Messages[i].Role == "user" { + if s, ok := req.Messages[i].Content.(string); ok && strings.TrimSpace(s) != "" { + reply = "ack:" + strings.TrimSpace(s) + break + } + } + } + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}]}\n\n"+ + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", reply) + })) + t.Cleanup(modelServer.Close) + + for _, mode := range []string{"CODEAF_SYNC_URL_off", "sync_otherwise_off"} { + t.Run(mode, func(t *testing.T) { + var relayRequests atomic.Int64 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + relayRequests.Add(1) + http.Error(w, "sync is off; relay must not be contacted", http.StatusForbidden) + })) + t.Cleanup(relay.Close) + + shortHome, err := os.MkdirTemp("", "af2072") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + sockets, _ := filepath.Glob(filepath.Join(shortHome, "v3", "engine-*.sock")) + for _, socket := range sockets { + _ = (cellstore.Daemon{Socket: socket}).Stop(context.Background()) + } + _ = os.RemoveAll(shortHome) + }) + + t.Setenv("HOME", shortHome) + t.Setenv(home.EnvVar, shortHome) + t.Setenv("CODEAF_PROFILE_DIR", shortHome) + t.Setenv("OPENROUTER_API_KEY", "smoke-test-key") + t.Setenv("CODEAF_BASE_URL", modelServer.URL) + t.Setenv("CODEAF_MODEL", "test/model") + t.Setenv("CODEAF_DAILY_BUDGET", "0") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("CODEAF_TELEMETRY_ENDPOINT", "") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv(codeupdate.NoUpdateCheckEnv, "1") + t.Setenv("CODEAF_NO_AUTOMATIONS", "1") + + workspace := t.TempDir() + for _, dir := range []string{"objects", "refs/heads"} { + if err := os.MkdirAll(filepath.Join(workspace, ".git", dir), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(workspace, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o600); err != nil { + t.Fatal(err) + } + + bucket, err := v3ProjectDir(workspace) + if err != nil { + t.Fatal(err) + } + baseAt := time.Now().UTC().Truncate(time.Second) + + seedSpokenTranscript := func(path, id, firstPrompt, firstReply string) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + body := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":%q}\n", + id, firstPrompt, firstReply, + ) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + + // 1. Uncopied legacy chat at /. + const legacyID = "01J00000000000000000LEGACY" + legacyDir := filepath.Join(bucket, legacyID) + seedSpokenTranscript(filepath.Join(legacyDir, "transcript.jsonl"), legacyID, "Legacy Uncopied Chat", "initial legacy answer") + if err := session.SaveMeta(legacyDir, session.Meta{ + ID: legacyID, + Title: "Legacy Uncopied Chat", + Workspace: workspace, + Created: baseAt.Add(-10 * time.Minute), + LastUserAt: baseAt.Add(-4 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 2. Legacy chat at / with a completed copy in /.moving/. + const copiedOrigID = "01J00000000000000000COPIED" + copiedOrigDir := filepath.Join(bucket, copiedOrigID) + seedSpokenTranscript(filepath.Join(copiedOrigDir, "transcript.jsonl"), copiedOrigID, "Stale Original Title", "before copy answer") + if err := session.SaveMeta(copiedOrigDir, session.Meta{ + ID: copiedOrigID, + Title: "Stale Original Title", + Workspace: workspace, + Created: baseAt.Add(-9 * time.Minute), + LastUserAt: baseAt.Add(-8 * time.Minute), + }); err != nil { + t.Fatal(err) + } + copiedCell, err := cell.CopyLegacy(context.Background(), copiedOrigDir, func(ctx context.Context, c cell.Cell) error { + return session.PrepareCopy(ctx, copiedOrigDir, c, nil) + }) + if err != nil { + t.Fatalf("CopyLegacy: %v", err) + } + seedSpokenTranscript(filepath.Join(copiedCell.Root, filepath.FromSlash(cell.TranscriptPath)), copiedCell.ID, "Copied Moving Chat", "after copy answer") + if err := session.SaveMeta(copiedCell.Root, session.Meta{ + ID: copiedCell.ID, + Title: "Copied Moving Chat", + Workspace: workspace, + Created: baseAt.Add(-9 * time.Minute), + LastUserAt: baseAt.Add(-3 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 3. Standalone current chat in /.moving/ with no top-level legacy folder. + standaloneCell, err := cell.CreateIn(filepath.Join(bucket, ".moving"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatalf("CreateIn(.moving): %v", err) + } + seedSpokenTranscript(filepath.Join(standaloneCell.Root, filepath.FromSlash(cell.TranscriptPath)), standaloneCell.ID, "Standalone Moving Chat", "initial moving answer") + if err := session.SaveMeta(standaloneCell.Root, session.Meta{ + ID: standaloneCell.ID, + Title: "Standalone Moving Chat", + Workspace: workspace, + Created: baseAt.Add(-7 * time.Minute), + LastUserAt: baseAt.Add(-2 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 4. Legacy chat whose `.moving` scratch copy has NOT finished (`copy-ready` absent). + const unfinishedID = "01J0000000000000UNFINISHED" + unfinishedDir := filepath.Join(bucket, unfinishedID) + seedSpokenTranscript(filepath.Join(unfinishedDir, "transcript.jsonl"), unfinishedID, "Unfinished Copy Chat", "unfinished copy answer") + if err := session.SaveMeta(unfinishedDir, session.Meta{ + ID: unfinishedID, + Title: "Unfinished Copy Chat", + Workspace: workspace, + Created: baseAt.Add(-6 * time.Minute), + LastUserAt: baseAt.Add(-1 * time.Minute), + }); err != nil { + t.Fatal(err) + } + unfinishedScratch := cell.CopyRoot(unfinishedDir) + if err := os.WriteFile(unfinishedScratch+".lock", nil, 0o600); err != nil { + t.Fatal(err) + } + seedSpokenTranscript(filepath.Join(unfinishedScratch, filepath.FromSlash(cell.TranscriptPath)), filepath.Base(unfinishedScratch), "half made", "half made") + if err := session.SaveMeta(unfinishedScratch, session.Meta{ + ID: filepath.Base(unfinishedScratch), + Title: "Half Made Scratch Copy", + Workspace: workspace, + Created: baseAt.Add(-5 * time.Minute), + LastUserAt: baseAt, + }); err != nil { + t.Fatal(err) + } + + // Verify v3NextSession from a `.moving` chat mints into the project bucket, never nested `.moving/.moving`. + sibling, err := v3NextSession(session.Place{Dir: standaloneCell.Root, Workspace: workspace}, workspace) + if err != nil { + t.Fatalf("v3NextSession from .moving chat: %v", err) + } + if session.BucketOf(sibling.Transcript()) != filepath.Clean(bucket) || strings.Contains(sibling.Dir, filepath.Join(".moving", ".moving")) { + t.Fatalf("v3NextSession minted sibling in wrong directory: %s (bucket=%s)", sibling.Dir, bucket) + } + _ = os.RemoveAll(sibling.Dir) + + // Seed an identity and a saved hold receipt from an earlier sync session so + // turning sync off exercises previously-held `.moving` chats as well. + var id identity.Identity + switch mode { + case "CODEAF_SYNC_URL_off": + id, err = identity.Ensure(shortHome) + if err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, "off") + case "sync_otherwise_off": + // Solo unpaired home with a configured relay URL: sync is quiet/off + // and must neither talk to the service nor block local chats. + id, err = identity.EnsureSolo(shortHome) + if err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, relay.URL) + } + dev, err := identity.LoadDevice(shortHome) + if err != nil { + t.Fatal(err) + } + ledger := cellstore.LedgerName(relay.URL, id.ID()) + holdSum := sha256.Sum256([]byte(ledger + "/" + dev.ID() + "/" + standaloneCell.ID)) + holdPath := filepath.Join(shortHome, "v3", "sync", "holds", hex.EncodeToString(holdSum[:])) + if err := os.MkdirAll(filepath.Dir(holdPath), 0o700); err != nil { + t.Fatal(err) + } + holdRaw, _ := json.Marshal(map[string]any{"cell": standaloneCell.ID, "fence": 9}) + if err := os.WriteFile(holdPath, holdRaw, 0o600); err != nil { + t.Fatal(err) + } + + // Check 1: ReadHome lists every chat on this computer, each once. + world := session.ReadHome() + rows := world.Sessions() + if len(rows) != 4 { + t.Fatalf("ReadHome() returned %d chats, want 4: %+v", len(rows), rows) + } + wantByTitle := map[string]string{ + "Legacy Uncopied Chat": filepath.Join(legacyDir, "transcript.jsonl"), + "Copied Moving Chat": filepath.Join(copiedCell.Root, filepath.FromSlash(cell.TranscriptPath)), + "Standalone Moving Chat": filepath.Join(standaloneCell.Root, filepath.FromSlash(cell.TranscriptPath)), + "Unfinished Copy Chat": filepath.Join(unfinishedDir, "transcript.jsonl"), + } + seenTitles := map[string]int{} + for _, row := range rows { + seenTitles[row.Title]++ + wantTranscript, ok := wantByTitle[row.Title] + if !ok { + t.Fatalf("unexpected row on Home: title=%q id=%q transcript=%q", row.Title, row.ID, row.Transcript) + } + if filepath.Clean(row.Transcript) != filepath.Clean(wantTranscript) { + t.Fatalf("row %q transcript = %s, want %s", row.Title, row.Transcript, wantTranscript) + } + } + for title := range wantByTitle { + if seenTitles[title] != 1 { + t.Fatalf("Home listed %q %d times, want 1", title, seenTitles[title]) + } + } + + // Opening the legacy original path with sync off still resolves to its existing `.moving` copy. + resolvedCopy, err := movablePlace(context.Background(), session.Place{Dir: copiedOrigDir, Workspace: workspace}) + if err != nil { + t.Fatalf("movablePlace(copiedOrigDir): %v", err) + } + if filepath.Clean(resolvedCopy.Dir) != filepath.Clean(copiedCell.Root) { + t.Fatalf("movablePlace(copiedOrigDir) = %s, want existing copy %s", resolvedCopy.Dir, copiedCell.Root) + } + + // Check 2: Drive the real Home TUI listing through runSurface (`Landing: true`). + oldRun := runSurfaceProgram + oldExecutable := surfaceExecutable + oldRunningExecutable := surfaceRunningExecutable + t.Cleanup(func() { + runSurfaceProgram = oldRun + surfaceExecutable = oldExecutable + surfaceRunningExecutable = oldRunningExecutable + }) + fakeExe := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(fakeExe, []byte("exe"), 0o755); err != nil { + t.Fatal(err) + } + surfaceExecutable = func(func() (string, error)) (string, error) { return fakeExe, nil } + surfaceRunningExecutable = func() (string, error) { return fakeExe, nil } + + runWindow := func(landing bool, sessionFile string, interact func(in io.Writer, frame *syncOffFrameCapture)) { + proc, err := openV3ProcessWith("chat", true) + if err != nil { + t.Fatalf("openV3ProcessWith: %v", err) + } + defer proc.closeAll() + + seed := v3Options{ + Model: "test/model", + Interactive: true, + Yolo: true, + OneModel: true, + Session: sessionFile, + } + launch, err := openV3Launch(proc, seed) + if err != nil { + t.Fatalf("openV3Launch(%q): %v", sessionFile, err) + } + cfg := launch.Config + cfg.AskConsent = true + cfg, open := v3Shape(cfg, v3LanesHere()) + agent, cfg, _, err := openV3Agent(cfg, launch.Project, open) + if err != nil { + t.Fatalf("openV3Agent(%q): %v", sessionFile, err) + } + proc.track(agent) + settled := *launch + settled.Config, settled.Place = cfg, cfg.Place + settled.SessionFile = cfg.SessionFile + seam := &v3Seam{proc: proc, boot: &settled, seed: seed} + + frame := &syncOffFrameCapture{} + inR, inW := io.Pipe() + defer inR.Close() + defer inW.Close() + + runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { + options.Input = inR + options.Output = frame + options.Width, options.Height = 120, 40 + return tui3.Run(ctx, options) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- runSurface(ctx, tui3.Options{ + Agent: agent, + ProfileDir: proc.ProfileDir, + Workspace: cfg.Workspace, + Owned: cfg.Place.Owned, + SessionFile: cfg.SessionFile, + Resumed: launch.Resumed, + Landing: landing, + Start: seam.start, + Open: seam.resume, + }) + }() + + interact(inW, frame) + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("runSurface exit: %v", err) + } + } + + // Boot a fresh landing window in `workspace` and verify the real Home listing renders every chat once. + origWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(workspace); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origWD) }) + + runWindow(true, "", func(_ io.Writer, frame *syncOffFrameCapture) { + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + plain := frame.Plain() + allPresent := true + for title := range wantByTitle { + if !strings.Contains(plain, title) { + allPresent = false + break + } + } + if allPresent { + for _, unwanted := range []string{"Stale Original Title", "Half Made Scratch Copy"} { + if strings.Contains(plain, unwanted) { + t.Fatalf("Home rendered hidden/stale chat %q:\n%s", unwanted, plain) + } + } + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("Home screen did not list all 4 chats before deadline:\n%s", frame.Plain()) + }) + + // Check 3: Open each chat on Home and write a new turn through the real surface. + for _, title := range []string{ + "Legacy Uncopied Chat", + "Copied Moving Chat", + "Standalone Moving Chat", + "Unfinished Copy Chat", + } { + transcriptPath := wantByTitle[title] + prompt := "write while sync off for " + title + expectedReply := "ack:" + prompt + + runWindow(false, transcriptPath, func(in io.Writer, frame *syncOffFrameCapture) { + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if frame.Plain() != "" { + break + } + time.Sleep(20 * time.Millisecond) + } + if _, err := io.WriteString(in, prompt+"\r"); err != nil { + t.Fatalf("write input for %q: %v", title, err) + } + writeDeadline := time.Now().Add(15 * time.Second) + for time.Now().Before(writeDeadline) { + raw, readErr := os.ReadFile(transcriptPath) + if readErr == nil && + strings.Contains(string(raw), prompt) && + strings.Contains(string(raw), expectedReply) { + return + } + time.Sleep(30 * time.Millisecond) + } + raw, _ := os.ReadFile(transcriptPath) + t.Fatalf("chat %q (%s) did not persist written turn:\ntranscript:\n%s\nscreen:\n%s", title, transcriptPath, string(raw), frame.Plain()) + }) + } + + // Check 4: Home still lists each of the 4 chats once and zero requests touched the sync service. + afterRows := session.ReadHome().Sessions() + if len(afterRows) != 4 { + t.Fatalf("ReadHome() after writing returned %d chats, want 4: %+v", len(afterRows), afterRows) + } + if got := relayRequests.Load(); got != 0 { + t.Fatalf("sync off contacted the sync service %d times, want 0", got) + } + }) + } +} diff --git a/cmd/codeaf/sync_progress.go b/cmd/codeaf/sync_progress.go new file mode 100644 index 0000000000..4e7eb27b52 --- /dev/null +++ b/cmd/codeaf/sync_progress.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// syncProgress says one delayed sentence only while a terminal is still waiting. +// The caller joins it before writing a result, keeping piped output unchanged. +func syncProgress(address string, terminal bool, out io.Writer, after func(time.Duration) <-chan time.Time) func() { + if !terminal { + return func() {} + } + stopped, done := make(chan struct{}), make(chan struct{}) + var once sync.Once + guard.Go("sync/progress", func() { + defer close(done) + select { + case <-stopped: + return + case <-after(2 * time.Second): + select { + case <-stopped: + return + default: + fmt.Fprintln(out, syncwords.Asking(address)) + } + } + }) + return func() { once.Do(func() { close(stopped) }); <-done } +} + +func terminalSyncProgress(address string) func() { + return syncProgress(address, stdinIsTerminal(os.Stderr), os.Stderr, time.After) +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index f5b2d2df07..c9100a91b5 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -59,7 +59,7 @@ func (b *driveBook) pendingDriveOf(c cell.Cell, engine cellstore.Engine, report if b == nil { return nil, noop } - if syncsetup.Resolve(home.Dir()).Off || !cell.Available() { + if !syncsetup.Resolve(home.Dir()).On() || !cell.Available() { return nil, noop } b.mu.Lock() @@ -142,7 +142,9 @@ func startDriveWith(c cell.Cell, engine cellstore.Engine, report func(error), no return nil, err } if err != nil || !ok { - report(err) + if err != nil { + report(err) + } return nil, err } if syncsetup.Quiet(home.Dir()) { @@ -300,3 +302,12 @@ func (b *driveBook) takeCell(id string) *liveDrive { delete(b.drive, id) return d } + +// chatSyncService keeps sync presentation quiet until this computer has paired. +// A remote chat can still supply its own engine's service explicitly. +func chatSyncService() string { + if syncsetup.Quiet(home.Dir()) { + return "" + } + return syncsetup.Resolve(home.Dir()).URL +} diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index b5faba5e9f..253c6c7137 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // liveDrive is the drive side of one chat, started when it can be. A chat opens @@ -128,7 +129,7 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) defer l.mu.Unlock() defer close(ready) l.ready = nil - l.drive, l.waiting, l.attempted = d, errors.Is(err, syncsetup.ErrNoIdentity) || errors.Is(err, syncsetup.ErrQuiet), true + l.drive, l.waiting, l.attempted = d, errors.Is(err, syncsetup.ErrNoIdentity) || errors.Is(err, syncsetup.ErrQuiet) || syncwords.NetworkFailure(err), true if d != nil { d.CatchUp() l.retryWait = 0 @@ -217,12 +218,10 @@ func (l *liveDrive) current() *syncsetup.Drive { // Gate is the drive side's gate: nothing is refused until a drive side exists. func (l *liveDrive) Gate() error { - for { - l.try() - ready := l.pending() - if ready == nil { - break - } + // A failed service startup falls back to this call's local writer. Retrying + // belongs to the next call, or an outage would trap this one in a retry loop. + l.try() + if ready := l.pending(); ready != nil { <-ready } if d := l.started(); d != nil { diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index fcfc9cd880..7f6cabb000 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -4,6 +4,8 @@ import ( "context" "errors" "log" + "net/url" + "os" "path/filepath" "time" @@ -32,6 +34,9 @@ var surfaceNotices = tui3.NewNotices() // when it cannot start, it adds nothing and the home screen must not fail to // start over a relay setting: every seam stays nil, and a nil seam is absent. func wireSync(o *tui3.Options) { + if o.Host != "" { + return + } if o.Notices == nil { o.Notices = surfaceNotices } @@ -63,9 +68,88 @@ func wireSync(o *tui3.Options) { // surfaceMachines keeps display hints out of the decision to send a message. type surfaceMachines struct{ *syncsetup.Sync } +// RowsAt enriches branch rows with whether their turns already exist in the +// local main chat, off the surface loop, so Home can tell when discarding a +// collapsed copy loses nothing. +func (s surfaceMachines) RowsAt(ctx context.Context) ([]chatlist.Row, uint64, error) { + if !syncsetup.Resolve(s.Home).On() { + return nil, 0, nil + } + rows, version, err := s.Sync.RowsAt(ctx) + if err != nil || len(rows) == 0 { + return rows, version, err + } + markLocalRedundantBranches(rows) + return rows, version, nil +} + +// SendLease withholds saved sync hold receipts while sync is off or quiet so a +// local chat never mistakes an earlier sync lease for another device holding it. +func (s surfaceMachines) SendLease(id string) (string, uint64, error) { + if !syncsetup.Resolve(s.Home).On() || (!s.Removed() && syncsetup.Quiet(s.Home)) { + return "", 0, os.ErrNotExist + } + return s.Sync.SendLease(id) +} + +func (s surfaceMachines) Rows(ctx context.Context) ([]chatlist.Row, error) { + rows, _, err := s.RowsAt(ctx) + return rows, err +} + +func markLocalRedundantBranches(rows []chatlist.Row) { + byCell := make(map[string]chatlist.Row, len(rows)) + hasBranch := false + for _, r := range rows { + byCell[r.Cell] = r + if r.Status == chatlist.Branch && r.Parent != "" && r.Head != "" { + hasBranch = true + } + } + if !hasBranch { + return + } + turnSets := map[string]map[string]bool{} + turnsOf := func(id string) map[string]bool { + if set, seen := turnSets[id]; seen { + return set + } + var set map[string]bool + if c, err := openCellByID(id); err == nil { + if logRecs, err := cellstore.Log(c); err == nil { + set = make(map[string]bool, len(logRecs)) + for _, rec := range logRecs { + set[rec.Turn.ID] = true + } + } + } + turnSets[id] = set + return set + } + for i := range rows { + if rows[i].Status != chatlist.Branch || rows[i].Parent == "" || rows[i].Head == "" { + continue + } + root := rows[i].Parent + for hops := 0; root != "" && hops < 16; hops++ { + parent, ok := byCell[root] + if !ok || parent.Status != chatlist.Branch || parent.Parent == "" || parent.Parent == root { + break + } + root = parent.Parent + } + if turns := turnsOf(root); turns[rows[i].Head] { + rows[i].BranchRedundant = true + } + } +} + // RowsForChatSend uses a live writer's receipt or a never-shared chat before asking the relay. // A known outage keeps unknown copies unsent without repeating the network wait. func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offline, holding bool) ([]chatlist.Row, uint64, bool, error) { + if !syncsetup.Resolve(s.Home).On() { + return nil, 0, true, nil + } root := session.DirOf(file) id := filepath.Base(root) if s.Removed() { @@ -97,6 +181,9 @@ func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offli } func (s surfaceMachines) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { + if !syncsetup.Resolve(s.Home).On() { + return nil, 0, nil + } if s.Relay != "" { current, ok, err := syncsetup.Open(s.Home) if err != nil { @@ -295,3 +382,18 @@ func chatContinuer(s *syncsetup.Sync, notify func(string)) *syncsetup.Continuer Notify: notify, }) } + +// ReleaseUnused leaves the installed files and kept work intact, just as closing +// a chat does, while returning an abandoned automatic acquisition to the directory. +func (t takeover) ReleaseUnused(ctx context.Context, id string, fence uint64) error { + return t.c.ReleaseUnused(ctx, id, fence) +} + +// RelayHost names the endpoint without exposing a path or credentials in chat copy. +func (s surfaceMachines) RelayHost() string { + parsed, err := url.Parse(s.Relay) + if err != nil { + return "" + } + return parsed.Hostname() +} diff --git a/cmd/codeaf/task_stability_test.go b/cmd/codeaf/task_stability_test.go new file mode 100644 index 0000000000..ed7ded2dd9 --- /dev/null +++ b/cmd/codeaf/task_stability_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/session" +) + +// S8 proves the ordinary do entry retains the original store and worker +// defaults even beside a file that would tighten a task door child's limits. +func TestDoWithoutTheHiddenFlagKeepsItsStoreAndControls(t *testing.T) { + profile := beltRunEnv(t) + t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t)) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + work := beltRepoWorkspace(t) + if err := os.WriteFile(filepath.Join(filepath.Dir(work), "request.json"), []byte(`{"max_steps":1,"no_progress":1,"effort":"high","limits":{"usd":0.00001}}`), 0600); err != nil { + t.Fatal(err) + } + base := finishingSeat(0.01) + seat := &beltSeat{ever: func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + if got := provider.ReasoningEffortFrom(ctx); got == provider.EffortHigh { + t.Error("ordinary do inherited remote effort") + } + return base.CompleteWithMessages(ctx, messages) + }} + out, err := runErrand(doRequest{task: "write out.txt", workspace: work, keep: true, timeout: 45 * time.Second, + stderr: &strings.Builder{}, newBeltCompleter: func(string) session.Completer { return seat }}, stubErrandSeats()) + if err != nil || out.status() != 0 { + t.Fatalf("ordinary do was constrained by task controls: %+v, %v", out, err) + } + if !strings.HasPrefix(out.recordKept, filepath.Join(profile, "runs", "codeaf-do-")) { + t.Fatalf("ordinary do left headlessStore: %s", out.recordKept) + } + paths, _ := filepath.Glob(filepath.Join(out.recordKept, "tasks", "*", "trajectory.jsonl")) + if len(paths) == 0 { + t.Fatal("the normal kept run has no trajectory") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, encoded := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var line map[string]json.RawMessage + if err := json.Unmarshal([]byte(encoded), &line); err != nil { + t.Fatal(err) + } + if line["recorded_order"] != nil || line["recorded_at"] != nil { + t.Fatalf("ordinary do wrote task-only ordering: %s", encoded) + } + } + } +} + +// S1 includes building v3Shape for every conversation, including linked windows. +func TestBuildingAPairedConversationDialsNothing(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("paired tasks require Linux or macOS") + } + profile := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROFILE_DIR", "") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.NotFound(w, r) + })) + defer server.Close() + t.Setenv("CODEAF_SYNC_URL", server.URL) + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + for _, lanes := range []v3Lanes{v3LanesHere(), v3LanesOverWire()} { + cfg, _ := v3Shape(session.Config{}, lanes) + if cfg.TaskComputers == nil { + t.Fatal("paired home lost the person's placement door") + } + } + if got := calls.Load(); got != 0 { + t.Fatalf("building the conversation made %d relay calls", got) + } +} + +// S1 requires construction to read local identity material without repairing +// or creating it. A missing device key cannot opt a home into task placement. +func TestBuildingATaskDoorNeverCreatesAMissingDevice(t *testing.T) { + profile := pairedTaskDefaultHome(t) + // The identity API owns the paths; removing its device file models an + // interrupted setup without creating a second identity. + devicePath := filepath.Join(profile, identity.DeviceFile) + if err := os.Remove(devicePath); err != nil { + t.Fatal(err) + } + if computers := v3TaskComputers(profile); computers != nil { + t.Fatal("construction repaired a missing device") + } + if _, err := os.Stat(devicePath); !os.IsNotExist(err) { + t.Fatalf("construction created a device file: %v", err) + } +} + +// S5 exercises the real command while preserving the test process's signals. +func TestPlainServeHelper(t *testing.T) { + if os.Getenv("CODEAF_TEST_PLAIN_SERVE") != "1" { + return + } + os.Setenv("HOME", os.Getenv("CODEAF_TEST_SERVE_HOME")) + os.Setenv("CODEAF_HOME", os.Getenv("CODEAF_TEST_SERVE_HOME")) + os.Setenv("CODEAF_PROFILE_DIR", "") + os.Setenv("CODEAF_SYNC_URL", os.Getenv("CODEAF_TEST_SERVE_RELAY")) + err := runServe([]string{"--via", os.Getenv("CODEAF_TEST_SERVE_RELAY")}) + if err != nil { + t.Fatal(err) + } +} + +// S5 keeps plain serve's sole registration and output on the machine door. +func TestPlainServeOpensNoTaskDoor(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the fixture stops serve with a Unix signal") + } + profile := t.TempDir() + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + var registrations atomic.Int32 + machine := &relay.Server{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/engine" { + registrations.Add(1) + } + machine.ServeHTTP(w, r) + })) + defer server.Close() + defer server.CloseClientConnections() + log := filepath.Join(profile, "serve.log") + output, err := os.Create(log) + if err != nil { + t.Fatal(err) + } + defer output.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestPlainServeHelper$") + cmd.Env = append(os.Environ(), "CODEAF_TEST_PLAIN_SERVE=1", "CODEAF_TEST_SERVE_HOME="+profile, "CODEAF_TEST_SERVE_RELAY="+server.URL, "CODEAF_HOME="+filepath.Join(profile, "bootstrap"), "HOME="+filepath.Join(profile, "bootstrap"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1") + cmd.Stdout, cmd.Stderr = output, output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + deadline := time.Now().Add(10 * time.Second) + for { + raw, _ := os.ReadFile(log) + if strings.Contains(string(raw), "this machine is reachable as") { + break + } + if time.Now().After(deadline) { + t.Fatalf("serve did not register: %s", raw) + } + time.Sleep(10 * time.Millisecond) + } + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + raw, _ := os.ReadFile(log) + t.Fatalf("serve: %v\n%s", err, raw) + } + raw, _ := os.ReadFile(log) + if registrations.Load() != 1 || strings.Contains(string(raw), "tasks") { + t.Fatalf("plain serve added a task door: registrations=%d\n%s", registrations.Load(), raw) + } + if _, err := os.Stat(filepath.Join(profile, "v3", "taskdoor")); !os.IsNotExist(err) { + t.Fatalf("plain serve touched task storage: %v", err) + } +} + +// S5 makes receiving tasks an explicit, discoverable command choice. +func TestServeHelpOffersTakeTasks(t *testing.T) { + out, _ := captureUsage(t) + if code := exitCodeOf(runServe([]string{"--help"})); code != 0 { + t.Fatalf("serve help exit=%d", code) + } + if !strings.Contains(out.String(), "--take-tasks") { + t.Fatalf("serve help hides the opt-in: %s", out.String()) + } +} diff --git a/cmd/codeaf/team_resume.go b/cmd/codeaf/team_resume.go index 66281e6923..1abd2411c1 100644 --- a/cmd/codeaf/team_resume.go +++ b/cmd/codeaf/team_resume.go @@ -1,13 +1,18 @@ package main import ( + "context" "errors" "net" "strings" "time" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/teams" ) // ── A TEAM CONVERSATION NOBODY HOLDS IS OPENED BY ITS HOST ────────────────── @@ -71,3 +76,17 @@ func resumeTeamConversation(file, workspace string, attach func(string) (net.Con } return client.Close() } + +// The same lookup serves hosted and in-process sessions. A quiet unpaired +// computer neither creates an identity nor sends a directory request here. +// One call opens one synchronization handle and checks all its targets together. +func teamComputers(ctx context.Context, members []teams.Member) (map[string]string, error) { + if !config.TeamsShareAt(config.ProfilePath("", "")) || !syncsetup.MayTalk(home.Dir()) { + return nil, nil + } + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + return nil, err + } + return s.TeamComputers(ctx, members) +} diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go index c578952c85..d769e174bc 100644 --- a/cmd/codeaf/usage.go +++ b/cmd/codeaf/usage.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/devbox" ) // This file is the ONE SEAM every subcommand's flags are built and parsed at. @@ -350,12 +351,18 @@ var longerCommands = []string{"cache clean"} // flag anybody added, which is the same defect the environment table's // interpolated dollar figures were fixed for. func usageForCommand(name string) string { + if name == "devbox" { + return devboxUsage + } // Collection editing has its own short reference page so the front page // can introduce organization without listing every membership operation. if name == "collections" { return collectionsUsage } lines := strings.Split(usageText, "\n") + if strings.HasPrefix(name, "devbox ") { + lines = strings.Split(devboxUsage, "\n") + } var blocks []string for index := 0; index < len(lines); index++ { if !strings.HasPrefix(lines[index], " codeaf ") { @@ -450,6 +457,10 @@ func askedForHelp(args []string) bool { // commandHelp answers that gesture for a door with no flag set of its own. func commandHelp(name string) error { + if name == "devbox" { + writeCommandUsage(usageOut, devboxFlags("devbox", true, &devbox.Options{})) + return exitHelped + } if name == "cluster" { fmt.Fprint(usageOut, clusterHelp) return exitHelped @@ -523,7 +534,7 @@ func commandWords() []string { // programs this build carries are joined to it where it is read // ([commandWords]). var knownCommands = []string{ - "chat", "resume", "pair", "serve", "devices", "cluster", "do", "plan", "revise", "run", "exec", + "chat", "resume", "pair", "serve", "devbox", "devices", "cluster", "do", "plan", "revise", "run", "exec", "show", "models", "pool", "notebook", "collections", "competence", "services", "patch", "doc", "web", "image", "doctor", diff --git a/docs/changes/unreleased/2020-explicit-fallbacks-and-finished-calls.md b/docs/changes/unreleased/2020-explicit-fallbacks-and-finished-calls.md new file mode 100644 index 0000000000..1ddca05043 --- /dev/null +++ b/docs/changes/unreleased/2020-explicit-fallbacks-and-finished-calls.md @@ -0,0 +1,22 @@ +--- +kind: fixed +title: Explicit fallback lists hold, and finished shell calls keep background processes +pr: 2020 +surface: [chat, engine] +invalidates: + - "A cancellation watcher could kill background processes after their shell call had already finished. A completed call now takes precedence over a late cancellation." + - "A fallback list containing only the failing model could trigger catalog guesses. Only a list with no named model now allows a catalog suggestion." +--- + +When a shell call's completion and cancellation are both ready, the cancellation +watcher checks completion before signalling the process group. Commands that are +still running retain their existing cancellation behavior. + +A fallback list that names only the current model leaves no model to try after +it. Duplicates and surrounding blanks keep that choice; a list of only blanks +still allows a catalog suggestion. + +Carries forward santoshkumarradha's completed-call cancellation and explicit-list +fixes and regressions from #1738. Pairing and moving chats already shipped +separately. The draft's broader task/checker pin and skill import policies remain +separate design decisions. diff --git a/docs/changes/unreleased/2028-validate-write-content.md b/docs/changes/unreleased/2028-validate-write-content.md new file mode 100644 index 0000000000..d5677e5510 --- /dev/null +++ b/docs/changes/unreleased/2028-validate-write-content.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: Reject write calls missing string content before changing files +pr: 2028 +surface: [chat, engine] +invalidates: + - "A missing or null write payload could silently create or truncate an empty file; compact, chat and append writes now require their string payload field." +--- + +Compact workers require `text`; chat writes require `content`, including appends. +Malformed calls return a tool error before creating directories or changing files. +An explicitly supplied empty string remains a valid request to write an empty file. diff --git a/docs/changes/unreleased/2030-guard-notices-to-the-log.md b/docs/changes/unreleased/2030-guard-notices-to-the-log.md new file mode 100644 index 0000000000..06b9fa0855 --- /dev/null +++ b/docs/changes/unreleased/2030-guard-notices-to-the-log.md @@ -0,0 +1,28 @@ +--- +kind: fixed +title: a file kept out for a secret during a background save no longer prints through the chat screen +pr: 2030 +surface: [chat] +invalidates: + - >- + The guard's doc said a notice with no route is one line on stderr, and that + was believed safe because every chat seat passes a route (WithNotices). The + older-chat copier runs inside the full-screen surface with an engine that + has no route, so its notices were raw rows through the frame. A notice with + no route now goes to the standard logger, which the surface keeps in + chat.log. +--- + +While the chat was open, the background copy of older chats sealed whole +project folders. Each file it kept out because it looked like it held a secret, +such as test fixtures with fake `sk-` keys, was written straight to the +terminal: `codeaf: Tests/…/BrowseTests.swift looks like it holds a secret +(api-key-sk), …` rows landed across whatever page was open and stayed until the +next repaint covered them. + +The copier builds its engine with `cellstore.EngineFor`, whose guard has no +notice route, and the guard's fallback wrote to `os.Stderr` directly, past the +logger the surface parks in the profile's `chat.log` (#404). The fallback now +writes through the standard logger: the line goes to `chat.log` while the +surface owns the terminal and to stderr outside it, prefixed `seal guard:`. A +seat with a route still shows its notices where it did. diff --git a/docs/changes/unreleased/2067-host-relay-engine-pingpong.md b/docs/changes/unreleased/2067-host-relay-engine-pingpong.md new file mode 100644 index 0000000000..669414b149 --- /dev/null +++ b/docs/changes/unreleased/2067-host-relay-engine-pingpong.md @@ -0,0 +1,27 @@ +--- +kind: fixed +title: codeaf chat --host no longer takes the engine away from a window that set CODEAF_SYNC_URL, and back +pr: 2067 +surface: [engine, remote] +invalidates: + - >- + A window attaching to a running session host compared its whole resolved + relay with the host's, so a launch with CODEAF_SYNC_URL unset (the ssh + child of --host, or any shell that never exported it) counted as a + different sync setting and retired an idle host or was refused by a busy + one. Now only a relay the launch names itself is compared whole; a launch + with the variable unset is compared only on the pairing's saved address + (else the hosted default), which the host records as sync-unnamed. + - >- + The host's provider `environment` fingerprint included the resolved relay. + It no longer does; the relay is judged only by SyncDiffers, and + EnvironmentMAC (the automations clock) still includes it. +--- + +On a machine where a person ran codeaf with `CODEAF_SYNC_URL` pointing at a +self-hosted relay, `codeaf chat --host` from another machine ran `codeaf engine` +under an ssh shell without that variable. The engine read the silence as a +different relay and retired the host or refused to attach, and the local +window's next launch did the same in reverse. A terminal that explicitly names +a different address or `off` is still refused or restarts the engine exactly as +before. diff --git a/docs/changes/unreleased/2073-reach-machines-through-sync-relay.md b/docs/changes/unreleased/2073-reach-machines-through-sync-relay.md new file mode 100644 index 0000000000..fff6bd5962 --- /dev/null +++ b/docs/changes/unreleased/2073-reach-machines-through-sync-relay.md @@ -0,0 +1,15 @@ +--- +kind: fixed +title: codeaf serve and chat --at work through the relay your chats already sync through +pr: 2073 +surface: [remote, docs] +invalidates: + - "codeaf serve and chat --at needed CODEAF_RELAY or a saved relay file; with neither they said no sync address was set up. They now fall back to the relay sync resolves (CODEAF_SYNC_URL, the relay a pairing saved, then the hosted relay)." + - "The relay client dropped the path of a relay address, so https://host/fabric was called at https://host/v1/engine. It now calls https://host/fabric/v1/engine and /fabric/v1/dial/, as every other client does." + - "The machine carrier was only the raw aforge-relay/1 HTTP upgrade. It now rides a WebSocket offering that protocol and falls back to the raw upgrade when a relay answers 426; codeaf relay accepts both." + - "The hosted relay had no machine doors and answered /v1/engine with 404. It now serves /v1/engine and /v1/dial/ through one Durable Object per machine name, which needs the v4-machines migration and the MACHINE binding in a deployment's config." + - "codeaf serve said lost sync on every retry and quit when the relay still held its own name from a dropped connection. It now says it once per outage and waits the stale name out." +--- + +A relay that is down, or one that cannot connect machines, is now one sentence naming the relay and what +to try, never an upstream body such as `error code: 522`. diff --git a/docs/changes/unreleased/2074-settings-devices-tab.md b/docs/changes/unreleased/2074-settings-devices-tab.md new file mode 100644 index 0000000000..e4862cac6e --- /dev/null +++ b/docs/changes/unreleased/2074-settings-devices-tab.md @@ -0,0 +1,20 @@ +--- +kind: added +title: /settings has a Devices tab to add, rename and remove your computers +pr: 2074 +surface: [chat] +invalidates: + - >- + Adding and removing computers in the chat was reachable only through + /devices, /pair and the home card, and /settings had nine tabs ending + Connections, Privacy. /settings now has a Devices tab between Connections + and Privacy that lists the same devices as /devices, with r to remove, n to + rename this device, and an "Add another device" row that starts /pair. + - >- + The online dot on /devices was a literal ● in every terminal. It now comes + through the palette's glyph door, so an ASCII terminal draws # beside o. +--- + +The tab hosts the same device list /devices opens rather than a second one, so +the two cannot disagree. Where pairing cannot work here, it says the same one +sentence /devices says instead of showing an empty list. diff --git a/docs/changes/unreleased/2075-never-locked-out.md b/docs/changes/unreleased/2075-never-locked-out.md new file mode 100644 index 0000000000..8f3abbace5 --- /dev/null +++ b/docs/changes/unreleased/2075-never-locked-out.md @@ -0,0 +1,25 @@ +--- +kind: fixed +title: A chat can always be written on the computer in front of you, even on an older sync service +pr: 2075 +surface: [chat, remote, build, docs] +invalidates: + - "A chat whose hold had lapsed could only be written after a conditional take, so on a sync service without codeaf-conditional-takes it opened read-only and Enter showed Continue this chat here? again; it is now taken and written on every service, and only taking a chat that is open on another computer waits for the update." + - "Turns this computer saved on top of the chat's last uploaded turn made a reopened chat read-only; they are now published when the chat opens." + - "A chat this computer held last and let lapse read as off and asked Continue this chat here? with this chat was last held here; it now reads as free and opens without the question." + - "Home offered Continue 's latest chat here? for a device removed with codeaf devices revoke; a removed device is no longer offered." + - "codeaf devices revoke --all looked only at devices that can use this machine and missed computers that sync chats; it now removes every device of that name of either kind." + - "The older-service refusal said the sync service must be updated before chats can move; it now says that taking an open chat from another computer waits for a sync service update and that everything else works." +--- + +A chat whose hold had lapsed needed a conditional take, which the client made +only when the service promised one. The deployed hosted service does not, so +every lapsed chat was refused, turns stayed local, and the next open found this +computer's own newer turns and opened the chat read-only. An older service +already refuses a live holder atomically and raises the fence, and the caller +checks the head the take answers, so a lapsed take is now made on every service. + +`codeaf-release service` asks the live hosted service, as a throwaway identity, +for every promise this build needs and fails when one is missing. The engine's +different-environment refusal now offers `codeaf chat --no-host`, and the +`.moving` folder that holds older chats' copies carries a `README.txt`. diff --git a/docs/changes/unreleased/2076-relay-machine-door-hardening.md b/docs/changes/unreleased/2076-relay-machine-door-hardening.md new file mode 100644 index 0000000000..4fcfc847f2 --- /dev/null +++ b/docs/changes/unreleased/2076-relay-machine-door-hardening.md @@ -0,0 +1,25 @@ +--- +kind: fixed +title: a stranger can no longer hold, squat or flood a machine reached through the relay +pr: 2076 +surface: [remote] +invalidates: + - >- + A relay stream had no deadline, so the serving machine's 30-second + handshake limit never applied: silent dials could hold all 16 of a + machine's slots, and one byte could burn its pairing code and freeze + pairing. Streams now end at the handshake deadline. + - >- + The hosted relay kept its rate limits per machine name. It now limits each + caller network at the front door (30 dials and 10 registrations a minute) + and refuses names codeaf cannot produce before any object is created. + - >- + A registration with a different key could replace a live machine that + stalled for five seconds. Only the same key may replace a silent one. + - >- + codeaf serve printed "lost sync — trying again" and then nothing when it + recovered. It now answers with "back — reachable as again". +--- + +Found by a review of #2073 after its machine doors went live on the hosted +relay, and checked by hand against the Worker run locally. diff --git a/docs/changes/unreleased/2077-full-check-reds.md b/docs/changes/unreleased/2077-full-check-reds.md new file mode 100644 index 0000000000..52ad693d42 --- /dev/null +++ b/docs/changes/unreleased/2077-full-check-reds.md @@ -0,0 +1,17 @@ +--- +kind: fixed +title: a finished run's record is saved before it is announced, and dev's full check is green again +pr: 2077 +surface: [engine, build] +invalidates: + - >- + dev's daily Full check was red from 2026-10-23 and the reds looked like + flaky run-spend tests. They were a real ordering bug: a finished task run + was announced before its row was saved, and closing the chat did not wait + for it, so the save could land after close. The row is now saved first + and close waits for a run that has already ended. + - >- + The promotion workflow tests demanded explicit dispatch only. The shipped + contract is still the Friday 17:00 Toronto schedule, and the tests now say + so. +--- diff --git a/docs/changes/unreleased/2078-continue-every-time.md b/docs/changes/unreleased/2078-continue-every-time.md new file mode 100644 index 0000000000..3f9bb4ce81 --- /dev/null +++ b/docs/changes/unreleased/2078-continue-every-time.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: Continuing a chat from another computer works every time, and codeaf doctor can repair sync +pr: 2078 +surface: [chat, engine] +invalidates: + - "After one continue in a window, a second or third continue here could do nothing, because the running-take mark was not cleared on every ending; it is now cleared when a take opens, is brought forward, fails or is cancelled, and a press during a take says a chat is already continuing here." + - "A failed continue closed its card and said why only on Home's say line; the failure now stands on the card with 1 retry and 2 leave it there, or in the chat." + - "A busy engine store failed a continue with the generic could-not-be-put-here sentence; it now names the program holding the store and its pid, and offers retry." + - "Under codeaf chat --host, Home offered continue here and showed chats the other computer held as held elsewhere, acting on this computer's disk; under --host continue here is not offered and those chats are not held elsewhere." + - "One chat could show twice on Home, once from this computer and once through sync, and each branch copy had its own row; rows are keyed by chat id and copies fold into the chat's row with a count." + - "codeaf doctor said nothing about sync; it now reports a busy store, unfinished continues, duplicate rows and lapsed holds, and codeaf doctor --fix repairs what is safe." +--- + +`codeaf doctor --fix` releases unfinished continues this computer started and +retires branch copies whose turns are all already in the main chat, printing a +`fixed:` line for each. It never touches another computer's live hold and never +removes a turn that exists only in what it would delete. A store lock whose +process has ended is already free, so doctor neither reports nor removes one. diff --git a/docs/changes/unreleased/2079-relay-per-channel.md b/docs/changes/unreleased/2079-relay-per-channel.md new file mode 100644 index 0000000000..5189fd7546 --- /dev/null +++ b/docs/changes/unreleased/2079-relay-per-channel.md @@ -0,0 +1,16 @@ +--- +kind: changed +title: Each release channel syncs through its own relay, and CI deploys a relay when its code changes +pr: 2079 +surface: [remote, build, docs] +invalidates: + - "Every build synced through https://codeaf.agentfield.ai/fabric by default; now only stable and rc do, staging builds use https://caf-fabric-staging.instrument-santosh.workers.dev, and dev tags, make build and tests use https://caf-fabric-dev.instrument-santosh.workers.dev. Channels do not sync with each other. CODEAF_SYNC_URL and a pairing's saved relay still win." + - "syncsetup.HostedRelayURL was one constant; it is now the channel's address, chosen from the build's revision, and the three addresses are ProductionRelayURL, StagingRelayURL and DevRelayURL in internal/syncsetup/relayurl.go." + - "pair.LinkOrigin was the constant https://codeaf.agentfield.ai; it is now set at startup to the channel's relay origin, and pairing links from any channel's origin are recognised." + - "Nothing deployed the hosted relay, so the client could move ahead of it unnoticed; the release workflow's relay job now deploys caf-fabric-dev on dev pushes and caf-fabric-staging on staging pushes when the relay's fingerprint changed, proves each deploy, and rolls back on failure. A failed relay job stops that channel's publish." + - "The production relay caf-fabric is still deployed by hand; CI deploys it only once the repository variable RELAY_DEPLOY_PRODUCTION is true, which it is not." +--- + +A relay change now reaches the dev relay with the dev build that needs it, and +staging a week later, before production is ever touched. A Worker rollback +cannot undo a one-way storage upgrade, which is why that order matters. diff --git a/docs/changes/unreleased/2080-teams-on-relay.md b/docs/changes/unreleased/2080-teams-on-relay.md new file mode 100644 index 0000000000..b1dadd7c8a --- /dev/null +++ b/docs/changes/unreleased/2080-teams-on-relay.md @@ -0,0 +1,27 @@ +--- +kind: added +title: opt-in team sharing between paired computers +pr: 2080 +surface: [chat] +invalidates: + - >- + Teams stayed on the computer they were made on, even between paired + computers. They can now be shared, but only by choice: Settings → General → + share teams is off by default on each computer, and teams travel only when + both paired computers turn it on. With it off, or unpaired, nothing changes. + - >- + Traffic existed only on the computer that wrote it. Once both computers + share teams, the other computer's recent Traffic shows as read-only history + in a separate mirror; delivery still reads only this computer's own log. + - >- + A team member whose chat was on another computer could only be opened + there. With sharing on, opening it asks Home's `Continue this chat here?`, + and a send that could not arrive is refused before anything is written. + +--- + +Turning share teams off stops all team sharing at once and keeps teams already +received. Existing vault carriers (credentials, provider keys, preferences, +memories) keep their merge, removal and apply rules exactly; only the team +carriers use the new guarded rules. The prompt and tool descriptions are +unchanged. diff --git a/docs/changes/unreleased/2081-devbox.md b/docs/changes/unreleased/2081-devbox.md new file mode 100644 index 0000000000..6c25a2aa2d --- /dev/null +++ b/docs/changes/unreleased/2081-devbox.md @@ -0,0 +1,28 @@ +--- +kind: added +title: Create a paired always-on computer on your own Railway account with codeaf devbox +pr: 2081 +surface: [chat, remote, docs] +invalidates: + - "codeaf could reach a remote computer someone had already set up, but could not create one. `codeaf devbox up ` now writes a readable Terraform folder and creates a paired computer on the person's own Railway account; `status` and `down` inspect and remove it." + - "A computer that only ran codeaf serve never received the identity's saved provider keys, so a --at chat there stopped on a missing key. serve now keeps keys, settings, shared memories and presence in step when started with CODEAF_SERVE_SYNC=1, which a dev box sets; without that variable serve behaves exactly as before." + - "An engine whose first profile load failed kept returning that failure to every later connection. It now caches only a successful load, so a connection after the keys arrive works." +--- + +`codeaf devbox up`, `status` and `down` create, inspect and remove one private +project, one service and one persistent volume with the person's own +RAILWAY_TOKEN; the person pays Railway and owns the Terraform folder (default +`/devbox//`). Up applies in three guarded steps: the box +without secrets, then a one-time pairing code with the box's remote key and +admission book (only the variable collection may change), then, once the +pinned remote key answers, a clear that removes those values from Railway and +from the state and deletes the backup that held them. The identity code still +never gives remote access; the laptop that ran up is the one admitted, and +`codeaf devices` and `--at` use the chosen name. + +Up sends the creating computer's saved keys once the box answers, so the first +`--at` chat has them. The box pins its sync relay to the laptop's (each release +channel has its own relay), and its first install uses the laptop's own release +when they share a channel, so it does not depend on GitHub's API limit. Pairing +and serve behave as before for anyone who never runs devbox. An AWS module is in +the source but has never been applied, and the command does not offer it. diff --git a/docs/changes/unreleased/2082-sync-off-keeps-chats.md b/docs/changes/unreleased/2082-sync-off-keeps-chats.md new file mode 100644 index 0000000000..6d25b7bd34 --- /dev/null +++ b/docs/changes/unreleased/2082-sync-off-keeps-chats.md @@ -0,0 +1,11 @@ +--- +kind: fixed +title: Sync off keeps every chat listed and writable, and a copied older chat can no longer be wiped +pr: 2082 +surface: [chat, remote, docs] +invalidates: + - "With CODEAF_SYNC_URL=off or sync quiet before pairing, movablePlace returned before looking for a completed .moving copy, so the untouched original opened; now an existing copy opens whatever sync says, and only preparing a new copy needs sync." + - "A rewind or a take onto a .moving copy removed .cell/copy-ready, and opening the original afterwards ran CopyLegacy, which removed the copy and every turn since; now restores keep this computer's .cell bookkeeping (copy-ready, copy-offered, published.json, adopted.json are in chainNames), a take re-stamps the copy markers, and CopyLegacy never removes a destination holding sealed turns." + - "Each reader of a project bucket walked its folders and resolved cell.Copied on its own; now session.BucketSessionDirs is the one walk over a bucket and its .moving folder, reading .moving at most once and making no copy lookups when a bucket has none." + - "#2072's two doctor items (naming a sync service capability gap, naming a removed device still referenced locally) are not built: the capability header only comes back on a write, and local references store display labels that change on a rename." +--- diff --git a/docs/changes/unreleased/2085-task-on-another-computer.md b/docs/changes/unreleased/2085-task-on-another-computer.md new file mode 100644 index 0000000000..bde8ed4aaf --- /dev/null +++ b/docs/changes/unreleased/2085-task-on-another-computer.md @@ -0,0 +1,31 @@ +--- +kind: added +title: Run a task on another paired computer while the chat stays here +pr: 2085 +surface: [chat, engine, remote, docs] +invalidates: + - "A task always ran on the computer the chat was on. It can now run on a paired Linux or macOS computer that runs `codeaf serve --take-tasks`, chosen with `/task --on `, the proposal card's computer choice, or the Run tasks on setting; the chat, conversation file and transcript stay here." + - "codeaf serve only made a machine reachable for `--at`. With `--take-tasks` it also takes tasks from the identity's paired computers; plain `codeaf serve` still takes none and prints what it did before." + - "propose_task had no way to name a computer. It gains an optional `computer` argument, present only after the Run tasks on setting names a paired computer; with the setting at this computer the schema is unchanged." + - "A dev box only served `--at` chats. Its boot now passes `--take-tasks` when the box's installed build lists the flag, so a task can be sent to it." +--- + +The task's copy is prepared after the task appears, then travels as an +encrypted, content-addressed delta through the relay, the other computer runs it +with its own provider keys, and steps, phase and spend stream back here; the +change lands through the ordinary landing road, or Review and Apply with private +task copies on. A computer that stops answering is shown as possibly still +working with `run it here`; nothing re-runs by itself, and an abandoned +attempt's late result never lands. Only a task that works in its own copy can +go, both ends must be Linux or macOS, and a remote task runs alone in its +conversation. Task cards and landing notices, the task rail, Background work's +result row, `/history` task rows and task pages name the recorded computer; +the initial start receipt does not, and Home has no task row. A task worker +there omits effort when its catalog excludes reasoning, or retries once +without effort and remembers that choice in its client when an unknown model +explicitly refuses thinking. Plain `codeaf do` and chat keep their behavior. + +Everything is opt-in. With defaults left alone, pairing adds no relay traffic, +and a local task's brief, records, the proposal card and the model's tool are +byte-identical to before; the Run tasks on settings row is the only new thing a +paired person sees. diff --git a/docs/changes/unreleased/2086-deleted-home-and-once-chats.md b/docs/changes/unreleased/2086-deleted-home-and-once-chats.md new file mode 100644 index 0000000000..4d01e1025f --- /dev/null +++ b/docs/changes/unreleased/2086-deleted-home-and-once-chats.md @@ -0,0 +1,15 @@ +--- +kind: fixed +title: A deleted codeaf home is never rewritten by an engine that is stopping +pr: 2086 +surface: [chat, engine] +invalidates: + - "An engine still saving after it was asked to stop could find its home deleted and write a brand-new identity.json, device.json, identity-solo and sync ledgers into it, as if it were a first run; codeaf now refuses with \"codeaf home was removed or replaced; restart codeaf to use the current folder\", never re-creates a home it started in, and a stopping engine exits at once when its home is removed. A first run is unchanged." + - "Naming the process that holds a busy engine store could take many seconds on macOS (one lsof per lock file, 500 ms each); one short budget now covers the whole lookup." +--- + +Also fixed: tests that failed on macOS (Option chords spelled `opt+`, files that +take their folder's group, bare `mktemp` ignoring `TMPDIR`, a bash 3.2 empty +array in the release workflow) and a test that wrote a lock file into the source +tree. Engine-dependent tests skip on GitHub's full tests because no furrow is +staged there; that is a known follow-up. diff --git a/docs/changes/unreleased/2087-pairing-connection-words.md b/docs/changes/unreleased/2087-pairing-connection-words.md new file mode 100644 index 0000000000..1a000d9c67 --- /dev/null +++ b/docs/changes/unreleased/2087-pairing-connection-words.md @@ -0,0 +1,30 @@ +--- +kind: fixed +title: Pairing names the failing sync service, an unused computer can join, and removal asks first +pr: 2087 +surface: [chat, engine, remote, docs] +invalidates: + - "A computer that had only opened codeaf once was refused by codeaf pair and told to use --replace; now a home whose chats never had a turn sent can join, including after opening with a connected provider, and a home with its own pairing keys and real chats is told how many and what --replace does." + - "r on /devices removed the selected computer at once; now it asks `remove ? it must pair again to come back · y removes · any other key keeps it` and only y removes it." + - "Quitting while the sync service was down printed could not save and exited 1 even though the chat was saved on this computer; now it says the chat is saved here and reaches the other devices when the service is back, and exits 0. A failed local write still says could not save. Mixed old and new builds keep the old words and exit codes." + - "A removed computer that rejoined with a current device's code stayed removed (adopt returned Already); now --replace gives it a fresh device key and it comes back." + - "Printed codeaf pair commands left out --via on a computer with its own sync service; now every one carries it, and a link waiting on another service is refused with the command to run instead." + - "chatlist.Unreachable, ReadTimedOut and SyncPaused blamed other machines; now they name the sync service. Running on on Home now reads held on , because a hold does not mean work is running." + - "/devices, /pair and /settings typed on Home started a new chat; now they open over Home." + - "internal/syncwords' HostedURL is no longer a constant; internal/syncsetup hands it this build's channel's hosted service at startup." +--- + +Terminal link approval stays `a / d`, the same deliberate key as the chat card, +and `codeaf --help` does not list `codeaf relay` because the front page is at its +line cap. + +A computer that was never paired and has chats of its own now refuses a typed +pairing code before anything is sent, so the approving computer no longer says +it was paired. Chats written before codeaf's window was ever opened (for +example by `codeaf chat --once`) still come along when that computer pairs, and +sync, as before. + +`codeaf serve` now adds this computer's device name after its machine name +(`reachable as canoe-paddle-65 (beta)`); `codeaf chat --at` still takes the +machine name. The weight of granting remote work is now said on the computer +that approves it, before its usual question. diff --git a/docs/changes/unreleased/2089-relay-l3-findings.md b/docs/changes/unreleased/2089-relay-l3-findings.md new file mode 100644 index 0000000000..c49cab1e67 --- /dev/null +++ b/docs/changes/unreleased/2089-relay-l3-findings.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: Continuing opens the chat you picked, a refused continue keeps its card, and a slow relay says so +pr: 2089 +surface: [chat, remote, docs] +invalidates: + - "A bare codeaf chat that was continuing the newest chat another computer let go of answered every explicit continue on Home with a chat is already continuing here, and the older chat opened; now a confirmed different chat waits, the automatic move finishes and is let go without opening, and the chosen chat opens. The busy line now answers only a second pick during a continue the person started." + - "A continue refused because the chat's holder changed showed another device continued this chat first in the footer only; every such refusal now keeps the 1 retry / 2 leave it there card and the draft, and when the holder named on the card simply reopened the chat the card asks again instead of blaming another device." + - "Home gave up on the chat listing after 5 seconds and said other machines did not answer in time; it now waits up to 25 seconds, says waiting for ; still checking after 5, keeps its rows, and still shows a real outage at once." + - "The first message sent right after a move could be left in the box under Home's timeout sentence; a send right after this window's own move now waits up to 30 seconds for the arriving chat to be ready and sends once, and a send that cannot leave says the message is still in the box and names the relay." + - "A self-hosted codeaf relay counted the first X-Forwarded-For address for its registration and dial limits whenever a request sent one, so a relay facing the internet could be given any address; forwarding headers now count only with --trust-proxy, for registration, dial and pairing limits alike, and a relay behind a proxy must pass it or every caller shares one allowance." + - "A chat this computer had never held came over serial object batches whenever the shared store had exchanged any other chat with the relay; it now downloads its frames, and a chat this computer already holds still fetches only what changed." +--- + +Found by the two-network test of the relay (lane L3). Each continue also writes +one `chat.log` line with the time spent in each phase, so a slow move can be read +instead of guessed. Consent to continue still binds the holder and its ownership +number shown on the card; nothing about ownership rules, wire or stored formats, +or the hosted relay changed. diff --git a/docs/changes/unreleased/2091-displaced-window-keeps-turns.md b/docs/changes/unreleased/2091-displaced-window-keeps-turns.md new file mode 100644 index 0000000000..3e6ff5fced --- /dev/null +++ b/docs/changes/unreleased/2091-displaced-window-keeps-turns.md @@ -0,0 +1,11 @@ +--- +kind: fixed +title: A displaced window keeps its unsent turns, and a taken chat can be taken again +pr: 2091 +surface: [remote, chat, docs] +invalidates: + - "When an ownership check found another device holding the chat, Drive.CheckLease made the window a viewer without telling the batcher, so unsent turns were not kept as a branch then; now the hold is retired at once and Batcher.Displaced resolves pending work before the window names the new holder, once." + - "A publish refused with a stale fence announced 'another device continued this chat' and then ' continued this chat'; OnLost now only retires the hold, and the window speaks once, after the branch." + - "Materialize left the sender's earlier .cell/published.json in place, naming a head this store never fetched, so the next take refused with 'snapshot … is not held in this store'; Materialize now records the received head as published." + - "The conversation tip read 'space space takes you back to home'; it now reads 'space space brings you back to home'." +--- diff --git a/docs/changes/unreleased/2092-remote-task-fixtures-macos.md b/docs/changes/unreleased/2092-remote-task-fixtures-macos.md new file mode 100644 index 0000000000..bb15992617 --- /dev/null +++ b/docs/changes/unreleased/2092-remote-task-fixtures-macos.md @@ -0,0 +1,7 @@ +--- +kind: internal +title: Remote-task session fixtures pass on macOS temporary folders +pr: 2092 +surface: [engine] +invalidates: [] +--- diff --git a/docs/relay.md b/docs/relay.md index 1821d6de32..90a1560fca 100644 --- a/docs/relay.md +++ b/docs/relay.md @@ -19,12 +19,14 @@ The separate `relay` binary accepts the same flags. | `--store ` | Persistent coordination and encrypted chunks. Without it, only the blind transport and pairing mailbox run; chat sync needs storage. | | `--quiet` | Suppress arrival, departure and request logs. | | `--status=false` | Disable `GET /status`. | -| `--trust-proxy` | Use the first `X-Forwarded-For` address for pairing limits, only behind a proxy that sets it. | +| `--trust-proxy` | use the first `X-Forwarded-For` address for registration, dial and pairing limits; absent or blank falls back to the socket peer. only behind a proxy that sets it. | Set `CODEAF_SYNC_URL=https://relay.example.com` on each computer, or use `codeaf pair --via https://relay.example.com`. Pair the computers with the same identity and relay. `CODEAF_SYNC_URL=off` stops sync, including existing clients. +forwarding headers are ignored by default. behind Railway, a load balancer or nginx, pass `--trust-proxy` or every caller shares one allowance. enable it only behind a proxy that sets the header. + The relay supplies no TLS or list of allowed people. Use a TLS proxy and a private network or a proxy that checks callers: any valid signature can create an identity. Logs contain a short identity id, route pattern, status and byte counts, never a body. diff --git a/docs/remote-access-testing.md b/docs/remote-access-testing.md index 30ed897545..5c10946dd5 100644 --- a/docs/remote-access-testing.md +++ b/docs/remote-access-testing.md @@ -375,7 +375,7 @@ clear refusal. Run it anyway; the refusals are the deliverable. ```sh codeaf devices # who may reach this machine, and its name -codeaf serve # be reachable (needs CODEAF_RELAY set) +codeaf serve # be reachable (through the relay sync uses, or CODEAF_RELAY) codeaf chat --at otter-lamp-42 # reach a machine by its name ``` diff --git a/docs/rules/ci.md b/docs/rules/ci.md index 98cde698ab..26957c0922 100644 --- a/docs/rules/ci.md +++ b/docs/rules/ci.md @@ -57,9 +57,19 @@ of tests exempted from passing on a clean checkout. `Full check` (`ci-full.yml`) runs nightly and validates promotion candidates. It runs full tests, cross builds and container-based remote checks; binary size is reported separately. `Promote to staging` requires a successful fresh Full -check on its exact candidate before moving the pointer. `Release` guards branch -ancestry, tests the applicable release surface and publishes platform builds. -Inspect workflow definitions and live branch rules for current required checks. +check on its exact candidate before moving the pointer. `Release` (`release.yml`) +guards branch ancestry, tests the applicable release surface, builds platform +binaries, and runs the `relay` job before `publish`. The `relay` job hashes the +hosted relay's inputs with `scripts/relay-fingerprint.sh`, skips when the live +Worker's deployment tag already matches, otherwise generates the channel's +Wrangler configuration with `scripts/relay-wrangler-config.sh`, creates the +channel's R2 bucket if needed, deploys the channel's Worker (`caf-fabric-dev` on +`dev`, `caf-fabric-staging` on `staging`, `caf-fabric` on `main` and `stable` +when the repository variable `RELAY_DEPLOY_PRODUCTION` is `true`), proves the +live service with `internal/relay/hosted_test.go` (`CODEAF_RELAY_HOSTED_URL`) +and `codeaf-release service --url`, and rolls back to the recorded prior version +id and stops `publish` if verification fails. Inspect workflow definitions and +live branch rules for current required checks. CI does not run the race detector. Run `go test -race` on the affected package when investigating a concurrency issue. A green ordinary test run does not diff --git a/docs/rules/promotion.md b/docs/rules/promotion.md index 5899791805..3190207f6c 100644 --- a/docs/rules/promotion.md +++ b/docs/rules/promotion.md @@ -62,3 +62,19 @@ make changelog VERSION=v0.2.0 Promote that commit through staging to main. Never write a release-only commit directly onto either pointer. To roll back, publish a forward repair or select an earlier stable release as Latest; do not rewrite branch history. + +## Channel relays + +Each release channel syncs through its own hosted relay Worker, deployed +automatically by `Release`'s `relay` job after `prepare` and before `publish`: + +- `dev` deploys `caf-fabric-dev` (`https://caf-fabric-dev.instrument-santosh.workers.dev`). +- `staging` deploys `caf-fabric-staging` (`https://caf-fabric-staging.instrument-santosh.workers.dev`). +- `main` (`rc`) and `stable` dispatch deploy `caf-fabric` (`https://codeaf.agentfield.ai/fabric`) only when the repository variable `RELAY_DEPLOY_PRODUCTION` is `true`; while unset or anything else, the `relay` job logs that production relay deploys are off and how to enable them, contacts nothing, and lets `publish` proceed against the currently live relay. + +Each deploy is gated by `scripts/relay-fingerprint.sh` (`--tag ` +and `--message `), verified live with `internal/relay/hosted_test.go` +and `codeaf-release service --url`, and rolled back with `wrangler rollback` if +verification fails, blocking `publish`. Because a Worker code rollback cannot +undo a one-way Durable Object or storage upgrade, every relay change lands on +`dev` first and soaks on `staging` for a week before reaching `main`. diff --git a/internal/atlas/pairing.go b/internal/atlas/pairing.go index 8e98a458fe..3c991ba611 100644 --- a/internal/atlas/pairing.go +++ b/internal/atlas/pairing.go @@ -22,7 +22,7 @@ var Pairing = &Map{ Files: []FileRef{{Path: "cmd/codeaf/pair.go", Symbols: []string{"runPair"}}}}, // ── services ────────────────────────────────────────────────────── {ID: "hosted", Label: "hosted relay", Short: "default", Kind: KindService, X: 0.38, Y: 0.1, - Summary: "The default service at https://codeaf.agentfield.ai/fabric: a Cloudflare Worker with Durable Objects and R2. It holds the directory, the encrypted blobs and pairing requests, and it cannot decrypt any of them.", + Summary: "The default hosted sync service selected per release channel in internal/syncsetup/relayurl.go: a Cloudflare Worker with Durable Objects and R2. It holds the directory, the encrypted blobs and pairing requests, and it cannot decrypt any of them.", Details: []string{ "Limits: 5 GiB/identity, 1200 req/min/identity, 600/min/device", "Pairing: 10-minute TTL, 10 link creates/hr, 3 pending per IP", diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index f2786e4ec6..39ca882460 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -35,6 +35,8 @@ const ( // connection checks, so a catalog discovery can read is also connectable. MaxCatalogBytes = 16 << 20 cacheName = "model-catalog.json" + pickerCacheName = "models.json" + cacheKeyBytes = 8 // DefaultBaseURL lives beside the compiled-in fallback rows because those // rows are this service's ids, so the package that serves them has to be // able to recognise its own base. @@ -1644,9 +1646,51 @@ func cachePath(dir, source, base string) string { if key == "" { return filepath.Join(dir, cacheName) } - extension := filepath.Ext(cacheName) - stem := strings.TrimSuffix(cacheName, extension) - return filepath.Join(dir, stem+"-"+key+extension) + return filepath.Join(dir, keyedCacheName(cacheName, key)) +} + +// IsCacheName recognizes only filenames the catalog writes. Pairing uses this +// ownership boundary so a person's similarly named file still counts as work. +func IsCacheName(name string) bool { + return isCacheName(name, cacheName) +} + +// PickerCacheName owns the picker's smaller cache filename. Sharing the name +// with pairing keeps a fetched model list from being mistaken for local work. +func PickerCacheName(source, base string) string { + return keyedCacheName(pickerCacheName, CacheKey(source, base)) +} + +// IsPickerCacheName accepts only picker filenames with a real ownership key, +// or the legacy default filename. Similar personal filenames remain work. +func IsPickerCacheName(name string) bool { + return isCacheName(name, pickerCacheName) +} + +func keyedCacheName(legacy, key string) string { + if key == "" { + return legacy + } + extension := filepath.Ext(legacy) + return strings.TrimSuffix(legacy, extension) + "-" + key + extension +} + +func isCacheName(name, legacy string) bool { + if name == legacy { + return true + } + extension := filepath.Ext(legacy) + stem := strings.TrimSuffix(legacy, extension) + key, ok := strings.CutPrefix(name, stem+"-") + if !ok { + return false + } + key, ok = strings.CutSuffix(key, extension) + if !ok { + return false + } + decoded, err := hex.DecodeString(key) + return err == nil && len(decoded) == cacheKeyBytes && hex.EncodeToString(decoded) == key } // CacheKey is the shared service-and-base ownership key. Empty is the legacy @@ -1658,7 +1702,7 @@ func CacheKey(source, base string) string { return "" } digest := sha256.Sum256([]byte(source + "\x00" + base)) - return hex.EncodeToString(digest[:8]) + return hex.EncodeToString(digest[:cacheKeyBytes]) } func readCache(path, source, base string) (cache, bool) { diff --git a/internal/catalog/l4fix2_names_test.go b/internal/catalog/l4fix2_names_test.go new file mode 100644 index 0000000000..782efa8e46 --- /dev/null +++ b/internal/catalog/l4fix2_names_test.go @@ -0,0 +1,23 @@ +package catalog + +import ( + "path/filepath" + "testing" +) + +// L4-H2, fix round 2: Only the catalog's own legacy and keyed filenames are +// startup cache, and the filename ownership check preserves cache isolation. +func TestL4Fix2CatalogCacheNames(t *testing.T) { + for _, name := range []string{"model-catalog.json", filepath.Base(cachePath("unused", "custom", DefaultBaseURL))} { + if !IsCacheName(name) { + t.Fatalf("catalog does not recognize its own file: %q", name) + } + } + for _, name := range []string{"model-catalog-my-notes.json", "model-catalog-0123456789abcdeF.json", "model-catalog-0123456789abcdef-more.json", "model-catalog-0123.json", "my-work.json"} { + if IsCacheName(name) { + t.Fatalf("unowned file accepted as cache: %q", name) + } + } + t.Run("legacy cache", TestALegacyCacheFileIsTheDefaultBasesCache) + t.Run("base isolation", TestACatalogCachedForOneBaseIsNeverServedToAnother) +} diff --git a/internal/catalog/l4fix4_picker_test.go b/internal/catalog/l4fix4_picker_test.go new file mode 100644 index 0000000000..ee97232703 --- /dev/null +++ b/internal/catalog/l4fix4_picker_test.go @@ -0,0 +1,28 @@ +package catalog + +import "testing" + +// L4-A: The cache owner preserves the existing legacy and keyed filenames and +// refuses look-alikes, paths and keys the catalog could never have generated. +func TestL4Fix4PickerCacheNames(t *testing.T) { + if got := PickerCacheName("", DefaultBaseURL); got != "models.json" { + t.Fatal(got) + } + if got := PickerCacheName("ollama", "http://localhost:11434/v1"); got != "models-2c2a3009790cef0b.json" { + t.Fatal(got) + } + for _, name := range []string{"models.json", "models-2c2a3009790cef0b.json"} { + if !IsPickerCacheName(name) { + t.Fatalf("picker does not recognize %q", name) + } + } + for _, name := range []string{ + "models-my-notes.json", "models-.json", "models-0123.json", + "models-0123456789abcdeF.json", "models-0123456789abcdef-more.json", + "models-0123456789abcdef.json.bak", "elsewhere/models.json", "model-catalog.json", + } { + if IsPickerCacheName(name) { + t.Fatalf("unowned picker file accepted: %q", name) + } + } +} diff --git a/internal/cell/copy.go b/internal/cell/copy.go index bca0022863..bf02b07d80 100644 --- a/internal/cell/copy.go +++ b/internal/cell/copy.go @@ -1,13 +1,18 @@ package cell import ( + "bufio" + "bytes" "context" "crypto/sha256" + "encoding/json" "errors" + "fmt" "io" "io/fs" "os" "path/filepath" + "strings" "github.com/Agent-Field/codeaf/internal/filelock" ) @@ -15,6 +20,10 @@ import ( // copyCheckpoint is a process-death seam; production never pauses construction. var copyCheckpoint = func(string) {} +// CopyReadyPath marks a legacy copy whose initial turn is durable on this +// computer; snapshots never carry it because it is local bookkeeping. +const CopyReadyPath = StateDir + "/copy-ready" + // CopyOfferedPath is local bookkeeping inside the harness-owned state area. const CopyOfferedPath = StateDir + "/copy-offered" @@ -27,11 +36,35 @@ func CopyRoot(original string) string { return filepath.Join(filepath.Dir(original), ".moving", encode128(raw)) } +// CopyFolderReadme is the note in the folder that holds moved chats. The +// folder's name reads like a transfer in progress, and removing it by hand +// would delete those chats, so the folder says what it is (#2072). The name is +// kept: older builds and copies already made look for it. +const CopyFolderReadme = "README.txt" + +const copyFolderNote = `This folder holds your codeaf chats. They are the live copies codeaf writes to, +not a move in progress. Do not delete it or anything in it: that deletes those +chats. The older originals beside it stay untouched for older codeaf builds. +` + +// explainCopyFolder writes the note once; an existing note is left as it is. +func explainCopyFolder(dir string) error { + f, err := os.OpenFile(filepath.Join(dir, CopyFolderReadme), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if errors.Is(err, fs.ErrExist) { + return nil + } + if err != nil { + return err + } + _, err = f.WriteString(copyFolderNote) + return errors.Join(err, f.Close()) +} + // Copied resolves only a fully prepared, durable copy. A crash before the // completion marker leaves the original as the only visible conversation. func Copied(original string) string { root := CopyRoot(original) - if _, err := os.Stat(filepath.Join(root, StateDir, "copy-ready")); err == nil { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(CopyReadyPath))); err == nil { if _, err := OpenAt(root, filepath.Base(root)); err == nil { return root } @@ -39,6 +72,53 @@ func Copied(original string) string { return original } +// StampCopyReady records on disk that root is a completed `.moving` copy on +// this computer so Home and legacy-open paths resolve it instead of the +// untouched original. +func StampCopyReady(root string) error { + p := filepath.Join(root, filepath.FromSlash(CopyReadyPath)) + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return err + } + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + return errors.Join(f.Sync(), f.Close()) +} + +// hasSealedTurns reports whether root already holds a cell with at least one +// sealed turn or adopted remote head. Engine.Seal appends to turns.jsonl only +// after composing meta.json and capturing the snapshot, and Materialize writes +// adopted.json only after restoring an incoming head, so either file proves +// root holds real conversation history rather than a half-copied scratch tree. +func hasSealedTurns(root string) bool { + if raw, err := os.ReadFile(filepath.Join(root, StateDir, "turns.jsonl")); err == nil { + sc := bufio.NewScanner(bytes.NewReader(raw)) + for sc.Scan() { + line := bytes.TrimSpace(sc.Bytes()) + if len(line) == 0 { + continue + } + var turn struct { + ID string `json:"id"` + } + if json.Unmarshal(line, &turn) == nil && strings.TrimSpace(turn.ID) != "" { + return true + } + } + } + if raw, err := os.ReadFile(filepath.Join(root, StateDir, "adopted.json")); err == nil { + var adopted struct { + Head string `json:"head"` + } + if json.Unmarshal(raw, &adopted) == nil && strings.TrimSpace(adopted.Head) != "" { + return true + } + } + return false +} + // CopyLegacy makes a private byte copy and invokes prepare before publishing // it. It never links files to the original: later writes must stay independent. // The lock lives outside the original and serializes windows and crash retries. @@ -55,6 +135,9 @@ func CopyLegacy(ctx context.Context, original string, prepare func(context.Conte if err := os.MkdirAll(filepath.Dir(root), 0700); err != nil { return Cell{}, err } + if err := explainCopyFolder(filepath.Dir(root)); err != nil { + return Cell{}, err + } lock, err := os.OpenFile(root+".lock", os.O_CREATE|os.O_RDWR, 0600) if err != nil { return Cell{}, err @@ -66,6 +149,20 @@ func CopyLegacy(ctx context.Context, original string, prepare func(context.Conte if Copied(original) != original { return OpenAt(root, filepath.Base(root)) } + // Never wipe a destination that already holds sealed turns even if an + // earlier restore or take-back lost its copy-ready marker: reopen and + // re-stamp it when its cell record is valid, or fail loudly so newer turns + // cannot be destroyed. + if hasSealedTurns(root) { + c, openErr := OpenAt(root, filepath.Base(root)) + if openErr != nil { + return Cell{}, fmt.Errorf("copy destination %s already holds sealed turns: %w", root, openErr) + } + if err := StampCopyReady(root); err != nil { + return Cell{}, err + } + return c, nil + } if err := ctx.Err(); err != nil { return Cell{}, err } @@ -108,11 +205,7 @@ func CopyLegacy(ctx context.Context, original string, prepare func(context.Conte if err := ctx.Err(); err != nil { return Cell{}, err } - f, err := os.OpenFile(filepath.Join(root, StateDir, "copy-ready"), os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return Cell{}, err - } - if err := errors.Join(f.Sync(), f.Close()); err != nil { + if err := StampCopyReady(root); err != nil { return Cell{}, err } copyCheckpoint("published") diff --git a/internal/cell/copy_test.go b/internal/cell/copy_test.go index f5c2e8c4d5..412ccd0a77 100644 --- a/internal/cell/copy_test.go +++ b/internal/cell/copy_test.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "reflect" + "strings" "testing" "time" ) @@ -181,3 +182,104 @@ func TestC29LinkedFolderCannotAliasTheOriginal(t *testing.T) { t.Fatal("copy through a folder link changed the original") } } + +// TestTheCopyFolderSaysItIsTheLiveStore pins #2072: the folder holding moved +// chats is named for a move in progress, so it says, to anyone who looks, that +// it holds the chats themselves and must not be removed. +func TestTheCopyFolderSaysItIsTheLiveStore(t *testing.T) { + original := olderFixture(t) + if _, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(CopyRoot(original)), CopyFolderReadme)) + if err != nil || !strings.Contains(string(raw), "Do not delete it") { + t.Fatalf("the copy folder does not explain itself: %q, %v", raw, err) + } +} + +// TestCopyLegacyRefusesToWipePopulatedDestination verifies the second line of +// defence when a destination copy already holds sealed turns (either in +// turns.jsonl or adopted.json) without copy-ready: CopyLegacy opens and +// re-stamps a valid cell without rerunning prepare or removing the destination, +// and fails loudly without removing the destination if OpenAt fails. +func TestCopyLegacyRefusesToWipePopulatedDestination(t *testing.T) { + for _, marker := range []string{"turns.jsonl", "adopted.json"} { + t.Run(marker, func(t *testing.T) { + original := olderFixture(t) + c, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }) + if err != nil { + t.Fatal(err) + } + // Append precious post-copy content and record a sealed turn marker. + transcriptPath := filepath.Join(c.Root, filepath.FromSlash(TranscriptPath)) + if err := os.WriteFile(transcriptPath, []byte("whole conversation\nprecious turn\n"), 0o600); err != nil { + t.Fatal(err) + } + switch marker { + case "turns.jsonl": + if err := os.WriteFile(filepath.Join(c.Root, StateDir, "turns.jsonl"), + []byte(`{"V":1,"id":"1111111111111111111111111111111111111111111111111111111111111111"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + case "adopted.json": + if err := os.WriteFile(filepath.Join(c.Root, StateDir, "adopted.json"), + []byte(`{"V":1,"head":"2222222222222222222222222222222222222222222222222222222222222222"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + // Remove copy-ready to simulate an older build's restore or take-back stripping it. + readyFile := filepath.Join(c.Root, StateDir, "copy-ready") + if err := os.Remove(readyFile); err != nil { + t.Fatal(err) + } + + reopened, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { + t.Fatal("CopyLegacy re-ran prepare on an already-populated copy") + return nil + }) + if err != nil { + t.Fatalf("CopyLegacy failed instead of opening populated copy: %v", err) + } + if reopened.Root != c.Root { + t.Fatalf("CopyLegacy returned root %s, want %s", reopened.Root, c.Root) + } + after, err := os.ReadFile(transcriptPath) + if err != nil || !strings.Contains(string(after), "precious turn") { + t.Fatalf("populated copy transcript lost precious turn: err=%v body=%q", err, string(after)) + } + if Copied(original) != c.Root { + t.Fatalf("Copied(original) = %s, want %s", Copied(original), c.Root) + } + }) + } + + t.Run("unopenable_populated_destination_fails_loudly_without_removal", func(t *testing.T) { + original := olderFixture(t) + c, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }) + if err != nil { + t.Fatal(err) + } + transcriptPath := filepath.Join(c.Root, filepath.FromSlash(TranscriptPath)) + if err := os.WriteFile(transcriptPath, []byte("precious unopenable turn\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, StateDir, "turns.jsonl"), + []byte(`{"V":1,"id":"1111111111111111111111111111111111111111111111111111111111111111"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(c.Root, StateDir, "copy-ready")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, filepath.FromSlash(MetaPath)), []byte("{corrupt"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }); err == nil { + t.Fatal("expected CopyLegacy to fail loudly when populated destination cannot be opened") + } + after, err := os.ReadFile(transcriptPath) + if err != nil || !strings.Contains(string(after), "precious unopenable turn") { + t.Fatalf("CopyLegacy deleted or altered populated unopenable destination: err=%v body=%q", err, string(after)) + } + }) +} diff --git a/internal/cellstats/home_removed_test.go b/internal/cellstats/home_removed_test.go new file mode 100644 index 0000000000..fc53743601 --- /dev/null +++ b/internal/cellstats/home_removed_test.go @@ -0,0 +1,43 @@ +package cellstats + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" +) + +// SPEC-3 item 2: late settlement cannot recreate a removed or replaced home. +func TestFixRound2StatsSettleRefusesRemovedHome(t *testing.T) { + for _, replaced := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "replaced"}[replaced], func(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + if err := os.Mkdir(h, 0700); err != nil { + t.Fatal(err) + } + home.Witness(h) + r := NewRecorder(h, "chat", MeterFunc(func() Counts { return Counts{Puts: 1} })) + if err := os.Rename(h, h+".old"); err != nil { + t.Fatal(err) + } + if replaced { + if err := os.Mkdir(h, 0700); err != nil { + t.Fatal(err) + } + } + r.Settle() + if r.Err() == nil { + t.Fatal("settled into removed home") + } + if replaced { + entries, err := os.ReadDir(h) + if err != nil || len(entries) != 0 { + t.Fatalf("replacement changed: %v %v", entries, err) + } + } else if _, err := os.Stat(h); !os.IsNotExist(err) { + t.Fatalf("home recreated: %v", err) + } + }) + } +} diff --git a/internal/cellstats/recorder.go b/internal/cellstats/recorder.go index 58ff690dc9..0a04be67c6 100644 --- a/internal/cellstats/recorder.go +++ b/internal/cellstats/recorder.go @@ -6,6 +6,7 @@ import ( "sync" "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/home" ) // Counts is a snapshot of what one store client has asked of the store so far. @@ -32,6 +33,8 @@ type Recorder struct { Path string Meter Meter // nil records zero request figures + home string + mu sync.Mutex last Counts err error @@ -39,7 +42,7 @@ type Recorder struct { // NewRecorder records one cell's flushes under home. A meter is optional. func NewRecorder(home, cellID string, m Meter) *Recorder { - return &Recorder{Path: Path(home, cellID), Meter: m} + return &Recorder{Path: Path(home, cellID), Meter: m, home: home} } // OnFlush is the cellsync.Batcher hook. It never fails the flush: a stats file @@ -47,7 +50,7 @@ func NewRecorder(home, cellID string, m Meter) *Recorder { func (r *Recorder) OnFlush(f cellsync.Flush) { r.mu.Lock() defer r.mu.Unlock() - r.err = appendLine(r.Path, r.line(f)) + r.err = appendLine(r.home, r.Path, r.line(f)) } // Settle writes what the meter has counted since the last line as a line of its @@ -59,7 +62,7 @@ func (r *Recorder) Settle() { r.mu.Lock() defer r.mu.Unlock() if l := r.line(cellsync.Flush{}); l != (Line{V: Version}) { - r.err = appendLine(r.Path, l) + r.err = appendLine(r.home, r.Path, l) } } @@ -88,8 +91,8 @@ func (r *Recorder) snapshot() Counts { // appendLine adds one line with a single write on an append-only handle, so a // line is never split and an earlier line is never touched. -func appendLine(path string, l Line) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { +func appendLine(root, path string, l Line) error { + if err := home.MkdirAllUnder(root, filepath.Dir(path), 0o700); err != nil { return err } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) diff --git a/internal/cellstore/chat_warmth_test.go b/internal/cellstore/chat_warmth_test.go new file mode 100644 index 0000000000..7537814df3 --- /dev/null +++ b/internal/cellstore/chat_warmth_test.go @@ -0,0 +1,29 @@ +package cellstore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +func TestChatWarmthBelongsToItsOwnHead(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + data := t.TempDir() + c := cell.Cell{ID: "new-chat", Root: t.TempDir()} + e := SyncEngine{Ledger: "used", Target: func(cell.Cell) Target { return Target{DataDir: data} }} + if err := os.WriteFile(filepath.Join(data, "published.used"), []byte("earlier chat's objects"), 0600); err != nil { + t.Fatal(err) + } + if e.Holds(c) { + t.Fatal("an unrelated chat's ledger made this chat warm") + } + if err := noteAdopted(c, "held-head"); err != nil { + t.Fatal(err) + } + if !e.Holds(c) { + t.Fatal("this chat's adopted history was forgotten") + } +} diff --git a/internal/cellstore/daemon.go b/internal/cellstore/daemon.go index 9a48366c2b..ceddc8ff8b 100644 --- a/internal/cellstore/daemon.go +++ b/internal/cellstore/daemon.go @@ -72,7 +72,7 @@ func (d Daemon) Do(ctx context.Context, t Target, op Op) ([]byte, error) { return nil, fmt.Errorf("engine %s answer interrupted: %w", op.Verb(), err) } if response.Err != "" { - return nil, fmt.Errorf("engine %s: %s", op.Verb(), response.Err) + return nil, classifyStoreBusyError(t.DataDir, d.Socket, fmt.Errorf("engine %s: %s", op.Verb(), response.Err)) } return response.Ok, nil } diff --git a/internal/cellstore/daemon_stuck.go b/internal/cellstore/daemon_stuck.go index 1eaf0a57f4..13a819cb41 100644 --- a/internal/cellstore/daemon_stuck.go +++ b/internal/cellstore/daemon_stuck.go @@ -14,27 +14,32 @@ func (e *SavingStuckError) Error() string { return fmt.Sprintf("saving is stuck; your turns stay on this computer, and saving resumes on its own within %s minutes", idleMinutes) } -func daemonServesSocket(uid, ownUID string, argv []string, socket string) bool { +func daemonSocketArg(uid, ownUID string, argv []string) (string, bool) { if uid != ownUID || len(argv) < 4 || argv[1] != "serve" { - return false + return "", false } name := filepath.Base(argv[0]) if !storageProgramName.MatchString(name) { - return false + return "", false } - found := false + socket := "" for i := 2; i < len(argv); i++ { if argv[i] == "--socket" { - if found || i+1 >= len(argv) || argv[i+1] != socket { - return false + if socket != "" || i+1 >= len(argv) || argv[i+1] == "" { + return "", false } - found = true + socket = argv[i+1] i++ } else if strings.HasPrefix(argv[i], "--socket=") { - return false + return "", false } } - return found + return socket, socket != "" +} + +func daemonServesSocket(uid, ownUID string, argv []string, socket string) bool { + got, ok := daemonSocketArg(uid, ownUID, argv) + return ok && got == socket } var storageProgramName = regexp.MustCompile(`^furrow(?:-[0-9]+\.[0-9]+\.[0-9]+-[0-9a-f]+)?$`) diff --git a/internal/cellstore/guard.go b/internal/cellstore/guard.go index c398a9757b..86a740f622 100644 --- a/internal/cellstore/guard.go +++ b/internal/cellstore/guard.go @@ -3,7 +3,6 @@ package cellstore import ( "fmt" "log" - "os" "path/filepath" "strings" @@ -31,12 +30,20 @@ import ( // fix those. // // The zero Guard works: the vault lives in the codeaf home, a kept-out file is -// one line in the log and a notice is one line on stderr. +// one line in the log and a notice is one line in the standard logger. +// +// A NOTICE WITH NO ROUTE GOES TO THE STANDARD LOGGER, NEVER STRAIGHT TO STDERR. +// The full-screen surface parks the logger in chat.log for its whole lifetime +// (cmd/codeaf withSurfaceLogger), and engines built with no route run inside +// it: the older-chat copier seals a whole folder in the background, and each +// fake key in a test file printed a raw row through the frame. Outside the +// surface the logger is stderr, so a command line still sees the line. type Guard struct { // Home is the codeaf home that holds the vault; empty means the state root. Home string // Notify receives one line the person should see (a file this machine - // cannot read, a vault that cannot be written); nil writes to stderr. + // cannot read, a vault that cannot be written); nil writes it to the + // standard logger. Notify func(string) // Kept receives one line for a path newly withheld because it holds a // secret; nil writes it to the log. @@ -59,7 +66,7 @@ func (g Guard) notify(line string) { g.Notify(line) return } - fmt.Fprintln(os.Stderr, "codeaf: "+line) + log.Printf("seal guard: %s", line) } // Screen is the pre-seal step for the tree the seal will capture. changed is diff --git a/internal/cellstore/guard_test.go b/internal/cellstore/guard_test.go index 80c45a7305..861899e183 100644 --- a/internal/cellstore/guard_test.go +++ b/internal/cellstore/guard_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io/fs" + "log" "os" "os/exec" "path/filepath" @@ -443,3 +444,38 @@ func TestSecretNoticesDescribeOnlyWhatAnUnpairedComputerKeeps(t *testing.T) { } } } + +// A guard with no route runs under the full-screen surface (the older-chat +// copier seals with one), so its notice goes to the standard logger, which the +// surface parks in chat.log, and never as a raw row on stderr. +func TestAGuardWithNoRouteLogsItsNoticeAndWritesNothingToStderr(t *testing.T) { + var logged bytes.Buffer + previous := log.Writer() + log.SetOutput(&logged) + defer log.SetOutput(previous) + + stderr, err := os.CreateTemp(t.TempDir(), "stderr") + if err != nil { + t.Fatal(err) + } + saved := os.Stderr + os.Stderr = stderr + defer func() { os.Stderr = saved }() + + r := newRig(t) + r.guard.Notify, r.guard.Kept = nil, nil + r.write("Tests/BrowseTests.swift", "let key = \""+fakeKey+"\"\n") + r.screen() + + os.Stderr = saved + raw, err := os.ReadFile(stderr.Name()) + if err != nil { + t.Fatal(err) + } + if len(raw) != 0 { + t.Fatalf("stderr %q: a notice must not tear through the surface", raw) + } + if got := logged.String(); !strings.Contains(got, "Tests/BrowseTests.swift") || strings.Contains(got, fakeKey) { + t.Fatalf("logged %q: want the file named, never the secret", got) + } +} diff --git a/internal/cellstore/homecapture_test.go b/internal/cellstore/homecapture_test.go index 1eb5bf8748..9d62393f97 100644 --- a/internal/cellstore/homecapture_test.go +++ b/internal/cellstore/homecapture_test.go @@ -182,8 +182,9 @@ func TestStartupInALargeHomeWalksNoHome(t *testing.T) { if err != nil { t.Fatal(err) } - if err := seat.Around(t.Context(), executor.Call{Tool: "read"}, func() ([]byte, bool) { - return []byte("ok"), false + // Warm capture only prepares an unused chat, so a completed call must give Drain a turn to save. + if err := seat.Around(t.Context(), executor.Call{Tool: "read", Changed: []string{}}, func() ([]byte, bool) { + return []byte("fixture"), false }); err != nil { t.Fatal(err) } diff --git a/internal/cellstore/l4_outage_test.go b/internal/cellstore/l4_outage_test.go new file mode 100644 index 0000000000..40ff8f539a --- /dev/null +++ b/internal/cellstore/l4_outage_test.go @@ -0,0 +1,31 @@ +package cellstore + +import ( + "errors" + "net/url" + "syscall" + "testing" +) + +// L4-H3a, L4-H3b, L4-H3c: a local seal cannot announce a recovered sync service. +func TestL4SyncOutageNeedsNetworkRecovery(t *testing.T) { + var w SealWatch + cause := &url.Error{Op: "Put", URL: "http://127.0.0.1:8901/v1/dir/devices/private", Err: syscall.ECONNREFUSED} + w.Report(cause) + want := "the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back" + if got := w.Take(); got != want { + t.Fatal(got) + } + w.Report(errors.Join(cause, errors.New("another retry"))) + w.Report(nil) + if got := w.Take(); got != "" { + t.Fatalf("false recovery or repeated outage: %s", got) + } + w.SyncReport(nil) + if got := w.Take(); got != "syncing again" { + t.Fatal(got) + } + if got := w.Take(); got != "" { + t.Fatal(got) + } +} diff --git a/internal/cellstore/localwork.go b/internal/cellstore/localwork.go index c4a45cf8fb..e28041cb71 100644 --- a/internal/cellstore/localwork.go +++ b/internal/cellstore/localwork.go @@ -191,7 +191,7 @@ func localPlanWork(raw []byte) (bool, bool, error) { continue } switch name { - case TurnsPath, publishedPath, adoptedPath, cell.CopyOfferedPath, cell.StateDir + "/copy-ready": + case TurnsPath, publishedPath, adoptedPath, cell.CopyOfferedPath, cell.CopyReadyPath: continue case cell.TranscriptPath: // The shared prefix is checked before a changed journal is discarded. diff --git a/internal/cellstore/published.go b/internal/cellstore/published.go index 8d82bd06e4..970755b4c0 100644 --- a/internal/cellstore/published.go +++ b/internal/cellstore/published.go @@ -46,6 +46,37 @@ func KnowsHead(c cell.Cell, head string) (bool, error) { return false, err } +// Extends says whether this computer's newest turn is head or was sealed on +// top of it. A computer whose own turns did not reach the service (it ran +// unsynced, or its last upload died with it) still owns that work: publishing +// it continues the record's history instead of replacing it. An adopted head +// has no line in the log, but its first local child names it as the parent. +func Extends(c cell.Cell, head string) (bool, error) { + local, err := Head(c) + if err != nil || local == nil { + return head == "", err + } + turns, err := Turns(c) + if err != nil { + return false, err + } + parent := make(map[string]string, len(turns)) + for _, t := range turns { + parent[t.ID] = t.Parent + } + for at, seen := local.Turn.ID, 0; seen <= len(turns); seen++ { + if at == head { + return true, nil + } + next, ok := parent[at] + if !ok || next == "" { + return false, nil + } + at = next + } + return false, nil +} + // NotePublished records a head that the directory accepted or this device // received, so a later take can distinguish uploaded history from local work. func NotePublished(c cell.Cell, head string) error { diff --git a/internal/cellstore/restate.go b/internal/cellstore/restate.go index 693a202ed3..538ecd2a3a 100644 --- a/internal/cellstore/restate.go +++ b/internal/cellstore/restate.go @@ -14,13 +14,19 @@ import ( ) // chainNames are the entries of .cell/ that a restore never writes: the chain -// describes the past rather than belongs to any turn's content (L12), and -// meta.json is the harness's own, composed afresh by every seal. +// describes the past rather than belongs to any turn's content (L12), meta.json +// is the harness's own composed afresh by every seal, and local publication, +// adoption, and legacy-copy markers are written after sealing or materializing +// so snapshots either omit them or hold an earlier turn's stale upload note. var chainNames = map[string]bool{ - path.Base(TurnsPath): true, - path.Base(ReceiptsDir): true, - path.Base(BlobsDir): true, - path.Base(cell.MetaPath): true, + path.Base(TurnsPath): true, + path.Base(ReceiptsDir): true, + path.Base(BlobsDir): true, + path.Base(cell.MetaPath): true, + path.Base(publishedPath): true, + path.Base(adoptedPath): true, + path.Base(cell.CopyReadyPath): true, + path.Base(cell.CopyOfferedPath): true, } // restoreKeepingChain puts the workspace and the rest of .cell/ (the diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index 57ed07229e..a2e22909a0 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -1,6 +1,9 @@ package cellstore import ( + "errors" + "github.com/Agent-Field/codeaf/internal/syncwords" + "net/url" "os" "sync" "time" @@ -43,14 +46,17 @@ type SealWatch struct { // it. OnTail func() // Quiet is TailQuiet unless a test sets it shorter. - Quiet time.Duration - mu sync.Mutex - idle bool - started bool - tail *time.Timer - cause string - said []string - failing bool + Quiet time.Duration + mu sync.Mutex + idle bool + started bool + tail *time.Timer + cause string + said []string + failing bool + syncFailing bool + // SyncAddress is selected by the door, so failures without a URL still name the right service. + SyncAddress string // requiredEnd follows conversation writes, so later bookkeeping cannot hide recovery. requiredEnd int64 } @@ -58,6 +64,10 @@ type SealWatch struct { // Report takes the outcome of one seal: nil when it held, the error when it did // not. It matches [Options.Report], so a watch's method is the recorder's hook. func (w *SealWatch) Report(err error) { + if syncwords.NetworkFailure(err) { + w.SyncReport(err) + return + } w.mu.Lock() defer w.mu.Unlock() if err == nil { @@ -265,3 +275,31 @@ func (w *SealWatch) ownTurnSaved() bool { } return true } + +// SyncReport is fed only by a completed service call. Local sealing has its +// own report path and cannot clear an outage while the network still refuses. +func (w *SealWatch) SyncReport(err error) { + w.mu.Lock() + defer w.mu.Unlock() + if err == nil { + if w.syncFailing { + w.say(syncwords.Recovered) + } + w.syncFailing = false + return + } + if !syncwords.NetworkFailure(err) { + return + } + var request *url.Error + if errors.As(err, &request) { + w.SyncAddress = request.URL + } + if w.SyncAddress == "" { + w.SyncAddress = syncwords.HostedURL + } + if !w.syncFailing { + w.say(syncwords.Outage(w.SyncAddress)) + } + w.syncFailing = true +} diff --git a/internal/cellstore/seat.go b/internal/cellstore/seat.go index d4502af803..53eaa5f0c2 100644 --- a/internal/cellstore/seat.go +++ b/internal/cellstore/seat.go @@ -88,10 +88,10 @@ func WithPipeline() SeatOption { return func(e *Engine) { e.pipeline = true } } func WithWarmCapture() SeatOption { return func(e *Engine) { e.warm = true } } // WithNotices sends the guard's one-line notices (a file this machine cannot -// read, a vault that cannot be written) to say instead of to stderr, which a -// full-screen surface cannot show. A file kept out for a secret is not one: -// it goes to the log ([Guard.Kept]). A door with a surface passes where its -// sentences go. +// read, a vault that cannot be written) to say instead of to the standard +// logger, which a full-screen surface keeps in its log file. A file kept out +// for a secret is not one: it goes to the log ([Guard.Kept]). A door with a +// surface passes where its sentences go. func WithNotices(say func(string)) SeatOption { return func(e *Engine) { e.Guard.Notify = say } } diff --git a/internal/cellstore/store_busy.go b/internal/cellstore/store_busy.go new file mode 100644 index 0000000000..1112940d84 --- /dev/null +++ b/internal/cellstore/store_busy.go @@ -0,0 +1,312 @@ +package cellstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" +) + +// ErrStoreBusy means another process holds the engine store's writer lock. +var ErrStoreBusy = errors.New("engine store is busy") + +const ( + storeWriterLockName = "writer.lock" + maxLockPayloadBytes = 4 << 10 + storeBusyInspectWithin = time.Second +) + +// StoreHolder identifies the process holding a store writer lock. +type StoreHolder struct { + PID int `json:"pid,omitempty"` + Command string `json:"command,omitempty"` + Lock string `json:"lock,omitempty"` +} + +// Describe formats the known parts of a holder and answers empty when neither +// command nor pid is known. +func (h StoreHolder) Describe() string { + cmd := strings.TrimSpace(h.Command) + switch { + case cmd != "" && h.PID > 0: + return fmt.Sprintf("%s, pid %d", cmd, h.PID) + case cmd != "": + return cmd + case h.PID > 0: + return fmt.Sprintf("pid %d", h.PID) + default: + return "" + } +} + +// StoreBusyError reports that DataDir's writer lock is held by another process. +type StoreBusyError struct { + DataDir string + Holder StoreHolder + Cause error +} + +func (e *StoreBusyError) Error() string { + if who := e.Holder.Describe(); who != "" { + return fmt.Sprintf("engine store is busy (held by %s)", who) + } + return ErrStoreBusy.Error() +} + +func (e *StoreBusyError) Unwrap() []error { + if e.Cause != nil { + return []error{ErrStoreBusy, e.Cause} + } + return []error{ErrStoreBusy} +} + +// SharedStoreDir returns the default shared engine store directory for the +// active state root. +func SharedStoreDir() string { + return home.Join("v3", "stores", "shared") +} + +// HoldStoreWriter acquires DataDir's writer lock and stamps this process as its +// holder until release runs. +func HoldStoreWriter(dataDir, command string) (func() error, error) { + if strings.TrimSpace(dataDir) == "" { + return nil, errors.New("empty store data directory") + } + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return nil, err + } + lockPath := filepath.Join(dataDir, storeWriterLockName) + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := filelock.Lock(file, true, true); err != nil { + _ = file.Close() + if filelock.IsBusy(err) { + holder, _ := InspectStoreBusy(dataDir, "") + return nil, &StoreBusyError{DataDir: dataDir, Holder: holder, Cause: err} + } + return nil, err + } + cmd := strings.TrimSpace(command) + if cmd == "" { + cmd = formatHolderCommand(os.Args) + } + payload, _ := json.Marshal(StoreHolder{PID: os.Getpid(), Command: cmd}) + payload = append(payload, '\n') + _, _ = file.Seek(0, io.SeekStart) + _, _ = file.Write(payload) + _ = file.Truncate(int64(len(payload))) + _ = file.Sync() + + var once sync.Once + var releaseErr error + return func() error { + once.Do(func() { + removeErr := os.Remove(lockPath) + if errors.Is(removeErr, os.ErrNotExist) { + removeErr = nil + } + unlockErr := filelock.Unlock(file) + closeErr := file.Close() + releaseErr = errors.Join(removeErr, unlockErr, closeErr) + }) + return releaseErr + }, nil +} + +var inspectStoreLock = probeStoreLockBusy + +// InspectStoreBusy checks whether another live process holds an advisory writer +// lock on dataDir. A daemon serving ownSocket is treated as this client's own +// transport rather than a competing holder. +func InspectStoreBusy(dataDir, ownSocket string) (StoreHolder, bool) { + ctx, cancel := context.WithTimeout(context.Background(), storeBusyInspectWithin) + defer cancel() + if strings.TrimSpace(dataDir) == "" { + return StoreHolder{}, false + } + for _, lockPath := range storeLockCandidates(dataDir, ownSocket) { + if ctx.Err() != nil { + break + } + holder, busy := inspectStoreLock(ctx, lockPath, ownSocket) + if ctx.Err() != nil { + return StoreHolder{}, busy + } + if busy { + return holder, true + } + } + return StoreHolder{}, false +} + +func classifyStoreBusyError(dataDir, ownSocket string, err error) error { + if err == nil { + return nil + } + var existing *StoreBusyError + if errors.As(err, &existing) { + return err + } + if isEngineStoreBusyText(err.Error()) { + holder, _ := InspectStoreBusy(dataDir, ownSocket) + return &StoreBusyError{DataDir: dataDir, Holder: holder, Cause: err} + } + return err +} + +func isEngineStoreBusyText(text string) bool { + lower := strings.ToLower(text) + for _, needle := range []string{ + "store writer is still busy", + "engine store is busy", + } { + if strings.Contains(lower, needle) { + return true + } + } + return false +} + +func storeLockCandidates(dataDir, ownSocket string) []string { + var paths []string + add := func(matches ...string) { + for _, m := range matches { + if m != "" { + paths = append(paths, m) + } + } + } + add(dataDir + ".lock") + for _, pattern := range []string{ + filepath.Join(dataDir, "*.lock"), + filepath.Join(dataDir, "store-v1", "*.lock"), + filepath.Join(dataDir, "store-v1", "*", "*.lock"), + } { + matches, _ := filepath.Glob(pattern) + add(matches...) + } + v3Dir := filepath.Dir(filepath.Dir(filepath.Clean(dataDir))) + if filepath.Base(filepath.Dir(filepath.Clean(dataDir))) == "stores" { + socks, _ := filepath.Glob(filepath.Join(v3Dir, "engine-*.sock.lock")) + for _, sockLock := range socks { + if ownSocket != "" && filepath.Clean(sockLock) == filepath.Clean(ownSocket+".lock") { + continue + } + // Another engine daemon socket lock only blocks this store when no + // socket is owned here or when inspecting stale locks across v3. + if ownSocket == "" { + add(sockLock) + } + } + } + return paths +} + +func probeStoreLockBusy(ctx context.Context, lockPath, ownSocket string) (StoreHolder, bool) { + if strings.HasSuffix(lockPath, ".sock.lock") { + // An active engine daemon socket lock is the normal daemon lease, not a + // stuck store writer, unless its socket file is missing. + sock := strings.TrimSuffix(lockPath, ".lock") + if !heldWithoutSocket(sock) { + return StoreHolder{}, false + } + } + file, err := os.OpenFile(lockPath, os.O_RDWR, 0) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return StoreHolder{}, false + } + file, err = os.Open(lockPath) + if err != nil { + return StoreHolder{}, false + } + } + defer file.Close() + + if err := filelock.Lock(file, true, true); err == nil { + _ = filelock.Unlock(file) + return StoreHolder{}, false + } else if !filelock.IsBusy(err) { + return StoreHolder{}, false + } + + holder := lockHolderFromOS(ctx, lockPath) + if holder.PID == os.Getpid() { + return StoreHolder{}, false + } + if holder.PID > 0 && pidServesSocket(ctx, holder.PID, ownSocket) { + return StoreHolder{}, false + } + if holder.PID == 0 || holder.Command == "" { + recorded := readLockPayload(file) + if recorded.PID == os.Getpid() { + return StoreHolder{}, false + } + if recorded.PID > 0 && pidServesSocket(ctx, recorded.PID, ownSocket) { + return StoreHolder{}, false + } + if holder.PID == 0 { + holder.PID = recorded.PID + } + if holder.Command == "" { + holder.Command = recorded.Command + } + } + if holder.Command == "" && holder.PID > 0 { + holder.Command = describeProcess(ctx, holder.PID) + } + holder.Lock = lockPath + return holder, true +} + +func readLockPayload(file *os.File) StoreHolder { + raw, err := io.ReadAll(io.NewSectionReader(file, 0, maxLockPayloadBytes)) + if err != nil { + return StoreHolder{} + } + text := strings.TrimSpace(string(raw)) + if text == "" { + return StoreHolder{} + } + var holder StoreHolder + if json.Unmarshal([]byte(text), &holder) == nil && (holder.PID > 0 || holder.Command != "") { + return holder + } + if pid, err := strconv.Atoi(text); err == nil && pid > 0 { + return StoreHolder{PID: pid} + } + return StoreHolder{} +} + +func formatHolderCommand(argv []string) string { + var clean []string + for _, arg := range argv { + arg = strings.TrimSpace(arg) + if arg != "" { + clean = append(clean, arg) + } + } + if len(clean) == 0 { + return "" + } + prog := filepath.Base(clean[0]) + if storageProgramName.MatchString(prog) { + prog = "furrow" + } + if len(clean) > 1 && !strings.HasPrefix(clean[1], "-") && !strings.Contains(clean[1], string(filepath.Separator)) { + return prog + " " + clean[1] + } + return prog +} diff --git a/internal/cellstore/store_busy_budget_test.go b/internal/cellstore/store_busy_budget_test.go new file mode 100644 index 0000000000..01b8f11b6a --- /dev/null +++ b/internal/cellstore/store_busy_budget_test.go @@ -0,0 +1,60 @@ +package cellstore + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// C4.4: classification shares one total budget across all candidates, and +// preserves the plain busy sentence when no holder can be found in time. +func TestStoreBusyClassificationSharesOneBudget(t *testing.T) { + root := t.TempDir() + for i := 0; i < 30; i++ { + if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("%02d.lock", i)), nil, 0o600); err != nil { + t.Fatal(err) + } + } + old := inspectStoreLock + t.Cleanup(func() { inspectStoreLock = old }) + calls := 0 + inspectStoreLock = func(ctx context.Context, _, _ string) (StoreHolder, bool) { + calls++ + select { + case <-time.After(storeBusyInspectWithin / 4): + return StoreHolder{}, false + case <-ctx.Done(): + return StoreHolder{}, false + } + } + start := time.Now() + err := classifyStoreBusyError(root, "", errors.New("engine store is busy")) + if elapsed := time.Since(start); elapsed > storeBusyInspectWithin+500*time.Millisecond { + t.Fatalf("classification took %s, budget %s", elapsed, storeBusyInspectWithin) + } + if calls >= 30 || calls == 0 { + t.Fatalf("inspected %d candidates", calls) + } + if err.Error() != "engine store is busy" { + t.Fatal(err) + } + // A timely holder ends the scan immediately and keeps the existing message. + calls = 0 + inspectStoreLock = func(context.Context, string, string) (StoreHolder, bool) { + calls++ + return StoreHolder{PID: 42, Command: "codeaf chat"}, true + } + err = classifyStoreBusyError(root, "", errors.New("engine store is busy")) + if calls != 1 || err.Error() != "engine store is busy (held by codeaf chat, pid 42)" { + t.Fatalf("calls=%d: %v", calls, err) + } + calls = 0 + _ = classifyStoreBusyError(root, "", errors.New("unrelated failure")) + if calls != 0 { + t.Fatal("healthy path inspected locks") + } +} diff --git a/internal/cellstore/store_busy_test.go b/internal/cellstore/store_busy_test.go new file mode 100644 index 0000000000..be53065a7b --- /dev/null +++ b/internal/cellstore/store_busy_test.go @@ -0,0 +1,207 @@ +package cellstore + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" +) + +type testBusyOp struct{} + +func (testBusyOp) Verb() string { return "want" } +func (testBusyOp) Args() []string { return []string{"want"} } + +func TestStoreBusyHelperProcess(t *testing.T) { + lockPath := os.Getenv("CODEAF_TEST_HOLD_STORE_LOCK") + if lockPath == "" { + return + } + if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { + os.Exit(2) + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + os.Exit(3) + } + if err := filelock.Lock(file, true, true); err != nil { + os.Exit(4) + } + if payload := os.Getenv("CODEAF_TEST_HOLD_PAYLOAD"); payload != "" { + _, _ = file.WriteString(payload) + _ = file.Sync() + } + _, _ = os.Stdout.WriteString("LOCKED\n") + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + _ = filelock.Unlock(file) + _ = file.Close() + os.Exit(0) +} + +func startLockHolderSubprocess(t *testing.T, lockPath, payload string) (*exec.Cmd, func()) { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(exe, "-test.run=^TestStoreBusyHelperProcess$", "chat") //codeaf:plumbing spawns this test binary to hold an advisory store lock in another process + cmd.Env = append(os.Environ(), + "CODEAF_TEST_HOLD_STORE_LOCK="+lockPath, + "CODEAF_TEST_HOLD_PAYLOAD="+payload, + ) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + line, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil || strings.TrimSpace(line) != "LOCKED" { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("helper failed to lock %s: line=%q err=%v", lockPath, line, err) + } + var stopped bool + stop := func() { + if stopped { + return + } + stopped = true + _, _ = stdin.Write([]byte("release\n")) + _ = stdin.Close() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = cmd.Process.Kill() + <-done + } + } + t.Cleanup(stop) + return cmd, stop +} + +func TestStoreBusyReportsHolderWithinSeconds(t *testing.T) { + dataDir := filepath.Join(t.TempDir(), "v3", "stores", "shared") + lockPath := filepath.Join(dataDir, "store-v1", "locks", "pack.lock") + payload := "" + if runtime.GOOS == "windows" { + raw, _ := json.Marshal(StoreHolder{Command: "codeaf chat"}) + payload = string(raw) + } + child, stopChild := startLockHolderSubprocess(t, lockPath, payload) + defer stopChild() + + // A held lock never pre-empts a request that the engine itself handles. + ran := 0 + var engineErr error + tr := Spawn{ + Binary: "/bin/echo", + Run: func(context.Context, string, []string, ...string) ([]byte, error) { + ran++ + if engineErr != nil { + return nil, engineErr + } + return []byte("{}"), nil + }, + } + + if _, err := tr.Do(context.Background(), Target{Tree: t.TempDir(), DataDir: dataDir}, testBusyOp{}); err != nil { + t.Fatalf("normal request failed while lock was held: %v", err) + } + if ran != 1 { + t.Fatalf("engine invocations = %d, want 1", ran) + } + + // Non-busy engine errors are left untouched even when a lock file exists. + engineErr = errors.New("furrow: exit status 1: remote writer lock is already held") + if _, err := tr.Do(context.Background(), Target{Tree: t.TempDir(), DataDir: dataDir}, testBusyOp{}); errors.Is(err, ErrStoreBusy) { + t.Fatalf("remote lock error misclassified as ErrStoreBusy: %v", err) + } + + // When the engine itself returns its busy error, the transport classifies it + // as ErrStoreBusy and names the foreign holder's command and pid. + engineErr = errors.New("furrow: exit status 1: store writer is still busy after waiting 3 seconds; retry the operation") + began := time.Now() + _, err := tr.Do(context.Background(), Target{Tree: t.TempDir(), DataDir: dataDir}, testBusyOp{}) + elapsed := time.Since(began) + + if !errors.Is(err, ErrStoreBusy) { + t.Fatalf("err = %v, want errors.Is(..., ErrStoreBusy)", err) + } + var busy *StoreBusyError + if !errors.As(err, &busy) { + t.Fatalf("err = %T (%v), want *StoreBusyError", err, err) + } + if elapsed > 3*time.Second { + t.Fatalf("busy store classification took %v, want within a few seconds", elapsed) + } + if runtime.GOOS != "windows" { + if busy.Holder.PID != child.Process.Pid { + t.Fatalf("holder pid = %d, want child pid %d", busy.Holder.PID, child.Process.Pid) + } + if busy.Holder.Command == "" { + t.Fatalf("holder command is empty; want child command") + } + } + + // Releasing the foreign writer lock lets the next operation proceed. + stopChild() + engineErr = nil + if _, err := tr.Do(context.Background(), Target{Tree: t.TempDir(), DataDir: dataDir}, testBusyOp{}); err != nil { + t.Fatalf("after lock release, Do returned %v", err) + } +} + +func TestStoreBusyIgnoresHomesOwnDaemon(t *testing.T) { + bin, err := sealingEngine() + if err != nil { + t.Skipf("no owned engine: %v", err) + } + stateRoot := t.TempDir() + t.Setenv(home.EnvVar, stateRoot) + t.Cleanup(StopSpawned) + + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("hello\n"), 0o600); err != nil { + t.Fatal(err) + } + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + e := Engine{Binary: bin, Workspace: work, Transport: Spawn{Binary: bin}} + sealed, err := e.Seal(t.Context(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + + // The home's own furrow serve daemon is the normal writer path and must + // never be reported as a busy-store blocker or pre-empt a spawned verb. + if holder, busy := InspectStoreBusy(e.StoreDir(c), ""); busy { + t.Fatalf("home's own daemon reported as busy holder: %+v", holder) + } + if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("changed\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := e.Sync(SyncKeys{}, "test").Materialize(t.Context(), c, sealed.Turn.ID); err != nil { + t.Fatalf("Materialize failed while home's own daemon was active: %v", err) + } +} diff --git a/internal/cellstore/store_busy_unix.go b/internal/cellstore/store_busy_unix.go new file mode 100644 index 0000000000..31823c3ecf --- /dev/null +++ b/internal/cellstore/store_busy_unix.go @@ -0,0 +1,233 @@ +//go:build linux || darwin + +package cellstore + +import ( + "bufio" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/unix" + + "github.com/Agent-Field/codeaf/internal/home" +) + +func storeProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +func pidServesSocket(ctx context.Context, pid int, socket string) bool { + uid, argv := storageProcess(ctx, pid) + ownUID := strconv.Itoa(os.Getuid()) + if socket != "" && daemonServesSocket(uid, ownUID, argv, socket) { + return true + } + // Each codeaf home has its own private saving daemon; it is the normal + // writer path for this home even when a caller inspects without naming a socket. + served, ok := daemonSocketArg(uid, ownUID, argv) + if !ok { + return false + } + if filepath.Clean(served) == filepath.Clean(socketFor(argv[0])) { + return true + } + return filepath.Dir(filepath.Clean(served)) == filepath.Clean(home.Join("v3")) +} + +func describeProcess(ctx context.Context, pid int) string { + _, argv := storageProcess(ctx, pid) + return formatHolderCommand(argv) +} + +func lockHolderFromOS(ctx context.Context, lockPath string) StoreHolder { + if runtime.GOOS == "linux" { + return lockHolderLinux(ctx, lockPath) + } + return lockHolderDarwin(ctx, lockPath) +} + +func lockHolderLinux(ctx context.Context, lockPath string) StoreHolder { + info, err := os.Stat(lockPath) + if err != nil { + return StoreHolder{} + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return StoreHolder{} + } + pid := pidFromProcLocks(ctx, uint64(stat.Dev), stat.Ino) + if pid <= 0 { + pid = pidFromProcFDs(ctx, info) + } + if pid <= 0 { + return StoreHolder{} + } + return resolveUnixHolder(ctx, pid) +} + +func pidFromProcLocks(ctx context.Context, dev, ino uint64) int { + file, err := os.Open("/proc/locks") + if err != nil { + return 0 + } + defer file.Close() + + wantMajor := unix.Major(dev) + wantMinor := unix.Minor(dev) + ownPID := os.Getpid() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + if ctx.Err() != nil { + return 0 + } + fields := strings.Fields(scanner.Text()) + // Standard format: "1: FLOCK ADVISORY WRITE :: 0 EOF" + // Blocked waiters carry "->" after the index ("1: -> FLOCK ..."); skip them. + if len(fields) < 6 || fields[1] == "->" { + continue + } + pid, err := strconv.Atoi(fields[4]) + if err != nil || pid <= 0 || pid == ownPID { + continue + } + parts := strings.Split(fields[5], ":") + if len(parts) != 3 { + continue + } + gotIno, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil || gotIno != ino { + continue + } + maj, err1 := strconv.ParseUint(parts[0], 16, 32) + min, err2 := strconv.ParseUint(parts[1], 16, 32) + if err1 != nil || err2 != nil { + continue + } + if uint32(maj) == wantMajor && uint32(min) == wantMinor { + return pid + } + } + return 0 +} + +func pidFromProcFDs(ctx context.Context, target os.FileInfo) int { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0 + } + ownUID := uint32(os.Getuid()) + ownPID := os.Getpid() + for _, entry := range entries { + if ctx.Err() != nil { + return 0 + } + if !entry.IsDir() { + continue + } + pid, err := strconv.Atoi(entry.Name()) + if err != nil || pid <= 0 || pid == ownPID { + continue + } + procDir := filepath.Join("/proc", entry.Name()) + pInfo, err := os.Stat(procDir) + if err != nil { + continue + } + if st, ok := pInfo.Sys().(*syscall.Stat_t); !ok || st.Uid != ownUID { + continue + } + fds, err := os.ReadDir(filepath.Join(procDir, "fd")) + if err != nil { + continue + } + for _, fd := range fds { + if ctx.Err() != nil { + return 0 + } + fdInfo, err := os.Stat(filepath.Join(procDir, "fd", fd.Name())) + if err == nil && os.SameFile(target, fdInfo) { + return pid + } + } + } + return 0 +} + +func resolveUnixHolder(ctx context.Context, pid int) StoreHolder { + _, argv := storageProcess(ctx, pid) + // A one-shot furrow verb belongs to the codeaf process that launched it; a + // furrow serve daemon is identified by its own socket so this home's normal + // writer is never mistaken for a competing codeaf process. + if len(argv) > 0 && storageProgramName.MatchString(filepath.Base(argv[0])) && (len(argv) < 2 || argv[1] != "serve") { + if ppid := linuxParentPID(pid); ppid > 1 && ppid != os.Getpid() { + _, parentArgv := storageProcess(ctx, ppid) + if len(parentArgv) > 0 { + base := filepath.Base(parentArgv[0]) + if base == "codeaf" || strings.HasPrefix(base, "codeaf-") { + return StoreHolder{PID: ppid, Command: formatHolderCommand(parentArgv)} + } + } + } + } + return StoreHolder{PID: pid, Command: formatHolderCommand(argv)} +} + +func linuxParentPID(pid int) int { + if runtime.GOOS != "linux" { + return 0 + } + raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status")) + if err != nil { + return 0 + } + for _, line := range strings.Split(string(raw), "\n") { + if after, ok := strings.CutPrefix(line, "PPid:"); ok { + ppid, _ := strconv.Atoi(strings.TrimSpace(after)) + return ppid + } + } + return 0 +} + +func lockHolderDarwin(ctx context.Context, lockPath string) StoreHolder { + raw, err := exec.CommandContext(ctx, "lsof", "-n", "-P", "-F", "pc", "--", lockPath).Output() //codeaf:plumbing inspects which local process holds an advisory store lock on macOS + if err != nil { + return StoreHolder{} + } + ownPID := os.Getpid() + var pid int + var cmd string + for _, line := range strings.Split(string(raw), "\n") { + switch { + case strings.HasPrefix(line, "p"): + parsed, err := strconv.Atoi(line[1:]) + if err == nil && parsed > 0 && parsed != ownPID { + pid = parsed + } else { + pid = 0 + } + case strings.HasPrefix(line, "c") && pid > 0 && cmd == "": + cmd = strings.TrimSpace(line[1:]) + } + } + if pid <= 0 { + return StoreHolder{} + } + holder := resolveUnixHolder(ctx, pid) + if holder.Command == "" { + holder.Command = cmd + } + return holder +} diff --git a/internal/cellstore/store_busy_windows.go b/internal/cellstore/store_busy_windows.go new file mode 100644 index 0000000000..00aac03b62 --- /dev/null +++ b/internal/cellstore/store_busy_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package cellstore + +import ( + "context" + "golang.org/x/sys/windows" +) + +const windowsStillActive = 259 + +func storeProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(handle) + var code uint32 + if err := windows.GetExitCodeProcess(handle, &code); err != nil { + return false + } + return code == windowsStillActive +} + +func pidServesSocket(context.Context, int, string) bool { return false } + +func describeProcess(context.Context, int) string { return "" } + +func lockHolderFromOS(context.Context, string) StoreHolder { return StoreHolder{} } diff --git a/internal/cellstore/syncops.go b/internal/cellstore/syncops.go index 7a53b6a650..6de311200e 100644 --- a/internal/cellstore/syncops.go +++ b/internal/cellstore/syncops.go @@ -241,12 +241,15 @@ func (e SyncEngine) importWith(ctx context.Context, c cell.Cell, op importOp) (i return out.Imported, e.ask(ctx, c, op, &out) } -// Holds implements the sync seam: the ledger file the engine keeps beside the -// store is written by every export and import against this relay, so it has -// content exactly when the device has exchanged objects with the relay. +// Holds reports history this computer held for this chat. The publication +// ledger belongs to the shared store, so another chat's exchange proves nothing +// about this one. Local adoption and turn records already supply this evidence. func (e SyncEngine) Holds(c cell.Cell) bool { - info, err := os.Stat(filepath.Join(e.Target(c).DataDir, "published."+e.Ledger)) - return err == nil && info.Size() > 0 + if _, ok := readAdoption(c); ok { + return true + } + turn, err := lastTurn(rel(c, TurnsPath)) + return err == nil && turn != nil } // Materialize implements the sync seam. @@ -260,15 +263,40 @@ func (e SyncEngine) Materialize(ctx context.Context, c cell.Cell, head string) e Held []heldName `json:"held"` } // The restore brings the sender's policy file and record over this machine's, - // which would forget the names an earlier take set apart: they are read first - // and written back with this take's. + // which would forget the names an earlier take set apart and any local + // legacy-copy markers snapshots never carry: they are read first and + // written back after the head is materialized. earlier := heldBefore(c, e.holdDir(c)) + _, readyErr := os.Stat(rel(c, cell.CopyReadyPath)) + _, lockErr := os.Stat(c.Root + ".lock") + keepCopyReady := readyErr == nil || (filepath.Base(filepath.Dir(c.Root)) == ".moving" && lockErr == nil) + _, offeredErr := os.Stat(rel(c, cell.CopyOfferedPath)) + keepCopyOffered := offeredErr == nil if err := e.ask(ctx, c, materializeOp{Head: head}, &out); err != nil { return err } + if keepCopyReady { + if err := cell.StampCopyReady(c.Root); err != nil { + return err + } + } + if keepCopyOffered { + offeredFile := rel(c, cell.CopyOfferedPath) + if _, err := os.Stat(offeredFile); os.IsNotExist(err) { + if err := writeFile(offeredFile, nil); err != nil { + return err + } + } + } if err := noteAdopted(c, head); err != nil { return err } + // A snapshot sealed after the first publish carries the sender's earlier + // upload note, whose snapshot this store may never have fetched; record + // the received head so local-work checks compare against a snapshot held here. + if err := NotePublished(c, head); err != nil { + return err + } return noteHeld(c, e.holdDir(c), mergeHeld(earlier, out.Held)) } diff --git a/internal/cellstore/transport.go b/internal/cellstore/transport.go index 947790ae2c..010fb066bb 100644 --- a/internal/cellstore/transport.go +++ b/internal/cellstore/transport.go @@ -66,14 +66,14 @@ func (s Spawn) Do(ctx context.Context, t Target, op Op) ([]byte, error) { if err != nil { return nil, err } + socket := s.Socket + if socket == "" { + socket = socketFor(bin) + } // Seals and durability fences always use codeaf's one daemon. The CLI's // implicit daemon is per data directory and escapes codeaf's child lifecycle; // invoking its direct sync would also miss the retained writer's pending group. if s.Run == nil && (op.Verb() == "seal" || op.Verb() == "sync") { - socket := s.Socket - if socket == "" { - socket = socketFor(bin) - } return (Daemon{Socket: socket, Binary: bin}).Do(ctx, t, op) } argv := append([]string{bin}, op.Args()...) @@ -84,7 +84,8 @@ func (s Spawn) Do(ctx context.Context, t Target, op Op) ([]byte, error) { argv = append(argv, "--timeline", t.Timeline) } environ := append(os.Environ(), dataDirEnv+"="+t.DataDir) - return s.run(ctx, t.Tree, append(environ, opEnv(op)...), argv...) + out, err := s.run(ctx, t.Tree, append(environ, opEnv(op)...), argv...) + return out, classifyStoreBusyError(t.DataDir, socket, err) } // EnvOp is an Op that carries secrets. A spawn hands them to the engine diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index 49d30391dc..78091877fa 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -9,6 +9,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/Agent-Field/codeaf/internal/wireauth" ) @@ -75,6 +76,9 @@ type Batcher struct { flushMu sync.Mutex // one flush, branch or close at a time backoff time.Duration failing bool // the last flush failed, so Idle must wait out the backoff + // lastFlushErr retains the latest publish or branch result when Close waits behind it. + // It is read and written with flushMu held. + lastFlushErr error } // Superseded says another device continued the chat. @@ -379,6 +383,12 @@ func (b *Batcher) Close(ctx context.Context) error { // first; otherwise another home could automatically open an older head. for b.Pending() > 0 { if err := ctx.Err(); err != nil { + if syncwords.UploadPending(b.lastFlushErr) { + return syncwords.UploadFailure(err) + } + if b.lastFlushErr != nil { + return b.lastFlushErr + } return err } if err := b.drain(ctx); err != nil { @@ -397,7 +407,7 @@ func (b *Batcher) release(ctx context.Context) error { } for { if err := ctx.Err(); err != nil { - return err + return syncwords.UploadFailure(err) } err := b.Publisher.Dir.Release(ctx, d.ID(), d.Fence) if err == nil || errors.Is(err, directory.ErrFenceStale) { @@ -407,13 +417,13 @@ func (b *Batcher) release(ctx context.Context) error { return nil } if !errors.Is(err, directory.ErrUnreachable) && !errors.Is(err, wireauth.ErrUnavailable) { - return err + return syncwords.UploadFailure(err) } if _, bounded := ctx.Deadline(); !bounded { - return err + return syncwords.UploadFailure(err) } if err := b.sleeper()(ctx, ReleaseRetryEvery); err != nil { - return err + return syncwords.UploadFailure(err) } } } @@ -426,7 +436,9 @@ func (b *Batcher) flush(ctx context.Context) time.Duration { if b.isClosing() { return b.interval() } - return b.after(b.drain(ctx)) + err := b.drain(ctx) + b.lastFlushErr = err + return b.after(err) } func (b *Batcher) isClosing() bool { @@ -516,6 +528,17 @@ func (b *Batcher) beat(ctx context.Context) { } } +// Displaced records that an ownership read found this writer's hold gone: new +// work is refused immediately and any pending turns are resolved under flushMu +// so an in-flight publish and a watch wakeup never announce displacement twice. +func (b *Batcher) Displaced(ctx context.Context) { + seen, _, stale := b.view() + if stale { + return + } + b.lost(ctx, seen) +} + // lost handles a heartbeat that found the lease of seen gone. It waits for any // flush in flight, and does nothing if that flush already branched. When the // branch fails the state stays stale and the next flush retries it. @@ -525,14 +548,14 @@ func (b *Batcher) lost(ctx context.Context, seen Driving) { if b.isClosing() { return } - if now, _, _ := b.view(); now.ID() != seen.ID() || now.Fence != seen.Fence { + if now, _, stale := b.view(); stale || now.ID() != seen.ID() || now.Fence != seen.Fence { return } b.setStale() if b.OnLost != nil { b.OnLost() } - _ = b.resolve(ctx) + b.lastFlushErr = b.resolve(ctx) } // resolve turns the orphaned turns into a branch, or, when there are none, diff --git a/internal/cellsync/branch.go b/internal/cellsync/branch.go index 7c9696e2b0..897e526a4e 100644 --- a/internal/cellsync/branch.go +++ b/internal/cellsync/branch.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // Brancher turns turns that lost their lease into a new cell (L12). Nothing is @@ -56,7 +57,7 @@ func (b Brancher) Branch(ctx context.Context, from *Driving, head string, turns ParentCell: from.ID(), OrphanTurns: turns, Frames: plan, }) if err != nil { - return "", err + return "", syncwords.UploadFailure(err) } if err := b.Map.Set(from.ID(), id); err != nil { return "", err diff --git a/internal/cellsync/chat_priming_test.go b/internal/cellsync/chat_priming_test.go new file mode 100644 index 0000000000..5a50d2fe77 --- /dev/null +++ b/internal/cellsync/chat_priming_test.go @@ -0,0 +1,210 @@ +package cellsync + +import ( + "bytes" + "context" + "crypto/rand" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +type chatFetchCounts struct { + blobstore.Store + frames, objects, bytes atomic.Int64 +} + +func (s *chatFetchCounts) GetFrame(ctx context.Context, id string) ([]byte, error) { + s.frames.Add(1) + raw, err := s.Store.GetFrame(ctx, id) + s.bytes.Add(int64(len(raw))) + return raw, err +} +func (s *chatFetchCounts) GetMany(ctx context.Context, ids []string) ([]blobstore.Object, error) { + s.objects.Add(1) + objects, err := s.Store.GetMany(ctx, ids) + for _, o := range objects { + s.bytes.Add(int64(len(o.Bytes))) + } + return objects, err +} + +type wantCountingEngine struct { + Engine + wants atomic.Int64 +} + +func (e *wantCountingEngine) Want(ctx context.Context, c cell.Cell, h string) ([]string, error) { + e.wants.Add(1) + return e.Engine.Want(ctx, c, h) +} + +type usedStoreRig struct { + sender cellstore.Engine + pub *Publisher + receiver cellstore.SyncEngine + fetch *Fetcher + counts *chatFetchCounts + cell cell.Cell + source cell.Cell + earlier cell.Cell + earlierHead string + body []byte + head string +} + +func newUsedStoreRig(t *testing.T) *usedStoreRig { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + celltest.RequireEngine(t) + binary, err := furrow.ResolveOwned() + if err != nil { + t.Skip(err) + } + t.Cleanup(cellstore.StopSpawned) + sender := cellstore.Engine{Binary: binary, DataRoot: t.TempDir(), Transport: cellstore.Spawn{Binary: binary}} + receiver := cellstore.Engine{Binary: binary, DataRoot: t.TempDir(), Transport: cellstore.Spawn{Binary: binary}} + keys := cellstore.SyncKeys{CellKey: make([]byte, 32), Dedup: make([]byte, 32)} + send := sender.Sync(keys, "0123456789abcdef") + recv := receiver.Sync(keys, "0123456789abcdef") + r := newRig(t) + pub := &Publisher{Engine: send, Store: r.mem, Dir: r.dirA} + counts := &chatFetchCounts{Store: r.mem} + f := &Fetcher{Engine: recv, Store: counts, Dir: r.dir.For(devB), Inbox: recv.Inbox} + makeChat := func() (cell.Cell, cell.Cell) { + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + local := cell.Cell{ID: c.ID, Root: filepath.Join(t.TempDir(), c.ID)} + if err := os.MkdirAll(local.Root, 0700); err != nil { + t.Fatal(err) + } + return c, local + } + publish := func(c cell.Cell, body []byte) string { + if err := os.WriteFile(filepath.Join(c.Root, "payload"), body, 0600); err != nil { + t.Fatal(err) + } + sealed, err := sender.Seal(t.Context(), c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if err := cellstore.Await(t.Context(), c); err != nil { + t.Fatal(err) + } + if err := pub.Publish(t.Context(), &Driving{Cell: c}, sealed.Turn.ID, PublishInfo{Class: "chat"}); err != nil { + t.Fatal(err) + } + return sealed.Turn.ID + } + earlier, localEarlier := makeChat() + first := publish(earlier, []byte("an earlier chat")) + if err := f.Fetch(t.Context(), localEarlier, first); err != nil { + t.Fatal(err) + } + source, local := makeChat() + body := make([]byte, 2<<20) + if _, err := rand.Read(body); err != nil { + t.Fatal(err) + } + head := publish(source, body) + counts.frames.Store(0) + counts.objects.Store(0) + counts.bytes.Store(0) + return &usedStoreRig{sender: sender, pub: pub, receiver: recv, fetch: f, counts: counts, cell: local, source: source, earlier: earlier, earlierHead: first, body: body, head: head} +} +func TestANewChatInAUsedStorePrimesFromItsPlan(t *testing.T) { + r := newUsedStoreRig(t) + // Skipping the plan was the old used-store path. Fetch the very same source + // through that object-only door in another already-used store for comparison. + local := cellstore.Engine{Binary: r.sender.Binary, DataRoot: t.TempDir(), Transport: cellstore.Spawn{Binary: r.sender.Binary}} + engine := local.Sync(r.receiver.Keys, r.receiver.Ledger) + before := &chatFetchCounts{Store: r.counts.Store} + plain := &Fetcher{Engine: engine, Store: before, Inbox: engine.Inbox} + if err := plain.Fetch(t.Context(), cell.Cell{ID: r.earlier.ID, Root: t.TempDir()}, r.earlierHead); err != nil { + t.Fatal(err) + } + before.frames.Store(0) + before.objects.Store(0) + before.bytes.Store(0) + plainCell := cell.Cell{ID: r.cell.ID, Root: t.TempDir()} + if err := plain.Fetch(t.Context(), plainCell, r.head); err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(filepath.Join(plainCell.Root, "payload")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(original, r.body) || before.frames.Load() != 0 || before.objects.Load() == 0 { + t.Fatal("object-only baseline did not complete the same bytes") + } + t.Logf("object-only cold baseline in used store: frame GETs=%d object POSTs=%d bytes=%d", before.frames.Load(), before.objects.Load(), before.bytes.Load()) + counted := &wantCountingEngine{Engine: r.receiver} + r.fetch.Engine = counted + if err := r.fetch.Fetch(t.Context(), r.cell, r.head); err != nil { + t.Fatal(err) + } + t.Logf("cold chat in used store: frame GETs=%d object POSTs=%d bytes=%d", r.counts.frames.Load(), r.counts.objects.Load(), r.counts.bytes.Load()) + if r.counts.frames.Load() == 0 || r.counts.objects.Load() != 0 { + t.Fatal("new chat skipped its frame plan") + } + if counted.wants.Load() < 2 { + t.Fatal("priming skipped the completeness loop") + } + got, err := os.ReadFile(filepath.Join(r.cell.Root, "payload")) + if err != nil || !bytes.Equal(got, r.body) || !bytes.Equal(got, original) { + t.Fatal("received different bytes", err) + } +} +func TestAWarmChatStillFetchesOnlyItsDelta(t *testing.T) { + r := newUsedStoreRig(t) + if err := r.fetch.Fetch(t.Context(), r.cell, r.head); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(r.source.Root, "small"), []byte("one file changed"), 0600); err != nil { + t.Fatal(err) + } + sealed, err := r.sender.Seal(t.Context(), r.source, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if err := cellstore.Await(t.Context(), r.source); err != nil { + t.Fatal(err) + } + // Reuse the actual published head/ownership, just as the existing writer does. + view, err := r.pub.Dir.Cell(t.Context(), r.source.ID) + if err != nil { + t.Fatal(err) + } + drv := &Driving{Cell: r.source, Fence: view.Cell.Lease.Fence, Head: view.Cell.Head, Frames: view.Cell.Frames} + if err := r.pub.Publish(t.Context(), drv, sealed.Turn.ID, PublishInfo{Class: "chat"}); err != nil { + t.Fatal(err) + } + r.counts.frames.Store(0) + r.counts.objects.Store(0) + r.counts.bytes.Store(0) + if err := r.fetch.Fetch(t.Context(), r.cell, sealed.Turn.ID); err != nil { + t.Fatal(err) + } + t.Logf("warm delta: frame GETs=%d object POSTs=%d bytes=%d", r.counts.frames.Load(), r.counts.objects.Load(), r.counts.bytes.Load()) + if r.counts.frames.Load() != 0 || r.counts.objects.Load() == 0 || r.counts.bytes.Load() >= int64(len(r.body)) { + t.Fatal("warm chat redownloaded its history") + } + got, err := os.ReadFile(filepath.Join(r.cell.Root, "payload")) + if err != nil || !bytes.Equal(got, r.body) { + t.Fatal("warm history changed", err) + } + small, err := os.ReadFile(filepath.Join(r.cell.Root, "small")) + if err != nil || string(small) != "one file changed" { + t.Fatal("delta missing", err) + } +} diff --git a/internal/cellsync/engine.go b/internal/cellsync/engine.go index c4289a14f5..538555219f 100644 --- a/internal/cellsync/engine.go +++ b/internal/cellsync/engine.go @@ -33,9 +33,9 @@ type Engine interface { // reach so far, and leaves every other file, which may be waiting for a // frame that has not landed. A take calls it while frames still download. ImportPartial(ctx context.Context, c cell.Cell, head, inbox string) (int, error) - // Holds reports whether this device already holds objects of c that the - // relay holds too: its ledger for this relay is not empty. A device that - // does has most of the chat's history and wants only a delta (contract + // Holds reports whether this device has already held this chat's history. + // Activity for another chat in a shared store says nothing about this one. + // A warm chat wants only its delta (contract // §22.5), so priming it with the chat's whole frame plan would download // what it has. Holds(c cell.Cell) bool diff --git a/internal/cellsync/fetch.go b/internal/cellsync/fetch.go index d7b3a62b7c..f8ca8efd8a 100644 --- a/internal/cellsync/fetch.go +++ b/internal/cellsync/fetch.go @@ -137,8 +137,7 @@ func (f *Fetcher) prime(ctx context.Context, c cell.Cell, head, inbox string) er } inbox = owned defer os.RemoveAll(inbox) - // A device that already exchanged objects with this relay holds most of the - // chat: the plan names its whole upload history, so priming it would + // A device that already held this chat has most of its history: the plan names its whole upload history, so priming it would // download what it has to learn about a one-file change. The want loop // fetches a warm take's delta by rid. if f.Engine.Holds(c) { diff --git a/internal/cellsync/held.go b/internal/cellsync/held.go index de031f0064..b55be0e626 100644 --- a/internal/cellsync/held.go +++ b/internal/cellsync/held.go @@ -24,7 +24,7 @@ const askFloor = 64 << 10 // fails the export is returned whole, and the puts that follow report the real // error. func (p *Publisher) skipHeld(ctx context.Context, c cell.Cell, ex Export) Export { - if ex.Bytes < askFloor { + if ex.Bytes < askFloor && !p.CheckHeld { return ex } held, err := p.holdings(ctx, ex.Frames) diff --git a/internal/cellsync/l4fix_close_test.go b/internal/cellsync/l4fix_close_test.go new file mode 100644 index 0000000000..f3ccc337f3 --- /dev/null +++ b/internal/cellsync/l4fix_close_test.go @@ -0,0 +1,111 @@ +package cellsync + +import ( + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" + "os" + "path/filepath" + "testing" + "time" +) + +type l4HeldUpload struct { + blobstore.Store + entered, release chan struct{} + err error +} + +func (s *l4HeldUpload) PutFrame(ctx context.Context, raw []byte) (string, error) { + close(s.entered) + <-s.release + return "", s.err +} + +// L4-R3: Close retains a service failure's origin when its budget ends behind an in-flight upload. +func TestL4FixCloseJoinsUploadOrigin(t *testing.T) { + r := newRig(t) + held := &l4HeldUpload{Store: r.mem, entered: make(chan struct{}), release: make(chan struct{}), err: blobstore.ErrUnreachable} + r.pub.Store = held + b := r.batcher() + head := r.seal(map[string]string{"a": "one"}) + b.Note(cellstore.Turn{ID: head}) + run, cancelRun := context.WithCancel(t.Context()) + defer cancelRun() + done := make(chan struct{}) + go func() { defer close(done); _ = b.Run(run) }() + <-held.entered + ctx, cancel := context.WithCancel(t.Context()) + cancel() + closing := make(chan error, 1) + go func() { closing <- b.Close(ctx) }() + close(held.release) + err := <-closing + cancelRun() + <-done + if !syncwords.UploadPending(err) || !errors.Is(err, context.Canceled) { + t.Fatalf("Close lost upload origin: %v", err) + } +} + +// L4-R3: A newer local branch-write failure replaces an earlier failed upload's provenance. +func TestL4FixLocalResolutionReplacesUploadFailure(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"a": "one"}) + newest := r.seal(map[string]string{"a": "two"}) + b := r.batcher() + b.Interval = time.Hour + ticks := make(chan chan struct{}) + resting := make(chan struct{}, 1) + b.Sleep = func(ctx context.Context, d time.Duration) error { + if d != directory.HeartbeatEvery { + select { + case resting <- struct{}{}: + default: + } + <-ctx.Done() + return ctx.Err() + } + step := make(chan struct{}) + select { + case ticks <- step: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-step: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + r.store.set(blobstore.ErrUnreachable, nil) + b.Note(cellstore.Turn{ID: newest}) + run, cancelRun := context.WithCancel(t.Context()) + defer cancelRun() + done := make(chan struct{}) + go func() { defer close(done); _ = b.Run(run) }() + <-resting + r.store.set(nil, nil) + r.takeOver() + blocker := filepath.Join(t.TempDir(), "blocked") + if err := os.WriteFile(blocker, []byte("blocks branch map"), 0600); err != nil { + t.Fatal(err) + } + r.branch.path = filepath.Join(blocker, "branches.json") + step := <-ticks + close(step) + <-ticks // The next interval acknowledges the failed heartbeat and its branch resolution. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := b.Close(ctx) + cancelRun() + <-done + var local *os.PathError + if syncwords.UploadPending(err) || !errors.As(err, &local) { + t.Fatalf("Close reused stale upload provenance: %v", err) + } +} diff --git a/internal/cellsync/publish.go b/internal/cellsync/publish.go index 22b8d294d3..c6857164dd 100644 --- a/internal/cellsync/publish.go +++ b/internal/cellsync/publish.go @@ -10,6 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // ErrSuperseded means another device took the lease while this one was @@ -19,6 +20,10 @@ var ErrSuperseded = fmt.Errorf("cellsync: another device continued this chat: %w // Publisher makes turns durable: objects into the store first, the head in the // directory last. Dir is already bound to this device. type Publisher struct { + // CheckHeld makes even a small independent upload ask which objects the + // relay lacks. Task-only cells use it because they have no directory head + // from which to assume what another sender has already published. + CheckHeld bool // WarmFrameHints opts into the bounded existing-frame prototype. WarmFrameHints bool Engine Engine @@ -83,7 +88,7 @@ func (p *Publisher) publish(ctx context.Context, d *Driving, head string, in Pub frames = unionFrames(d.Frames, frames) fence, err := p.advance(ctx, *d, head, in, frames) if err != nil { - return ex, err + return ex, syncwords.UploadFailure(err) } d.Fence, d.Head, d.Frames = fence, head, frames return ex, nil @@ -147,7 +152,7 @@ func (p *Publisher) put(ctx context.Context, f FrameFile) (string, int64, error) return "", 0, err } id, err := p.Store.PutFrame(ctx, frame) - return id, int64(len(frame)), err + return id, int64(len(frame)), syncwords.UploadFailure(err) } // advance moves the directory head and answers the fence now held. diff --git a/internal/chatlist/collapse_copies_test.go b/internal/chatlist/collapse_copies_test.go new file mode 100644 index 0000000000..fa44ff60b9 --- /dev/null +++ b/internal/chatlist/collapse_copies_test.go @@ -0,0 +1,108 @@ +package chatlist + +import ( + "strings" + "testing" + "time" +) + +func TestCollapseCopiesIntoMainRowAndExplainLoss(t *testing.T) { + rows := []Row{ + { + Cell: "chat-main", + Title: "Refactor router", + Device: "tower", + Status: Running, + Head: "turn-3", + DurableAgo: 2 * time.Minute, + }, + { + Cell: "chat-copy-same", + Title: "Refactor router", + Device: "laptop", + Parent: "chat-main", + Status: Branch, + Head: "turn-3", + OrphanTurns: 1, + DurableAgo: 5 * time.Minute, + }, + { + Cell: "chat-copy-extra", + Title: "Refactor router", + Device: "desk", + Parent: "chat-main", + Status: Branch, + Head: "turn-other", + OrphanTurns: 2, + DurableAgo: 6 * time.Minute, + }, + { + Cell: "orphan-no-parent", + Title: "Standalone branch", + Device: "mac", + Status: Branch, + OrphanTurns: 1, + DurableAgo: 10 * time.Minute, + }, + } + + collapsed, byLocal := CollapseCopies(rows, nil) + if len(byLocal) != 0 { + t.Fatalf("unexpected local copies: %+v", byLocal) + } + if len(collapsed) != 2 { + t.Fatalf("collapsed len = %d, want 2: %+v", len(collapsed), collapsed) + } + main := collapsed[0] + if main.Cell != "chat-main" || len(main.Copies) != 2 { + t.Fatalf("main row copies = %+v, want 2 collapsed copies", main) + } + if !main.Copies[0].Redundant || main.Copies[1].Redundant { + t.Fatalf("copy redundancy = %+v, want [true, false]", main.Copies) + } + + line := CopiesLine(main.Copies) + if !strings.Contains(line, "2 copies") || !strings.Contains(line, "unsent turns set aside") || !strings.Contains(line, "discarding archives 2 turns not in this chat") { + t.Fatalf("non-redundant CopiesLine = %q", line) + } + + onlyRedundant := CopiesLine(main.Copies[:1]) + if !strings.Contains(onlyRedundant, "1 copy") || !strings.Contains(onlyRedundant, "unsent turns set aside") || !strings.Contains(onlyRedundant, "discarding loses nothing") { + t.Fatalf("redundant CopiesLine = %q", onlyRedundant) + } + + takeover := TakeoverLine(main) + if !strings.Contains(takeover, line) { + t.Fatalf("TakeoverLine = %q, want collapsed copy explanation %q", takeover, line) + } +} + +func TestCollapseCopiesAttachesToLocalOnlyChatAndComparesInSame(t *testing.T) { + rows := []Row{ + { + Cell: "branch-1", + Title: "Local main", + Device: "laptop", + Parent: "local-main", + Status: Branch, + OrphanTurns: 3, + BranchRedundant: true, + }, + } + collapsed, byLocal := CollapseCopies(rows, map[string]bool{"local-main": true}) + if len(collapsed) != 0 { + t.Fatalf("collapsed = %+v, want branch folded into local row", collapsed) + } + if got := byLocal["local-main"]; len(got) != 1 || !got[0].Redundant { + t.Fatalf("byLocal = %+v, want 1 redundant copy", byLocal) + } + + a := []Row{{Cell: "c1", Copies: []BranchCopy{{Cell: "b1", OrphanTurns: 1, Redundant: true}}}} + b := []Row{{Cell: "c1", Copies: []BranchCopy{{Cell: "b1", OrphanTurns: 1, Redundant: false}}}} + if Same(a, b) { + t.Fatal("Same ignored Copies difference") + } + if !Same(a, []Row{{Cell: "c1", DurableAgo: time.Minute, Copies: []BranchCopy{{Cell: "b1", OrphanTurns: 1, Redundant: true}}}}) { + t.Fatal("Same rejected identical Copies with different DurableAgo") + } +} diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index f4bacefd6c..4f816b5a80 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" "time" + + "github.com/Agent-Field/codeaf/internal/syncwords" ) // The frozen sentences. Every surface takes its words @@ -18,10 +20,10 @@ const ( ContinueVerb = "Continue here" LostRace = "another device continued this chat first" NoIdentity = "this machine has no identity yet: codeaf identity import" - SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync address" - Unreachable = "other machines unreachable" - ReadTimedOut = "other machines did not answer in time" - SyncPaused = "this chat is not syncing because other machines are unreachable; it keeps working here" + SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync service's address" + Unreachable = "the sync service is not answering" + ReadTimedOut = "the sync service did not answer in time" + SyncPaused = syncwords.UnknownOutage SyncRestored = "this chat is syncing again" ClockOff = "this computer's clock is off by more than 5 minutes" @@ -33,13 +35,13 @@ const ( // cellsync table lists them). None names a code or a number of requests: // each says what is true for the person and what, if anything, to do. relayFull = "your sync space is full%s, so new turns stay on this computer; free space there and reopen this chat" - Removed = "this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back" + Removed = removedChatStart + "run codeaf pair to bring it back" SlowDown = "sync is asking this computer to slow down; new turns stay here and go up as soon as it allows" tooManyNewIn = "too many new identities from this network today — try again in about %s" TooManyNew = "this network has started too many new identities today; sync begins when it allows more" ReplacedGone = "your identity was replaced and sync has deleted the old one; pair this computer again (/pair on a computer that has the new one)" Replaced = "your chats are moving to a new identity; when that is done, pair this computer again (/pair on the computer that moved them)" - runningOn = "running on %s" + heldOn = "held on %s" deviceOff = "%s offline" branchLine = "%s from %s: merge / discard" branchKeep = "%s from %s: discard" @@ -152,7 +154,7 @@ func nameList(names []string) string { func StatusLine(r Row) string { switch r.Status { case Running: - return fmt.Sprintf(runningOn, r.Device) + return fmt.Sprintf(heldOn, r.Device) case Off: return fmt.Sprintf(deviceOff, r.Device) case Branch: @@ -186,15 +188,82 @@ func BranchShort(r Row) string { return fmt.Sprintf(branchShort, turns(r.OrphanT func TakeoverLine(r Row) string { line := fmt.Sprintf(takeoverLine, agoWord(r.DurableAgo)) if r.Mine { - return lastHeldHere + "; " + line + line = lastHeldHere + "; " + line + } else { + if r.Pending > 0 { + line += fmt.Sprintf(takeoverMore, turns(r.Pending), r.Device) + } + if r.Status == Off && r.Device != "" { + line = fmt.Sprintf("%s offline; %s", r.Device, line) + } else { + line = holderLead(r) + line + } + } + if extra := CopiesLine(r.Copies); extra != "" { + line += "; " + extra + } + return line +} + +// CopiesLine summarizes branch copies collapsed into a main chat row: how many +// copies exist, what a copy is, and whether discarding loses anything. Empty +// when there are no copies (the emptiness law). +func CopiesLine(copies []BranchCopy) string { + if len(copies) == 0 { + return "" + } + count := copiesWord(len(copies)) + allRedundant, extraTurns := copiesLoss(copies) + if allRedundant { + return fmt.Sprintf("%s (unsent turns set aside; already in this chat, so discarding loses nothing)", count) } - if r.Pending > 0 { - line += fmt.Sprintf(takeoverMore, turns(r.Pending), r.Device) + if extraTurns > 0 { + return fmt.Sprintf("%s (unsent turns set aside; discarding archives %s not in this chat)", count, turns(extraTurns)) } - if r.Status == Off && r.Device != "" { - return fmt.Sprintf("%s offline; %s", r.Device, line) + return fmt.Sprintf("%s (unsent turns set aside; discarding archives turns not in this chat)", count) +} + +// CopiesShort is the narrow-column spelling for collapsed branch copies. +func CopiesShort(copies []BranchCopy) string { + if len(copies) == 0 { + return "" + } + count := copiesWord(len(copies)) + allRedundant, extraTurns := copiesLoss(copies) + if allRedundant { + return count + " · unsent, loses nothing" + } + if extraTurns > 0 { + return fmt.Sprintf("%s · unsent, %s not here", count, turns(extraTurns)) + } + return count + " · unsent turns" +} + +// CopiesCount is the count alone, for a row whose own state already fills the +// narrow spelling and has room for nothing more about its copies. +func CopiesCount(copies []BranchCopy) string { + if len(copies) == 0 { + return "" } - return holderLead(r) + line + return copiesWord(len(copies)) +} + +func copiesWord(n int) string { + if n == 1 { + return "1 copy" + } + return fmt.Sprintf("%d copies", n) +} + +func copiesLoss(copies []BranchCopy) (allRedundant bool, extraTurns uint32) { + allRedundant = true + for _, c := range copies { + if !c.Redundant { + allRedundant = false + extraTurns += c.OrphanTurns + } + } + return allRedundant, extraTurns } // agoWord spells an age in its largest whole unit, so an hour reads `1h` and @@ -211,13 +280,13 @@ func agoWord(d time.Duration) string { return fmt.Sprintf("%dd", int64(d/(24*time.Hour))) } -// holderLead names the device a live chat runs on, and is empty for a chat -// nobody is running. +// holderLead names the computer holding a live chat; a hold alone says nothing +// about whether work is running there. func holderLead(r Row) string { if r.Status != Running { return "" } - return fmt.Sprintf(runningOn+"; ", r.Device) + return fmt.Sprintf(heldOn+"; ", r.Device) } // KeptEdits is said after a takeover kept local edits as a branch. @@ -314,3 +383,33 @@ func Moved(from string, elapsed time.Duration, hadRunning bool) string { } return line } + +// IsRemovedNotice accepts the wire spelling from earlier engines as well as +// each current reading place, so removal keeps its recovery behavior. +func IsRemovedNotice(text string) bool { + return text == Removed || text == RemovedChat || text == LegacyRemoved +} + +// LegacySyncNotice preserves the vocabulary an older surface recognizes. +func LegacySyncNotice(text string) string { + if text == syncwords.Recovered { + return SyncRestored + } + if IsRemovedNotice(text) { + return LegacyRemoved + } + if syncwords.IsOutage(text) { + return syncwords.LegacyOutage + } + return text +} + +// A removed computer's chat sentence is read in two places. The terminal keeps +// its command; a chat names the slash command that works where it is read. +const ( + removedChatStart = "this device was removed by another of your devices, so this chat stays here only — " + // RemovedChat is the in-chat reading of Removed. + RemovedChat = removedChatStart + "/pair brings it back" + // LegacyRemoved is retained for old surfaces that recognize terminal guidance by text. + LegacyRemoved = removedChatStart + "run `codeaf pair` to bring it back" +) diff --git a/internal/chatlist/devices.go b/internal/chatlist/devices.go index 8f0a4e852f..3024843ca7 100644 --- a/internal/chatlist/devices.go +++ b/internal/chatlist/devices.go @@ -60,11 +60,11 @@ func deviceBefore(a, b Device) bool { // Mark is the dot and name of a device, filled when it is online. A device that // is not online says so in words as well, since a hollow dot alone is easy to // miss on a plain terminal. -func (d Device) Mark(online bool) string { +func (d Device) Mark(online bool, mark string) string { if online || d.Self { - return "● " + d.Name + return mark + " " + d.Name } - return "○ " + d.Name + " " + offlineTag + return mark + " " + d.Name + " " + offlineTag } // VerbFor is the verb the devices row offers for a chat on another device. diff --git a/internal/chatlist/devices_test.go b/internal/chatlist/devices_test.go index 7c36863f4b..01a492d9a1 100644 --- a/internal/chatlist/devices_test.go +++ b/internal/chatlist/devices_test.go @@ -7,7 +7,8 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" ) -func TestDevicesListSelfFirstSkipRevokedAndMarkPresence(t *testing.T) { +// L4-H8b: The caller supplies every mark; chatlist contributes only the words. +func TestL4B2DevicesListSelfFirstSkipRevokedAndMarkPresence(t *testing.T) { l := directory.Listing{Devices: map[string]directory.Device{ "dev_b": {Name: "b64-tower"}, "dev_a": {Name: "b64-desk"}, "dev_me": {Platform: "darwin"}, "dev_gone": {Revoked: true}, @@ -25,8 +26,8 @@ func TestDevicesListSelfFirstSkipRevokedAndMarkPresence(t *testing.T) { if len(got) != 3 || got[0].Name != "This Mac" || got[1].Name != "desk" || got[2].Name != "tower" { t.Fatalf("devices %+v", got) } - if got[0].Mark(false) != "● This Mac" || got[2].Mark(true) != "● tower" || got[1].Mark(false) != "○ desk (offline)" { - t.Fatalf("marks %q %q %q", got[0].Mark(false), got[2].Mark(true), got[1].Mark(false)) + if got[0].Mark(false, "o") != "o This Mac" || got[2].Mark(true, "#") != "# tower" || got[1].Mark(false, "o") != "o desk (offline)" { + t.Fatalf("marks %q %q %q", got[0].Mark(false, "o"), got[2].Mark(true, "#"), got[1].Mark(false, "o")) } } diff --git a/internal/chatlist/l4fix_words_test.go b/internal/chatlist/l4fix_words_test.go new file mode 100644 index 0000000000..2ab730e24c --- /dev/null +++ b/internal/chatlist/l4fix_words_test.go @@ -0,0 +1,29 @@ +package chatlist + +import ( + "github.com/Agent-Field/codeaf/internal/directory" + "strings" + "testing" +) + +// L4-G3: The sync-off command refusal names the service's address. +func TestL4FixSyncOffWords(t *testing.T) { + if SyncOff != "sync is off: set CODEAF_SYNC_URL to your sync service's address" { + t.Fatal(SyncOff) + } +} + +// L4-G4: A live hold alone never claims work is running on the holding computer. +func TestL4FixIdleHolderWords(t *testing.T) { + rows := Rows(listing(map[string]directory.Cell{"c": cell(other, 1_010_000, nil)}), self, plain) + if len(rows) != 1 { + t.Fatal(rows) + } + line := StatusLine(rows[0]) + if strings.Contains(line, "running on") || line != "held on host-a" { + t.Fatal(line) + } + if text := TakeoverLine(rows[0]); strings.Contains(text, "running on") || !strings.Contains(text, "held on host-a") { + t.Fatal(text) + } +} diff --git a/internal/chatlist/offer_test.go b/internal/chatlist/offer_test.go index f94868c2e9..d73a42e3f5 100644 --- a/internal/chatlist/offer_test.go +++ b/internal/chatlist/offer_test.go @@ -26,7 +26,7 @@ func TestOfferCopy(t *testing.T) { func TestStatusLineCopy(t *testing.T) { cases := map[Status]string{ - Running: "running on host-a", + Running: "held on host-a", Off: "host-a offline", Branch: "2 turns from host-a: merge / discard", Idle: "", @@ -54,7 +54,7 @@ func TestTakeoverLineOmitsClauseAtZero(t *testing.T) { // continuing it here stops it there. func TestTakeoverLineNamesTheDeviceARunningChatIsOn(t *testing.T) { r := Row{Status: Running, Device: "host-a", DurableAgo: 12 * time.Second} - if got, want := TakeoverLine(r), "running on host-a; last saved turn 12s ago"; got != want { + if got, want := TakeoverLine(r), "held on host-a; last saved turn 12s ago"; got != want { t.Fatalf("got %q, want %q", got, want) } } @@ -84,11 +84,11 @@ func TestFrozenSentences(t *testing.T) { } for got, want := range map[string]string{ NoIdentity: "this machine has no identity yet: codeaf identity import", - Unreachable: "other machines unreachable", + Unreachable: "the sync service is not answering", ClockOff: "this computer's clock is off by more than 5 minutes", ReplacedGone: "your identity was replaced and sync has deleted the old one; pair this computer again (/pair on a computer that has the new one)", Replaced: "your chats are moving to a new identity; when that is done, pair this computer again (/pair on the computer that moved them)", - Removed: "this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back", + Removed: "this device was removed by another of your devices, so this chat stays here only — run codeaf pair to bring it back", SlowDown: "sync is asking this computer to slow down; new turns stay here and go up as soon as it allows", TooManyNew: "this network has started too many new identities today; sync begins when it allows more", } { diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go index 2143e6d15d..910cffe1d3 100644 --- a/internal/chatlist/rows.go +++ b/internal/chatlist/rows.go @@ -6,6 +6,7 @@ package chatlist import ( "context" + "slices" "sort" "strings" "time" @@ -25,6 +26,16 @@ const ( Branch Status = "branch" // OrphanTurns > 0, not archived ) +// BranchCopy is one unsent branch copy set aside for a chat during a handoff. +// Redundant reports that every turn in this copy is already in the main chat, +// so archiving it loses nothing. +type BranchCopy struct { + Cell string + Device string + OrphanTurns uint32 + Redundant bool +} + // Row is one chat as the list shows it. type Row struct { Cell, Title, Device, DeviceID, Parent string @@ -42,6 +53,14 @@ type Row struct { // Released distinguishes a real release from a revoked or offline holder. Released bool ReleasedAgo time.Duration + // HolderRemoved says the last holder was removed from the person's + // devices: its lease reads as released and it is never offered (#2072). + HolderRemoved bool + // BranchRedundant reports that a branch row's saved head is already in its + // parent chat's turn history, so archiving the branch loses nothing. + BranchRedundant bool + // Copies are branch copies of this chat collapsed onto its single Home row. + Copies []BranchCopy } // Quiet is the row of a chat whose live holder has gone: a lease that still @@ -146,6 +165,8 @@ func rowOf(id string, c directory.Cell, l directory.Listing, self string, open O SavedAt: c.SavedAt, Pending: c.Lease.Pending, OrphanTurns: c.OrphanTurns, + + HolderRemoved: l.Devices[c.Lease.Device].Revoked, } } @@ -160,7 +181,9 @@ func heldStatus(c directory.Cell, l directory.Listing, self string) Status { return st } -// statusOf applies the precedence Branch, Here, Running, Off, Idle. +// statusOf applies the precedence Branch, Here, Running, Off, Idle. A lapsed +// lease of this device's own is free, not off: nobody else holds the chat, so +// there is nothing to continue from and it opens without a question (#2072). func statusOf(c directory.Cell, now int64, self string) Status { switch { case c.OrphanTurns > 0: @@ -169,7 +192,7 @@ func statusOf(c directory.Cell, now int64, self string) Status { return Here case c.Lease.Expires > now: return Running - case c.Lease.Expires != 0: + case c.Lease.Expires != 0 && c.Lease.Device != self: return Off } return Idle @@ -205,3 +228,116 @@ func nameOr(open Opener, sealed, fallback string) string { } return fallback } + +// CollapseCopies folds branch copies with a known parent chat onto that main +// chat's single row (or returns them under byLocal when the main chat is known +// only locally on Home). Standalone branch rows with no parent stay on their +// own row so orphan branches remain visible and actionable. +func CollapseCopies(rows []Row, localIDs map[string]bool) ([]Row, map[string][]BranchCopy) { + if len(rows) == 0 { + return nil, nil + } + byCell := make(map[string]int, len(rows)) + mainIdx := make(map[string]int, len(rows)) + for i, r := range rows { + byCell[r.Cell] = i + if r.Status != Branch { + mainIdx[r.Cell] = i + } + } + rootOf := func(r Row) string { + cur := strings.TrimSpace(r.Parent) + for hops := 0; cur != "" && hops < 16; hops++ { + idx, ok := byCell[cur] + if !ok || rows[idx].Status != Branch { + break + } + next := strings.TrimSpace(rows[idx].Parent) + if next == "" || next == cur { + break + } + cur = next + } + return cur + } + orphanCounts := map[string]int{} + firstBranchIdx := map[string]int{} + for i, r := range rows { + if r.Status != Branch { + continue + } + root := rootOf(r) + if root == "" { + continue + } + if _, ok := mainIdx[root]; ok || localIDs[root] { + continue + } + orphanCounts[root]++ + if _, seen := firstBranchIdx[root]; !seen { + firstBranchIdx[root] = i + } + } + + skip := make([]bool, len(rows)) + attached := make(map[int][]BranchCopy) + var byLocal map[string][]BranchCopy + + for i, r := range rows { + if r.Status != Branch { + continue + } + root := rootOf(r) + if root == "" { + continue + } + if idx, ok := mainIdx[root]; ok { + redundant := r.BranchRedundant || (r.Head != "" && r.Head == rows[idx].Head) + attached[idx] = append(attached[idx], BranchCopy{ + Cell: r.Cell, + Device: r.Device, + OrphanTurns: r.OrphanTurns, + Redundant: redundant, + }) + skip[i] = true + continue + } + if localIDs[root] { + if byLocal == nil { + byLocal = make(map[string][]BranchCopy) + } + byLocal[root] = append(byLocal[root], BranchCopy{ + Cell: r.Cell, + Device: r.Device, + OrphanTurns: r.OrphanTurns, + Redundant: r.BranchRedundant, + }) + skip[i] = true + continue + } + if orphanCounts[root] > 1 { + if idx := firstBranchIdx[root]; idx != i { + redundant := r.BranchRedundant || (r.Head != "" && r.Head == rows[idx].Head) + attached[idx] = append(attached[idx], BranchCopy{ + Cell: r.Cell, + Device: r.Device, + OrphanTurns: r.OrphanTurns, + Redundant: redundant, + }) + skip[i] = true + } + } + } + + out := make([]Row, 0, len(rows)) + for i, r := range rows { + if skip[i] { + continue + } + if more := attached[i]; len(more) > 0 { + r.Copies = append(slices.Clone(r.Copies), more...) + } + out = append(out, r) + } + return out, byLocal +} diff --git a/internal/chatlist/rows_test.go b/internal/chatlist/rows_test.go index cea824bad5..e4f90bfa9b 100644 --- a/internal/chatlist/rows_test.go +++ b/internal/chatlist/rows_test.go @@ -152,3 +152,23 @@ func TestEveryLabelOfADeviceDropsTheLocalSuffix(t *testing.T) { t.Errorf("DeviceName shows %q", got) } } + +// TestThisComputersLapsedChatOpensWithoutAQuestion pins #2072: a chat this +// computer last held, whose lease lapsed with nobody else holding it, has +// nothing to continue from. It reads as free, so it opens without the card. +func TestThisComputersLapsedChatOpensWithoutAQuestion(t *testing.T) { + r := Rows(listing(map[string]directory.Cell{"c": cell(self, 900_000, nil)}), self, plain)[0] + if r.Status != Idle || !r.Mine || OfferFor(r).Kind != Open { + t.Fatalf("this computer's lapsed chat: status %s, offer %+v", r.Status, OfferFor(r)) + } +} + +// TestARowSaysItsHolderWasRemoved pins #2072: a removed device is never named +// in an offer, so the row carries that its holder is gone. +func TestARowSaysItsHolderWasRemoved(t *testing.T) { + l := listing(map[string]directory.Cell{"c": cell(other, 900_000, nil)}) + l.Devices[other] = directory.Device{Name: "host-a", Revoked: true} + if r := Rows(l, self, plain)[0]; !r.HolderRemoved { + t.Fatalf("row of a removed holder: %+v", r) + } +} diff --git a/internal/chatlist/same.go b/internal/chatlist/same.go index daf824bf74..f2adff5ca8 100644 --- a/internal/chatlist/same.go +++ b/internal/chatlist/same.go @@ -1,5 +1,10 @@ package chatlist +import ( + "slices" + "time" +) + // Same reports whether two listings say the same thing about the chats. // // DurableAgo is left out on purpose: it is measured against the directory's @@ -13,15 +18,43 @@ func Same(a, b []Row) bool { return false } for i := range a { - if timeless(a[i]) != timeless(b[i]) { + if timeless(a[i]) != timeless(b[i]) || !slices.Equal(a[i].Copies, b[i].Copies) { return false } } return true } -// timeless is a row with the read-time-dependent age taken out. -func timeless(r Row) Row { - r.DurableAgo = 0 - return r +type timelessRow struct { + cell, title, device, deviceID, parent string + head string + status Status + durableAt int64 + pending, orphanTurns uint32 + fence uint64 + mine, released bool + releasedAgo time.Duration + holderRemoved, branchRedundant bool +} + +// timeless is a row with the read-time-dependent age and slice fields taken out. +func timeless(r Row) timelessRow { + return timelessRow{ + cell: r.Cell, + title: r.Title, + device: r.Device, + deviceID: r.DeviceID, + parent: r.Parent, + head: r.Head, + status: r.Status, + durableAt: r.DurableAt, + pending: r.Pending, + orphanTurns: r.OrphanTurns, + fence: r.Fence, + mine: r.Mine, + released: r.Released, + releasedAgo: r.ReleasedAgo, + holderRemoved: r.HolderRemoved, + branchRedundant: r.BranchRedundant, + } } diff --git a/internal/clusterctl/control.go b/internal/clusterctl/control.go index 9c90a9a600..cf18f45289 100644 --- a/internal/clusterctl/control.go +++ b/internal/clusterctl/control.go @@ -17,11 +17,14 @@ import ( const Path = "/v2/cluster/control" +// NamesHeader opts into optional membership labels without changing older status replies. +const NamesHeader = "Codeaf-Cluster-Names" + var ErrUnavailable = errors.New(cs.LeadershipUnavailable) // ErrUnsupported distinguishes an absent leadership capability from a lost // quorum. Clients can refuse before saving an intent the relay cannot finish. -var ErrUnsupported = errors.New("this relay does not offer cluster leadership yet; your saved chats are still here") +var ErrUnsupported = errors.New("this sync service does not offer cluster leadership yet; your saved chats are still here") type CommittedRefusal struct { Code cs.Code `json:"code"` @@ -33,6 +36,7 @@ func (e CommittedRefusal) Error() string { } type Status struct { + Members []cs.MemberLabel `json:"members,omitempty"` Ready []uint64 `json:"ready,omitempty"` LeadershipUnavailable bool `json:"leadership_unavailable,omitempty"` Cluster string `json:"cluster"` @@ -105,6 +109,10 @@ func Handler(auth wireauth.Authenticate, open func(string) (Backend, error)) htt http.Error(w, cs.LeadershipUnavailable, http.StatusServiceUnavailable) return } + if status, ok := result.(Status); ok && r.Header.Get(NamesHeader) != "1" { + status.Members = nil + result = status + } b, err := cs.Encode(result) if err != nil { http.Error(w, cs.LeadershipUnavailable, 503) @@ -129,6 +137,7 @@ func (c Client) request(ctx context.Context, method string, body []byte, out any if c.Sign == nil { return wireauth.ErrUnauthorized } + req.Header.Set(NamesHeader, "1") c.Sign(req, body) hc := http.Client{Timeout: ForwardTimeout} if c.HTTP != nil { diff --git a/internal/clusterctl/l4_names_test.go b/internal/clusterctl/l4_names_test.go new file mode 100644 index 0000000000..3bb6c527b5 --- /dev/null +++ b/internal/clusterctl/l4_names_test.go @@ -0,0 +1,45 @@ +package clusterctl + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cs "github.com/Agent-Field/codeaf/internal/clusterstate" +) + +type l4NamesBackend struct{} + +func (l4NamesBackend) Status(context.Context, string) (Status, error) { + return Status{Self: 4, Members: []cs.MemberLabel{{Node: 4, Device: "dev_laptop"}}}, nil +} +func (l4NamesBackend) Lead(context.Context, cs.Command) (cs.LeadershipReceipt, error) { + return cs.LeadershipReceipt{}, nil +} + +// L4-M14a: optional name mappings never break an older client's strict status decoder. +func TestL4ClusterNamesAreNegotiated(t *testing.T) { + handler := Handler(func(*http.Request, []byte) (string, string, error) { return "id", "dev", nil }, func(string) (Backend, error) { return l4NamesBackend{}, nil }) + server := httptest.NewServer(handler) + defer server.Close() + response, err := http.Get(server.URL + Path) + if err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), `"members"`) { + t.Fatalf("legacy reply contains optional fields: %s", raw) + } + client := Client{Base: server.URL, Sign: func(*http.Request, []byte) {}} + status, err := client.Status(context.Background()) + if err != nil || len(status.Members) != 1 || status.Members[0].Device != "dev_laptop" { + t.Fatalf("%+v %v", status, err) + } +} diff --git a/internal/clusterstate/personal_host.go b/internal/clusterstate/personal_host.go index 16b8cf7aff..b2c4747dca 100644 --- a/internal/clusterstate/personal_host.go +++ b/internal/clusterstate/personal_host.go @@ -138,9 +138,17 @@ func PlanPersonalAdmission(cluster string, proof V1Request, now int64, read Read return out, nil } +// MemberLabel keeps optional status replies small without copying transport records. +type MemberLabel struct { + Node uint64 `json:"node"` + Device string `json:"device,omitempty"` + Anchor bool `json:"anchor,omitempty"` +} + // PersonalControl is the shared status shape. All counters use the canonical // directory encoding, including fields nested in membership and configuration. type PersonalControl struct { + Members []MemberLabel `json:"members,omitempty"` Ready []uint64 `json:"ready,omitempty"` LeadershipUnavailable bool `json:"leadership_unavailable,omitempty"` Cluster string `json:"cluster"` @@ -166,7 +174,7 @@ func PersonalControlView(cluster, actor, certificate string, conf Configuration, } out.Membership = h for _, peer := range peers { - if peer.Revoked || peer.Voter { + if peer.Revoked { continue } raw, e := read(MemberKey(peer.Node)) @@ -177,6 +185,7 @@ func PersonalControlView(cluster, actor, certificate string, conf Configuration, if Decode(raw, &m) != nil { return out, BadRequest, ErrDependency } + out.Members = append(out.Members, MemberLabel{Node: m.Node, Device: m.Device, Anchor: m.Anchor}) if LearnerReady(m, conf, progress[m.Node], m.ReadyIndex) { out.Ready = append(out.Ready, m.Node) } diff --git a/internal/config/config.go b/internal/config/config.go index f563427804..957b3da6d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -291,6 +291,8 @@ type Config struct { // rather than loaded here, because a config that fetched would make building // a client a network operation. Models *catalog.Catalog + // DropRefusedEffort is a runtime-only task-child option, never a saved setting. + DropRefusedEffort bool `json:"-"` } // ErrNoAPIKey is what [Load] returns when nothing — the two variables, the @@ -1097,6 +1099,7 @@ func WithoutSeatPin(configured provider.Config) provider.Config { func (c Config) ClientConfig(model string) provider.Config { configured := ClientConfigFor(c.Sources.OrDefault(c.APIKey, c.BaseURL), model) configured.Timeout = c.Timeout + configured.DropRefusedEffort = c.DropRefusedEffort // The published answer to "does this model take this field", from rows // already in memory. A nil catalog and a catalog still warming both say // "unknown", which the adapter treats as "send nothing on your own diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index cb85be72e8..52b34cc228 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -115,10 +115,12 @@ func TestEveryRowIsGroupedNamedAndReadable(t *testing.T) { // wave apiece — so what is named here is the thing the reader touches, not the // spelling of the key. var settingReaders = map[string]string{ + KeyTaskRunsOn: "TaskRunsOnAt", KeyKnowledgeModel: "KnowledgeModelAt", KeyChatModelRotation: "ChatModelRotationAt", KeyWork: "workMode", KeyIcons: "iconMode", + KeyTeamsShare: "TeamsShareAt", KeyDailyBudget: "DailyBudgetUSD", KeyPlanConsent: "PlanConsentUSD", KeyDocumentEngine: "DocumentEngine", diff --git a/internal/config/settings.go b/internal/config/settings.go index 2c5c16e989..2c7da65acd 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,6 +1,7 @@ package config import ( + "encoding/hex" "encoding/json" "fmt" "os" @@ -295,6 +296,8 @@ const ( KeyWork = "ui.work" // KeyIcons selects the step icon repertoire independently of colour. KeyIcons = "ui.icons" + // KeyTeamsShare stays local because every computer chooses whether its teams travel. + KeyTeamsShare = "teams.share" // KeyTaskAudit is whether an independent auditor verifies each task node // before its work may merge (internal/session's task_audit.go). It sits // beside the guardian because both spend a model on the person's behalf: @@ -445,6 +448,13 @@ const ( // somewhere quieter. KeyTaskModel = "task.model" + // KeyTaskRunsOn remembers a device rather than its name, so a rename does + // not move the person's default. Blank keeps tasks on this computer. + KeyTaskRunsOn = "task.runs_on" + + // TaskRunsHere is the setting's empty value in the person's words. + TaskRunsHere = "this computer" + // KeyTaskSettle is WHO DECIDES a task that finished but that nobody could // check (internal/session's task_contract.go). It sits under `task.` beside // the audit row because it is the other end of that row's question: the audit @@ -1033,6 +1043,10 @@ var OperatorEnvPins = []string{ // people who build the page; a row offering to hide your real floor behind a // fixture is not a preference, so plumbing. "CODEAF_FACTORY_FIXTURE", + // The desktop's bundled launch marks a trusted local window with + // CODEAF_APP_LOCAL_GUI; the engine consumes it at start. It is launch + // plumbing a person never sets, not a preference. + "CODEAF_APP_LOCAL_GUI", // Factory worker routing and stage posture are launch plumbing, not // editable profile preferences. "CODEAF_FACTORY_BENCHES", @@ -1139,6 +1153,19 @@ var OperatorEnvPins = []string{ // line. Plumbing rather than a row for the plainest reason there is: a row // would persist a preference this binary cannot act on. "CODEAF_INSTALL_NAME", + // The dev box's one-time setup values (internal/devbox). They are service + // variables Terraform hands the box's boot script for its first join, and + // codeaf never reads them from its own environment: Go spells them only to + // find them in the Terraform state and remove them once the box answers. A + // row would persist a pairing code and a private key, so plumbing. + "CODEAF_DEVBOX_CODE", + "CODEAF_DEVBOX_REMOTE_KEY", + "CODEAF_DEVBOX_REMOTE_DEVICES", + // CODEAF_SERVE_SYNC asks `codeaf serve` to keep the identity's saved keys, + // settings and presence in step (internal/syncsetup). A dev box's boot + // script sets it for the one process it starts; it is one launch's posture + // rather than a preference, so plumbing, like CODEAF_NO_AUTOMATIONS. + "CODEAF_SERVE_SYNC", // The two pins on the model-call log (internal/calllog). CODEAF_CALL_LOG // switches it off or moves the file; CODEAF_CALL_LOG_BODIES adds the whole // request and response to every line. Plumbing rather than settings rows, @@ -1784,6 +1811,9 @@ type SettingGroup struct { // own: the running model slots and the surface's own divider preference. type SettingsOptions struct { ProfileDir string + // Include omits controls unavailable to the caller from discovery and writes. + // Nil keeps the full registry for callers that supply their own presentation. + Include func(key string) bool // ModelValue and SetModel front the model slots. They stay in the chat // prefs file; the registry does not migrate them. @@ -1837,9 +1867,23 @@ type Settings struct { // divider has nowhere to be written without [SettingsOptions.SaveSplitPct], and // a read-only divider is not a row somebody can read — it is a control whose // only behaviour is to refuse. +// +// [SettingsOptions.Include] is the caller's own narrowing, applied after the +// build: a control the caller cannot offer at all (Run tasks on, in a +// conversation with no task computers) is left out of discovery and writes +// alike, rather than listed and refused. func NewSettings(options SettingsOptions) *Settings { registry := &Settings{options: options} registry.rows = registry.build() + if options.Include != nil { + rows := registry.rows[:0] + for _, row := range registry.rows { + if options.Include(row.Key) { + rows = append(rows, row) + } + } + registry.rows = rows + } for index := range registry.rows { registry.rows[index].write = registry.announce(registry.rows[index].Key, registry.rows[index].write) } @@ -2266,6 +2310,18 @@ func (s *Settings) build() []Setting { read: func() string { return IconsAt(dir) }, write: func(raw string) error { return writeChoice(dir, KeyIcons, raw, IconModes) }, }, + Setting{ + Key: KeyTeamsShare, Category: CategoryInterface, Kind: SettingChoice, + Label: "share teams", Choices: teamsShareChoices, + Hint: "Share teams, their members and settings, and recent Traffic as read-only history; off until turned on, and both computers must turn it on (pairing required).", + read: func() string { + if TeamsShareAt(dir) { + return "on" + } + return "off" + }, + write: func(raw string) error { return writeChoice(dir, KeyTeamsShare, raw, teamsShareChoices) }, + }, // Before the audit row, because it comes first in the life of a task: this // says what STARTS when you type /task, the audit row says what has to be // true before what started is allowed to land. @@ -2402,6 +2458,13 @@ func (s *Settings) build() []Setting { // how long you get to redirect it, who checks it, and whose hands it is // in — and none of them is a rule about how this conversation's own calls // are made. + Setting{ + Key: KeyTaskRunsOn, Category: CategoryTasks, Kind: SettingText, + Label: "Run tasks on", EmptyLabel: TaskRunsHere, + Hint: "The paired computer for tasks that work in their own copy. Leave it blank for " + TaskRunsHere + ".", + read: func() string { return TaskRunsOnAt(dir) }, + write: func(raw string) error { return WriteTaskRunsOn(dir, raw) }, + }, Setting{ Key: KeyTaskModel, Category: CategoryTasks, Kind: SettingText, Label: "task model", EmptyLabel: "the crew's worker", @@ -4311,6 +4374,27 @@ func TaskModelAt(profileDir string) string { return "" } +// TaskRunsOnAt reads the stable placement preference. A removed device is +// resolved to this computer by the session against its current roster. +func TaskRunsOnAt(profileDir string) string { + value, _ := persistedString(profileDir, KeyTaskRunsOn) + return strings.TrimSpace(value) +} + +// WriteTaskRunsOn accepts only a device identity. The computer picker resolves +// shown names before reaching this writer, keeping renames harmless. +func WriteTaskRunsOn(profileDir, raw string) error { + value := strings.TrimSpace(raw) + if value != "" { + hexID, ok := strings.CutPrefix(value, "dev_") + _, err := hex.DecodeString(hexID) + if !ok || len(hexID) != 32 || err != nil { + return fmt.Errorf("choose a paired computer or %s", TaskRunsHere) + } + } + return writeText(profileDir, KeyTaskRunsOn, value) +} + // ConsentTimeoutAt resolves the approval countdown, in seconds. 0 is a clock // that is off: the question waits from the start. A positive number is how // long the reminder runs before the card pauses; it never answers no. diff --git a/internal/config/settings_presentation.go b/internal/config/settings_presentation.go index 4a38ebc339..46bf026bc9 100644 --- a/internal/config/settings_presentation.go +++ b/internal/config/settings_presentation.go @@ -56,6 +56,9 @@ func (s Setting) ChatPresentation() SettingPresentation { p.Description = "Collapsed keeps completed tool steps behind one summary. Expanded keeps those details visible." case KeyIcons: p.Label = "tool icons" + case KeyTeamsShare: + p.Scope = "This computer" + p.Activation = "Applies immediately" case KeyTaskColumn: p.Label = "task sidebar" case KeyQuickSwitch: diff --git a/internal/config/task_runs_on_test.go b/internal/config/task_runs_on_test.go new file mode 100644 index 0000000000..55ee2368aa --- /dev/null +++ b/internal/config/task_runs_on_test.go @@ -0,0 +1,32 @@ +package config + +import "testing" + +// Contract 7 keeps placement stable when a paired computer is renamed. +func TestTaskRunsOnKeepsADeviceIDAndDefaultsToThisComputer(t *testing.T) { + dir := t.TempDir() + if got := TaskRunsOnAt(dir); got != "" { + t.Fatalf("default = %q", got) + } + settings := NewSettings(SettingsOptions{ProfileDir: dir}) + row, ok := settings.Row(KeyTaskRunsOn) + if !ok { + t.Fatal("missing Run tasks on setting") + } + if err := row.Apply("work computer"); err == nil { + t.Fatal("accepted a name instead of a device id") + } + const device = "dev_0123456789abcdef0123456789abcdef" + if err := row.Apply(device); err != nil { + t.Fatal(err) + } + if got := TaskRunsOnAt(dir); got != device { + t.Fatalf("saved = %q", got) + } + if err := row.Apply(""); err != nil { + t.Fatal(err) + } + if got := TaskRunsOnAt(dir); got != "" { + t.Fatalf("here = %q", got) + } +} diff --git a/internal/config/teams_share.go b/internal/config/teams_share.go new file mode 100644 index 0000000000..368965c7c2 --- /dev/null +++ b/internal/config/teams_share.go @@ -0,0 +1,11 @@ +package config + +var teamsShareChoices = []string{"off", "on"} + +// TeamsShareAt reads the local opt-in at use time, so a running round or chat +// stops sharing as soon as this computer turns it off. Missing or damaged +// settings leave the existing local-only behavior in place. +func TeamsShareAt(profileDir string) bool { + value, _ := persistedString(profileDir, KeyTeamsShare) + return value == "on" +} diff --git a/internal/config/teams_share_test.go b/internal/config/teams_share_test.go new file mode 100644 index 0000000000..be0bacafef --- /dev/null +++ b/internal/config/teams_share_test.go @@ -0,0 +1,47 @@ +package config + +import "testing" + +func TestTeamSharingIsLocalOffByDefaultAndChangesImmediately(t *testing.T) { + dir := t.TempDir() + rows := registry(t, dir) + row, ok := rows.Row("teams.share") + if !ok { + t.Fatal("missing team sharing setting") + } + if row.Value() != "off" { + t.Fatal("team sharing must start off") + } + presentation := row.ChatPresentation() + if row.Category != CategoryInterface || presentation.Category != "General" || presentation.Scope != "This computer" || presentation.Activation != "Applies immediately" { + t.Fatal("team sharing must be a local General setting", row.Category, presentation) + } + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if row.Value() != "on" { + t.Fatal("setting did not turn on") + } + preferences, err := ReadPreferences(dir) + if err != nil { + t.Fatal(err) + } + if _, ok := preferences[row.Key]; ok || PreferenceKey(row.Key) { + t.Fatal("team sharing must stay local") + } + if err := ApplyPreferences(dir, map[string]string{row.Key: `"off"`}); err != nil { + t.Fatal(err) + } + if row.Value() != "on" { + t.Fatal("remote preferences changed the local switch") + } + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + if row.Value() != "off" { + t.Fatal("setting did not turn off") + } + if err := row.Apply("maybe"); err == nil { + t.Fatal("invalid choice accepted") + } +} diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index ca80dfc831..b3e414d4ad 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -101,11 +101,13 @@ task.min_free_mb task.model task.parallel task.repair_rounds +task.runs_on task.settle task.start teams.cap_usd_day teams.depth_limit teams.questions_up +teams.share teams.sub_share_pct teams.wake telemetry diff --git a/internal/devbox/applies_test.go b/internal/devbox/applies_test.go new file mode 100644 index 0000000000..a42d5c2ddd --- /dev/null +++ b/internal/devbox/applies_test.go @@ -0,0 +1,379 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" +) + +// R6.1: the non-secret install version is present and unchanged through all three phases. +// R5.4: the clear is a third guarded apply with no second confirmation. +// R3.1: no offer or secrets exist until the confirmed first apply has returned. +// R3.2: the second apply follows the settle wait and a variables-only guard. +func TestDevboxR31R32R54R61ThreeAppliesSeparateCreationPairingAndClear(t *testing.T) { + t.Setenv("TF_VAR_pair_code", "inherited-code") + t.Setenv("TF_VAR_remote_key", "inherited-key") + t.Setenv("TF_VAR_remote_devices", "inherited-devices") + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Interactive = true + f.In = strings.NewReader("y\n") + var offered atomic.Bool + firstApplied, settled := false, false + joined := make(chan struct{}) + var order []string + const version = "dev-20261030-aaaaaaaaaaaa" + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + order = append(order, args[0]) + raw, err := os.ReadFile(filepath.Join(dir, "codeaf.auto.tfvars.json")) + if err != nil { + t.Fatal(err) + } + var vars map[string]string + if err := json.Unmarshal(raw, &vars); err != nil { + t.Fatal(err) + } + if vars["install_version"] != version { + t.Fatalf("R6.1 version during %s = %q, want %q", args[0], vars["install_version"], version) + } + if !firstApplied { + if offered.Load() { + t.Error("offer started before first apply returned") + } + for _, entry := range env { + if strings.HasPrefix(entry, "TF_VAR_") { + t.Errorf("phase A secret environment: %s", strings.SplitN(entry, "=", 2)[0]) + } + } + if vars["via"] != "" { + t.Error("phase A has a relay") + } + for _, key := range []string{"pair_code", "remote_key", "remote_devices"} { + if _, found := vars[key]; found { + t.Errorf("phase A contains %s", key) + } + } + } else if args[0] == "plan" && !settled { + t.Error("second plan before settle") + } + err = RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && err == nil { + if !firstApplied { + firstApplied = true + } else if slices.Contains(env, "TF_VAR_pair_code=42-123-456") { + close(joined) + } + } + return err + } + f.Settle = func(_ context.Context, token, service, environment string) error { + if !firstApplied || offered.Load() || token != "fixture-token" || service != "service" || environment != "environment" { + t.Error("wrong settle order or inputs") + } + order = append(order, "settle") + settled = true + return nil + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + offered.Store(true) + if !settled { + t.Error("offer before settle") + } + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-joined: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Version: version}); err != nil { + t.Fatal(err) + } + if got := strings.Join(order, ","); got != "init,plan,apply,output,settle,plan,show,apply,plan,show,apply" { + t.Fatalf("order: %s", got) + } + if strings.Count(out.String(), "Create these on your Railway account? [y/N]") != 1 { + t.Fatalf("confirmation: %s", &out) + } + if strings.Contains(out.String(), "show-only-sensitive-value") { + t.Fatal("plan JSON streamed") + } + if _, err := os.Stat(filepath.Join(dir, "terraform.tfstate.backup")); !os.IsNotExist(err) { + t.Fatal("R5.4 backup remains") + } + for _, name := range []string{"terraform.tfstate"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("state is not private: %s %v", name, err) + } + } +} + +// R3.2: only the variable collection may change; unsafe or unreadable plans never apply or leak JSON. +func TestDevboxR32GuardRefusesOtherResourcesAndDiscardsSensitiveJSON(t *testing.T) { + for _, mode := range []string{"service", "project", "replacement", "read", "malformed", "show-failed"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + address, actions := "railway_service.box", `["update"]` + if mode == "project" { + address = "railway_project.box" + } + if mode == "replacement" { + actions = `["delete","create"]` + } + if mode == "read" { + actions = `["read"]` + } + raw := `{"resource_changes":[{"address":"railway_variable_collection.box","change":{"actions":["update"],"after":{"secret":"show-only-sensitive-value"}}},{"address":"` + address + `","change":{"actions":` + actions + `,"after":{"secret":"show-only-sensitive-value"}}}]}` + if mode == "malformed" { + raw = `{"resource_changes":["show-only-sensitive-value` + } + t.Setenv("GUARD_JSON", raw) + if mode == "show-failed" { + t.Setenv("FAIL_ACTION", "show") + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + <-ctx.Done() + return ctx.Err() + } + err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}) + if err == nil { + t.Fatal("guard accepted unsafe plan") + } + all := out.String() + err.Error() + for _, secret := range []string{"fixture-token", "42-123-456", "fixture-remote-key", "fixture-remote-devices", "show-only-sensitive-value"} { + if strings.Contains(all, secret) { + t.Errorf("leaked %s", secret) + } + } + if mode != "malformed" && mode != "show-failed" && !strings.Contains(err.Error(), address) { + t.Fatalf("missing refused address: %v", err) + } + calls, _ := os.ReadFile(filepath.Join(dir, "calls")) + if strings.Count(string(calls), "apply -input=false") != 1 { + t.Fatalf("applied guarded plan: %s", calls) + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) != 0 { + t.Fatal("sensitive plan retained") + } + }) + } +} + +// R5.3: remote credentials never change across retries; only the pairing code is fresh. +// R3.3: interrupted or expired joins get at most three fresh codes; other errors end immediately. +// R3.4: reaching the settle bound still proceeds with a single notice. +func TestDevboxR33R34R53FreshCodesReuseRemoteCredentials(t *testing.T) { + for _, mode := range []string{"joined-on-second", "three-interruptions", "expired", "refused", "cancelled"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Settle = func(context.Context, string, string, string) error { return errors.New("wait ended") } + var attempts atomic.Int32 + prepared := 0 + f.Prepare = func(context.Context, string) (Offer, error) { + prepared++ + if attempts.Load() != 0 { + t.Error("R5.3 prepared remote files after the first code") + } + return Offer{Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}, nil + } + var joined atomic.Bool + f.Probe = func(context.Context, string) bool { return joined.Load() } + applied := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if args[0] == "plan" && attempts.Load() > 0 && !joined.Load() { + for key, want := range map[string]string{"TF_VAR_pair_code": fmt.Sprintf("fixture-TF_VAR_pair_code-%d", attempts.Load()), "TF_VAR_remote_key": "fixture-remote-key", "TF_VAR_remote_devices": "fixture-remote-devices"} { + if !slices.Contains(env, key+"="+want) { + t.Errorf("R5.3 incorrect %s across retries", key) + } + } + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && attempts.Load() > 0 && !joined.Load() { + applied <- struct{}{} + } + return err + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + n := attempts.Add(1) + ready(Offer{Code: fmt.Sprintf("fixture-TF_VAR_pair_code-%d", n), Via: "https://relay.example", RemoteKey: "", RemoteDevices: ""}) + select { + case <-applied: + case <-ctx.Done(): + return ctx.Err() + } + if mode == "joined-on-second" && n == 2 { + joined.Store(true) + return nil + } + if mode == "refused" { + return errors.New("refused on the other device") + } + if mode == "cancelled" { + cancel() + return context.Canceled + } + if mode == "expired" { + return context.DeadlineExceeded + } + return ErrJoinInterrupted + } + err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: true}) + wantAttempts := int32(3) + if mode == "joined-on-second" { + wantAttempts = 2 + } else if mode == "refused" || mode == "cancelled" { + wantAttempts = 1 + } + if prepared != 1 { + t.Fatalf("R5.3 prepared remote files %d times", prepared) + } + if attempts.Load() != wantAttempts { + t.Fatalf("attempts %d, want %d (error %v)", attempts.Load(), wantAttempts, err) + } + if (err == nil) != (mode == "joined-on-second") { + t.Fatalf("result: %v", err) + } + if err != nil { + reason := "joining was interrupted or the pairing code expired" + if mode == "refused" { + reason = "refused on the other device" + } + if mode == "cancelled" { + reason = "context canceled" + } + want := "box did not join: " + reason + ". It is on your Railway account; remove it with `codeaf devbox down box --dir " + shellQuote(dir) + "`, then run up again." + if err.Error() != want { + t.Fatalf("failure: %v", err) + } + } + if strings.Count(out.String(), "Railway did not settle") != 1 { + t.Fatalf("settle notice: %s", &out) + } + for n := int32(2); n <= wantAttempts; n++ { + if !strings.Contains(out.String(), fmt.Sprintf("box did not finish joining; sending a new code (%d of 3).", n)) { + t.Fatalf("retry receipt: %s", &out) + } + } + if mode != "joined-on-second" && strings.Contains(out.String(), "box is up") { + t.Fatal("claimed join") + } + if strings.Contains(out.String(), "fixture-TF_VAR") { + t.Fatal("fresh secret leaked") + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) != 0 { + t.Fatal("sensitive plan retained") + } + }) + } +} + +// R5.4/R3.6: all three plans filter piped output and keep correct flags. +func TestDevboxR36R54TerraformOutputAndFlags(t *testing.T) { + for _, terminal := range []bool{false, true} { + t.Run(fmt.Sprint(terminal), func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.StdoutTerminal = terminal + applied := make(chan struct{}) + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-applied: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + var commands []string + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + commands = append(commands, args[0]) + if slices.Contains(args, "-no-color") == terminal || !terminal && (len(args) < 2 || args[1] != "-no-color") { + t.Errorf("terminal %v: %v", terminal, args) + } + if args[0] == "plan" { + for _, chunk := range []string{"\x1b[32mPlan: 3 to add.\x1b[0m\nTo perform exactly these actions, run the fol", "lowing command to apply:\n terraform apply \"", "private-plan-name\"\n", "Next real line\n"} { + _, _ = io.WriteString(stdout, chunk) + } + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && slices.Contains(env, "TF_VAR_pair_code=42-123-456") { + close(applied) + } + return err + } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"To perform exactly", "terraform apply", "private-plan-name"} { + if strings.Contains(out.String(), forbidden) { + t.Errorf("unfiltered %q: %s", forbidden, &out) + } + } + if !terminal && strings.Contains(out.String(), "\x1b") { + t.Fatal("piped ANSI output") + } + if strings.Count(out.String(), "Next real line") != 3 { + t.Fatal("filter lost real output") + } + f.Out = &out + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + if !slices.Contains(commands, "output") || !slices.Contains(commands, "destroy") { + t.Fatal("missed command flags") + } + }) + } +} + +// R3.2: a failed Terraform write still makes both state files owner-only. +func TestDevboxR32FailedTerraformWriteProtectsState(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + f.RunTerraform = func(_ context.Context, _, dir string, _, _ []string, _, _ io.Writer) error { + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("sensitive state"), 0o644); err != nil { + t.Fatal(err) + } + } + return errors.New("write failed") + } + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}); err == nil { + t.Fatal("failure hidden") + } + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("state permissions: %s %v", name, err) + } + } +} diff --git a/internal/devbox/boot_test.go b/internal/devbox/boot_test.go new file mode 100644 index 0000000000..533726ac17 --- /dev/null +++ b/internal/devbox/boot_test.go @@ -0,0 +1,437 @@ +package devbox + +import ( + "context" + "encoding/base64" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// R6.2: the first install pins its release, retries unpinned, and later boots follow the channel. +func TestDevboxR62BootInstallVersion(t *testing.T) { + const version = "dev-20261030-aaaaaaaaaaaa" + for _, row := range []struct { + name, version, failure, want string + existing bool + backoff bool + }{ + {name: "first-install-pins", version: version, want: version + "\n"}, + {name: "failed-pin-retries-unpinned", version: version, failure: "pinned", want: version + "\n\n"}, + {name: "failed-both-back-off-once", version: version, failure: "all", want: version + "\n\n", backoff: true}, + {name: "existing-binary-refreshes-unpinned", version: version, existing: true, want: "\n"}, + {name: "failed-refresh-keeps-earlier-binary", version: version, existing: true, failure: "all", want: "\n"}, + {name: "no-version-installs-unpinned", want: "\n"}, + {name: "no-version-failure-backs-off", failure: "all", want: "\n", backoff: true}, + } { + t.Run(row.name, func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "fake-bin") + if err := os.Mkdir(bin, 0o700); err != nil { + t.Fatal(err) + } + for name, script := range map[string]string{ + "curl": `#!/bin/bash +cat <<'INSTALLER' +set -eu +printf '%s\n' "${VERSION-}" >> "$CODEAF_DEVBOX_DATA_ROOT/versions" +if [[ "$INSTALL_FAILURE" == all || ( "$INSTALL_FAILURE" == pinned && -n "${VERSION:-}" ) ]]; then exit 22; fi +mkdir -p "$CODEAF_INSTALL_DIR" +cp "$CODEAF_DEVBOX_DATA_ROOT/fake-bin/codeaf" "$CODEAF_INSTALL_DIR/$CODEAF_INSTALL_NAME" +INSTALLER +`, + "sleep": "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/sleeps\"\n", + "codeaf": "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/calls\"\n", + } { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + if row.existing { + installed := filepath.Join(data, "home", ".codeaf", "bin") + if err := os.MkdirAll(installed, 0o700); err != nil { + t.Fatal(err) + } + fake, err := os.ReadFile(filepath.Join(bin, "codeaf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "codeaf"), fake, 0o700); err != nil { + t.Fatal(err) + } + } + raw, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash") + cmd.Stdin = strings.NewReader(string(raw)) + cmd.Env = append(os.Environ(), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "PATH="+bin+":"+os.Getenv("PATH"), "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_CODE=42-123-456", "CODEAF_DEVBOX_NAME=box", "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_VERSION="+row.version, "VERSION=stray-environment-version", "INSTALL_FAILURE="+row.failure, "CODEAF_DEVBOX_REMOTE_KEY=", "CODEAF_DEVBOX_REMOTE_DEVICES=") + if row.version == "" { + cmd.Env = slices.DeleteFunc(cmd.Env, func(entry string) bool { return strings.HasPrefix(entry, "CODEAF_DEVBOX_VERSION=") }) + } + out, err := cmd.CombinedOutput() + versions, readErr := os.ReadFile(filepath.Join(data, "versions")) + if readErr != nil || string(versions) != row.want { + t.Fatalf("R6.2 installer versions = %q (%v), want %q; boot: %v %s", versions, readErr, row.want, err, out) + } + sleeps, _ := os.ReadFile(filepath.Join(data, "sleeps")) + calls, _ := os.ReadFile(filepath.Join(data, "calls")) + if row.backoff { + if err == nil || string(sleeps) != "60\n" || len(calls) != 0 || !strings.Contains(string(out), "installation failed and no earlier binary is available") { + t.Fatalf("R6.2 backoff: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + } else if err != nil || len(sleeps) != 0 || !strings.Contains(string(calls), "serve --workspace "+filepath.Join(data, "home", "work")) { + t.Fatalf("R6.2 boot: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + }) + } +} + +// Contracts 3 and 4: the real boot script pairs once, persists HOME and serves through the same relay. +func TestDevboxR35BootPairsOnceAcrossRestarts(t *testing.T) { + for _, firstFailure := range []bool{false, true} { + t.Run(map[bool]string{true: "retry", false: "restart"}[firstFailure], func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "home", ".codeaf", "bin") + if err := os.MkdirAll(bin, 0o700); err != nil { + t.Fatal(err) + } + fake := `#!/bin/bash +set -eu +printf '%s|%s\n' "$HOME" "$*" >> "$CODEAF_DEVBOX_DATA_ROOT/calls" +if [[ "$1" == pair ]]; then + [[ "$2" == 42-123-456 && "$3" == --name && "$4" == box && "$5" == --via && "$6" == https://relay.example ]] + [[ -z "${FAIL_PAIR:-}" ]] +else + [[ -z "${CODEAF_DEVBOX_CODE+x}" && -z "${CODEAF_DEVBOX_REMOTE_KEY+x}" && -z "${CODEAF_DEVBOX_REMOTE_DEVICES+x}" ]] + cmp "$CODEAF_HOME/v3/remote/device.key" "$CODEAF_DEVBOX_DATA_ROOT/expected-key" + cmp "$CODEAF_HOME/v3/remote/devices.json" "$CODEAF_DEVBOX_DATA_ROOT/expected-devices" + [[ "$1" == serve && "$2" == --workspace && "$3" == "$HOME/work" && "$4" == --via && "$5" == https://relay.example ]] +fi +` + if err := os.WriteFile(filepath.Join(bin, "codeaf"), []byte(fake), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "sleep"), []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/sleeps\"\n"), 0o700); err != nil { + t.Fatal(err) + } + // A failed refresh must fall back to the existing binary on the volume. + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte("#!/bin/sh\nexit 22\n"), 0o700); err != nil { + t.Fatal(err) + } + script, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + run := func(code string, fail bool) (string, error) { + cmd := exec.Command("bash") + cmd.Stdin = strings.NewReader(string(script)) + cmd.Env = append(os.Environ(), "HOME="+filepath.Join(data, "home"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_NAME=box", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_CODE="+code, "FAIL_PAIR="+map[bool]string{true: "1", false: ""}[fail]) + out, err := cmd.CombinedOutput() + return string(out), err + } + key := "key file bytes\n" + devices := "admission file bytes\n" + for name, raw := range map[string]string{"expected-key": key, "expected-devices": devices} { + if err := os.WriteFile(filepath.Join(data, name), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", base64.StdEncoding.EncodeToString([]byte(key))) + t.Setenv("CODEAF_DEVBOX_REMOTE_DEVICES", base64.StdEncoding.EncodeToString([]byte(devices))) + marker := filepath.Join(data, ".codeaf-devbox-joined") + if firstFailure { + if out, err := run("42-123-456", true); err != nil { + t.Fatalf("failed join did not wait: %v %s", err, out) + } + if sleeps, _ := os.ReadFile(filepath.Join(data, "sleeps")); string(sleeps) != "infinity\n" { + t.Fatalf("R3.5 failed pair did not sleep: %q", sleeps) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("failed join wrote marker") + } + } + if out, err := run("42-123-456", false); err != nil { + t.Fatalf("boot: %v %s", err, out) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal(err) + } + // Each release channel syncs through its own relay, so the box must + // sync through the one the laptop paired it through, whatever its + // installed build's own default is. + savedRelay := filepath.Join(data, "home", ".codeaf", syncsetup.SavedURLFile) + if raw, err := os.ReadFile(savedRelay); err != nil || string(raw) != "https://relay.example\n" { + t.Fatalf("the box's saved relay = %q (%v); want the laptop's relay", raw, err) + } + remote := filepath.Join(data, "home", ".codeaf", "v3", "remote") + for path, mode := range map[string]os.FileMode{remote: 0o700, filepath.Join(remote, "device.key"): 0o600, filepath.Join(remote, "devices.json"): 0o600} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != mode { + t.Fatalf("%s mode = %o, want %o", path, info.Mode().Perm(), mode) + } + } + // Later admissions and revocations must survive restarts, even with stale service variables. + devices = "changed admissions\n" + if err := os.WriteFile(filepath.Join(data, "home", ".codeaf", "v3", "remote", "devices.json"), []byte(devices), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, "expected-devices"), []byte(devices), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", base64.StdEncoding.EncodeToString([]byte("different key"))) + // A relay a later pairing saved belongs to the volume, like its admissions. + if err := os.WriteFile(savedRelay, []byte("https://later.example\n"), 0o600); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(data, "calls")) + if out, err := run("", false); err != nil { + t.Fatalf("restart: %v %s", err, out) + } + after, _ := os.ReadFile(filepath.Join(data, "calls")) + added := strings.TrimPrefix(string(after), string(before)) + if strings.Contains(added, "pair") || !strings.Contains(added, "serve --workspace "+filepath.Join(data, "home", "work")) { + t.Fatalf("restart: %s", added) + } + if raw, _ := os.ReadFile(savedRelay); string(raw) != "https://later.example\n" { + t.Fatalf("a restart replaced the saved relay with %q", raw) + } + // An existing admission book is retained even when the key alone needs seeding. + if err := os.Remove(filepath.Join(data, "home", ".codeaf", "v3", "remote", "device.key")); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", base64.StdEncoding.EncodeToString([]byte(key))) + if out, err := run("", false); err != nil { + t.Fatalf("key-only seed: %v %s", err, out) + } + t.Setenv("CODEAF_DEVBOX_REMOTE_KEY", "") + t.Setenv("CODEAF_DEVBOX_REMOTE_DEVICES", "") + if out, err := run("", false); err != nil { + t.Fatalf("unseeded restart: %v %s", err, out) + } + }) + } +} + +// R3.5: a missing code waits before downloading, a failed pair waits, and an install failure backs off. +func TestDevboxR35BootWaitsWithoutCodeAndBacksOffFailedInstall(t *testing.T) { + for _, mode := range []string{"no-code", "install-failure"} { + t.Run(mode, func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "fake-bin") + if err := os.Mkdir(bin, 0o700); err != nil { + t.Fatal(err) + } + for name, script := range map[string]string{ + "curl": "#!/bin/sh\necho download >> \"$CODEAF_DEVBOX_DATA_ROOT/calls\"\nexit 22\n", + "sleep": "#!/bin/sh\necho \"$*\" >> \"$CODEAF_DEVBOX_DATA_ROOT/sleeps\"\n", + } { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + raw, _ := module.ReadFile("terraform/boot.sh") + cmd := exec.Command("bash") + cmd.Stdin = strings.NewReader(string(raw)) + cmd.Env = append(os.Environ(), "HOME="+filepath.Join(data, "home"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "PATH="+bin+":"+os.Getenv("PATH"), "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_CODE=", "CODEAF_DEVBOX_BINARY=", "CODEAF_DEVBOX_INSTALL_URL=") + if mode == "install-failure" { + cmd.Env = append(cmd.Env, "CODEAF_DEVBOX_CODE=42-123-456", "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf") + } + out, err := cmd.CombinedOutput() + sleeps, _ := os.ReadFile(filepath.Join(data, "sleeps")) + calls, _ := os.ReadFile(filepath.Join(data, "calls")) + if mode == "no-code" { + if err != nil || string(sleeps) != "infinity\n" || len(calls) != 0 || string(out) != "codeaf devbox: waiting for a pairing code from codeaf devbox up\n" { + t.Fatalf("wait: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + } else if err == nil || string(sleeps) != "60\n" || string(calls) != "download\n" { + t.Fatalf("backoff: %v, output %q, sleeps %q, calls %q", err, out, sleeps, calls) + } + for _, path := range []string{".codeaf-devbox-joined", "home/.codeaf/v3/remote/device.key"} { + if _, err := os.Stat(filepath.Join(data, path)); !os.IsNotExist(err) { + t.Fatalf("created %s", path) + } + } + }) + } +} + +// R5.5: each absent seed is atomic and independent; existing admissions belong to the volume. +func TestDevboxR55BootSeedsIndependentlyAndAtomically(t *testing.T) { + for _, mode := range []string{"key-present", "book-present", "bad-key", "bad-book"} { + t.Run(mode, func(t *testing.T) { + data := t.TempDir() + remote := filepath.Join(data, "home", ".codeaf", "v3", "remote") + bin := filepath.Join(data, "home", ".codeaf", "bin") + for _, dir := range []string{remote, bin} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + for name, body := range map[string]string{"curl": "exit 22", "codeaf": "exit 0"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(data, ".codeaf-devbox-joined"), nil, 0o600); err != nil { + t.Fatal(err) + } + key, book := base64.StdEncoding.EncodeToString([]byte("new-key")), base64.StdEncoding.EncodeToString([]byte("new-book")) + if mode == "key-present" { + if err := os.WriteFile(filepath.Join(remote, "device.key"), []byte("existing-key"), 0o600); err != nil { + t.Fatal(err) + } + } + if mode == "book-present" { + if err := os.WriteFile(filepath.Join(remote, "devices.json"), []byte("existing-book"), 0o600); err != nil { + t.Fatal(err) + } + } + if mode == "bad-key" { + key = "cGFydGlhbA==!" + } + if mode == "bad-book" { + book = "cGFydGlhbA==!" + } + raw, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash") + cmd.Stdin = strings.NewReader(string(raw)) + cmd.Env = append(os.Environ(), "HOME="+filepath.Join(data, "home"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_REMOTE_KEY="+key, "CODEAF_DEVBOX_REMOTE_DEVICES="+book) + out, err := cmd.CombinedOutput() + if strings.HasPrefix(mode, "bad-") { + if err == nil { + t.Fatalf("R5.5 accepted failed decode: %s", out) + } + name := "device.key" + if mode == "bad-book" { + name = "devices.json" + } + if _, err := os.Stat(filepath.Join(remote, name)); !os.IsNotExist(err) { + t.Fatalf("R5.5 partial final %s remains", name) + } + files, err := os.ReadDir(remote) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if strings.HasPrefix(file.Name(), ".seed-") { + t.Fatal("R5.5 temporary seed remains") + } + } + return + } + if err != nil { + t.Fatalf("R5.5 boot: %v %s", err, out) + } + for name, want := range map[string]string{"device.key": "new-key", "devices.json": "new-book"} { + if mode == "key-present" && name == "device.key" { + want = "existing-key" + } + if mode == "book-present" && name == "devices.json" { + want = "existing-book" + } + got, err := os.ReadFile(filepath.Join(remote, name)) + if err != nil || string(got) != want { + t.Fatalf("R5.5 %s = %q, %v", name, got, err) + } + } + }) + } +} + +// Contract 3 and stability: the box's serve, and only the box's, asks to keep +// the identity's keys and presence in step, spelled with the one constant. +func TestDevboxBootAsksServeToKeepStateInStep(t *testing.T) { + script, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + export := "export " + syncsetup.ServeStateVar + "=1\n" + at := strings.Index(string(script), export) + if at < 0 || at > strings.Index(string(script), "serve --workspace") { + t.Fatalf("boot.sh must export %q before it starts serve", strings.TrimSpace(export)) + } +} + +// Stability: a box takes tasks only when its installed build's serve lists the +// flag, and a build that predates it, or whose help fails, still serves. +func TestDevboxBootTakesTasksOnlyWhenServeListsTheFlag(t *testing.T) { + flag := "--" + taskdoor.TakeTasksFlag + for _, row := range []struct { + name, help string + helpExit int + want bool + }{ + {name: "build-lists-the-flag", help: "flags:\n " + flag + "\n take tasks from your paired computers\n", want: true}, + {name: "build-predates-the-flag", help: "flags:\n --via string\n --workspace string\n"}, + {name: "help-fails", help: "flag provided but not defined: -help\n", helpExit: 2}, + } { + t.Run(row.name, func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "home", ".codeaf", "bin") + if err := os.MkdirAll(bin, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, ".codeaf-devbox-joined"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, "help"), []byte(row.help), 0o600); err != nil { + t.Fatal(err) + } + fake := `#!/bin/bash +if [[ "$1" == serve && "$2" == --help ]]; then + cat "$CODEAF_DEVBOX_DATA_ROOT/help" + exit "$HELP_EXIT" +fi +printf '%s\n' "$*" >> "$CODEAF_DEVBOX_DATA_ROOT/calls" +` + if err := os.WriteFile(filepath.Join(bin, "codeaf"), []byte(fake), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte("#!/bin/sh\nexit 22\n"), 0o700); err != nil { + t.Fatal(err) + } + script, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash") + cmd.Stdin = strings.NewReader(string(script)) + cmd.Env = append(os.Environ(), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_NAME=box", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_CODE=", "CODEAF_DEVBOX_REMOTE_KEY=", "CODEAF_DEVBOX_REMOTE_DEVICES=", "HELP_EXIT="+strconv.Itoa(row.helpExit)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("boot: %v %s", err, out) + } + calls, err := os.ReadFile(filepath.Join(data, "calls")) + if err != nil { + t.Fatal(err) + } + serve := "serve --workspace " + filepath.Join(data, "home", "work") + " --via https://relay.example" + want := serve + "\n" + if row.want { + want = serve + " " + flag + "\n" + } + if string(calls) != want { + t.Fatalf("the box ran %q, want %q", calls, want) + } + }) + } +} diff --git a/internal/devbox/bootstrap.go b/internal/devbox/bootstrap.go new file mode 100644 index 0000000000..fb95f235d9 --- /dev/null +++ b/internal/devbox/bootstrap.go @@ -0,0 +1,123 @@ +package devbox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const deviceReceipt = "codeaf.devbox.json" + +func (f Flow) recordDevice(ctx context.Context, o Options) error { + if f.Devices == nil { + return nil + } + devices, err := f.devices(ctx) + if err != nil { + return err + } + var id string + for _, d := range devices { + if !d.Removed && d.ID != "" && strings.EqualFold(d.Name, o.Name) { + if id != "" && id != d.ID { + return errors.New("more than one active device has the box's name") + } + id = d.ID + } + } + if id == "" { + return errors.New("the joined device is not in this identity's list yet") + } + raw, err := json.Marshal(struct { + ID string `json:"device_id"` + }{id}) + if err != nil { + return err + } + file, err := os.CreateTemp(o.Dir, ".codeaf-device-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, writeErr := file.Write(append(raw, '\n')) + if err := errors.Join(writeErr, file.Close()); err != nil { + return err + } + return os.Rename(file.Name(), filepath.Join(o.Dir, deviceReceipt)) +} + +func readDeviceID(dir string) (string, error) { + raw, err := os.ReadFile(filepath.Join(dir, deviceReceipt)) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + var receipt struct { + ID string `json:"device_id"` + } + if err := json.Unmarshal(raw, &receipt); err != nil { + return "", err + } + return receipt.ID, nil +} + +// State, including its previous copy after a failed clear, is the cleanup receipt. +// Read only the three bootstrap values, retaining them solely for redaction. +func bootstrapSecrets(dir string) ([]string, error) { + var secrets []string + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, errors.New("could not read the Terraform state for setup cleanup") + } + var state any + if json.Unmarshal(raw, &state) != nil { + return nil, errors.New("could not read the Terraform state for setup cleanup") + } + var visit func(any) + visit = func(value any) { + switch v := value.(type) { + case map[string]any: + if name, ok := v["name"].(string); ok && (name == "CODEAF_DEVBOX_CODE" || name == "CODEAF_DEVBOX_REMOTE_KEY" || name == "CODEAF_DEVBOX_REMOTE_DEVICES") { + if secret, ok := v["value"].(string); ok && secret != "" { + secrets = append(secrets, secret) + } + } + for _, child := range v { + visit(child) + } + case []any: + for _, child := range v { + visit(child) + } + } + } + visit(state) + } + return secrets, nil +} + +func (f Flow) clearBootstrap(ctx context.Context, terraform, token string, o Options, secrets []string) error { + secrets = append(secrets, token) + fmt.Fprintln(f.Out, "Removing the one-time setup values from Railway.") + if err := f.applyPlan(ctx, terraform, o.Dir, []string{"RAILWAY_TOKEN=" + token}, secrets, true, func() error { return ctx.Err() }); err != nil { + return f.clearFailed(o, err, secrets...) + } + if err := os.Remove(filepath.Join(o.Dir, "terraform.tfstate.backup")); err != nil && !errors.Is(err, os.ErrNotExist) { + return f.clearFailed(o, err, secrets...) + } + return nil +} + +func (f Flow) clearFailed(o Options, err error, secrets ...string) error { + return fmt.Errorf("%s is up, but its one-time setup values are still on Railway and in %s: %s. Run %s again to finish, or %s to remove it.", o.Name, o.Dir, reason(errors.New(redact(err.Error(), secrets...))), f.command("up", o), f.command("down", o)) +} diff --git a/internal/devbox/deployments.go b/internal/devbox/deployments.go new file mode 100644 index 0000000000..72b79550c9 --- /dev/null +++ b/internal/devbox/deployments.go @@ -0,0 +1,88 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "time" +) + +const deploymentPollEvery = 3 * time.Second +const deploymentSettleWithin = 3 * time.Minute + +func settleDeployments(ctx context.Context, endpoint, token, serviceID, environmentID string) error { + if endpoint == "" { + endpoint = railwayGraphQLURL + } + return pollDeployments(ctx, &http.Client{Timeout: 20 * time.Second}, endpoint, token, serviceID, environmentID, deploymentPollEvery, deploymentSettleWithin) +} + +func pollDeployments(ctx context.Context, client *http.Client, endpoint, token, serviceID, environmentID string, every, within time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, within) + defer cancel() + body, _ := json.Marshal(struct { + Query string `json:"query"` + Variables map[string]string `json:"variables"` + }{ + Query: `query($s: String!, $e: String!) { deployments(first: 10, input: {serviceId: $s, environmentId: $e}) { edges { node { id status createdAt } } } }`, + Variables: map[string]string{"s": serviceID, "e": environmentID}, + }) + ticker := time.NewTicker(every) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return errors.New("Railway deployments did not settle within the wait") + } + settled, err := deploymentsSettled(ctx, client, endpoint, token, body) + if err == nil && settled { + return nil + } + select { + case <-ctx.Done(): + return errors.New("Railway deployments did not settle within the wait") + case <-ticker.C: + } + } +} + +func deploymentsSettled(ctx context.Context, client *http.Client, endpoint, token string, body []byte) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return false, errors.New("could not check Railway deployments") + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return false, errors.New("could not check Railway deployments") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, errors.New("could not check Railway deployments") + } + var response struct { + Data struct { + Deployments *struct { + Edges []struct { + Node struct { + Status string `json:"status"` + } `json:"node"` + } `json:"edges"` + } `json:"deployments"` + } `json:"data"` + Errors []json.RawMessage `json:"errors"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&response); err != nil || len(response.Errors) != 0 || response.Data.Deployments == nil { + return false, errors.New("could not read Railway deployments") + } + for _, edge := range response.Data.Deployments.Edges { + switch edge.Node.Status { + case "WAITING", "QUEUED", "INITIALIZING", "BUILDING", "DEPLOYING", "REMOVING": + return false, nil + } + } + return true, nil +} diff --git a/internal/devbox/deployments_test.go b/internal/devbox/deployments_test.go new file mode 100644 index 0000000000..479ce7933a --- /dev/null +++ b/internal/devbox/deployments_test.go @@ -0,0 +1,131 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync/atomic" + "testing" + "time" +) + +// R3.4: polling checks every in-progress status and sends credentials only in the header. +func TestDevboxR34DeploymentsSettleAndKeepTokenInHeader(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fixture-token" || r.URL.RawQuery != "" || r.Method != http.MethodPost { + t.Error("wrong authorization or method") + } + var body struct { + Query string `json:"query"` + Variables map[string]string `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if strings.Contains(body.Query, "fixture-token") || strings.Contains(fmt.Sprint(body.Variables), "fixture-token") { + t.Error("token in body") + } + if body.Variables["s"] != "service" || body.Variables["e"] != "environment" || !strings.Contains(body.Query, "deployments(first: 10") || !strings.Contains(body.Query, "id status createdAt") { + t.Errorf("query: %+v", body) + } + statuses := []string{"WAITING", "QUEUED", "INITIALIZING", "BUILDING", "DEPLOYING", "REMOVING"} + n := int(calls.Add(1)) + status := "SUCCESS" + if n <= len(statuses) { + status = statuses[n-1] + } + fmt.Fprintf(w, `{"data":{"deployments":{"edges":[{"node":{"id":"deployment","status":%q,"createdAt":"2026-10-30T12:00:00Z"}},{"node":{"id":"old","status":"REMOVED"}}]}}}`, status) + })) + defer server.Close() + if err := pollDeployments(context.Background(), server.Client(), server.URL, "fixture-token", "service", "environment", time.Millisecond, time.Second); err != nil { + t.Fatal(err) + } + if calls.Load() != 7 { + t.Fatalf("polls = %d, want 7", calls.Load()) + } +} + +// R3.4: an unsettled service, bad response, or hanging request cannot outlive the wait bound. +func TestDevboxR34DeploymentsWaitIsBoundedAndErrorsHideResponse(t *testing.T) { + for _, mode := range []string{"busy", "bad-json", "graphql-error", "http-error", "hanging"} { + t.Run(mode, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + // Consume the request so net/http can observe disconnects while the response hangs. + _, _ = io.Copy(io.Discard, r.Body) + switch mode { + case "hanging": + <-r.Context().Done() + case "busy": + fmt.Fprint(w, `{"data":{"deployments":{"edges":[{"node":{"status":"DEPLOYING"}}]}}}`) + case "bad-json": + fmt.Fprint(w, `fixture-token`) + case "graphql-error": + fmt.Fprint(w, `{"errors":[{"message":"fixture-token"}]}`) + case "http-error": + w.WriteHeader(503) + fmt.Fprint(w, `fixture-token`) + } + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + err := pollDeployments(ctx, server.Client(), server.URL, "fixture-token", "service", "environment", time.Millisecond, 20*time.Millisecond) + if err == nil || strings.Contains(err.Error(), "fixture-token") || calls.Load() == 0 || ctx.Err() != nil { + t.Fatalf("bounded wait: %v (%d requests, parent %v)", err, calls.Load(), ctx.Err()) + } + }) + } +} + +// R3.4: the real up flow polls the overridable GraphQL endpoint before sending a code. +func TestDevboxR34UpUsesDeploymentEndpoint(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var polled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fixture-token" { + t.Error("missing token header") + } + polled.Store(true) + fmt.Fprint(w, `{"data":{"deployments":{"edges":[]}}}`) + })) + defer server.Close() + f.GraphQLEndpoint = server.URL + f.Settle = nil + joined := make(chan struct{}) + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + if !polled.Load() { + t.Error("pairing before polling") + } + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-joined: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && slices.Contains(env, "TF_VAR_pair_code=42-123-456") { + close(joined) + } + return err + } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "fixture-token") { + t.Fatal("token in polling output") + } +} diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go new file mode 100644 index 0000000000..799f33f5cd --- /dev/null +++ b/internal/devbox/devbox.go @@ -0,0 +1,665 @@ +// Package devbox owns the readable Terraform folders for a person's computers. +package devbox + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/guard" +) + +const TokenNeeded = "codeaf devbox needs a Railway token: create one at https://railway.com/account/tokens and set RAILWAY_TOKEN." +const TerraformNeeded = "codeaf devbox needs Terraform: install it from https://developer.hashicorp.com/terraform/install." +const FreshFor = 5 * time.Minute +const probeWithin = 3 * time.Second +const maxPairingCodes = 3 +const reachableEvery = 5 * time.Second +const reachableWithin = 3 * time.Minute + +var errNotReachable = errors.New("the pinned remote key did not answer within three minutes") + +// An acknowledged offer needs a distinct recovery receipt when reachability fails. +type reachabilityError struct{ error } + +// ErrJoinInterrupted allows a fresh offer after a deployment swap or an expired code. +var ErrJoinInterrupted = errors.New("joining was interrupted or the pairing code expired") + +// Options describes one command, including the folder the person owns. +type Options struct { + Name, Dir, Region, Workspace, Channel, Version string + Yes bool +} + +type Device struct { + ID string + Name string + LastSeen time.Time + Removed bool + Online *bool +} + +// Offer carries configuration secrets only in memory until Terraform consumes them. +type Offer struct { + Code, Via, RemoteKey, RemoteDevices string +} + +// Runner executes Terraform without putting credentials in its arguments. +type Runner func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error + +// Flow connects provisioning to the existing identity and pairing doors. +type Flow struct { + Home string + In io.Reader + Out, Err io.Writer + Interactive bool + StdoutTerminal bool + GraphQLEndpoint string + Settle func(ctx context.Context, token, serviceID, environmentID string) error + Getenv func(string) string + LookPath func(string) (string, error) + RunTerraform Runner + Workspaces func(context.Context, string) ([]Workspace, error) + // Prepare creates and pins the remote configuration once, before any identity code. + Prepare func(context.Context, string) (Offer, error) + // Pair blocks until the identity offer ends. Ready supplies only a fresh code and relay. + Pair func(ctx context.Context, name string, ready func(Offer)) error + // Probe verifies a remembered remote key; failure falls back to directory presence. + Probe func(context.Context, string) bool + Devices func(context.Context) ([]Device, error) + Remove func(context.Context, string, string) (bool, error) + // ShareState sends this computer's saved provider keys and settings to the + // identity once. Nil sends nothing. + ShareState func(context.Context) error + Unavailable func() string + probeEvery, readyWithin time.Duration +} + +func (f Flow) Run(ctx context.Context, action string, o Options) error { + if f.Out == nil { + f.Out = io.Discard + } + if f.Err == nil { + f.Err = f.Out + } + if f.In == nil { + f.In = strings.NewReader("") + } + if f.Getenv == nil { + f.Getenv = os.Getenv + } + if f.LookPath == nil { + f.LookPath = exec.LookPath + } + if f.RunTerraform == nil { + f.RunTerraform = RunTerraform + } + if f.Workspaces == nil { + f.Workspaces = LookupWorkspaces + } + if f.Settle == nil { + f.Settle = func(ctx context.Context, token, serviceID, environmentID string) error { + return settleDeployments(ctx, f.GraphQLEndpoint, token, serviceID, environmentID) + } + } + var token string + if action == "up" || action == "down" { + token = strings.TrimSpace(f.Getenv("RAILWAY_TOKEN")) + if token == "" { + token = strings.TrimSpace(f.Getenv("RAILWAY_API_TOKEN")) + } + if token == "" { + return errors.New(TokenNeeded) + } + } + terraform, err := f.LookPath("terraform") + if err != nil { + return errors.New(TerraformNeeded) + } + if o.Name != "" { + if err := ValidateName(o.Name); err != nil { + return err + } + } + if o.Dir == "" { + o.Dir = filepath.Join(f.Home, "devbox", o.Name) + } + switch action { + case "up": + return f.up(ctx, terraform, token, o) + case "down": + return f.down(ctx, terraform, token, o) + case "status": + return f.status(ctx, terraform, o) + default: + return errors.New("usage: codeaf devbox up | status [] | down ") + } +} + +var projectName = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +func ValidateName(name string) error { + clean, err := devname.Clean(name) + if err != nil { + return err + } + if clean != name || !projectName.MatchString(name) { + return errors.New("a dev box name uses lowercase letters, digits and hyphens, starting and ending with a letter or digit") + } + return nil +} + +func (f Flow) devices(ctx context.Context) ([]Device, error) { + if f.Devices == nil { + return nil, nil + } + return f.Devices(ctx) +} + +func (f Flow) up(ctx context.Context, terraform, token string, o Options) error { + if o.Name == "" { + return errors.New("usage: codeaf devbox up ") + } + if o.Channel == "" { + o.Channel = "dev" + } + installURL, binary, err := Channel(o.Channel) + if err != nil { + return err + } + exists, err := hasProject(o.Dir) + if err != nil { + return err + } + if exists { + if f.probe(ctx, o.Name) { + secrets, err := bootstrapSecrets(o.Dir) + if err != nil { + return f.clearFailed(o, err, token) + } + if len(secrets) > 0 { + id, err := readDeviceID(o.Dir) + if err != nil { + return f.clearFailed(o, err, append(secrets, token)...) + } + if id == "" { + if err := f.recordDevice(ctx, o); err != nil { + return f.clearFailed(o, err, append(secrets, token)...) + } + } + if err := f.clearBootstrap(ctx, terraform, token, o, secrets); err != nil { + return err + } + f.sayUp(o) + return nil + } + } + return fmt.Errorf("%s is already up — `%s`", o.Name, f.command("status", o)) + } + if f.Unavailable != nil { + if reason := f.Unavailable(); reason != "" { + return errors.New(reason) + } + } + devices, err := f.devices(ctx) + if err != nil { + return err + } + for _, d := range devices { + if !d.Removed && strings.EqualFold(d.Name, o.Name) { + return fmt.Errorf("a device is already called %s — choose another dev box name", o.Name) + } + } + workspace, err := resolveWorkspace(ctx, token, o.Workspace, f.Workspaces) + if err != nil { + return err + } + if f.Pair == nil || f.Prepare == nil { + return errors.New("pairing is unavailable here") + } + // Create the infrastructure before offering a code, so deployment swaps cannot spend it. + vars := map[string]string{"name": o.Name, "workspace_id": workspace, "region": o.Region, "image": DefaultImage, "install_url": installURL, "binary": binary, "install_version": o.Version, "via": ""} + if err := WriteFolder(o.Dir, vars); err != nil { + return err + } + env := []string{"RAILWAY_TOKEN=" + token} + if err := f.terraform(ctx, terraform, o.Dir, []string{"init", "-input=false"}, env, token, ""); err != nil { + return err + } + if err := f.applyPlan(ctx, terraform, o.Dir, env, []string{token}, false, func() error { + return f.confirm(ctx, o.Yes, "Create these on your Railway account? [y/N]") + }); err != nil { + return err + } + outputs, err := f.outputs(ctx, terraform, o.Dir, env, token) + if err != nil { + return f.pairingFailed(o, err) + } + if outputs["service_id"] == "" || outputs["environment_id"] == "" { + return f.pairingFailed(o, errors.New("Terraform did not return the service and environment ids")) + } + fmt.Fprintln(f.Out, "Waiting for Railway to finish starting the box.") + if err := f.Settle(ctx, token, outputs["service_id"], outputs["environment_id"]); err != nil { + if ctx.Err() != nil { + return f.pairingFailed(o, ctx.Err()) + } + fmt.Fprintln(f.Out, "Railway did not settle before the wait ended; sending the pairing code anyway.") + } + remote, err := f.Prepare(ctx, o.Name) + if err != nil { + return f.pairingFailed(o, err) + } + secrets := []string{token, remote.RemoteKey, remote.RemoteDevices} + for attempt := 1; attempt <= maxPairingCodes; attempt++ { + if err := ctx.Err(); err != nil { + return f.pairingFailed(o, err) + } + err := f.sendPairingCode(ctx, terraform, token, o, vars, remote, &secrets) + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + err = ErrJoinInterrupted + } + if err == nil || errors.Is(err, ErrJoinInterrupted) && f.probe(ctx, o.Name) { + f.shareState(ctx, o.Name) + if err := f.recordDevice(ctx, o); err != nil { + return f.clearFailed(o, err, secrets...) + } + if err := f.clearBootstrap(ctx, terraform, token, o, secrets); err != nil { + return err + } + f.sayUp(o) + return nil + } + var unreachable reachabilityError + if errors.As(err, &unreachable) { + return fmt.Errorf("%s took its pairing code but is not reachable yet: %s. It is on your Railway account; check it with %s, or remove it with %s.", o.Name, reason(err), f.command("status", o), f.command("down", o)) + } + if ctx.Err() != nil { + return f.pairingFailed(o, ctx.Err()) + } + if errors.Is(err, context.DeadlineExceeded) { + err = ErrJoinInterrupted + } + if !errors.Is(err, ErrJoinInterrupted) || attempt == maxPairingCodes { + return f.pairingFailed(o, errors.New(redact(err.Error(), secrets...))) + } + fmt.Fprintf(f.Out, "%s did not finish joining; sending a new code (%d of %d).\n", o.Name, attempt+1, maxPairingCodes) + } + return f.pairingFailed(o, ErrJoinInterrupted) +} + +// shareState runs once the box answers. A laptop that was a fleet of one until +// this up offered its identity has never sent its saved keys anywhere, so the +// box would find none and the first `--at` chat would stop on a missing key. A +// failure is said once and is not fatal: codeaf sends them the next time it +// opens here. +func (f Flow) shareState(ctx context.Context, name string) { + if f.ShareState == nil { + return + } + if err := f.ShareState(ctx); err != nil && ctx.Err() == nil { + fmt.Fprintf(f.Out, "Could not send this computer's saved keys yet: %s. Open codeaf here once, or a chat on %s can stop on a missing key.\n", reason(err), name) + } +} + +func (f Flow) sayUp(o Options) { + fmt.Fprintf(f.Out, "%s is up. Open a chat on it: codeaf chat --at %s\nThe Terraform is in %s.\n", o.Name, o.Name, o.Dir) +} + +func (f Flow) probe(ctx context.Context, name string) bool { + if f.Probe == nil || ctx.Err() != nil { + return false + } + ctx, cancel := context.WithTimeout(ctx, probeWithin) + defer cancel() + return f.Probe(ctx, name) +} + +func (f Flow) sendPairingCode(ctx context.Context, terraform, token string, o Options, vars map[string]string, remote Offer, secrets *[]string) error { + offerCtx, cancel := context.WithCancel(ctx) + finished := make(chan struct{}) + var pairErr error + ready := make(chan Offer, 1) + guard.Go("devbox pairing", func() { + defer close(finished) + pairErr = f.Pair(offerCtx, o.Name, func(offer Offer) { + select { + case ready <- offer: + case <-offerCtx.Done(): + } + }) + }) + defer func() { cancel(); <-finished }() + var offer Offer + select { + case offer = <-ready: + case <-finished: + select { + case offer = <-ready: + default: + if pairErr != nil { + return pairErr + } + return errors.New("no device joined") + } + case <-ctx.Done(): + return ctx.Err() + } + if offer.Via != remote.Via { + return errors.New("the pairing relay changed while setting up the box") + } + vars["via"] = remote.Via + if err := WriteFolder(o.Dir, vars); err != nil { + return err + } + *secrets = append(*secrets, offer.Code) + env := []string{"RAILWAY_TOKEN=" + token, "TF_VAR_pair_code=" + offer.Code, "TF_VAR_remote_key=" + remote.RemoteKey, "TF_VAR_remote_devices=" + remote.RemoteDevices} + if err := f.applyPlan(ctx, terraform, o.Dir, env, *secrets, true, func() error { + select { + case <-finished: + if pairErr != nil { + return pairErr + } + default: + } + if err := ctx.Err(); err != nil { + return err + } + fmt.Fprintf(f.Out, "Sending %s its pairing code.\n", o.Name) + return nil + }); err != nil { + return err + } + fmt.Fprintln(f.Out, "Waiting for the box to install codeaf, join and become reachable; this can take a few minutes.") + return f.awaitReachable(ctx, o.Name, finished, func() error { return pairErr }) +} + +// Probe independently of the acknowledgement so a lost reply cannot strand a joined box. +func (f Flow) awaitReachable(ctx context.Context, name string, finished <-chan struct{}, result func() error) error { + every, within := f.probeEvery, f.readyWithin + if every <= 0 { + every = reachableEvery + } + if within <= 0 { + within = reachableWithin + } + probeCtx, cancel := context.WithCancel(ctx) + done, reachable := make(chan struct{}), make(chan struct{}) + guard.Go("devbox reachability", func() { + defer close(done) + ticks := time.NewTicker(every) + defer ticks.Stop() + for probeCtx.Err() == nil { + if f.probe(probeCtx, name) { + close(reachable) + return + } + select { + case <-probeCtx.Done(): + return + case <-ticks.C: + } + } + }) + defer func() { cancel(); <-done }() + var timeout <-chan time.Time + var timer *time.Timer + defer func() { + if timer != nil { + timer.Stop() + } + }() + for { + select { + case <-reachable: + return nil + case <-finished: + err := result() + if err != nil { + if errors.Is(err, ErrJoinInterrupted) || errors.Is(err, context.DeadlineExceeded) { + return ErrJoinInterrupted + } + return err + } + finished = nil + timer = time.NewTimer(within) + timeout = timer.C + case <-timeout: + return reachabilityError{errNotReachable} + case <-ctx.Done(): + if finished == nil { + return reachabilityError{ctx.Err()} + } + return ctx.Err() + } + } +} + +func reason(err error) string { + if err == nil { + return "no device joined" + } + return strings.TrimRight(strings.Join(strings.Fields(err.Error()), " "), ".") +} + +func (f Flow) pairingFailed(o Options, err error) error { + return fmt.Errorf("%s did not join: %s. It is on your Railway account; remove it with `%s`, then run up again.", o.Name, reason(err), f.command("down", o)) +} + +// A recovery command must keep pointing to the folder that owns the paid resources. +func (f Flow) command(action string, o Options) string { + command := "codeaf devbox " + action + " " + o.Name + actual, _ := filepath.Abs(o.Dir) + defaultDir, _ := filepath.Abs(filepath.Join(f.Home, "devbox", o.Name)) + if actual != defaultDir { + command += " --dir " + shellQuote(o.Dir) + } + return command +} + +var shellBare = regexp.MustCompile(`^[a-zA-Z0-9_./:@%+=,-]+$`) + +func shellQuote(s string) string { + if shellBare.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +func (f Flow) down(ctx context.Context, terraform, token string, o Options) error { + if o.Name == "" { + return errors.New("usage: codeaf devbox down ") + } + if _, err := os.Stat(filepath.Join(o.Dir, "main.tf")); err != nil { + return fmt.Errorf("read the Terraform folder: %w", err) + } + if raw, err := os.ReadFile(filepath.Join(o.Dir, "codeaf.auto.tfvars.json")); err == nil { + var vars struct { + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &vars); err != nil { + return fmt.Errorf("read the Terraform variables: %w", err) + } + if vars.Name != "" && vars.Name != o.Name { + return fmt.Errorf("this Terraform folder belongs to %s; use `codeaf devbox down %s --dir %s`", vars.Name, vars.Name, shellQuote(o.Dir)) + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if err := f.confirm(ctx, o.Yes, fmt.Sprintf("Destroy %s and everything in project codeaf-%s? [y/N]", o.Name, o.Name)); err != nil { + return err + } + if err := f.terraform(ctx, terraform, o.Dir, []string{"destroy", "-input=false", "-auto-approve"}, []string{"RAILWAY_TOKEN=" + token}, token, ""); err != nil { + return err + } + fmt.Fprintf(f.Out, "The Terraform is still in %s.\n", o.Dir) + if f.Remove != nil { + id, err := readDeviceID(o.Dir) + if err != nil { + return fmt.Errorf("the box was destroyed, but its saved device id could not be read: %w", err) + } + removed, err := f.Remove(ctx, o.Name, id) + if err != nil { + return fmt.Errorf("the box was destroyed, but its device could not be removed: %w", err) + } + if !removed { + fmt.Fprintf(f.Out, "No active device matches %s; no device was removed.\n", o.Name) + } + } + fmt.Fprintf(f.Out, "%s is down.\n", o.Name) + return nil +} + +func (f Flow) confirm(ctx context.Context, yes bool, prompt string) error { + if yes { + return nil + } + if !f.Interactive { + return errors.New("codeaf devbox needs --yes when stdin is not a terminal") + } + fmt.Fprint(f.Out, prompt+" ") + answer := make(chan string, 1) + go func() { line, _ := bufio.NewReader(f.In).ReadString('\n'); answer <- strings.TrimSpace(line) }() + select { + case line := <-answer: + if strings.EqualFold(line, "y") || strings.EqualFold(line, "yes") { + return nil + } + return errors.New("cancelled") + case <-ctx.Done(): + return ctx.Err() + } +} + +func hasProject(dir string) (bool, error) { + raw, err := os.ReadFile(filepath.Join(dir, "terraform.tfstate")) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + var state struct { + Resources []struct { + Type string + Instances []json.RawMessage + } + } + if err := json.Unmarshal(raw, &state); err != nil { + return false, fmt.Errorf("read Terraform state: %w", err) + } + for _, r := range state.Resources { + if r.Type == "railway_project" && len(r.Instances) > 0 { + return true, nil + } + } + return false, nil +} + +func (f Flow) status(ctx context.Context, terraform string, o Options) error { + names := []string{o.Name} + if o.Name == "" { + entries, err := os.ReadDir(o.Dir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + names = nil + for _, entry := range entries { + if entry.IsDir() { + names = append(names, entry.Name()) + } + } + } + var devices []Device + devicesLoaded := false + for _, name := range names { + dir := o.Dir + if o.Name == "" { + dir = filepath.Join(dir, name) + } + up, err := hasProject(dir) + if err != nil { + return err + } + if !up { + fmt.Fprintf(f.Out, "%s: not up\n", name) + continue + } + values, err := f.outputs(ctx, terraform, dir, nil) + if err != nil { + return err + } + get := func(key string) string { return values[key] } + state := "not up" + var last time.Time + reached := false + reached = f.probe(ctx, name) + if reached { + state = "up" + last = time.Now() + } else { + if !devicesLoaded { + var err error + devices, err = f.devices(ctx) + if err != nil { + return err + } + devicesLoaded = true + } + for _, d := range devices { + if !d.Removed && strings.EqualFold(d.Name, name) { + last = d.LastSeen + if d.Online != nil { + if *d.Online { + state = "up" + } + } else if !last.IsZero() && time.Since(last) <= FreshFor { + state = "up (seen recently)" + } + break + } + } + } + fields := []string{name + ": " + state} + if region := get("region"); region != "" { + fields = append(fields, "region "+region) + } + if project := get("project_name"); project != "" { + fields = append(fields, "project "+project) + } + if !last.IsZero() { + fields = append(fields, "last seen "+last.UTC().Format(time.RFC3339)) + } + fmt.Fprintln(f.Out, strings.Join(fields, "; ")) + } + return nil +} + +func (f Flow) terraform(ctx context.Context, path, dir string, args, env []string, secrets ...string) error { + filteredOut := &terraformOutput{out: f.Out, stripColor: !f.StdoutTerminal} + filteredErr := &terraformOutput{out: f.Err, stripColor: !f.StdoutTerminal} + out := newRedactor(filteredOut, secrets...) + errs := newRedactor(filteredErr, secrets...) + err := f.runTerraform(ctx, path, dir, args, env, out, errs) + out.Flush() + errs.Flush() + filteredOut.Flush() + filteredErr.Flush() + if err != nil { + return fmt.Errorf("terraform %s failed: %s", args[0], redact(err.Error(), secrets...)) + } + return nil +} diff --git a/internal/devbox/devbox_test.go b/internal/devbox/devbox_test.go new file mode 100644 index 0000000000..dddbaf392d --- /dev/null +++ b/internal/devbox/devbox_test.go @@ -0,0 +1,593 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Contract 1: prerequisites refuse before any local or external side effect. +func TestDevboxMissingPrerequisitesCreateNothing(t *testing.T) { + for _, action := range []string{"up", "down"} { + t.Run(action, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := Flow{Out: &out, Getenv: func(string) string { return "" }, LookPath: func(string) (string, error) { t.Fatal("terraform looked up before token"); return "", nil }} + err := f.Run(context.Background(), action, Options{Name: "box", Dir: dir}) + if err == nil || err.Error() != TokenNeeded { + t.Fatalf("refusal = %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("folder created: %v", err) + } + if out.Len() != 0 || strings.Count(TokenNeeded, "\n") != 0 { + t.Fatal("extra output") + } + }) + } +} + +// Contract 1: a missing executable has the same absence of side effects. +func TestDevboxMissingTerraformCreatesNothing(t *testing.T) { + for _, action := range []string{"up", "down"} { + dir := filepath.Join(t.TempDir(), "box") + f := Flow{Getenv: func(string) string { return "token" }, LookPath: func(string) (string, error) { return "", os.ErrNotExist }} + if err := f.Run(context.Background(), action, Options{Name: "box", Dir: dir}); err == nil || err.Error() != TerraformNeeded { + t.Fatalf("refusal %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("folder created") + } + } +} + +func fakeTerraform(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "terraform") + script := `#!/bin/sh +set -eu +action="$1" +printf '%s\n' "$*" >> calls +shift +positional= +for arg in "$@"; do + case "$arg" in + -*) [ -z "$positional" ] || exit 54 ;; + *) positional=1 ;; + esac +done +if [ "${1:-}" = -no-color ]; then shift; fi +set -- "$action" "$@" +if [ ! -f terraform.tfstate ]; then + [ -z "${TF_VAR_pair_code:-}" ] || exit 51 + [ -z "${TF_VAR_remote_key:-}" ] || exit 52 + [ -z "${TF_VAR_remote_devices:-}" ] || exit 53 +fi +if [ "$action" != output ]; then + [ "${RAILWAY_TOKEN:-}" = fixture-token ] || exit 41 + [ -z "${RAILWAY_API_TOKEN:-}" ] || exit 42 + [ -z "${TF_LOG:-}" ] || exit 43 +fi +if [ -n "${TF_VAR_pair_code:-}" ]; then + [ -n "${TF_VAR_remote_key:-}" ] || exit 47 + [ -n "${TF_VAR_remote_devices:-}" ] || exit 48 +else + [ -z "${TF_VAR_remote_key:-}" ] || exit 49 + [ -z "${TF_VAR_remote_devices:-}" ] || exit 50 +fi +if [ "${FAIL_ACTION:-}" = "$action" ]; then echo 'fixture failure' >&2; exit 44; fi +case "$action" in + init) ;; + plan) + for arg in "$@"; do case "$arg" in -out=*) plan="${arg#-out=}";; esac; done + printf 'saved plan' > "$plan" + printf 'Create project codeaf-box\nSensitive: fixture-token %s %s %s\n' "${TF_VAR_pair_code:-}" "${TF_VAR_remote_key:-}" "${TF_VAR_remote_devices:-}" + ;; + show) + printf '%s' "${GUARD_JSON:-}" + if [ -z "${GUARD_JSON:-}" ]; then + printf '%s' '{"format_version":"1.2","resource_changes":[{"address":"railway_project.box","change":{"actions":["no-op"]}},{"address":"railway_service.box","change":{"actions":["no-op"]}},{"address":"railway_variable_collection.box","change":{"actions":["update"],"after":{"variables":[{"name":"secret","value":"show-only-sensitive-value"}]},"after_sensitive":{"variables":true}}}]}' + fi + ;; + apply) + test -f "$3" + if [ -f terraform.tfstate ]; then cp terraform.tfstate terraform.tfstate.backup; fi + printf '{"resources":[{"type":"railway_project","instances":[{"attributes":{"id":"project"}}]},{"type":"railway_variable_collection","instances":[{"attributes":{"variables":[{"name":"CODEAF_DEVBOX_CODE","value":"%s"},{"name":"CODEAF_DEVBOX_REMOTE_KEY","value":"%s"},{"name":"CODEAF_DEVBOX_REMOTE_DEVICES","value":"%s"}]}}]}]}' "${TF_VAR_pair_code:-}" "${TF_VAR_remote_key:-}" "${TF_VAR_remote_devices:-}" > terraform.tfstate + if [ ! -f terraform.tfstate.backup ]; then cp terraform.tfstate terraform.tfstate.backup; fi + chmod 644 terraform.tfstate terraform.tfstate.backup + ;; + destroy) printf '{"resources":[]}' > terraform.tfstate; chmod 644 terraform.tfstate ;; + output) printf '{"project_name":{"value":"codeaf-box"},"region":{"value":"europe-west4"},"service_id":{"value":"service"},"environment_id":{"value":"environment"}}' ;; +esac +` + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return path +} + +func fixtureFlow(t *testing.T, dir string, out *bytes.Buffer) Flow { + t.Helper() + executable := fakeTerraform(t) + return Flow{Home: t.TempDir(), Out: out, Prepare: func(context.Context, string) (Offer, error) { + return Offer{Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}, nil + }, Probe: func(context.Context, string) bool { return true }, StdoutTerminal: true, Settle: func(context.Context, string, string, string) error { return nil }, Getenv: func(key string) string { + if key == "RAILWAY_TOKEN" { + return "fixture-token" + } + return "" + }, LookPath: func(string) (string, error) { return executable, nil }, Workspaces: func(context.Context, string) ([]Workspace, error) { + return []Workspace{{ID: "workspace", Name: "Personal"}}, nil + }} +} + +// R5.3/R5.4: standalone files, secret isolation, guarded clear and a reachable receipt. +// R6.1: an up without a matching release keeps its install version empty. +func TestDevboxR53R54UpWaitsForJoinAndKeepsOnlyNonsecretInputs(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + applied := make(chan struct{}) + join := make(chan struct{}) + f.Probe = func(context.Context, string) bool { + select { + case <-join: + return true + default: + return false + } + } + done := make(chan error, 1) + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if strings.Contains(strings.Join(args, " "), "fixture-") || strings.Contains(strings.Join(args, " "), "42-123-456") { + t.Error("secret argument") + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + close(applied) + } + return err + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + select { + case <-join: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + go func() { + done <- f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true, Channel: "staging"}) + }() + select { + case <-applied: + case <-time.After(5 * time.Second): + t.Fatal("apply never ran") + } + select { + case err := <-done: + t.Fatalf("reported before join: %v", err) + default: + } + close(join) + if err := <-done; err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "box is up") || !strings.Contains(out.String(), "codeaf chat --at box") { + t.Fatal(out.String()) + } + calls, err := os.ReadFile(filepath.Join(dir, "calls")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 10 || lines[0] != "init -input=false" || !strings.HasPrefix(lines[1], "plan -input=false -out=") || !strings.HasPrefix(lines[2], "apply -input=false ") || lines[3] != "output -json" || !strings.HasPrefix(lines[4], "plan -input=false -out=") || !strings.HasPrefix(lines[5], "show -json ") || !strings.HasPrefix(lines[6], "apply -input=false ") { + t.Fatalf("order: %s", calls) + } + for _, name := range []string{"main.tf", "variables.tf", "outputs.tf", "boot.sh", "codeaf.auto.tfvars.json"} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "fixture-") || strings.Contains(string(raw), "42-123-456") { + t.Fatalf("secret in %s", name) + } + } + vars, _ := os.ReadFile(filepath.Join(dir, "codeaf.auto.tfvars.json")) + var choices map[string]string + if err := json.Unmarshal(vars, &choices); err != nil { + t.Fatal(err) + } + if version, present := choices["install_version"]; !present || version != "" { + t.Fatalf("R6.1 an unpinned up must save an empty version, got %q (present %v)", version, present) + } + for _, want := range []string{"https://relay.example", "https://agentfield.ai/get/stageaf", `"binary": "stageaf"`, `"name": "box"`} { + if !strings.Contains(string(vars), want) { + t.Errorf("missing %s: %s", want, vars) + } + } + if strings.Contains(out.String(), "fixture-") || strings.Contains(out.String(), "42-123-456") { + t.Fatal("secret in output") + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) != 0 { + t.Fatal("plan retained") + } + f.Pair = func(context.Context, string, func(Offer)) error { t.Fatal("second offer"); return nil } + if err := f.Run(context.Background(), "up", Options{Name: "box", Dir: dir, Yes: true}); err == nil || !strings.Contains(err.Error(), "already up") { + t.Fatalf("second up: %v", err) + } +} + +// R6.1: the service gets the optional first-install version through the empty-value filter. +func TestDevboxR61VariableCollectionFiltersEmptyVersion(t *testing.T) { + dir := t.TempDir() + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + main, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`{ name = "CODEAF_DEVBOX_VERSION", value = var.install_version }`, `] : variable if variable.value != ""]`} { + if !strings.Contains(string(main), want) { + t.Fatalf("R6.1 module is missing %s", want) + } + } +} + +// Contract 2: existing device names and unsupported names refuse before folder writes. +func TestDevboxUpRefusesDuplicateAndInvalidNames(t *testing.T) { + for _, name := range []string{"box", "../box", "UPPER", "bad name", "-box", "box-", strings.Repeat("a", 33)} { + t.Run(name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "new") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Devices = func(context.Context) ([]Device, error) { return []Device{{Name: "BOX"}}, nil } + f.Pair = func(context.Context, string, func(Offer)) error { t.Fatal("offer started"); return nil } + if err := f.Run(context.Background(), "up", Options{Name: name, Dir: dir, Yes: true}); err == nil { + t.Fatal("accepted") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("created folder") + } + }) + } +} + +// R5.6: destruction precedes removal, and a failed destroy retains access. +func TestDevboxR56DownRemovesDeviceOnlyAfterSuccessfulDestroy(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(fmt.Sprint(fail), func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + t.Setenv("FAIL_ACTION", map[bool]string{true: "destroy", false: ""}[fail]) + removed := false + f.Remove = func(context.Context, string, string) (bool, error) { + calls, _ := os.ReadFile(filepath.Join(dir, "calls")) + if !strings.Contains(string(calls), "destroy") { + t.Fatal("removed before destroy") + } + removed = true + return true, nil + } + err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}) + if (err != nil) != fail || removed == fail { + t.Fatalf("err=%v removed=%v", err, removed) + } + if _, err := os.Stat(filepath.Join(dir, "main.tf")); err != nil { + t.Fatal("folder removed") + } + }) + } +} + +// R5.3: a failed probe retains observed presence and unknown fields stay absent. +func TestDevboxR53StatusUsesDevicePresenceAndOmitsUnknownFields(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Probe = nil + if err := os.WriteFile(filepath.Join(dir, "terraform.tfstate"), []byte(`{"resources":[{"type":"railway_project","instances":[{}]}]}`), 0o600); err != nil { + t.Fatal(err) + } + online := true + f.Devices = func(context.Context) ([]Device, error) { + return []Device{{Name: "box", Online: &online, LastSeen: time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)}}, nil + } + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + for _, want := range []string{"box: up", "region europe-west4", "last seen 2026-10-30T12:00:00Z"} { + if !strings.Contains(out.String(), want) { + t.Fatal(out.String()) + } + } + out.Reset() + f.Devices = nil + f.RunTerraform = func(context.Context, string, string, []string, []string, io.Writer, io.Writer) error { return nil } + // Empty output is an error, not a fabricated region or status. + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err == nil { + t.Fatal("invalid outputs accepted") + } + f.RunTerraform = func(_ context.Context, _, _ string, _, _ []string, out, _ io.Writer) error { + _, err := io.WriteString(out, `{}`) + return err + } + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + if out.String() != "box: not up\n" { + t.Fatalf("unknown fields: %q", out.String()) + } +} + +// Contract 2: rewrites preserve state and initialized providers. +func TestDevboxFolderRewriteKeepsStateAndProviderFiles(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".terraform"), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup", ".terraform/keep"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil || info.Mode().Perm() != 0o700 { + t.Fatalf("folder is not private: %v %v", info, err) + } + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup", ".terraform/keep"} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || string(raw) != "keep" { + t.Fatalf("lost %s", name) + } + } +} + +// Contract 2: declining a plan or losing the offer never applies or leaves its code on disk. +func TestDevboxUpDeclineAndPairingFailureCleanPlans(t *testing.T) { + for _, mode := range []string{"decline", "nonterminal", "plan-failure", "apply-failure", "expired", "cancelled"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + f.Interactive = mode != "nonterminal" + f.Probe = nil + f.In = strings.NewReader("n\n") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + applied := make(chan struct{}) + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, out, errs io.Writer) error { + err := RunTerraform(ctx, path, dir, args, env, out, errs) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + close(applied) + } + return err + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + if mode == "expired" || mode == "cancelled" { + select { + case <-applied: + if mode == "cancelled" { + cancel() + <-ctx.Done() + return ctx.Err() + } + return errors.New("offer expired") + case <-ctx.Done(): + return ctx.Err() + } + } + <-ctx.Done() + return ctx.Err() + } + if mode == "plan-failure" { + t.Setenv("FAIL_ACTION", "plan") + } + if mode == "apply-failure" { + t.Setenv("FAIL_ACTION", "apply") + } + yes := mode != "decline" && mode != "nonterminal" + err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: yes}) + if err == nil { + t.Fatal("reported up") + } + if (mode == "expired" || mode == "cancelled") && !strings.Contains(err.Error(), "codeaf devbox down box") { + t.Fatalf("missing cleanup: %v", err) + } + calls, _ := os.ReadFile(filepath.Join(dir, "calls")) + if (mode == "decline" || mode == "nonterminal" || mode == "plan-failure") && strings.Contains(string(calls), "apply") { + t.Fatal("applied without consent or plan") + } + if strings.Contains(string(calls), "destroy") || strings.Contains(out.String(), "box is up") { + t.Fatal("destroyed automatically or reported success") + } + plans, _ := filepath.Glob(filepath.Join(dir, ".codeaf-plan-*")) + if len(plans) > 0 { + t.Fatal("plan retained") + } + }) + } +} + +// Contract 1: token preference and fallback are passed only as the canonical child environment. +func TestDevboxTokenPreferenceAndTerraformEnvironment(t *testing.T) { + for _, primary := range []bool{true, false} { + t.Run(fmt.Sprint(primary), func(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + WriteFolder(dir, map[string]string{"name": "box"}) + f.Getenv = func(key string) string { + if key == "RAILWAY_API_TOKEN" || primary && key == "RAILWAY_TOKEN" { + return "fixture-token" + } + return "" + } + t.Setenv("RAILWAY_API_TOKEN", "inherited-token") + t.Setenv("RAILWAY_TOKEN", "inherited-token") + t.Setenv("TF_VAR_pair_code", "inherited-code") + t.Setenv("TF_VAR_remote_key", "inherited-key") + t.Setenv("TF_VAR_remote_devices", "inherited-devices") + t.Setenv("TF_LOG", "TRACE") + t.Setenv("TF_CLI_ARGS", "bad-injected-argument") + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + }) + } +} + +// Contract 2: the embedded module contains one project, one service, and one persistent volume. +func TestDevboxStandaloneModuleHasOneComputerAndOptionalRegion(t *testing.T) { + for _, region := range []string{"", "europe-west4"} { + dir := t.TempDir() + if err := WriteFolder(dir, map[string]string{"name": "box", "region": region}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(dir, "main.tf")) + hcl := string(raw) + for _, resource := range []string{`resource "railway_project"`, `resource "railway_service"`, `volume = {`} { + if strings.Count(hcl, resource) != 1 { + t.Fatalf("resource %s: %s", resource, hcl) + } + } + if strings.Contains(hcl, "railway_service_domain") || strings.Contains(hcl, "railway_tcp_proxy") { + t.Fatal("inbound endpoint") + } + if (region != "") != strings.Contains(hcl, "region = var.region") { + t.Fatalf("region %q: %s", region, hcl) + } + if _, err := os.Stat(filepath.Join(dir, ".terraform.lock.hcl")); !os.IsNotExist(err) { + t.Fatal("lockfile shipped") + } + } +} + +// Contract 6: all default folders are listed, with empty state rendered as not up. +func TestDevboxStatusListsEveryDefaultFolderWithoutToken(t *testing.T) { + home := t.TempDir() + for _, name := range []string{"one", "two"} { + os.MkdirAll(filepath.Join(home, "devbox", name), 0o700) + } + var out bytes.Buffer + f := fixtureFlow(t, "", &out) + f.Home = home + f.Getenv = func(string) string { t.Fatal("status read token"); return "" } + if err := f.Run(context.Background(), "status", Options{}); err != nil { + t.Fatal(err) + } + if out.String() != "one: not up\ntwo: not up\n" { + t.Fatal(out.String()) + } +} + +// Contract 2: secrets remain hidden even when process writes split through their middle. +func TestDevboxTerraformOutputRedactsSplitSecrets(t *testing.T) { + var out bytes.Buffer + r := newRedactor(&out, "fixture-token", "42-123-456", "fixture-remote-key", "fixture-remote-devices") + for _, part := range []string{"before fi", "xture-t", "oken and 42-", "123-456 and fixture-remo", "te-key and fixture-remote-", "devices after"} { + if _, err := io.WriteString(r, part); err != nil { + t.Fatal(err) + } + } + r.Flush() + if out.String() != "before [redacted] and [redacted] and [redacted] and [redacted] after" { + t.Fatal(out.String()) + } +} + +// Contract 5: a destructive confirmation always names the folder's actual box. +func TestDevboxDownConfirmsAndRejectsADifferentFoldersName(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + if err := WriteFolder(dir, map[string]string{"name": "box"}); err != nil { + t.Fatal(err) + } + if err := f.Run(context.Background(), "down", Options{Name: "different", Dir: dir, Yes: true}); err == nil || !strings.Contains(err.Error(), "belongs to box") { + t.Fatalf("wrong folder: %v", err) + } + f.Interactive = true + f.In = strings.NewReader("n\n") + if err := f.Run(context.Background(), "down", Options{Name: "box", Dir: dir}); err == nil { + t.Fatal("decline accepted") + } + if out.String() != "Destroy box and everything in project codeaf-box? [y/N] " { + t.Fatalf("prompt: %q", out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "calls")); !os.IsNotExist(err) { + t.Fatal("Terraform ran before confirmation") + } +} + +// Round 2: released serve is reachable even without a directory watch; failures retain the existing fallback. +func TestDevboxStatusProbesBeforeFallingBackToPresence(t *testing.T) { + for _, reachable := range []bool{true, false} { + t.Run(fmt.Sprint(reachable), func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "terraform.tfstate"), []byte(`{"resources":[{"type":"railway_project","instances":[{}]}]}`), 0o600); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + offline := false + seen := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + f.Devices = func(context.Context) ([]Device, error) { + if reachable { + t.Fatal("reachable box depended on directory presence") + } + return []Device{{Name: "box", Online: &offline, LastSeen: seen}}, nil + } + probed := 0 + f.Probe = func(ctx context.Context, name string) bool { + probed++ + deadline, ok := ctx.Deadline() + if name != "box" || !ok || time.Until(deadline) > 5*time.Second { + t.Error("probe has no short bound") + } + return reachable + } + before := time.Now().UTC().Truncate(time.Second) + if err := f.Run(context.Background(), "status", Options{Name: "box", Dir: dir}); err != nil { + t.Fatal(err) + } + if probed != 1 { + t.Fatalf("probes: %d", probed) + } + if reachable { + if !strings.HasPrefix(out.String(), "box: up;") || strings.Contains(out.String(), "2020-") { + t.Fatal(out.String()) + } + stamp := strings.TrimSpace(strings.Split(out.String(), "last seen ")[1]) + now, err := time.Parse(time.RFC3339, stamp) + if err != nil || now.Before(before) || now.After(time.Now()) { + t.Fatalf("last seen %s: %v", stamp, err) + } + } else if !strings.Contains(out.String(), "box: not up;") || !strings.Contains(out.String(), seen.Format(time.RFC3339)) { + t.Fatal(out.String()) + } + }) + } +} diff --git a/internal/devbox/output.go b/internal/devbox/output.go new file mode 100644 index 0000000000..f7886ed124 --- /dev/null +++ b/internal/devbox/output.go @@ -0,0 +1,62 @@ +package devbox + +import ( + "io" + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Terraform's apply hint names a temporary file that codeaf applies and then deletes. +// Buffer lines so split writes and color sequences cannot leave half of that hint behind. +type terraformOutput struct { + out io.Writer + stripColor bool + pending string + applyHint bool +} + +func (w *terraformOutput) Write(p []byte) (int, error) { + w.pending += string(p) + for { + line, rest, found := strings.Cut(w.pending, "\n") + if !found { + break + } + w.pending = rest + if err := w.line(line, "\n"); err != nil { + return 0, err + } + } + return len(p), nil +} + +func (w *terraformOutput) line(line, ending string) error { + plain := ansi.Strip(line) + clean := strings.TrimSpace(plain) + if strings.HasPrefix(clean, "To perform exactly these actions, run the following command to apply:") { + w.applyHint = true + return nil + } + if w.applyHint { + if clean == "" { + return nil + } + w.applyHint = false + if strings.HasPrefix(clean, "terraform apply ") { + return nil + } + } + if w.stripColor { + line = plain + } + _, err := io.WriteString(w.out, line+ending) + return err +} + +func (w *terraformOutput) Flush() { + if w.pending != "" { + _ = w.line(w.pending, "") + w.pending = "" + } +} diff --git a/internal/devbox/reachable_test.go b/internal/devbox/reachable_test.go new file mode 100644 index 0000000000..0d8eb13284 --- /dev/null +++ b/internal/devbox/reachable_test.go @@ -0,0 +1,240 @@ +package devbox + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +// R5.3: an acknowledgement alone is insufficient; only the pinned probe can announce up. +func TestDevboxR53UpRequiresReachabilityAndFindsLostAcknowledgement(t *testing.T) { + for _, mode := range []string{"not-reachable", "lost-ack", "interrupted-ack", "delayed-probe"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "custom folder") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var applied, cancelled atomic.Bool + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + applied.Store(true) + } + return err + } + var probes atomic.Int32 + f.Probe = func(ctx context.Context, name string) bool { + if name != "box" { + t.Error("R5.3 wrong pinned alias") + } + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > probeWithin { + t.Error("R5.3 unbounded probe") + } + n := probes.Add(1) + return applied.Load() && mode != "not-reachable" && (mode != "delayed-probe" || n >= 2) + } + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + defer func() { + if mode == "lost-ack" && ctx.Err() != nil { + cancelled.Store(true) + } + }() + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + for !applied.Load() { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Millisecond): + } + } + if mode == "lost-ack" { + <-ctx.Done() + cancelled.Store(true) + return ctx.Err() + } + if mode == "interrupted-ack" { + return ErrJoinInterrupted + } + return nil + } + f.probeEvery = time.Millisecond + f.readyWithin = 25 * time.Millisecond + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: true}) + if mode == "not-reachable" { + want := "box took its pairing code but is not reachable yet: the pinned remote key did not answer within three minutes. It is on your Railway account; check it with codeaf devbox status box --dir '" + dir + "', or remove it with codeaf devbox down box --dir '" + dir + "'." + if err == nil || err.Error() != want || strings.Contains(out.String(), "box is up") { + t.Fatalf("R5.3/R5.7 premature up or receipt: %v %s", err, &out) + } + } else if err != nil || !strings.Contains(out.String(), "box is up") || mode == "lost-ack" && !cancelled.Load() { + t.Fatalf("R5.3 readiness: %v, cancelled %v, %s", err, cancelled.Load(), &out) + } + if probes.Load() == 0 { + t.Fatal("R5.3 never probed") + } + }) + } +} + +// R5.4: clear uses a guarded third apply without any bootstrap inputs, erases the backup and can resume. +func TestDevboxR54ClearSecretsAndResumeWithoutPairing(t *testing.T) { + for _, mode := range []string{"success", "apply-fails", "apply-wrote-then-failed", "guard-refuses"} { + t.Run(mode, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "custom folder") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var joined atomic.Bool + f.Pair = func(ctx context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example", RemoteKey: "fixture-remote-key", RemoteDevices: "fixture-remote-devices"}) + for !joined.Load() { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Millisecond): + } + } + return nil + } + f.Probe = func(context.Context, string) bool { return joined.Load() } + var clears, guards int + failed := false + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + clear := joined.Load() + if clear { + for _, entry := range env { + if strings.HasPrefix(entry, "TF_VAR_pair_code=") || strings.HasPrefix(entry, "TF_VAR_remote_key=") || strings.HasPrefix(entry, "TF_VAR_remote_devices=") { + t.Error("R5.4 clear contains bootstrap input") + } + } + } + if clear && args[0] == "show" { + guards++ + if mode == "guard-refuses" && !failed { + failed = true + io.WriteString(stdout, `{"resource_changes":[{"address":"railway_service.box","change":{"actions":["delete","create"]}}]}`) + return nil + } + } + if clear && args[0] == "apply" { + clears++ + if mode == "apply-fails" && !failed { + failed = true + io.WriteString(stderr, "fixture-remote-key 42-123-456 fixture-remote-devices") + return errors.New("fixture-remote-key rejected") + } + if mode == "apply-wrote-then-failed" && !failed { + failed = true + if err := RunTerraform(ctx, path, dir, args, env, stdout, stderr); err != nil { + return err + } + return errors.New("provider lost the clear reply") + } + } + err := RunTerraform(ctx, path, dir, args, env, stdout, stderr) + if args[0] == "apply" && strings.Contains(strings.Join(env, " "), "TF_VAR_pair_code=") { + joined.Store(true) + } + return err + } + opts := Options{Name: "box", Dir: dir, Yes: true} + err := f.Run(t.Context(), "up", opts) + if mode != "success" { + if err == nil || !strings.HasPrefix(err.Error(), "box is up, but its one-time setup values are still on Railway and in "+dir+": ") || !strings.Contains(err.Error(), "codeaf devbox up box --dir '"+dir+"' again to finish, or codeaf devbox down box --dir '"+dir+"' to remove it.") { + t.Fatalf("R5.4/R5.7 clear failure: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "terraform.tfstate")); err != nil { + t.Fatal("R5.4 lost state") + } + f.Pair = func(context.Context, string, func(Offer)) error { t.Fatal("R5.4 rerun re-paired"); return nil } + f.Prepare = func(context.Context, string) (Offer, error) { + t.Fatal("R5.4 rerun regenerated key") + return Offer{}, nil + } + if err := f.Run(t.Context(), "up", opts); err != nil { + t.Fatalf("R5.4 rerun: %v", err) + } + } else if err != nil { + t.Fatal(err) + } + wantGuards, wantClears := 1, 1 + if mode != "success" { + wantGuards = 2 + } + if mode == "apply-fails" || mode == "apply-wrote-then-failed" { + wantClears = 2 + } + if guards != wantGuards || clears != wantClears { + t.Fatalf("R5.4 clear guard/apply count: %d/%d, want %d/%d", guards, clears, wantGuards, wantClears) + } + secrets, err := bootstrapSecrets(dir) + if err != nil || len(secrets) != 0 { + t.Fatalf("R5.4 state still carries setup values: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "terraform.tfstate.backup")); !os.IsNotExist(err) { + t.Fatalf("R5.4 secret backup remains: %v", err) + } + for _, secret := range []string{"fixture-token", "fixture-remote-key", "fixture-remote-devices", "42-123-456"} { + if strings.Contains(out.String(), secret) { + t.Fatalf("R5.4 leaked %s", secret) + } + } + before := clears + if err := f.Run(t.Context(), "up", opts); err == nil || !strings.Contains(err.Error(), "already up") { + t.Fatalf("R5.4 clean rerun: %v", err) + } + if clears != before { + t.Fatal("R5.4 clean rerun applied again") + } + }) + } +} + +// Contract 3: a box created from a laptop that was a fleet of one receives that +// laptop's saved keys, because up sends them once the box answers and before it +// says the box is up. A failed send is said once and does not undo a working box. +func TestDevboxUpSendsThisComputersSavedKeysOnceTheBoxAnswers(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "sent", true: "failed"}[fail], func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "box") + var out bytes.Buffer + f := fixtureFlow(t, dir, &out) + var answered atomic.Bool + f.Probe = func(context.Context, string) bool { answered.Store(true); return true } + f.Pair = func(_ context.Context, _ string, ready func(Offer)) error { + ready(Offer{Code: "42-123-456", Via: "https://relay.example"}) + return nil + } + var shares atomic.Int32 + f.ShareState = func(context.Context) error { + shares.Add(1) + if !answered.Load() || strings.Contains(out.String(), "box is up") { + t.Error("the keys were sent before the box answered, or after up said it was up") + } + if fail { + return errors.New("relay unreachable.") + } + return nil + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := f.Run(ctx, "up", Options{Name: "box", Dir: dir, Yes: true}); err != nil { + t.Fatal(err) + } + if shares.Load() != 1 || !strings.Contains(out.String(), "box is up") { + t.Fatalf("keys sent %d times; %s", shares.Load(), &out) + } + warning := "Could not send this computer's saved keys yet: relay unreachable. Open codeaf here once, or a chat on box can stop on a missing key." + if strings.Contains(out.String(), warning) != fail { + t.Fatalf("the one-line warning appeared = %v, want %v: %s", !fail, fail, &out) + } + }) + } +} diff --git a/internal/devbox/receipts_test.go b/internal/devbox/receipts_test.go new file mode 100644 index 0000000000..8807528b22 --- /dev/null +++ b/internal/devbox/receipts_test.go @@ -0,0 +1,61 @@ +package devbox + +import ( + "bytes" + "context" + "io" + "path/filepath" + "strings" + "testing" +) + +// R5.7: every recovery receipt preserves the actual folder, including shell-significant names. +func TestDevboxR57RecoveryReceiptsKeepTheFolder(t *testing.T) { + for _, mode := range []string{"default", "plain", "spaces", "apostrophe", "tilde"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + var out bytes.Buffer + f := fixtureFlow(t, "", &out) + dir := filepath.Join(f.Home, "devbox", "box") + suffix := "" + switch mode { + case "plain": + dir = filepath.Join(root, "custom") + suffix = " --dir " + dir + case "spaces": + dir = filepath.Join(root, "custom folder") + suffix = " --dir '" + dir + "'" + case "apostrophe": + dir = filepath.Join(root, "owner's $box") + suffix = " --dir '" + root + "/owner'\"'\"'s $box'" + case "tilde": + dir = "~/box" + suffix = " --dir '~/box'" + } + f.Pair = func(context.Context, string, func(Offer)) error { + t.Fatal("R5.7 missing outputs started pairing") + return nil + } + f.RunTerraform = func(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if args[0] == "output" { + _, err := io.WriteString(stdout, `{}`) + return err + } + return RunTerraform(ctx, path, dir, args, env, stdout, stderr) + } + opts := Options{Name: "box", Dir: dir, Yes: true} + err := f.Run(t.Context(), "up", opts) + if err == nil || !strings.Contains(err.Error(), "`codeaf devbox down box"+suffix+"`") { + t.Fatalf("R5.7 pairing receipt: %v", err) + } + err = f.Run(t.Context(), "up", opts) + if err == nil || !strings.Contains(err.Error(), "`codeaf devbox status box"+suffix+"`") { + t.Fatalf("R5.7 existing receipt: %v", err) + } + if mode == "default" && strings.Contains(err.Error(), "--dir") { + t.Fatal("R5.7 default unnecessarily changes command") + } + }) + } +} diff --git a/internal/devbox/terraform.go b/internal/devbox/terraform.go new file mode 100644 index 0000000000..e083bc2089 --- /dev/null +++ b/internal/devbox/terraform.go @@ -0,0 +1,231 @@ +package devbox + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +const DefaultImage = "buildpack-deps:bookworm-scm" + +//go:embed terraform/railway/*.tf terraform/boot.sh +var module embed.FS + +func Channel(channel string) (installURL, binary string, err error) { + switch channel { + case "stable": + binary = "codeaf" + case "staging": + binary = "stageaf" + case "dev": + binary = "devaf" + default: + return "", "", fmt.Errorf("channel must be stable, staging or dev") + } + return "https://agentfield.ai/get/" + binary, binary, nil +} + +// WriteFolder replaces only the files shipped by codeaf, preserving local state and provider downloads. +func WriteFolder(dir string, variables map[string]string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + for _, name := range []string{"main.tf", "variables.tf", "outputs.tf", "boot.sh"} { + path := "terraform/railway/" + name + if name == "boot.sh" { + path = "terraform/boot.sh" + } + raw, err := module.ReadFile(path) + if err != nil { + return err + } + if name == "main.tf" && variables["region"] != "" { + raw = []byte(strings.Replace(string(raw), "regions = null", "regions = [{ region = var.region, num_replicas = 1 }]", 1)) + } + if err := os.WriteFile(filepath.Join(dir, name), raw, 0o600); err != nil { + return err + } + } + raw, err := json.MarshalIndent(variables, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "codeaf.auto.tfvars.json"), append(raw, '\n'), 0o600) +} + +func RunTerraform(ctx context.Context, path, dir string, args, extraEnv []string, stdout, stderr io.Writer) error { + cmd := exec.CommandContext(ctx, path, args...) + cmd.Dir = dir + cmd.Stdout = stdout + cmd.Stderr = stderr + // Disable Terraform debug logs and inherited argument injection: plans carry the remote key and single-use code. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if key == "RAILWAY_TOKEN" || key == "RAILWAY_API_TOKEN" || key == "TF_VAR_pair_code" || key == "TF_VAR_remote_key" || key == "TF_VAR_remote_devices" || strings.HasPrefix(key, "TF_LOG") || strings.HasPrefix(key, "TF_CLI_ARGS") { + continue + } + cmd.Env = append(cmd.Env, entry) + } + cmd.Env = append(cmd.Env, extraEnv...) + return cmd.Run() +} + +// redactor withholds enough trailing bytes to cover secrets split across writes. +type redactor struct { + out io.Writer + secrets []string + pending string + keep int +} + +func newRedactor(out io.Writer, secrets ...string) *redactor { + r := &redactor{out: out} + for _, secret := range secrets { + if secret != "" { + r.secrets = append(r.secrets, secret) + r.keep = max(r.keep, len(secret)-1) + } + } + return r +} +func redact(s string, secrets ...string) string { + for _, secret := range secrets { + if secret != "" { + s = strings.ReplaceAll(s, secret, "[redacted]") + } + } + return s +} +func (r *redactor) Write(p []byte) (int, error) { + r.pending += string(p) + n := len(r.pending) - r.keep + if n > 0 { + // Move the cut before any secret spanning it. + for _, s := range r.secrets { + start := strings.LastIndex(r.pending[:min(len(r.pending), n+len(s)-1)], s) + if start >= 0 && start < n && start+len(s) > n { + n = start + } + } + if n > 0 { + if _, err := io.WriteString(r.out, redact(r.pending[:n], r.secrets...)); err != nil { + return 0, err + } + r.pending = r.pending[n:] + } + } + return len(p), nil +} +func (r *redactor) Flush() { + _, _ = io.WriteString(r.out, redact(r.pending, r.secrets...)) + r.pending = "" +} + +// Every command goes through the same output flags and state permissions, including failed writes. +func (f Flow) runTerraform(ctx context.Context, path, dir string, args, env []string, stdout, stderr io.Writer) error { + if !f.StdoutTerminal { + args = append([]string{args[0], "-no-color"}, args[1:]...) + } + runErr := f.RunTerraform(ctx, path, dir, args, env, stdout, stderr) + var stateErr error + switch args[0] { + case "init", "plan", "apply", "destroy": + for _, name := range []string{"terraform.tfstate", "terraform.tfstate.backup"} { + if err := os.Chmod(filepath.Join(dir, name), 0o600); err != nil && !os.IsNotExist(err) { + stateErr = errors.Join(stateErr, fmt.Errorf("keep Terraform state private: %w", err)) + } + } + } + return errors.Join(runErr, stateErr) +} + +func (f Flow) applyPlan(ctx context.Context, terraform, dir string, env, secrets []string, guarded bool, beforeApply func() error) error { + plan, err := os.CreateTemp(dir, ".codeaf-plan-*") + if err != nil { + return err + } + defer os.Remove(plan.Name()) + if err := plan.Close(); err != nil { + return err + } + name := filepath.Base(plan.Name()) + if err := f.terraform(ctx, terraform, dir, []string{"plan", "-input=false", "-out=" + name}, env, secrets...); err != nil { + return err + } + if guarded { + if err := f.guardPlan(ctx, terraform, dir, name, env); err != nil { + return err + } + } + if err := beforeApply(); err != nil { + return err + } + return f.terraform(ctx, terraform, dir, []string{"apply", "-input=false", name}, env, secrets...) +} + +func (f Flow) guardPlan(ctx context.Context, terraform, dir, plan string, env []string) error { + // Show exposes sensitive values in plain text. Neither stream nor its errors may leave memory. + var raw bytes.Buffer + if err := f.runTerraform(ctx, terraform, dir, []string{"show", "-json", plan}, env, &raw, io.Discard); err != nil { + return errors.New("could not inspect the pairing plan") + } + var planJSON struct { + Resources []struct { + Address string `json:"address"` + Change struct { + Actions []string `json:"actions"` + } `json:"change"` + } `json:"resource_changes"` + } + if err := json.Unmarshal(raw.Bytes(), &planJSON); err != nil { + return errors.New("could not read the pairing plan") + } + var refused []string + for _, resource := range planJSON.Resources { + if len(resource.Change.Actions) == 1 && resource.Change.Actions[0] == "no-op" { + continue + } + if resource.Address != "railway_variable_collection.box" { + refused = append(refused, strconv.Quote(resource.Address)) + } + } + if len(refused) != 0 { + return fmt.Errorf("refusing a pairing plan that changes %s", strings.Join(refused, ", ")) + } + return nil +} + +func (f Flow) outputs(ctx context.Context, terraform, dir string, env []string, secrets ...string) (map[string]string, error) { + var raw bytes.Buffer + errs := newRedactor(f.Err, secrets...) + err := f.runTerraform(ctx, terraform, dir, []string{"output", "-json"}, env, &raw, errs) + errs.Flush() + if err != nil { + return nil, fmt.Errorf("terraform output failed: %s", redact(err.Error(), secrets...)) + } + var values map[string]struct { + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(raw.Bytes(), &values); err != nil { + return nil, errors.New("could not read Terraform outputs") + } + result := make(map[string]string, len(values)) + for key, output := range values { + var value string + _ = json.Unmarshal(output.Value, &value) + result[key] = value + } + return result, nil +} diff --git a/internal/devbox/terraform/aws/main.tf b/internal/devbox/terraform/aws/main.tf new file mode 100644 index 0000000000..3c73997443 --- /dev/null +++ b/internal/devbox/terraform/aws/main.tf @@ -0,0 +1,120 @@ +# This module has never been applied. The devbox command does not offer AWS. +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.region +} + +data "aws_ami" "debian" { + most_recent = true + owners = ["136693071363"] + filter { + name = "name" + values = ["debian-12-amd64-*"] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } + filter { + name = "architecture" + values = ["x86_64"] + } +} + +data "aws_subnet" "box" { + id = var.subnet_id +} + +resource "aws_security_group" "box" { + name_prefix = "codeaf-${var.name}-" + vpc_id = data.aws_subnet.box.vpc_id + ingress = [] + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } +} + +resource "aws_ebs_volume" "home" { + availability_zone = data.aws_subnet.box.availability_zone + size = var.volume_size + type = "gp3" + encrypted = true + tags = { Name = "codeaf-${var.name}-home" } +} + +resource "aws_volume_attachment" "home" { + device_name = "/dev/sdf" + volume_id = aws_ebs_volume.home.id + instance_id = aws_instance.box.id +} + +resource "aws_instance" "box" { + ami = data.aws_ami.debian.id + instance_type = var.instance_type + subnet_id = var.subnet_id + vpc_security_group_ids = [aws_security_group.box.id] + tags = { Name = "codeaf-${var.name}" } + user_data = <<-USERDATA + #!/bin/bash + set -euo pipefail + apt-get update + apt-get install -y bash curl ca-certificates tar git + # Nitro exposes EBS disks by volume id; attachment can finish after boot. + volume_id='${replace(aws_ebs_volume.home.id, "-", "")}' + device="" + for attempt in $(seq 1 180); do + for candidate in /dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_$volume_id /dev/sdf /dev/xvdf; do + if [ -b "$candidate" ]; then device="$candidate"; break; fi + done + [ -n "$device" ] && break + sleep 2 + done + [ -n "$device" ] + if ! blkid "$device"; then mkfs.ext4 "$device"; fi + mkdir -p /data + uuid=$(blkid -s UUID -o value "$device") + printf 'UUID=%s /data ext4 defaults,nofail 0 2\n' "$uuid" >> /etc/fstab + mount /data + printf %s '${base64encode(file("${path.module}/../boot.sh"))}' | base64 -d > /usr/local/lib/codeaf-boot.sh + printf %s '${base64encode(join("\n", [ + "CODEAF_DEVBOX_NAME=${var.name}", + "CODEAF_DEVBOX_INSTALL_URL=${var.install_url}", + "CODEAF_DEVBOX_BINARY=${var.binary}", + "CODEAF_DEVBOX_VIA=${var.via}", + "CODEAF_DEVBOX_CODE=${var.pair_code}", + "CODEAF_DEVBOX_REMOTE_KEY=${var.remote_key}", + "CODEAF_DEVBOX_REMOTE_DEVICES=${var.remote_devices}", +]))}' | base64 -d > /etc/codeaf-devbox.env + chmod 600 /etc/codeaf-devbox.env + cat > /etc/systemd/system/codeaf-devbox.service <<'UNIT' + [Unit] + Description=codeaf devbox + Wants=network-online.target + After=network-online.target + RequiresMountsFor=/data + [Service] + Type=simple + EnvironmentFile=/etc/codeaf-devbox.env + ExecStart=/bin/bash /usr/local/lib/codeaf-boot.sh + Restart=always + RestartSec=10 + [Install] + WantedBy=multi-user.target + UNIT + systemctl daemon-reload + systemctl enable --now codeaf-devbox.service + USERDATA +} diff --git a/internal/devbox/terraform/aws/outputs.tf b/internal/devbox/terraform/aws/outputs.tf new file mode 100644 index 0000000000..ed6cbf92b4 --- /dev/null +++ b/internal/devbox/terraform/aws/outputs.tf @@ -0,0 +1,12 @@ +output "instance_id" { + value = aws_instance.box.id +} +output "name" { + value = var.name +} +output "region" { + value = var.region +} +output "volume_id" { + value = aws_ebs_volume.home.id +} diff --git a/internal/devbox/terraform/aws/variables.tf b/internal/devbox/terraform/aws/variables.tf new file mode 100644 index 0000000000..5088aecc64 --- /dev/null +++ b/internal/devbox/terraform/aws/variables.tf @@ -0,0 +1,45 @@ +variable "name" { + type = string +} +variable "region" { + type = string + default = "us-east-1" +} +variable "subnet_id" { + type = string + description = "An existing subnet with outbound internet access." +} +variable "instance_type" { + type = string + default = "t3.small" +} +variable "volume_size" { + type = number + default = 20 +} +variable "install_url" { + type = string + default = "https://agentfield.ai/get/codeaf" +} +variable "binary" { + type = string + default = "codeaf" +} +variable "via" { + type = string +} +variable "pair_code" { + type = string + sensitive = true + default = "" +} +variable "remote_key" { + type = string + sensitive = true + default = "" +} +variable "remote_devices" { + type = string + sensitive = true + default = "" +} diff --git a/internal/devbox/terraform/boot.sh b/internal/devbox/terraform/boot.sh new file mode 100644 index 0000000000..1d7a691e99 --- /dev/null +++ b/internal/devbox/terraform/boot.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The override lets the boot contract run against an isolated disposable volume. +data_root="${CODEAF_DEVBOX_DATA_ROOT:-/data}" +export HOME="$data_root/home" +export CODEAF_HOME="$HOME/.codeaf" +export CODEAF_INSTALL_DIR="$CODEAF_HOME/bin" +export CODEAF_NO_MODIFY_PATH=1 CODEAF_NO_START=1 +export PATH="$CODEAF_INSTALL_DIR:$PATH" +mkdir -p "$HOME" +umask 077 + +say() { printf 'codeaf devbox: %s\n' "$*"; } +marker="$data_root/.codeaf-devbox-joined" +if [[ ! -f "$marker" && -z "${CODEAF_DEVBOX_CODE:-}" ]]; then + say 'waiting for a pairing code from codeaf devbox up' + exec sleep infinity +fi +export CODEAF_INSTALL_NAME="$CODEAF_DEVBOX_BINARY" +install_codeaf() ( + # The release pin is only for the first install; refreshes follow the channel. + unset VERSION + if [[ ! -x "$CODEAF_INSTALL_DIR/$CODEAF_DEVBOX_BINARY" && -n "${CODEAF_DEVBOX_VERSION:-}" ]]; then + if curl -fsSL "$CODEAF_DEVBOX_INSTALL_URL" | VERSION="$CODEAF_DEVBOX_VERSION" bash; then + return 0 + fi + say 'pinned installation failed; trying the newest channel build' + fi + curl -fsSL "$CODEAF_DEVBOX_INSTALL_URL" | bash +) +say 'installing codeaf' +if ! install_codeaf; then + if [[ ! -x "$CODEAF_INSTALL_DIR/$CODEAF_DEVBOX_BINARY" ]]; then + say 'installation failed and no earlier binary is available' + sleep 60 + exit 1 + fi + say 'installation failed; using the earlier binary' +fi + +# Each release channel syncs through its own relay, and a pairing saves none when +# the creating laptop uses its own build's default. Pin the box to the laptop's +# relay the way a pairing that named one would, so a box whose installed build +# defaults elsewhere still shares the laptop's keys and devices. A relay saved +# later by another pairing is left alone. +if [[ -n "${CODEAF_DEVBOX_VIA:-}" && ! -e "$CODEAF_HOME/sync-url" ]]; then + mkdir -p "$CODEAF_HOME" + printf '%s\n' "$CODEAF_DEVBOX_VIA" > "$CODEAF_HOME/sync-url" +fi +if [[ ! -f "$marker" ]]; then + if ! "$CODEAF_DEVBOX_BINARY" pair "$CODEAF_DEVBOX_CODE" --name "$CODEAF_DEVBOX_NAME" --via "$CODEAF_DEVBOX_VIA" "$temporary"; then + rm -f "$temporary" + return 1 + fi + chmod 600 "$temporary" + mv "$temporary" "$remote_dir/$name" +} +seed_remote device.key "${CODEAF_DEVBOX_REMOTE_KEY:-}" +seed_remote devices.json "${CODEAF_DEVBOX_REMOTE_DEVICES:-}" +unset CODEAF_DEVBOX_CODE CODEAF_DEVBOX_REMOTE_KEY CODEAF_DEVBOX_REMOTE_DEVICES +mkdir -p "$HOME/work" +say 'serving the work folder' +# A box only serves, so it asks serve to keep the identity's saved keys, +# settings and presence in step; builds that predate the variable ignore it. +export CODEAF_SERVE_SYNC=1 +# A box also takes the identity's tasks, but only from a build whose serve lists +# `--take-tasks`: a box can follow a channel whose build predates the flag, and +# serve refuses a flag it does not know, which would leave the box serving +# nothing. Two exec lines rather than an array, because `set -u` rejects an +# empty array on the bash 3.2 a Mac runs this script's test with. +serve_help="$("$CODEAF_DEVBOX_BINARY" serve --help 2>&1 /tmp/codeaf-boot.sh && exec bash /tmp/codeaf-boot.sh'" +} + +resource "railway_variable_collection" "box" { + environment_id = railway_project.box.default_environment.id + service_id = railway_service.box.id + variables = [for variable in [ + { name = "CODEAF_DEVBOX_BOOT", value = filebase64("${path.module}/boot.sh") }, + { name = "CODEAF_DEVBOX_INSTALL_URL", value = var.install_url }, + { name = "CODEAF_DEVBOX_BINARY", value = var.binary }, + { name = "CODEAF_DEVBOX_VERSION", value = var.install_version }, + { name = "CODEAF_DEVBOX_NAME", value = var.name }, + { name = "CODEAF_DEVBOX_VIA", value = var.via }, + { name = "CODEAF_DEVBOX_CODE", value = var.pair_code }, + { name = "CODEAF_DEVBOX_REMOTE_KEY", value = var.remote_key }, + { name = "CODEAF_DEVBOX_REMOTE_DEVICES", value = var.remote_devices }, + ] : variable if variable.value != ""] +} diff --git a/internal/devbox/terraform/railway/outputs.tf b/internal/devbox/terraform/railway/outputs.tf new file mode 100644 index 0000000000..377b05c4b1 --- /dev/null +++ b/internal/devbox/terraform/railway/outputs.tf @@ -0,0 +1,18 @@ +output "project_id" { + value = railway_project.box.id +} +output "project_name" { + value = railway_project.box.name +} +output "service_id" { + value = railway_service.box.id +} +output "environment_id" { + value = railway_project.box.default_environment.id +} +output "region" { + value = try(railway_service.box.regions[0].region, "") +} +output "name" { + value = var.name +} diff --git a/internal/devbox/terraform/railway/variables.tf b/internal/devbox/terraform/railway/variables.tf new file mode 100644 index 0000000000..1df2ba7679 --- /dev/null +++ b/internal/devbox/terraform/railway/variables.tf @@ -0,0 +1,49 @@ +variable "name" { + type = string + description = "The name your devices use for this computer." +} +variable "workspace_id" { + type = string + default = "" +} +variable "region" { + type = string + default = "" +} +variable "image" { + type = string + default = "buildpack-deps:bookworm-scm" +} +variable "install_url" { + type = string + default = "https://agentfield.ai/get/codeaf" +} +variable "binary" { + type = string + default = "codeaf" +} +variable "install_version" { + type = string + default = "" + description = "The creating computer's release tag, used only for the first install." +} +variable "via" { + default = "" + type = string + description = "The relay used for both identity pairing and serving." +} +variable "pair_code" { + type = string + sensitive = true + default = "" +} +variable "remote_key" { + type = string + sensitive = true + default = "" +} +variable "remote_devices" { + type = string + sensitive = true + default = "" +} diff --git a/internal/devbox/workspaces.go b/internal/devbox/workspaces.go new file mode 100644 index 0000000000..3be0f07c26 --- /dev/null +++ b/internal/devbox/workspaces.go @@ -0,0 +1,107 @@ +package devbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +const railwayGraphQLURL = "https://backboard.railway.com/graphql/v2" + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` +} + +var workspaceID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +func resolveWorkspace(ctx context.Context, token, chosen string, lookup func(context.Context, string) ([]Workspace, error)) (string, error) { + if workspaceID.MatchString(chosen) { + return chosen, nil + } + workspaces, err := lookup(ctx, token) + if err != nil { + return "", err + } + if len(workspaces) == 0 { + if chosen != "" { + return "", errors.New("this token cannot look up workspace names; use --workspace with its id") + } + return "", nil + } + if chosen != "" { + for _, w := range workspaces { + if strings.EqualFold(w.Name, chosen) { + return w.ID, nil + } + } + return "", fmt.Errorf("no Railway workspace is called %q; use --workspace with a listed name or id", chosen) + } + if len(workspaces) == 1 { + return workspaces[0].ID, nil + } + names := make([]string, len(workspaces)) + for i, w := range workspaces { + names[i] = w.Name + } + return "", fmt.Errorf("choose a Railway workspace with --workspace : %s", strings.Join(names, ", ")) +} + +func LookupWorkspaces(ctx context.Context, token string) ([]Workspace, error) { + return lookupWorkspaces(ctx, &http.Client{Timeout: 20 * time.Second}, token) +} + +// The client seam lets tests answer the public GraphQL request without a network. +func lookupWorkspaces(ctx context.Context, client *http.Client, token string) ([]Workspace, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, railwayGraphQLURL, bytes.NewBufferString(`{"query":"{ me { workspaces { id name } } }"}`)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, errors.New("could not look up Railway workspaces; check the connection and token") + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Railway workspace lookup failed (%d)", resp.StatusCode) + } + var body struct { + Data struct { + Me *struct { + Workspaces []Workspace `json:"workspaces"` + } `json:"me"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + Path []string `json:"path"` + } `json:"errors"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return nil, errors.New("could not read Railway workspaces") + } + if len(body.Errors) > 0 { + for _, e := range body.Errors { + text := strings.ToLower(e.Message) + if strings.Contains(text, "unauthor") || strings.Contains(text, "forbidden") || strings.Contains(text, "not authorized") || strings.Contains(text, "not authenticated") { + return nil, nil + } + } + return nil, errors.New("Railway could not list workspaces; use --workspace with its id") + } + if body.Data.Me == nil { + return nil, nil + } + return body.Data.Me.Workspaces, nil +} diff --git a/internal/devbox/workspaces_test.go b/internal/devbox/workspaces_test.go new file mode 100644 index 0000000000..fd02c9241e --- /dev/null +++ b/internal/devbox/workspaces_test.go @@ -0,0 +1,50 @@ +package devbox + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// Contract 2: workspace resolution uses only the public query, with no live account. +func TestDevboxWorkspaceLookupAndSelection(t *testing.T) { + client := &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(r.Body) + if r.Header.Get("Authorization") != "Bearer fixture-token" || string(body) != `{"query":"{ me { workspaces { id name } } }"}` { + t.Fatalf("request: %s %s", r.Header, body) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"data":{"me":{"workspaces":[{"id":"one","name":"Personal"},{"id":"two","name":"Team"}]}}}`))}, nil + })} + lookup := func(ctx context.Context, token string) ([]Workspace, error) { + return lookupWorkspaces(ctx, client, token) + } + if _, err := resolveWorkspace(context.Background(), "fixture-token", "", lookup); err == nil || !strings.Contains(err.Error(), "Personal, Team") || !strings.Contains(err.Error(), "--workspace") { + t.Fatalf("ambiguity: %v", err) + } + if id, err := resolveWorkspace(context.Background(), "fixture-token", "Team", lookup); err != nil || id != "two" { + t.Fatalf("named: %s %v", id, err) + } + id := "11111111-2222-3333-4444-555555555555" + if got, err := resolveWorkspace(context.Background(), "fixture-token", id, func(context.Context, string) ([]Workspace, error) { t.Fatal("id looked up"); return nil, nil }); err != nil || got != id { + t.Fatalf("id: %s %v", got, err) + } +} + +// Contract 2: a workspace-scoped token may create without looking up me. +func TestDevboxWorkspaceTokenCanOmitWorkspace(t *testing.T) { + for _, response := range []string{`{"errors":[{"message":"Not Authorized","path":["me"]}]}`, `{"data":{"me":null}}`} { + client := &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response))}, nil + })} + got, err := lookupWorkspaces(context.Background(), client, "fixture-token") + if err != nil || len(got) != 0 { + t.Fatalf("workspace token: %v %v", got, err) + } + } +} diff --git a/internal/devname/device.go b/internal/devname/device.go new file mode 100644 index 0000000000..2f0b408ed5 --- /dev/null +++ b/internal/devname/device.go @@ -0,0 +1,31 @@ +package devname + +import ( + "fmt" + "strings" +) + +// TailLength is shared by the terminal list and the chat's device picker. +const TailLength = 4 + +// DeviceLabel gives duplicate device names the same selectable suffix everywhere. +func DeviceLabel(name, id string, duplicate bool) string { + if !duplicate { + return name + } + return DistinctName(name, id) +} + +// MatchesDevice accepts both the printed label and its space-free terminal form. +func MatchesDevice(typed, name, id string) bool { + if strings.EqualFold(strings.TrimSpace(typed), name) { + return true + } + label := DeviceLabel(name, id, true) + return strings.EqualFold(strings.ReplaceAll(strings.TrimSpace(typed), " #", "#"), strings.ReplaceAll(label, " #", "#")) +} + +// Ambiguous names the selectors a person can use in their next terminal command. +func Ambiguous(name string, choices []string) error { + return fmt.Errorf("more than one device is called %q: %s — name one, for example: codeaf devices revoke %s", name, strings.Join(choices, ", "), strings.ReplaceAll(choices[0], " #", "#")) +} diff --git a/internal/devname/devname.go b/internal/devname/devname.go index 5c8369e8c0..0c5316b1e7 100644 --- a/internal/devname/devname.go +++ b/internal/devname/devname.go @@ -122,3 +122,8 @@ func Set(dir, typed string) (string, error) { } func path(dir string) string { return filepath.Join(dir, fileName) } + +// DistinctName is the devices page's label when computers share a shown name. +func DistinctName(name, id string) string { + return name + " #" + id[max(len(id)-TailLength, 0):] +} diff --git a/internal/directory/capabilities.go b/internal/directory/capabilities.go new file mode 100644 index 0000000000..6c72c51139 --- /dev/null +++ b/internal/directory/capabilities.go @@ -0,0 +1,48 @@ +package directory + +import ( + "context" + "net/http" +) + +// Capability is a promise a sync service makes in a response header, and the +// one feature that waits while a service does not make it. Every other feature +// works on a service that lacks it. +type Capability struct { + Header string + Feature string +} + +// Capabilities is what this build of codeaf needs a sync service to promise. +// The release gate (codeaf-release service) refuses to ship a build whose +// service does not answer every one, and the client says which feature waits. +var Capabilities = []Capability{ + {Header: ConditionalTakeHeader, Feature: conditionalTakeFeature}, +} + +// conditionalTakeFeature is what waits on a service without conditional takes: +// displacing a live holder. A lapsed chat is taken on every service. +const conditionalTakeFeature = "taking an open chat from another computer" + +// missingFrom answers the capabilities a response did not promise. +func missingFrom(h http.Header) []Capability { + var missing []Capability + for _, c := range Capabilities { + if h.Get(c.Header) != "1" { + missing = append(missing, c) + } + } + return missing +} + +// Missing reads chat id and answers the capabilities the service did not +// promise on that read. A chat's own read carries every promise, because the +// promises concern what may be done to a chat. +func (h *HTTP) Missing(ctx context.Context, id string) ([]Capability, error) { + var v CellView + hdr, err := h.doHeader(ctx, http.MethodGet, cellPath(id, ""), nil, &v) + if err != nil { + return nil, err + } + return missingFrom(hdr), nil +} diff --git a/internal/directory/client.go b/internal/directory/client.go index 93bff63921..1c49b7c0ce 100644 --- a/internal/directory/client.go +++ b/internal/directory/client.go @@ -49,9 +49,10 @@ type Client interface { // AcquireOpts says how a device takes a lease. Its JSON is the acquire // request body, and an empty body means the zero value. type AcquireOpts struct { - // RequireConditional is a continuation's local safety requirement, even - // when the observed holder has gone quiet. An ordinary non-forced resume - // still works on older services, whose live-holder check is already atomic. + // RequireConditional asks the coordinating runtime to check the expected + // holder and fence even when the observed holder has gone quiet. A service + // without conditional takes still takes a lapsed lease, because its + // live-holder check is already atomic; only Force waits for the promise. RequireConditional bool `json:"-"` // Force takes the lease from a live holder. It is for a person who chose to // continue a chat where it runs now: waiting out the holder's lease would @@ -93,9 +94,9 @@ var ( ErrUnauthorized = errors.New("directory: unauthorized") ErrUnreachable = errors.New("directory: unreachable") // transport; callers degrade ErrTooOld = errors.New("directory: this relay is too old for that") - // ErrConditionalTake names the update needed before an old service can - // safely move chats; its other read and pairing doors remain usable. - ErrConditionalTake = errors.New("the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service") + // ErrConditionalTake is the one feature an older service cannot offer + // safely. It says what still works and the one thing to do instead. + ErrConditionalTake = errors.New(conditionalTakeFeature + " waits for a sync service update; all else works, so close it there and continue here") ) // ReleaseQuiet distinguishes background preparation from quitting an open diff --git a/internal/directory/conditional_take_test.go b/internal/directory/conditional_take_test.go index e6041ee6ce..7e72e2510f 100644 --- a/internal/directory/conditional_take_test.go +++ b/internal/directory/conditional_take_test.go @@ -13,8 +13,8 @@ import ( ) // TestC7OldServiceRefusesTakesAcrossClients proves C7 at the HTTP door: a -// service without the promise never receives a take, from any client, and its -// absence is read again before each attempt (one read per refused take). +// service without the promise never receives a forced take, from any client, +// and its absence is read again before each attempt (one read per refused take). func TestC7OldServiceRefusesTakesAcrossClients(t *testing.T) { reads, takes := 0, 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -26,9 +26,9 @@ func TestC7OldServiceRefusesTakesAcrossClients(t *testing.T) { _ = json.NewEncoder(w).Encode(directory.CellView{}) })) defer srv.Close() - for _, opts := range []directory.AcquireOpts{{Force: true}, {RequireConditional: true}} { + for range 2 { client := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) - _, err := client.Acquire(context.Background(), cellX, opts) + _, err := client.Acquire(context.Background(), cellX, directory.AcquireOpts{Force: true, RequireConditional: true}) if !errors.Is(err, directory.ErrConditionalTake) { t.Fatalf("take = %v", err) } @@ -72,6 +72,25 @@ func TestHTTPF2HeadGuardRequiresAtomicSupport(t *testing.T) { } } +// TestOldServiceStillTakesALapsedLease pins #2072: a lapsed lease is taken on +// a service without the promise, because its own acquire still refuses a live +// holder. Waiting for the service's update must never lock a person out. +func TestOldServiceStillTakesALapsedLease(t *testing.T) { + takes := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/acquire") { + takes++ + } + _ = json.NewEncoder(w).Encode(directory.CellView{}) + })) + defer srv.Close() + client := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) + _, err := client.Acquire(context.Background(), cellX, directory.AcquireOpts{RequireConditional: true, Expect: &directory.LeaseExpectation{Device: devA, Fence: 1}}) + if err != nil || takes != 1 { + t.Fatalf("lapsed take on an old service: err=%v takes=%d", err, takes) + } +} + // TestC7OldServiceStillAllowsAnOrdinaryResume proves C7's compatibility // boundary: a local non-forced resume cannot displace a live other holder. func TestC7OldServiceStillAllowsAnOrdinaryResume(t *testing.T) { diff --git a/internal/directory/http.go b/internal/directory/http.go index 2bf1932264..982d859548 100644 --- a/internal/directory/http.go +++ b/internal/directory/http.go @@ -79,7 +79,11 @@ func (h *HTTP) Create(ctx context.Context, id string, in CellInit) (v CellView, } func (h *HTTP) Acquire(ctx context.Context, id string, o AcquireOpts) (v CellView, err error) { - if o.Force || o.RequireConditional { + // Only displacing a live holder needs the promise. A take of a lapsed lease + // is safe on every service: an older one still refuses a live holder + // atomically, raises the fence, and answers the head the caller compares + // with the one it saw, so a person is never kept from their own chat (#2072). + if o.Force { if err := h.requireConditionalTake(ctx, id); err != nil { return v, err } @@ -250,7 +254,7 @@ func refusal(resp *http.Response) error { if resp.StatusCode == http.StatusNotFound { return ErrTooOld } - if resp.StatusCode == http.StatusServiceUnavailable { + if resp.StatusCode >= http.StatusInternalServerError { // Coordination can refuse before a directory handler has an error // body. It is the same retryable refusal the coded answer names, not // an unreachable network, so closing releases retry it and nothing diff --git a/internal/dirwatch/feed.go b/internal/dirwatch/feed.go index 402cb07cd3..0fcfd97d72 100644 --- a/internal/dirwatch/feed.go +++ b/internal/dirwatch/feed.go @@ -119,7 +119,14 @@ func (f *Feed) leave(s *sub) (last bool) { func (f *Feed) snapshot() State { f.mu.Lock() defer f.mu.Unlock() - return f.state + st := f.state + // A suspended timer cannot keep presence fresh. Read-time expiry also covers + // a blocked write and the first frame after this computer wakes from sleep. + now := f.clock.Now() + if st.Up && (f.heard.IsZero() || now.Before(f.heard) || now.Sub(f.heard) >= DeadAfter) { + st.Up, st.Vouching, st.Online = false, false, nil + } + return st } // set replaces the state and wakes the followers when it differs. @@ -242,6 +249,9 @@ func (f *Feed) serve(ctx context.Context, s Stream) error { f.events = f.events.forgotten() st.Online = f.events.online }) + f.mu.Lock() + f.heard = time.Time{} + f.mu.Unlock() f.drainKick() in := pump(ctx, s) k := newKeeper(f.clock) @@ -253,6 +263,13 @@ func (f *Feed) serve(ctx context.Context, s Stream) error { if r.err != nil { return r.err } + last := f.lastHeard() + now := f.clock.Now() + if !last.IsZero() && (now.Before(last) || now.Sub(last) >= DeadAfter) { + // A late pong is not a replacement roster. Subscribe again before + // believing any presence retained from the stalled connection. + return errDead + } f.hear(r.frame, vouching) k.heard() case <-k.C(): diff --git a/internal/dirwatch/l4b2_presence_test.go b/internal/dirwatch/l4b2_presence_test.go new file mode 100644 index 0000000000..cddefd0b7e --- /dev/null +++ b/internal/dirwatch/l4b2_presence_test.go @@ -0,0 +1,93 @@ +package dirwatch + +import ( + "testing" + "time" +) + +// L4-M17a, L4-M17b, L4-M17c: A stalled source loses its presence claim even if its timer cannot run. +func TestL4B2PresenceExpiresWithoutTimer(t *testing.T) { + clock := newFakeClock() + f := newFeed(nil, clock, func() float64 { return 0 }) + on := PresenceEvent("laptop-a", true, clock.Now().UnixMilli()) + f.hear(Frame{Event: &on}, false) + if st := f.snapshot(); !st.Up || len(st.Online) != 1 { + t.Fatalf("online answer: %+v", st) + } + clock.mu.Lock() + clock.now = clock.now.Add(DeadAfter + time.Millisecond) + clock.mu.Unlock() + if st := f.snapshot(); st.Up || len(st.Online) != 0 { + t.Fatalf("stale online claim: %+v", st) + } + off := PresenceEvent("laptop-a", false, clock.Now().UnixMilli()) + f.hear(Frame{Event: &off}, false) + if st := f.snapshot(); !st.Up || len(st.Online) != 0 { + t.Fatalf("new answer: %+v", st) + } + f.hear(Frame{Event: &on}, false) + if st := f.snapshot(); !st.Up || len(st.Online) != 1 { + t.Fatalf("peer return: %+v", st) + } +} + +// L4-M17b: A service stall ends the old subscription; a fresh subscription replaces the old online set. +func TestL4B2PresenceResubscribesAfterStall(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + r.dialAt() + s := <-list + on := PresenceEvent("laptop-a", true, r.clock.Now().UnixMilli()) + s.send(Frame{Event: &on}, nil) + r.changed() + r.clock.tick(KeepEvery) + <-s.pings + r.clock.tick(PongWithin) + <-s.closed + r.changed() + if st := r.sub.State(); st.Up || len(st.Online) != 0 { + t.Fatalf("stale presence: %+v", st) + } + r.clock.tick(RetryBase * 2 / 5) + r.dialAt() + next := <-list + next.send(Frame{Version: 2}, nil) + r.changed() + if st := r.sub.State(); !st.Up || len(st.Online) != 0 { + t.Fatalf("old online set survived: %+v", st) + } + next.send(Frame{Event: &on}, nil) + r.changed() + if st := r.sub.State(); !st.Up || len(st.Online) != 1 { + t.Fatalf("new online answer missing: %+v", st) + } +} + +// L4-M17b, L4-M17c: A pong delivered after the freshness window cannot revive an old online set. +func TestL4B2LatePongRequiresNewPresence(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + r.dialAt() + s := <-list + on := PresenceEvent("laptop-a", true, r.clock.Now().UnixMilli()) + s.send(Frame{Event: &on}, nil) + r.changed() + r.clock.await(KeepEvery) + r.clock.mu.Lock() + r.clock.now = r.clock.now.Add(DeadAfter + time.Millisecond) + r.clock.mu.Unlock() + s.send(Frame{Pong: true}, nil) + <-s.closed + r.changed() + if st := r.sub.State(); st.Up || len(st.Online) != 0 { + t.Fatalf("late pong revived presence: %+v", st) + } + r.clock.tick(RetryBase * 2 / 5) + r.dialAt() + next := <-list + next.send(Frame{Version: 2}, nil) + r.changed() + if st := r.sub.State(); !st.Up || len(st.Online) != 0 { + t.Fatalf("subscription inherited old presence: %+v", st) + } +} diff --git a/internal/e2e/l4fix4_joinable_e2e_test.go b/internal/e2e/l4fix4_joinable_e2e_test.go new file mode 100644 index 0000000000..b151067d5f --- /dev/null +++ b/internal/e2e/l4fix4_joinable_e2e_test.go @@ -0,0 +1,167 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "io/fs" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// L4-A: The real provider-connected window remains joinable until a person +// sends a turn. No cache or startup store is invented by this fixture. +func TestL4ProviderWindowCanJoinUntilATurnIsSent(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("no tmux on PATH: this suite drives the real binary in a real terminal") + } + _ = binary(t) + const model = "l4-window" + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"data":[{"id":%q,"context_length":32768}]}`, model) + }) + mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, _ *http.Request) { + send, ok := automationStream(w) + if ok { + send(`{"content":"L4 window answered."}`, "") + send(`{}`, "stop") + send("", "[DONE]") + } + }) + var listener net.Listener + // The lane shares this laptop and reserves only these ports for its runs. + var err error + for port := 8900; port <= 8999; port++ { + listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + break + } + } + if err != nil { + t.Fatal(err) + } + server := &httptest.Server{Listener: listener, Config: &http.Server{Handler: mux}} + server.Start() + t.Cleanup(server.Close) + base := server.URL + "/v1" + for _, sent := range []bool{false, true} { + t.Run(fmt.Sprintf("sent=%v", sent), func(t *testing.T) { + login, err := os.MkdirTemp("/tmp", "rn-l4b-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(login) }) + root := filepath.Join(login, ".codeaf") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + // The native engine can outlive the window; stop this home's own + // sockets before removing the home that makes them reachable. + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + sockets, err := filepath.Glob(filepath.Join(root, "v3", "engine-*.sock")) + if err != nil { + t.Error(err) + } + for _, socket := range sockets { + if err := (cellstore.Daemon{Socket: socket}).Stop(ctx); err != nil { + t.Error(err) + } + } + }) + writeJSON(t, filepath.Join(root, "config.json"), map[string]any{ + "model.talk": model + "@local-models", "model.work": model + "@local-models", + }) + // Connect through the same owner as `codeaf connect ollama`, with + // a real listing probe and the persisted service/base selection. + for _, source := range modelsource.Vendored() { + if source.ID != modelsource.CustomID { + continue + } + outcome, err := config.ConnectService(t.Context(), root, config.PersistedSource{ + ID: modelsource.CustomID, Written: "local-models", Address: base, KeyOptional: true, Order: 1, + }, source, nil) + if err != nil || outcome.Kind != modelsource.OutcomeConnected { + t.Fatalf("connect local models: %+v, %v", outcome, err) + } + } + // A minimal repository makes this a borrowed project without any + // git mutation commands or a pre-created conversation. + ws := filepath.Join(login, "project") + for _, dir := range []string{"objects", "refs"} { + if err := os.MkdirAll(filepath.Join(ws, ".git", dir), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(ws, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0600); err != nil { + t.Fatal(err) + } + env := []string{"HOME=" + login, "CODEAF_BASE_URL=" + base, "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1"} + for _, name := range append(append([]string{}, keylessEnv...), "CODEAF_BASE_URL", "CODEAF_MODEL", "CODEAF_RELAY", "CODEAF_SYNC_URL") { + env = append([]string{"-u", name}, env...) + } + r := startWithEnv(t, env, fmt.Sprintf("l4_provider_%v", sent), root, ws, 120, 40, "chat", "--no-host") + r.skipSetup(t) + r.keys("Space", "Space") + if _, ok := waitPlain(r, 20*time.Second, say(t, "placeRestWord")); !ok { + t.Fatal("window did not reach Home") + } + cache := filepath.Join(root, "v3", "models-"+catalog.CacheKey(modelsource.CustomID, base)+".json") + if !waitUntil(20*time.Second, func() bool { _, err := os.Stat(cache); return err == nil }) { + t.Fatal("window did not write its keyed picker cache") + } + if sent { + r.lit("Please say hello.") + r.keys("Enter") + if _, ok := waitPlain(r, 30*time.Second, "L4 window answered."); !ok { + t.Fatal("scripted answer never reached the window") + } + } + r.lit("/quit") + r.keys("Enter") + if !waitUntil(20*time.Second, func() bool { + out, _ := exec.Command("tmux", "display-message", "-p", "-t", r.name, "#{pane_dead}").Output() + return strings.TrimSpace(string(out)) == "1" + }) { + t.Fatal("window did not finish quitting") + } + r.kill() + _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err == nil && !entry.IsDir() { + rel, _ := filepath.Rel(root, path) + t.Logf("window wrote %s", rel) + } + return err + }) + joinable, err := identity.Joinable(root) + if err != nil || joinable == sent { + t.Errorf("Joinable = %v, %v; sent=%v", joinable, err, sent) + } + count, err := identity.ChatCount(root) + want := 0 + if sent { + want = 1 + } + if err != nil || count != want { + t.Errorf("ChatCount = %d, %v; want %d", count, err, want) + } + }) + } +} diff --git a/internal/e2e/pairjourney_steps_e2e_test.go b/internal/e2e/pairjourney_steps_e2e_test.go index 92f0b11e7d..b4ca3f1e94 100644 --- a/internal/e2e/pairjourney_steps_e2e_test.go +++ b/internal/e2e/pairjourney_steps_e2e_test.go @@ -34,7 +34,7 @@ func journeyRun(j *journey, env []string, homeA, homeB, wsA, wsB string, online a.keys("Space") screen, ok := waitPlain(a, 12*time.Second, "Add another device") j.check(ok, "the home screen never offered another machine") - j.check(strings.Contains(screen, "pick up your work anywhere"), "pitch missing") + j.check(strings.Contains(screen, say(t, "addMachinePitch")), "pitch missing") j.see(a) }) diff --git a/internal/e2e/task_other_home_e2e_test.go b/internal/e2e/task_other_home_e2e_test.go new file mode 100644 index 0000000000..efe094779a --- /dev/null +++ b/internal/e2e/task_other_home_e2e_test.go @@ -0,0 +1,737 @@ +//go:build e2e + +package e2e + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +const otherHomeModel = "stub/paired-task" +const otherHomeFile = "made-on-other-home.txt" +const otherHomeContents = "the paired worker wrote this\n" +const otherHomeCommit = "write the paired task result" +const otherHomeBrief = "Write made-on-other-home.txt containing exactly the paired worker wrote this followed by a newline, commit it with subject write the paired task result, and finish." +const otherHomeCallCost = 0.0125 + +// DESIGN 1–5: the connection invokes the session entry used by /task --on. +// Only the two providers are scripted; both engines, the child, encrypted +// transport, plan store, ledger and landing are the shipped binary's own. +func TestATaskRunsOnTheOtherHomeAndLandsHere(t *testing.T) { + began := time.Now() + f := newOtherHomeTask(t) + f.brainB.gate = make(chan struct{}) + f.startServing(t) + a := f.openConversation(t, false) + f.say(t, a, "Remember ORIGIN_TRANSCRIPT_ONLY and reply CHAT_STAYS_HERE.") + id := f.startTask(t, a) + // The worker cannot receive its finishing call until its first step is + // readable here, so an implementation that buffers until finish fails. + otherHomeWait(t, f.ctx, "the first step while the other worker is running", func() bool { + page, ok, err := a.ReadPlanTaskPage(strconv.FormatUint(id, 10)) + if err != nil || !ok { + return false + } + for _, step := range page.Steps { + if strings.Contains(step.Command, "TASK_INSPECTED_HERE") { + return true + } + } + return false + }) + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + return n.Computer == "work computer" && n.State == session.TaskRunning && !n.RanElsewhere + }) + close(f.brainB.gate) + f.waitDone(t, a, id) + // R9.1 requires the finished A-side reading to carry the placement words, + // rather than merely retaining an executor field that no row can draw. + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + return n.Computer == "work computer" && n.RanElsewhere && n.Doing == "ran on work computer" + }) + + t.Run("model_calls_reach_only_the_other_home", func(t *testing.T) { + // Contract 1 distinguishes actual worker prompts from naming and chat calls. + ac, aw := f.brainA.counts() + bc, bw := f.brainB.counts() + if ac == 0 || aw != 0 || bc == 0 || bw < 2 { + t.Fatalf("provider calls: here=%d (worker=%d), there=%d (worker=%d)", ac, aw, bc, bw) + } + }) + t.Run("the_file_lands_in_this_project", func(t *testing.T) { + // Contract 3 includes the worker's commit and excludes the chat's record. + f.assertFile(t) + if got := f.git(t, "log", "--format=%s"); !strings.Contains("\n"+got+"\n", "\n"+otherHomeCommit+"\n") { + t.Fatalf("the worker's commit is absent from the project history: %s", got) + } + transcript := a.Client().Welcome().SessionFile + raw, err := os.ReadFile(transcript) + if err != nil || !strings.Contains(string(raw), "ORIGIN_TRANSCRIPT_ONLY") || !strings.HasPrefix(transcript, f.a+string(os.PathSeparator)) { + t.Fatalf("origin transcript = %q, %v", transcript, err) + } + err = filepath.WalkDir(f.b, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !(strings.HasSuffix(path, ".jsonl") || strings.HasSuffix(path, ".json") || strings.HasSuffix(path, ".md")) { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), "ORIGIN_TRANSCRIPT_ONLY") || entry.Name() == filepath.Base(transcript) { + t.Errorf("the conversation travelled to %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + }) + t.Run("the_steps_arrived_in_order", func(t *testing.T) { + // Contract 2 reads A's ordinary task page, backed by its plan trajectory. + page, ok, err := a.ReadPlanTaskPage(strconv.FormatUint(id, 10)) + if err != nil || !ok { + t.Fatalf("task page: %v, %v", ok, err) + } + first, second := -1, -1 + for i, step := range page.Steps { + if strings.Contains(step.Command, "TASK_INSPECTED_HERE") { + first = i + } + if strings.Contains(step.Command, "git add -- "+otherHomeFile) { + second = i + } + } + if first < 0 || second <= first { + t.Fatalf("worker steps missing or reordered: %+v", page.Steps) + } + }) + t.Run("spend_is_on_this_ledger_once", func(t *testing.T) { + // Contract 5 compares the mirrored calls with B's own per-call charges. + aLines := otherHomeLedger(t, f.a) + bLines := otherHomeLedger(t, f.b) + seen := map[string]bool{} + transcript, err := os.Open(a.Client().Welcome().SessionFile) + if err != nil { + t.Fatal(err) + } + var header struct { + ID string `json:"id"` + } + err = json.NewDecoder(transcript).Decode(&header) + _ = transcript.Close() + if err != nil || header.ID == "" { + t.Fatalf("conversation header: %+v, %v", header, err) + } + var here, there float64 + count := 0 + for _, line := range aLines { + if line.RemoteEvent == "" { + continue + } + if seen[line.RemoteEvent] || line.Computer != "work computer" || line.Task != strconv.FormatUint(id, 10) || line.Session != header.ID { + t.Fatalf("duplicate or unattributed remote charge: %+v", line) + } + seen[line.RemoteEvent] = true + count++ + here += line.USD + } + for _, line := range bLines { + there += line.USD + } + bc, _ := f.brainB.counts() + if count != bc || math.Abs(here-there) > 1e-9 || math.Abs(here-float64(bc)*otherHomeCallCost) > 1e-9 { + t.Fatalf("charges here=%d/$%f, there=$%f, provider calls=%d", count, here, there, bc) + } + page, err := a.Client().Ledger(time.Time{}) + if err != nil { + t.Fatal(err) + } + var pageUSD float64 + pageCalls := 0 + for _, line := range page.Lines { + if line.RemoteEvent != "" { + pageUSD += line.USD + pageCalls += line.Calls + } + } + if pageCalls != count || math.Abs(pageUSD-here) > 1e-9 { + t.Fatalf("spend page calls=%d/$%f, ledger calls=%d/$%f", pageCalls, pageUSD, count, here) + } + }) + t.Run("private_copies_wait_for_review_and_apply", func(t *testing.T) { + // Contract 4 takes another real remote result through the person-only doors. + if err := os.Remove(filepath.Join(f.project, otherHomeFile)); err != nil { + t.Fatal(err) + } + f.git(t, "add", "--", otherHomeFile) + f.git(t, "commit", "-qm", "clear the previous result") + private := f.openConversation(t, true) + privateID := f.startTask(t, private) + f.waitDone(t, private, privateID) + if _, err := os.Stat(filepath.Join(f.project, otherHomeFile)); !os.IsNotExist(err) { + t.Fatalf("private result landed before apply: %v", err) + } + var review session.TaskWorkspaceReview + // The done notice precedes the final store close; Review itself is the + // public readiness check and must keep refusing while that join lives. + otherHomeWait(t, f.ctx, "the private result to be ready for review", func() bool { + var err error + review, err = private.ReviewTaskWorkspaceRoot(f.ctx, privateID, "work") + if err == nil { + return true + } + if err.Error() != "this workspace layout may still have a writer or checker; its private work was retained" { + t.Fatal(err) + } + return false + }) + if !strings.Contains(review.Diff, "+the paired worker wrote this") || review.Snapshot == "" { + t.Fatalf("review: %+v", review) + } + // A person's later edit must not change the snapshot Apply installs. + if err := os.WriteFile(filepath.Join(review.Copy, otherHomeFile), []byte("unreviewed later edit\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := private.ApplyTaskWorkspaceRoot(f.ctx, privateID, "work"); err != nil { + t.Fatal(err) + } + f.assertFile(t) + }) + t.Logf("two-home real-binary wall time: %s", time.Since(began).Round(time.Millisecond)) +} + +// DESIGN 8: the exact failed-start sentence and explicit local replacement +// cross the same connection doors as the card's run-it-here action. +func TestATaskForAComputerNotServingSaysSoAndRunsHere(t *testing.T) { + f := newOtherHomeTask(t) + stop := f.startServing(t) + stop() + a := f.openConversation(t, false) + id := f.startTask(t, a) + want := "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" + f.waitNotice(t, a, id, func(notice session.TaskNotice) bool { + return notice.Remote != nil && notice.Remote.StartFailed && strings.Contains(notice.Report, want) + }) + if _, err := a.RunTaskHere(f.ctx, id); err != nil { + t.Fatal(err) + } + f.waitDone(t, a, id) + f.assertFile(t) + if _, workers := f.brainA.counts(); workers < 2 { + t.Fatal("local replacement did not use this home's worker") + } + if calls, _ := f.brainB.counts(); calls != 0 { + t.Fatalf("stopped home received %d calls", calls) + } +} + +// S5: a real paired home running plain serve never opts into receiving tasks. +func TestPlainServeTakesNoTasks(t *testing.T) { + f := newOtherHomeTask(t) + f.startServe(t, false) + a := f.openConversation(t, false) + id := f.startTask(t, a) + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + return n.Remote != nil && n.Remote.StartFailed && strings.Contains(n.Report, taskdoor.NotTakingTasks("work computer")) + }) + if calls, _ := f.brainB.counts(); calls != 0 { + t.Fatalf("plain serve ran a task: provider calls=%d", calls) + } + if _, err := os.Stat(filepath.Join(f.b, "v3", "taskdoor")); !os.IsNotExist(err) { + t.Fatalf("plain serve opened task storage: %v", err) + } +} + +type otherHomeTask struct { + ctx context.Context + a, b, project, relay, product string + brainA, brainB *otherHomeBrain +} + +func newOtherHomeTask(t *testing.T) *otherHomeTask { + t.Helper() + product := binary(t) + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("paired tasks need Linux or macOS on both computers") + } + short := func(prefix string) string { + dir, err := os.MkdirTemp("/tmp", prefix) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir + } + f := &otherHomeTask{a: short("rn-l1-a"), b: short("rn-l1-b"), product: product} + var cancel context.CancelFunc + f.ctx, cancel = context.WithTimeout(t.Context(), 3*time.Minute) + t.Cleanup(cancel) + t.Setenv("HOME", f.a) + t.Setenv("CODEAF_HOME", f.a) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + f.brainA, f.brainB = newOtherHomeBrain(t, "synthetic-home-a-key"), newOtherHomeBrain(t, "synthetic-home-b-key") + for i, home := range []string{f.a, f.b} { + key := []string{f.brainA.key, f.brainB.key}[i] + rows := map[string]any{"api_key": key, "memory.enabled": false, "tools.approvalMode": "allow", "model_pool": "off", "task.audit": "off", "task.start": "single", "task.max_load": 0, "task.min_free_mb": 0, "setup_seen_at": time.Now().UTC().Format(time.RFC3339)} + for _, name := range []string{config.KeyChatModel, config.KeyTaskModel, config.KeyTierWorkerModel, config.KeyTierLowModel, config.KeyTierHighModel, config.KeyTierReflexModel, config.KeyTierMastermindModel} { + rows[name] = otherHomeModel + } + raw, _ := json.Marshal(rows) + if err := os.WriteFile(filepath.Join(home, "config.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + var addr string + for port := 8900; port <= 8999; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + addr = ln.Addr().String() + _ = ln.Close() + break + } + } + if addr == "" { + t.Fatal("no free task relay port in 8900–8999") + } + f.relay = "http://" + addr + relay := guardedCommand(t, f.ctx, f.a, f.env(f.a), product, "relay", "--listen", addr, "--store", filepath.Join(f.a, "relay-store"), "--quiet") + otherHomeProcess(t, relay, filepath.Join(f.a, "relay.log")) + otherHomeWait(t, f.ctx, "local relay", func() bool { + conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true + }) + t.Setenv("CODEAF_SYNC_URL", f.relay) + id, err := identity.Ensure(f.a) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(f.b, id, false); err != nil { + t.Fatal(err) + } + for i, home := range []string{f.a, f.b} { + name := []string{"chat computer", "work computer"}[i] + if _, err := devname.Set(home, name); err != nil { + t.Fatal(err) + } + paired, ok, err := syncsetup.Open(home) + if err != nil || !ok { + t.Fatalf("paired home: %v, %v", ok, err) + } + if err := syncsetup.PutOwnDevice(f.ctx, paired.Dir, paired.Identity, paired.Device, name); err != nil { + t.Fatal(err) + } + // Stop only each disposable home's native service, after its engines exit. + t.Cleanup(func() { + matches, _ := filepath.Glob(filepath.Join(home, "v3", "*.sock")) + for _, socket := range matches { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = (cellstore.Daemon{Socket: socket}).Stop(ctx) + cancel() + } + }) + } + f.project = filepath.Join(f.a, "project") + if err := os.Mkdir(f.project, 0700); err != nil { + t.Fatal(err) + } + f.git(t, "init", "-q", "-b", "task-e2e") + if err := os.WriteFile(filepath.Join(f.project, "README.md"), []byte("paired task fixture\n"), 0600); err != nil { + t.Fatal(err) + } + f.git(t, "add", "README.md") + f.git(t, "commit", "-qm", "initial project") + t.Cleanup(func() { + states, _ := filepath.Glob(filepath.Join(f.b, "v3", "taskdoor", "*", "state.json")) + for _, path := range states { + raw, _ := os.ReadFile(path) + var state struct { + PID int `json:"pid"` + Phase string `json:"phase"` + } + if json.Unmarshal(raw, &state) == nil && state.PID > 0 && state.Phase == "running" { + // These groups were launched only by this fixture's task door. + _ = syscall.Kill(-state.PID, syscall.SIGKILL) + } + } + }) + return f +} + +func (f *otherHomeTask) env(home string) []string { + brain := f.brainA + if home == f.b { + brain = f.brainB + } + return []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "CODEAF_HOME=" + home, "CODEAF_PROFILE_DIR=", config.APIKeyEnv + "=" + brain.key, + "CODEAF_BASE_URL=" + brain.server.URL, "CODEAF_SYNC_URL=" + f.relay, "CODEAF_RELAY=" + f.relay, "CODEAF_TASK_BELT=bash", + "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_NO_UPDATE_CHECK=1", "CODEAF_NO_AUTOMATIONS=1"} +} + +func (f *otherHomeTask) startServing(t *testing.T) func() { + return f.startServe(t, true) +} + +func (f *otherHomeTask) startServe(t *testing.T, takeTasks bool) func() { + t.Helper() + log := filepath.Join(f.b, "serve.log") + args := []string{"serve", "--via", f.relay} + ready := "this machine is reachable as" + if takeTasks { + args = append(args, "--take-tasks") + ready = taskdoor.TakingTasksLine + } + cmd := guardedCommand(t, f.ctx, f.b, f.env(f.b), f.product, args...) + cmd.Dir = f.b + stop := otherHomeProcess(t, cmd, log) + otherHomeWait(t, f.ctx, "codeaf serve", func() bool { + raw, _ := os.ReadFile(log) + return strings.Contains(string(raw), ready) + }) + return stop +} + +type otherHomePipe struct { + io.Reader + io.Writer + close func() error +} + +func (p otherHomePipe) Close() error { return p.close() } + +func (f *otherHomeTask) openConversation(t *testing.T, private bool) *remote.Agent { + t.Helper() + env := append(f.env(f.a), "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=0") + if private { + env = append(env, "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1") + } + cmd := guardedCommand(t, f.ctx, f.a, env, f.product, "engine", "--no-host", "--workspace", f.project) + cmd.Dir = f.project + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + log := filepath.Join(f.a, fmt.Sprintf("engine-%v.log", private)) + stop := otherHomeProcess(t, cmd, log) + client, err := remote.Dial(otherHomePipe{Reader: out, Writer: in, close: func() error { return in.Close() }}, "this computer", remote.Hello{Workspace: f.project, Headless: true, Model: otherHomeModel, New: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close(); stop() }) + if !client.Welcome().TaskComputers { + t.Fatal("paired task controls are absent") + } + return client.Agent() +} + +func (f *otherHomeTask) say(t *testing.T, a *remote.Agent, words string) { + t.Helper() + events, err := a.Submit(f.ctx, words) + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatalf("conversation: %+v", event) + } + } +} + +func (f *otherHomeTask) startTask(t *testing.T, a *remote.Agent) uint64 { + t.Helper() + id, _, _, err := a.StartTaskOn(f.ctx, otherHomeBrief, true, "work computer") + if err != nil { + t.Fatal(err) + } + return id +} + +func (f *otherHomeTask) waitDone(t *testing.T, a *remote.Agent, id uint64) { + t.Helper() + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + if n.State == session.TaskDone { + return true + } + if n.State != session.TaskRunning && n.State != session.TaskQueued { + t.Fatalf("task ended without done: %+v", n) + } + return false + }) +} + +func (f *otherHomeTask) waitNotice(t *testing.T, a *remote.Agent, id uint64, ready func(session.TaskNotice) bool) { + t.Helper() + events, stop := a.WatchTaskUpdates() + defer stop() + for { + select { + case event, ok := <-events: + if !ok { + t.Fatal("task connection closed") + } + if event.Task != nil && event.Task.ID == id && ready(*event.Task) { + return + } + case <-f.ctx.Done(): + t.Fatalf("waiting for task %d: %v", id, f.ctx.Err()) + } + } +} + +func (f *otherHomeTask) assertFile(t *testing.T) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(f.project, otherHomeFile)) + if err != nil || string(raw) != otherHomeContents { + t.Fatalf("landed file = %q, %v", raw, err) + } +} + +func (f *otherHomeTask) git(t *testing.T, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", f.project, "-c", "user.name=paired task fixture", "-c", "user.email=task@example.test", "-c", "commit.gpgsign=false"}, args...)...) + cmd.Env = f.env(f.a) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("fixture git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func otherHomeProcess(t *testing.T, cmd *exec.Cmd, log string) func() { + t.Helper() + file, err := os.Create(log) + if err != nil { + t.Fatal(err) + } + cmd.Stderr = file + if cmd.Stdout == nil { + cmd.Stdout = file + } + if err := cmd.Start(); err != nil { + _ = file.Close() + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var once sync.Once + stop := func() { + once.Do(func() { + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-done + } + _ = file.Close() + }) + } + t.Cleanup(func() { + stop() + if t.Failed() { + raw, _ := os.ReadFile(log) + t.Logf("%s:\n%s", filepath.Base(log), raw) + } + }) + return stop +} + +func otherHomeWait(t *testing.T, ctx context.Context, what string, ready func() bool) { + t.Helper() + for !ready() { + select { + case <-ctx.Done(): + t.Fatalf("waiting for %s: %v", what, ctx.Err()) + case <-time.After(50 * time.Millisecond): + } + } +} + +func otherHomeLedger(t *testing.T, home string) []session.UsageLine { + t.Helper() + file, err := os.Open(filepath.Join(home, "v3", "usage.jsonl")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var lines []session.UsageLine + scan := bufio.NewScanner(file) + for scan.Scan() { + var line session.UsageLine + if err := json.Unmarshal(scan.Bytes(), &line); err != nil { + t.Fatal(err) + } + lines = append(lines, line) + } + if err := scan.Err(); err != nil { + t.Fatal(err) + } + return lines +} + +type otherHomeBrain struct { + server *httptest.Server + key string + gate chan struct{} + mu sync.Mutex + calls, workers int +} + +func newOtherHomeBrain(t *testing.T, key string) *otherHomeBrain { + t.Helper() + b := &otherHomeBrain{key: key} + b.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + fmt.Fprintf(w, `{"data":[{"id":%q,"canonical_slug":%q,"name":"Paired task fixture","context_length":200000,"pricing":{"prompt":"0.000001","completion":"0.000001"},"supported_parameters":["tools","tool_choice"]}]}`, otherHomeModel, otherHomeModel) + case "/key", "/auth/key", "/credits": + fmt.Fprint(w, `{"data":{"limit_remaining":100,"total_credits":100,"total_usage":0}}`) + case "/chat/completions": + if r.Header.Get("Authorization") != "Bearer "+key { + t.Error("provider received the other home's key") + http.Error(w, "wrong synthetic key", 401) + return + } + var request struct { + Stream bool + Model string + Messages []struct { + Role string + Content json.RawMessage + ToolCalls []struct{ Function struct{ Arguments string } } `json:"tool_calls"` + } + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + http.Error(w, "bad fixture request", 400) + return + } + var assignment []string + checker := false + turn := 0 + for _, message := range request.Messages { + checker = checker || strings.Contains(string(message.Content), "## Who checks this work") + if message.Role == "tool" && strings.Contains(string(message.Content), "TASK_INSPECTED_HERE") { + turn = max(turn, 1) + } + for _, call := range message.ToolCalls { + if strings.Contains(call.Function.Arguments, "git add -- "+otherHomeFile) { + turn = 2 + } + } + if match := freeConsentAssignment.FindStringSubmatch(string(message.Content)); len(match) == 3 { + assignment = match + } + } + b.mu.Lock() + b.calls++ + if len(assignment) == 3 { + b.workers++ + } + b.mu.Unlock() + if turn == 1 && b.gate != nil { + if request.Stream { + w.Header().Set("Content-Type", "text/event-stream") + } + beat := time.NewTicker(time.Second) + defer beat.Stop() + waiting: + for { + select { + case <-b.gate: + break waiting + case <-r.Context().Done(): + return + case <-beat.C: + if request.Stream { + keepalive(w) + } + } + } + } + var call any + text := "CHAT_STAYS_HERE" + if len(assignment) == 3 { + command := "printf TASK_INSPECTED_HERE" + if turn == 1 { + command = "printf 'the paired worker wrote this\\n' > " + otherHomeFile + " && git add -- " + otherHomeFile + " && git -c user.name='paired worker' -c user.email=worker@example.test -c commit.gpgsign=false commit -qm '" + otherHomeCommit + "' && plandb done " + assignment[1] + " --agent " + assignment[2] + " --result 'The requested file and commit are ready.'" + } + if checker || turn > 1 { + command = "grep -qx 'the paired worker wrote this' " + otherHomeFile + " && plandb done " + assignment[1] + " --agent " + assignment[2] + " --result 'holds: the requested file and commit are ready.'" + } + args, _ := json.Marshal(map[string]string{"command": command}) + call = map[string]any{"index": 0, "id": fmt.Sprintf("paired-%s-%d", assignment[2], turn), "type": "function", "function": map[string]string{"name": "bash", "arguments": string(args)}} + } + otherHomeResponse(w, request.Model, text, call, request.Stream) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(b.server.Close) + return b +} + +func (b *otherHomeBrain) counts() (int, int) { + b.mu.Lock() + defer b.mu.Unlock() + return b.calls, b.workers +} + +func otherHomeResponse(w http.ResponseWriter, model, text string, call any, stream bool) { + message := map[string]any{"role": "assistant", "content": text} + finish := "stop" + if call != nil { + message["content"] = "" + message["tool_calls"] = []any{call} + finish = "tool_calls" + } + usage := map[string]any{"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18, "cost": otherHomeCallCost} + if stream { + w.Header().Set("Content-Type", "text/event-stream") + for _, choice := range []map[string]any{{"index": 0, "delta": message, "finish_reason": nil}, {"index": 0, "delta": map[string]any{}, "finish_reason": finish}} { + raw, _ := json.Marshal(map[string]any{"id": "paired-fixture", "model": model, "choices": []any{choice}, "usage": usage}) + fmt.Fprintf(w, "data: %s\n\n", raw) + } + fmt.Fprint(w, "data: [DONE]\n\n") + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": "paired-fixture", "model": model, "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finish}}, "usage": usage}) +} diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 149e39d032..378620a3e6 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -1229,6 +1229,11 @@ var tuiWords = map[string]tuiWord{ why: "the dim line the conversation says after esc, which is the other half of a front door: " + "a person who declined is told the next direct road rather than left on an empty screen", }, + "addMachinePitch": { + screen: "Pick up your work anywhere, exactly where you left it.", + why: "the pitch on home's add-another-device card (homeband_addmachine.go's addMachinePitch), " + + "read by the pairing journey so a respelled card fails the untagged word gate before tmux runs", + }, } // say is how the tmux suite reaches a literal, and THE ONLY WAY IT MAY. diff --git a/internal/enginehost/environment.go b/internal/enginehost/environment.go index dfe452b688..4d1a453045 100644 --- a/internal/enginehost/environment.go +++ b/internal/enginehost/environment.go @@ -23,8 +23,19 @@ const environmentName = "environment" // must be refused even when a provider environment change can join busy work. const syncEnvironmentName = "sync-environment" -func syncEnvironment() string { - relay := syncsetup.Resolve(home.Dir()) +// syncUnnamedName is the relay the host would have resolved with no +// CODEAF_SYNC_URL of its own. A launch that names no relay is compared against +// THIS: `codeaf chat --host` runs `codeaf engine` under a non-interactive ssh +// shell that rarely carries the variable, and comparing its silence with the +// variable of the terminal that started the host retired that host, and the +// terminal's next launch retired the ssh one's, back and forth. +const syncUnnamedName = "sync-unnamed" + +func syncEnvironment() string { return relayWord(syncsetup.Resolve(home.Dir())) } + +func syncUnnamed() string { return relayWord(syncsetup.ResolveUnnamed(home.Dir())) } + +func relayWord(relay syncsetup.Relay) string { if relay.Off { return "off" } @@ -96,11 +107,18 @@ func fingerprintMAC(salt []byte, value string) []byte { // writeEnvironment writes a fresh salt and digest atomically, so a launcher // never compares a partly written answer. No environment value is saved, even // when the environment contains a provider key with an unfamiliar shape. +// +// THE PROVIDER FINGERPRINT LEAVES THE RELAY OUT. The relay has its own two +// answers and its own rule ([SyncDiffers]), and folding it in here too would +// let a launch that named no relay restart an idle host through the back door. func writeEnvironment(dir, workspace string) error { if err := writeFingerprint(dir, syncEnvironmentName, syncEnvironment()); err != nil { return err } - return writeFingerprint(dir, environmentName, resolvedEnvironment(workspace)) + if err := writeFingerprint(dir, syncUnnamedName, syncUnnamed()); err != nil { + return err + } + return writeFingerprint(dir, environmentName, canonicalEnvironment(workspace)) } func writeFingerprint(dir, name, value string) error { @@ -127,15 +145,27 @@ func writeFingerprint(dir, name, value string) error { // or malformed files mean the host has not said, which must never be a reason // to restart it. Comparison stays local and takes constant time for the digest. func EnvironmentDiffers(workspace string) bool { - return fingerprintDiffers(workspace, environmentName, resolvedEnvironment(workspace), false) + return fingerprintDiffers(workspace, environmentName, canonicalEnvironment(workspace), false) || SyncDiffers(workspace) } // SyncDiffers compares the relay the host resolved at startup with this // terminal's choice, including a pairing's saved address and the off switch. +// +// ONLY A RELAY THIS PROCESS NAMES IS A CHOICE. With CODEAF_SYNC_URL set, the +// whole resolved answer is compared, and an address or off that differs from +// the host's must not be joined. With it unset, this process said nothing +// about the relay, so only what both sides read from the same home — a +// pairing's saved address, else the hosted default — is compared; the +// variable the host's own terminal carried is that terminal's word and stands. func SyncDiffers(workspace string) bool { - // A host from before this fingerprint cannot confirm an explicit address - // or off switch. That choice must not fall through to busy-host reuse. - return fingerprintDiffers(workspace, syncEnvironmentName, syncEnvironment(), env.Get(syncsetup.URLVar) != "") + if env.Get(syncsetup.URLVar) != "" { + // A host from before this fingerprint cannot confirm an explicit address + // or off switch. That choice must not fall through to busy-host reuse. + return fingerprintDiffers(workspace, syncEnvironmentName, syncEnvironment(), true) + } + // A host from before the unnamed fingerprint cannot say, and a process that + // named nothing has no choice for it to be ignoring: join it. + return fingerprintDiffers(workspace, syncUnnamedName, syncUnnamed(), false) } func fingerprintDiffers(workspace, name, value string, unknown bool) bool { diff --git a/internal/enginehost/home_removed_test.go b/internal/enginehost/home_removed_test.go new file mode 100644 index 0000000000..d0dfa6e3d1 --- /dev/null +++ b/internal/enginehost/home_removed_test.go @@ -0,0 +1,88 @@ +package enginehost + +import ( + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" +) + +type closingHomeAgent struct { + stubAgent + closing chan struct{} + release chan struct{} +} + +func (a *closingHomeAgent) Close() error { + close(a.closing) + <-a.release + return nil +} + +// C4.3: removal during a blocked close calls the command's exit hook promptly; +// an intact home keeps waiting for the same close, without calling the hook. +func TestShutdownExitsOnlyWhenHomeRemoved(t *testing.T) { + for _, removed := range []bool{false, true} { + t.Run(map[bool]string{false: "intact", true: "removed"}[removed], func(t *testing.T) { + root := filepath.Join(t.TempDir(), "home") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, root) + home.Witness(root) + dir := filepath.Join(root, "host") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + agent := &closingHomeAgent{closing: make(chan struct{}), release: make(chan struct{})} + defer close(agent.release) + exit := make(chan struct{}, 1) + h := &Host{dir: dir, listener: listener, opts: Options{HomeRemoved: func() { exit <- struct{}{} }}, + sessions: map[string]*remote.Session{"chat": remote.NewSession(&remote.Engine{Agent: agent}, true)}} + finished := make(chan struct{}) + go func() { h.shutdown(); close(finished) }() + select { + case <-agent.closing: + case <-time.After(time.Second): + t.Fatal("close never started") + } + if removed { + if err := os.RemoveAll(root); err != nil { + t.Fatal(err) + } + select { + case <-exit: + case <-time.After(time.Second): + t.Fatal("exit hook did not run after removal") + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("home recreated: %v", err) + } + } else { + select { + case <-exit: + t.Fatal("exit hook called for intact home") + case <-finished: + t.Fatal("close returned before release") + case <-time.After(200 * time.Millisecond): + } + } + // Release and join before the next fixture changes the process environment. + agent.release <- struct{}{} + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("close did not finish") + } + }) + } +} diff --git a/internal/enginehost/host.go b/internal/enginehost/host.go index f99b0139c2..91f24d2952 100644 --- a/internal/enginehost/host.go +++ b/internal/enginehost/host.go @@ -24,6 +24,7 @@ import ( "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/remote" ) @@ -124,6 +125,10 @@ type Options struct { // told to every asker ([remote.HostSelf.Cells]) so a door in the other mode // can replace an idle host or refuse a busy one instead of attaching. Cells bool + + // HomeRemoved ends the process when shutdown can no longer save into its + // original home. The command owns process exit; libraries only call this hook. + HomeRemoved func() } // Host is one workspace's conversations, and the socket they are reached @@ -209,7 +214,9 @@ func Run(workspace string, opts Options) error { // THE ENVIRONMENT IS RECORDED BEFORE THE SOCKET ANSWERS. Only the lock // owner writes it, so a replacement cannot publish the previous host's // fingerprint during its birth. A failed write leaves the answer unknown. - _ = os.Remove(filepath.Join(dir, environmentName)) + for _, name := range []string{environmentName, syncEnvironmentName, syncUnnamedName} { + _ = os.Remove(filepath.Join(dir, name)) + } _ = writeEnvironment(dir, workspace) // Whatever socket file is lying there belongs to a host that is gone: the @@ -817,6 +824,30 @@ func (h *Host) stopped() bool { // not wait at all. A second host already saving holds it too; this one then // saves without it. func (h *Host) shutdown() { + if h.opts.HomeRemoved != nil { + root := home.Dir() + if home.Removed(root) { + h.opts.HomeRemoved() + return + } + finished := make(chan struct{}) + defer close(finished) + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-finished: + return + case <-ticker.C: + if home.Removed(root) { + h.opts.HomeRemoved() + return + } + } + } + }() + } h.mu.Lock() h.closed = true sessions := make([]*remote.Session, 0, len(h.sessions)) diff --git a/internal/exec/bare/promote.go b/internal/exec/bare/promote.go index abe88e5b8b..c4fd79c856 100644 --- a/internal/exec/bare/promote.go +++ b/internal/exec/bare/promote.go @@ -263,6 +263,13 @@ func watchCancel(ctx context.Context, call *BashCall, done <-chan struct{}) { guard.Go("exec/bare cancel watch", func() { select { case <-ctx.Done(): + // Both cases can be ready after a successful call returns. Its + // background processes no longer belong to this cancellation. + select { + case <-done: + return + default: + } if _, _, adopted := call.close(); !adopted { killProcessGroup(call.cmd) } diff --git a/internal/exec/bare/tools.go b/internal/exec/bare/tools.go index 4b8f022d81..c5e83de101 100644 --- a/internal/exec/bare/tools.go +++ b/internal/exec/bare/tools.go @@ -957,13 +957,17 @@ func newWriteTool(cwd string) Tool { Schema: json.RawMessage(writeSchemaJSON), Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { var p struct { - Path string `json:"path"` - Content string `json:"content"` + Path string `json:"path"` + Content *string `json:"content"` } if err := json.Unmarshal(args, &p); err != nil { return RejectArguments(ctx, "Invalid arguments: "+err.Error()), true, nil } + if p.Content == nil { + return "Invalid arguments: content is required and must be a string", true, nil + } + absPath := resolveToCwd(p.Path, cwd) return withFileMutationQueue(absPath, func() (string, bool, error) { @@ -977,7 +981,7 @@ func newWriteTool(cwd string) Tool { if ctx.Err() != nil { return "Operation aborted", true, nil } - written, err := writeFileObserved(absPath, []byte(p.Content)) + written, err := writeFileObserved(absPath, []byte(*p.Content)) if err != nil { return err.Error(), true, nil } @@ -992,7 +996,7 @@ func newWriteTool(cwd string) Tool { } // pi uses content.length which is the JS string char length // (UTF-16 code units), NOT the byte length. - return fmt.Sprintf("Successfully wrote %d bytes to %s", utf16Length(p.Content), p.Path), false, nil + return fmt.Sprintf("Successfully wrote %d bytes to %s", utf16Length(*p.Content), p.Path), false, nil }) }, } diff --git a/internal/exec/bare/watchcancel_test.go b/internal/exec/bare/watchcancel_test.go new file mode 100644 index 0000000000..913ed43472 --- /dev/null +++ b/internal/exec/bare/watchcancel_test.go @@ -0,0 +1,42 @@ +//go:build !windows + +package bare + +import ( + "context" + "os/exec" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// A call that has already returned is not killed by the cancellation that +// follows it. Both of the watcher's cases are ready when the call's own context +// is cancelled just behind its return; the group it left running must survive +// whichever the watcher is handed. +func TestACallThatReturnedLeavesItsBackgroundProcessAlone(t *testing.T) { + cmd := exec.Command("sleep", "30") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + pgid := cmd.Process.Pid + // The process is reaped as soon as it dies, so a killed group reads as gone. + reaped := make(chan struct{}) + go func() { _ = cmd.Wait(); close(reaped) }() + t.Cleanup(func() { _ = syscall.Kill(-pgid, syscall.SIGKILL); <-reaped }) + + for i := 0; i < 200; i++ { + ctx, cancel := context.WithCancel(context.Background()) + over := make(chan struct{}) + cancel() + close(over) + watchCancel(ctx, newBashCall("sleep 30", cmd, nil, ctx), over) + } + time.Sleep(50 * time.Millisecond) + if !processgroup.Alive(pgid) { + t.Fatal("the cancellation after a finished call killed the group it left running") + } +} diff --git a/internal/exec/bare/write_validation_test.go b/internal/exec/bare/write_validation_test.go new file mode 100644 index 0000000000..c4c8a0fa48 --- /dev/null +++ b/internal/exec/bare/write_validation_test.go @@ -0,0 +1,77 @@ +package bare + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBareWriteRejectsInvalidContentWithoutChangingFiles(t *testing.T) { + root := t.TempDir() + tools := AllTools(root) + var write Tool + for _, tool := range tools { + if tool.Name == "write" { + write = tool + } + } + for _, fields := range []string{`"text":"intended content"`, `"content":null`, `"content":42`, `"content":true`, `"content":{}`, `"content":[]`} { + t.Run(fields, func(t *testing.T) { + for _, path := range []string{"new/file.txt", "existing.txt"} { + t.Run(path, func(t *testing.T) { + full := filepath.Join(root, path) + if path == "existing.txt" { + if err := os.WriteFile(full, []byte("keep these bytes"), 0o644); err != nil { + t.Fatal(err) + } + } + content, isError, err := write.Execute(context.Background(), json.RawMessage(`{"path":"`+path+`",`+fields+`}`)) + result := struct { + Content string + IsError bool + }{content, isError} + if err != nil { + t.Fatal(err) + } + if !result.IsError || !strings.Contains(result.Content, "content") { + t.Errorf("invalid content must report an actionable error, got %+v", result) + } + if path == "existing.txt" { + body, err := os.ReadFile(full) + if err != nil || string(body) != "keep these bytes" { + t.Errorf("existing file changed: %q, %v", body, err) + } + } else if _, err := os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) { + t.Errorf("malformed write created its parent directory: %v", err) + } + }) + } + }) + } +} + +func TestBareWriteAcceptsExplicitEmptyContent(t *testing.T) { + root := t.TempDir() + for _, exists := range []bool{false, true} { + if exists { + mustWriteFile(t, root, "file.txt", "old bytes") + } + var write Tool + for _, tool := range AllTools(root) { + if tool.Name == "write" { + write = tool + } + } + text, isError, err := write.Execute(context.Background(), json.RawMessage(`{"path":"file.txt","content":""}`)) + if err != nil || isError { + t.Fatalf("empty content rejected: %s, %v", text, err) + } + body, err := os.ReadFile(filepath.Join(root, "file.txt")) + if err != nil || len(body) != 0 { + t.Fatalf("empty write = %q, %v", body, err) + } + } +} diff --git a/internal/exec/tools.go b/internal/exec/tools.go index 5f355d87ef..e9b8af5dbe 100644 --- a/internal/exec/tools.go +++ b/internal/exec/tools.go @@ -1670,10 +1670,14 @@ func exitCode(res executor.ExecResult, err error) int { } func (t *Toolbox) write(args map[string]any) Result { - path, text := stringArg(args, "path"), stringArg(args, "text") + path := stringArg(args, "path") if path == "" { return errorf("write needs path") } + text, ok := args["text"].(string) + if !ok { + return errorf("write needs text as a string (use text, not content; an empty string is allowed)") + } full, err := t.workspace.Resolve(path) if err != nil { return errorf("%v", err) diff --git a/internal/exec/write_validation_test.go b/internal/exec/write_validation_test.go new file mode 100644 index 0000000000..307b28e5e3 --- /dev/null +++ b/internal/exec/write_validation_test.go @@ -0,0 +1,80 @@ +package exec + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteRejectsInvalidTextWithoutChangingFiles(t *testing.T) { + root := t.TempDir() + workspace, err := NewWorkspace(root) + if err != nil { + t.Fatal(err) + } + tools := NewToolbox(workspace, "1", nil) + for _, fields := range []string{`"content":"intended content"`, `"text":null`, `"text":42`, `"text":true`, `"text":{}`, `"text":[]`} { + t.Run(fields, func(t *testing.T) { + for _, path := range []string{"new/file.txt", "existing.txt"} { + t.Run(path, func(t *testing.T) { + full := filepath.Join(root, path) + if path == "existing.txt" { + if err := os.WriteFile(full, []byte("keep these bytes"), 0o644); err != nil { + t.Fatal(err) + } + } + result := tools.Execute(context.Background(), "write", `{"path":"`+path+`",`+fields+`}`) + if !result.IsError || !strings.Contains(result.Content, "text") { + t.Errorf("missing text must report an actionable error, got %+v", result) + } + if path == "existing.txt" { + body, err := os.ReadFile(full) + if err != nil || string(body) != "keep these bytes" { + t.Errorf("existing file changed: %q, %v", body, err) + } + } else if _, err := os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) { + t.Errorf("malformed write created its parent directory: %v", err) + } + }) + } + }) + } +} + +func TestWriteAcceptsExplicitText(t *testing.T) { + for _, text := range []string{"nonempty text", ""} { + t.Run(text, func(t *testing.T) { + root := t.TempDir() + workspace, err := NewWorkspace(root) + if err != nil { + t.Fatal(err) + } + tools := NewToolbox(workspace, "1", nil) + full := filepath.Join(root, "file.txt") + if err := os.WriteFile(full, []byte("old bytes"), 0o644); err != nil { + t.Fatal(err) + } + result := tools.Execute(context.Background(), "write", `{"path":"file.txt","text":"`+text+`"}`) + if result.IsError { + t.Fatalf("valid write failed: %+v", result) + } + body, err := os.ReadFile(full) + if err != nil || string(body) != text { + t.Fatalf("write = %q, %v; want %q", body, err, text) + } + }) + } +} + +func TestWriteRequiresPath(t *testing.T) { + workspace, err := NewWorkspace(t.TempDir()) + if err != nil { + t.Fatal(err) + } + result := NewToolbox(workspace, "1", nil).Execute(context.Background(), "write", `{"text":"hello"}`) + if !result.IsError || !strings.Contains(result.Content, "path") { + t.Fatalf("missing path = %+v", result) + } +} diff --git a/internal/executor/environment_test.go b/internal/executor/environment_test.go index 3776670d81..07ee003d06 100644 --- a/internal/executor/environment_test.go +++ b/internal/executor/environment_test.go @@ -13,10 +13,11 @@ import ( ) func TestWorkspaceEnvironmentRoutesRealTemporaryFilesThroughBothRunnerDoors(t *testing.T) { + // macOS mktemp needs an explicit template to create its file inside the assigned TMPDIR. scratch, cache := t.TempDir(), t.TempDir() originalHOME := os.Getenv("HOME") seat := executor.WithEnvironment(executor.Host, []string{"TMPDIR=" + scratch, "XDG_CACHE_HOME=" + cache, "CODEAF_WORKSPACE_URI=codeaf-workspace://example/run"}) - command := `printf '%s\n' "$HOME" "$XDG_CACHE_HOME" "$CODEAF_WORKSPACE_URI"; mktemp` + command := `printf '%s\n' "$HOME" "$XDG_CACHE_HOME" "$CODEAF_WORKSPACE_URI"; mktemp "$TMPDIR/codeaf-runtime.XXXXXX"` for _, door := range []string{"exec", "command", "setup"} { runner := seat.In(t.TempDir()) if door == "setup" { diff --git a/internal/handoff/handoff.go b/internal/handoff/handoff.go index 48a833cce1..411515c97a 100644 --- a/internal/handoff/handoff.go +++ b/internal/handoff/handoff.go @@ -12,6 +12,7 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/fsreplace" "os" + "path/filepath" "time" "github.com/Agent-Field/codeaf/internal/cell" @@ -106,7 +107,9 @@ func (t Taker) Take(ctx context.Context, id string) (Taken, error) { // TakeSeen continues the lease the person saw on the card. Older callers // without that reading still bind the claim to the record read before fetch. func (t Taker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (Taken, error) { + directoryRead := Trace(t.OnPhase, "directory") view, err := t.Dir.Cell(ctx, id) + directoryRead() if err != nil { return Taken{}, fmt.Errorf("handoff: read chat %s: %w", id, err) } @@ -269,7 +272,9 @@ func (t Taker) fetchAndAcquire(ctx context.Context, stage cell.Cell, from, head if t.OnPhase != nil { t.OnPhase(Phase{Name: "first acquire", Started: time.Now()}) } + acquiring := Trace(t.OnPhase, "acquire") got, err := t.Dir.Acquire(ctx, stage.ID, how) + acquiring() if err != nil { return "", 0, nil, fmt.Errorf("handoff: take %s: %w", stage.ID, err) } @@ -316,6 +321,30 @@ func freshDir(dir string) error { // staging folder is dropped. A copy is replaced by the staging folder. func (t Taker) install(ctx context.Context, stage, c cell.Cell, head string) error { defer Trace(t.OnPhase, "restore")() + // Snapshots arriving from another computer never carry this computer's local + // copy-ready or copy-offered markers, so installing over a `.moving` copy + // root must keep or re-stamp them lest its `.lock` sibling hide the chat or + // revive the untouched original. + keepCopyReady := exists(filepath.Join(c.Root, filepath.FromSlash(cell.CopyReadyPath))) || + (filepath.Base(filepath.Dir(c.Root)) == ".moving" && exists(c.Root+".lock")) + offeredFile := filepath.Join(c.Root, filepath.FromSlash(cell.CopyOfferedPath)) + keepCopyOffered := exists(offeredFile) + restoreCopyMarkers := func() error { + if keepCopyReady { + if err := cell.StampCopyReady(c.Root); err != nil { + return err + } + } + if keepCopyOffered && !exists(offeredFile) { + if err := os.MkdirAll(filepath.Dir(offeredFile), 0o700); err != nil { + return err + } + if err := os.WriteFile(offeredFile, nil, 0o600); err != nil { + return err + } + } + return nil + } if t.InPlace == nil || !t.InPlace(c) { if t.Prepare != nil { if err := freshDir(stage.Root); err != nil { @@ -328,11 +357,17 @@ func (t Taker) install(ctx context.Context, stage, c cell.Cell, head string) err return err } } - return t.replace(ctx, stage, c) + if err := t.replace(ctx, stage, c); err != nil { + return err + } + return restoreCopyMarkers() } if err := t.Fetch.Fetch(ctx, c, head); err != nil { return err } + if err := restoreCopyMarkers(); err != nil { + return err + } return os.RemoveAll(stage.Root) } @@ -373,7 +408,10 @@ func swap(stage, root string) error { // here hands it back, so the chat is takeable at once instead of after a TTL. func (t Taker) open(ctx context.Context, c cell.Cell, fence uint64) (cell.Cell, error) { for _, hook := range t.After { - if err := hook(ctx, c); err != nil { + done := Trace(t.OnPhase, "hooks") + err := hook(ctx, c) + done() + if err != nil { return cell.Cell{}, t.giveBack(ctx, c.ID, fence, fmt.Errorf("handoff: %w", err)) } } diff --git a/internal/home/witness.go b/internal/home/witness.go new file mode 100644 index 0000000000..9f70e02d2d --- /dev/null +++ b/internal/home/witness.go @@ -0,0 +1,135 @@ +package home + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" +) + +var witnessed sync.Map + +// ErrRemoved refuses late writes into a home this process has lost. +var ErrRemoved = errors.New("codeaf home was removed or replaced; restart codeaf to use the current folder") + +// Witness remembers the first directory this process saw at dir. A later +// missing or replaced root must not be mistaken for a first launch. +func Witness(dir string) { + key := absolute(dir) + if before, ok := witness(dir); ok { + witnessed.LoadOrStore(key, before) + return + } + if info, err := os.Stat(dir); err == nil && info.IsDir() { + before, _ := witnessed.LoadOrStore(canonicalPath(key), info) + witnessed.LoadOrStore(key, before) + } +} + +func witness(dir string) (any, bool) { + key := absolute(dir) + if before, ok := witnessed.Load(key); ok { + return before, true + } + return witnessed.Load(canonicalPath(key)) +} + +// canonicalPath resolves the surviving ancestor when the home itself is gone. +// This preserves aliases such as /tmp and /private/tmp after a removal. +func canonicalPath(dir string) string { + ancestor := dir + var links int + var rest []string + for { + if resolved, err := filepath.EvalSymlinks(ancestor); err == nil { + for i := len(rest) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, rest[i]) + } + return resolved + } + // A dangling symlink still identifies its missing target. Resolving only + // its parent would turn that alias back into an unwitnessed first use. + if target, err := os.Readlink(ancestor); err == nil { + if links >= 255 { + return dir + } + links++ + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(ancestor), target) + } + ancestor = target + continue + } + parent := filepath.Dir(ancestor) + if parent == ancestor { + return dir + } + rest = append(rest, filepath.Base(ancestor)) + ancestor = parent + } +} + +// Removed reports whether a witnessed root has disappeared or been replaced. +// Unwitnessed roots remain available for first use. +func Removed(dir string) bool { + before, ok := witness(dir) + if !ok { + return false + } + after, err := os.Stat(dir) + return os.IsNotExist(err) || (err == nil && !os.SameFile(before.(os.FileInfo), after)) +} + +// MkdirAllUnder makes directories without ever creating a witnessed root. +// Making children one at a time lets removal race the write without a recursive +// mkdir silently rebuilding the home. First use retains recursive creation. +func MkdirAllUnder(root, dir string, perm os.FileMode) error { + root, dir = absolute(root), absolute(dir) + rel, err := filepath.Rel(root, dir) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return &os.PathError{Op: "mkdir", Path: dir, Err: os.ErrInvalid} + } + Witness(root) + if _, ok := witness(root); !ok { + if err := os.MkdirAll(dir, perm); err != nil { + return err + } + Witness(root) + return nil + } + if rel != "." { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + if Removed(root) { + return ErrRemoved + } + return nil + } + } + if Removed(root) { + return ErrRemoved + } + if rel == "." { + return nil + } + next := root + for _, part := range strings.Split(rel, string(filepath.Separator)) { + next = filepath.Join(next, part) + if err := os.Mkdir(next, perm); err != nil { + if Removed(root) { + return ErrRemoved + } + info, statErr := os.Stat(next) + if !os.IsExist(err) || statErr != nil || !info.IsDir() { + return err + } + } + } + if Removed(root) { + return ErrRemoved + } + return nil +} diff --git a/internal/home/witness_test.go b/internal/home/witness_test.go new file mode 100644 index 0000000000..ca334adb08 --- /dev/null +++ b/internal/home/witness_test.go @@ -0,0 +1,69 @@ +package home + +import ( + "os" + "path/filepath" + "testing" +) + +// SPEC-3 item 4: alias spellings share the witness even after descendants vanish. +func TestFixRound4WitnessAliasesSurviveRemoval(t *testing.T) { + for _, viaAlias := range []bool{false, true} { + t.Run(map[bool]string{false: "physical", true: "alias"}[viaAlias], func(t *testing.T) { + base := t.TempDir() + real := filepath.Join(base, "real") + alias := filepath.Join(base, "alias") + if err := os.Mkdir(real, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, alias); err != nil { + t.Fatal(err) + } + physical := filepath.Join(real, "hh", "b") + spelled := filepath.Join(alias, "hh", "b") + if err := os.MkdirAll(physical, 0700); err != nil { + t.Fatal(err) + } + if viaAlias { + Witness(spelled) + } else { + Witness(physical) + } + if Removed(physical) || Removed(spelled) { + t.Fatal("intact alias refused") + } + if err := os.Rename(filepath.Join(real, "hh"), filepath.Join(real, "old")); err != nil { + t.Fatal(err) + } + if !Removed(physical) || !Removed(spelled) { + t.Fatal("alias lost its witness after ancestor removal") + } + if err := os.MkdirAll(physical, 0700); err != nil { + t.Fatal(err) + } + if !Removed(physical) || !Removed(spelled) { + t.Fatal("replacement accepted through alias") + } + }) + } +} + +// SPEC-3 item 4: a dangling symlink still names the witnessed home it pointed at. +func TestFixRound4DanglingHomeAliasKeepsWitness(t *testing.T) { + base := t.TempDir() + real := filepath.Join(base, "real") + alias := filepath.Join(base, "alias") + if err := os.Mkdir(real, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", alias); err != nil { + t.Fatal(err) + } + Witness(real) + if err := os.Rename(real, real+".old"); err != nil { + t.Fatal(err) + } + if !Removed(alias) { + t.Fatal("dangling alias forgot the witnessed target") + } +} diff --git a/internal/iconlaw/iconlaw_test.go b/internal/iconlaw/iconlaw_test.go index b05d677297..798651f1be 100644 --- a/internal/iconlaw/iconlaw_test.go +++ b/internal/iconlaw/iconlaw_test.go @@ -48,6 +48,7 @@ import ( // flag. var surfaces = []string{ "../atlas", // C16 holds the architecture map to the same vocabulary. + "../chatlist", // The Home strip receives marks from its surface. "../tui3", // v3, the live chat "../head", // the resident's head "../resident", // the resident itself @@ -63,6 +64,7 @@ var surfaces = []string{ // state the vocabulary already had a mark for, and a build that finds one has // found a surface drawing outside the table again. var ownedRunes = map[rune]string{ + '○': "tokens.GQueued", '◐': "tokens.GWorking", '✓': "tokens.GSettled", @@ -96,9 +98,13 @@ var ownedRunes = map[rune]string{ // exemptions are the (file, rune) pairs allowed to spell a mark, with the // reason each one is out. A reason is required: an exemption without one is how -// a list like this stops meaning anything. There is exactly one, and it is not -// a mark at all. +// a list like this stops meaning anything. Protocol bytes and literal keyboard +// labels are content rather than platform or state marks. var exemptions = map[string]string{ + // The Mac modifier is a key label, so replacing it with an Apple platform + // icon would tell the person to press a key that their keyboard does not name. + "internal/tui3/chords.go" + tokens.GlyphPlatformMac: "the macOS Command key label is keyboard text, not the device platform mark", + "internal/resident/bank.go⚑": "resident.NoteMark is a PROTOCOL BYTE, not a cell on a screen: " + "a job-board note is written with it and read back by prefix (cmd/codeaf/chat.go strips it " + "before anybody sees the line). Routing it through the tier would make the bytes in the " + @@ -153,6 +159,16 @@ func TestNoSurfaceSpellsAnIconItself(t *testing.T) { continue } slot, owned := ownedRunes[r] + // Platform slots must use the door on every surface. Presence in + // chatlist is supplied by its caller, including the filled circle. + for _, id := range []tokens.GlyphID{tokens.GPlatformMac, tokens.GPlatformLinux, tokens.GPlatformWindows, tokens.GPlatformMobile, tokens.GPlatformOther} { + if string(r) == tokens.Plain.Glyph(id) { + slot, owned = "the platform glyph slot", true + } + } + if surface == "../chatlist" && string(r) == tokens.Plain.Glyph(tokens.GStepDone) { + slot, owned = "tokens.GStepDone", true + } if surface == "../atlas" { for _, b := range tokens.Vocabulary() { if !b.Geometry && b.Plain == string(r) && r > 127 { @@ -227,3 +243,13 @@ func literalText(lit *ast.BasicLit) (string, bool) { func repoPath(path string) string { return "internal/" + strings.TrimPrefix(filepath.ToSlash(path), "../") } + +// L4-H8c: The presence words package stays inside the glyph-law walk. +func TestL4B2ChatlistIsUnderIconLaw(t *testing.T) { + for _, surface := range surfaces { + if surface == "../chatlist" { + return + } + } + t.Fatal("internal/chatlist is outside the icon-law walk") +} diff --git a/internal/identity/chats.go b/internal/identity/chats.go new file mode 100644 index 0000000000..f812fa5217 --- /dev/null +++ b/internal/identity/chats.go @@ -0,0 +1,50 @@ +package identity + +import ( + "os" + "path/filepath" +) + +// ChatCount counts conversations with submitted work, rather than startup-only +// headers. It reads only session transcripts: workspace files are not chats and +// walking their trees would make a pairing refusal cost the size of every repo. +func ChatCount(home string) (int, error) { + root := filepath.Join(home, "v3", "projects") + projects, err := os.ReadDir(root) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, err + } + count := 0 + for _, project := range projects { + if !project.IsDir() { + continue + } + sessions, err := os.ReadDir(filepath.Join(root, project.Name())) + if err != nil { + return 0, err + } + for _, session := range sessions { + if !session.IsDir() { + continue + } + dir := filepath.Join(root, project.Name(), session.Name()) + for _, name := range []string{filepath.Join(".cell", "transcript.jsonl"), "transcript.jsonl"} { + used, err := noTurns(filepath.Join(dir, name)) + if os.IsNotExist(err) { + continue + } + if err != nil { + return 0, err + } + if used { + count++ + } + break + } + } + } + return count, nil +} diff --git a/internal/identity/device.go b/internal/identity/device.go index 6c8f2124b1..de1669b563 100644 --- a/internal/identity/device.go +++ b/internal/identity/device.go @@ -84,6 +84,9 @@ func Device(home string) (Dev, error) { // deviceFor returns the stored device when its cert verifies under id, else a // fresh device. A replaced identity therefore never keeps a stale device. func deviceFor(home string, id Identity) (Dev, error) { + if err := checkHome(home); err != nil { + return Dev{}, err + } if d, err := LoadDevice(home); err == nil && d.Cert.Verify(id.PublicKey()) == nil { return d, nil } diff --git a/internal/identity/home_removed_test.go b/internal/identity/home_removed_test.go new file mode 100644 index 0000000000..207c512a26 --- /dev/null +++ b/internal/identity/home_removed_test.go @@ -0,0 +1,158 @@ +package identity + +import ( + "errors" + "os" + "path/filepath" + "testing" + + statehome "github.com/Agent-Field/codeaf/internal/home" +) + +// C4.1: every identity and device writer refuses a removed or replaced root. +func TestIdentityRefusesRemovedHome(t *testing.T) { + for _, replaced := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "replaced"}[replaced], func(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + id, err := EnsureSolo(h) + if err != nil { + t.Fatal(err) + } + if err := os.Rename(h, h+".old"); err != nil { + t.Fatal(err) + } + if replaced { + if err := os.Mkdir(h, 0o700); err != nil { + t.Fatal(err) + } + } + writers := map[string]func() error{ + "solo": func() error { _, err := EnsureSolo(h); return err }, + "ensure": func() error { _, err := Ensure(h); return err }, + "root": func() error { _, err := ensureRoot(h); return err }, + "save": func() error { return Save(h, id) }, + "device": func() error { _, err := deviceFor(h, id); return err }, + } + for name, write := range writers { + if err := write(); !errors.Is(err, ErrHomeRemoved) { + t.Errorf("%s: %v", name, err) + } + } + if replaced { + entries, err := os.ReadDir(h) + if err != nil || len(entries) != 0 { + t.Fatalf("replacement changed: %v, %v", entries, err) + } + } else if _, err := os.Stat(h); !os.IsNotExist(err) { + t.Fatalf("home recreated: %v", err) + } + }) + } +} + +// C4.2: first use still creates keys, and witnessing an intact home permits loads and saves. +func TestIdentityFirstUseAndIntactWitness(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + statehome.Witness(h) + id, err := EnsureSolo(h) + if err != nil { + t.Fatal(err) + } + if !Solo(h) { + t.Fatal("first use lost its solo marker") + } + statehome.Witness(h) + loaded, err := EnsureSolo(h) + if err != nil || loaded.ID() != id.ID() { + t.Fatalf("identity changed: %v", err) + } + dev, err := Device(h) + if err != nil { + t.Fatal(err) + } + if err := Save(h, id); err != nil { + t.Fatal(err) + } + again, err := Device(h) + if err != nil || again.ID() != dev.ID() { + t.Fatalf("device changed: %v", err) + } + if loaded, err := Load(h); err != nil || loaded.ID() != id.ID() { + t.Fatalf("saved identity: %v", err) + } +} + +// SPEC-3 item 1: removal after the writer's check is still a removed-home error. +func TestFixRound1IdentityWriteMapsConcurrentRemoval(t *testing.T) { + for _, replaced := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "replaced"}[replaced], func(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + if _, err := Ensure(h); err != nil { + t.Fatal(err) + } + err := write(filepath.Join(h, DeviceFile), removeHomeDuringMarshal{home: h, replaced: replaced}, os.Rename) + if !errors.Is(err, ErrHomeRemoved) { + t.Fatalf("concurrent removal = %v", err) + } + if replaced { + entries, err := os.ReadDir(h) + if err != nil || len(entries) != 0 { + t.Fatalf("replacement changed: %v %v", entries, err) + } + } else if _, err := os.Stat(h); !os.IsNotExist(err) { + t.Fatalf("home recreated: %v", err) + } + }) + } +} + +type removeHomeDuringMarshal struct { + home string + replaced bool +} + +func (v removeHomeDuringMarshal) MarshalJSON() ([]byte, error) { + if err := os.Rename(v.home, v.home+".old"); err != nil { + return nil, err + } + if v.replaced { + if err := os.Mkdir(v.home, 0700); err != nil { + return nil, err + } + } + return []byte(`{}`), nil +} + +// SPEC-3 item 1: removal at publication maps the filesystem failure to the home error. +func TestFixRound1IdentityWriteMapsRemovalDuringPublish(t *testing.T) { + for _, replaced := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "replaced"}[replaced], func(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + if _, err := Ensure(h); err != nil { + t.Fatal(err) + } + err := write(filepath.Join(h, DeviceFile), struct{}{}, func(from, to string) error { + if err := os.Rename(h, h+".old"); err != nil { + return err + } + if replaced { + if err := os.Mkdir(h, 0700); err != nil { + return err + } + } + return os.Rename(from, to) + }) + if !errors.Is(err, ErrHomeRemoved) { + t.Fatalf("publication after removal = %v", err) + } + if replaced { + entries, err := os.ReadDir(h) + if err != nil || len(entries) != 0 { + t.Fatalf("replacement changed: %v %v", entries, err) + } + } else if _, err := os.Stat(h); !os.IsNotExist(err) { + t.Fatalf("home recreated: %v", err) + } + }) + } +} diff --git a/internal/identity/joinable.go b/internal/identity/joinable.go index b44f00dd72..5f8a36f9ef 100644 --- a/internal/identity/joinable.go +++ b/internal/identity/joinable.go @@ -1,12 +1,5 @@ package identity -import ( - "database/sql" - "github.com/Agent-Field/codeaf/internal/sqliteuri" - "path/filepath" - "strings" -) - // Joinable accepts startup state plus personal memory and preferences, which // merge into the new fleet (C30.4). Chats and unknown files still require the // explicit replacement door; Pristine continues to mean genuinely unused. @@ -16,7 +9,7 @@ func Joinable(home string) (bool, error) { rulesCopy[key] = judge } rulesCopy["graph.db"] = memoryOnlyGraph - for _, key := range []string{"provider-keys.ledger", "preferences-sync.ledger", "memory-sync.ledger", "state-sync.lock"} { + for _, key := range []string{"provider-keys.ledger", "preferences-sync.ledger", "memory-sync.ledger", "teams-sync.ledger", "teams-traffic-sync.ledger", "state-sync.lock"} { rulesCopy[key] = never } used, err := within(rulesCopy)(home) @@ -24,30 +17,5 @@ func Joinable(home string) (bool, error) { } func memoryOnlyGraph(path string) (bool, error) { - if sound, err := soundDatabase(path); !sound || err != nil { - return true, err - } - uri, err := sqliteuri.FromPath(filepath.Clean(path)) - if err != nil { - return true, err - } - uri.RawQuery = "mode=ro" - db, err := sql.Open("sqlite", uri.String()) - if err != nil { - return true, err - } - defer db.Close() - tables, err := graphTables(db) - if err != nil { - return true, err - } - for _, table := range tables { - if table == "memories" || strings.HasPrefix(table, "memory_") { - continue - } - if used, err := holdsWork(db, table); used || err != nil { - return true, err - } - } - return false, nil + return databaseWork(path, graphStartupTables(), true) } diff --git a/internal/identity/l4_joinable_test.go b/internal/identity/l4_joinable_test.go new file mode 100644 index 0000000000..727b498456 --- /dev/null +++ b/internal/identity/l4_joinable_test.go @@ -0,0 +1,54 @@ +package identity + +import ( + "database/sql" + "path/filepath" + "testing" +) + +// L4-H2a: indexing an empty conversation does not turn startup bookkeeping into a chat. +func TestL4EmptySessionIndexCanJoin(t *testing.T) { + h := t.TempDir() + makeGraph(t, h, []string{`CREATE TABLE sessions (id TEXT)`}) + // This synthetic graph predates the actual event schema. The real first-start + // regression covers startup events through Store.Open instead. + db, err := sql.Open("sqlite", filepath.Join(h, "graph.db")) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`DROP TABLE events`); err != nil { + t.Fatal(err) + } + db.Close() + put(t, filepath.Join(h, "device-name"), "desk-b") + if ok, err := Joinable(h); err != nil || !ok { + t.Fatalf("empty indexed chat: %v %v", ok, err) + } + db, err = sql.Open("sqlite", filepath.Join(h, "graph.db")) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO sessions VALUES ('sent')`); err != nil { + t.Fatal(err) + } + db.Close() + if ok, err := Joinable(h); ok || err != nil { + t.Fatalf("stored session: %v %v", ok, err) + } + put(t, filepath.Join(h, cellDir, "transcript.jsonl"), startedFiles[cellDir+"/transcript.jsonl"]+`{"type":"message","role":"user","content":"hello"}`+"\n") + if ok, err := Joinable(h); err != nil || ok { + t.Fatalf("submitted chat: %v %v", ok, err) + } +} + +// L4-H2b: counting chats never scans user repositories or counts their transcript files. +func TestL4CountOnlyChatTranscripts(t *testing.T) { + h := t.TempDir() + sent := `{"type":"message","role":"user","content":"hello"}` + "\n" + put(t, filepath.Join(h, "v3/projects/p/chat/.cell/transcript.jsonl"), sent) + put(t, filepath.Join(h, "v3/projects/p/chat/.cell/env/repo/transcript.jsonl"), sent) + put(t, filepath.Join(h, "v3/projects/p/empty/.cell/transcript.jsonl"), `{"type":"session"}`+"\n") + if n, err := ChatCount(h); err != nil || n != 1 { + t.Fatalf("count=%d %v", n, err) + } +} diff --git a/internal/identity/l4fix2_startup_test.go b/internal/identity/l4fix2_startup_test.go new file mode 100644 index 0000000000..199bc61330 --- /dev/null +++ b/internal/identity/l4fix2_startup_test.go @@ -0,0 +1,189 @@ +package identity_test + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/automation" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/knowledge" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/sqliteuri" + "github.com/Agent-Field/codeaf/internal/store" +) + +const startupChat = "v3/projects/project/chat/.cell" + +func startupFile(t *testing.T, home, name, text string) { + t.Helper() + path := filepath.Join(home, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(text), 0600); err != nil { + t.Fatal(err) + } +} + +// firstStart uses the actual database owners and history reader, including the +// worker's empty-chat cursor and waiting job rather than an invented SQLite shape. +func firstStart(t *testing.T) string { + t.Helper() + home := t.TempDir() + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + graph, err := store.Open(filepath.Join(home, "graph.db")) + if err != nil { + t.Fatal(err) + } + if err := graph.Close(); err != nil { + t.Fatal(err) + } + autos, err := automation.Open(filepath.Join(home, "v3/automations")) + if err != nil { + t.Fatal(err) + } + if err := autos.Close(); err != nil { + t.Fatal(err) + } + for _, name := range []string{"teams.json.lock", "v3/automations/clock.lock", "v3/automations/clock.log", "v3/automations/environment.salt", "v3/automations/windows/window", "v3/projects/project/chat/meta.lock", startupChat + "/.furrowpolicy"} { + startupFile(t, home, name, "") + } + startupFile(t, home, "model-catalog-a88839f814a18779.json", "{}") + startupFile(t, home, "v3/projects/project/chat/meta.json", `{"id":"chat","workspace":"/project"}`) + startupFile(t, home, startupChat+"/transcript.jsonl", "{\"type\":\"session\",\"id\":\"chat\",\"cwd\":\"/project\"}\n") + for _, name := range []string{"meta.json", "session.json", "memories.jsonl"} { + startupFile(t, home, startupChat+"/"+name, "") + } + vault := knowledge.Open(home) + // The ceiling is for building the fixture, not a claim about how fast the + // worker is: a loaded machine must not turn setup into a failure. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + done := make(chan error, 1) + guard.Go("identity/startup-fixture", func() { + done <- vault.Run(ctx, session.KnowledgeHistory(filepath.Join(home, "v3/projects")), func() string { return "" }) + }) + for { + snapshot, err := vault.Status() + if err != nil { + cancel() + <-done + t.Fatal(err) + } + if snapshot.Build.Phase == knowledge.BuildWaiting { + break + } + if ctx.Err() != nil { + t.Fatal("startup knowledge worker did not finish", <-done) + } + // A short pause rather than a spin: every status read opens the worker's + // own database, and spinning on it starves the writer being waited for. + select { + case <-ctx.Done(): + case <-time.After(10 * time.Millisecond): + } + } + cancel() + <-done + return home +} + +func startupSQL(t *testing.T, home, path, query string) { + t.Helper() + uri := sqliteuri.File(filepath.Join(home, path)) + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(query); err != nil { + t.Fatal(err) + } +} + +// L4-H2, fix round 2: a real first-start shape can join, while every form of +// submitted work, stored knowledge and unknown state retains the refusal. +func TestL4Fix2FirstStartCanJoin(t *testing.T) { + for _, tc := range []struct { + name string + change func(*testing.T, string) + }{ + {"only opened", nil}, + {"sent turn", func(t *testing.T, h string) { + startupFile(t, h, startupChat+"/transcript.jsonl", "{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}\n") + }}, + {"tracked item", func(t *testing.T, h string) { startupFile(t, h, startupChat+"/state.json", `{"tracked":["result"]}`) }}, + {"chat memory", func(t *testing.T, h string) { startupFile(t, h, startupChat+"/memories.jsonl", `{"text":"remember"}`) }}, + {"automation", func(t *testing.T, h string) { + autos, err := automation.Open(filepath.Join(h, "v3/automations")) + if err != nil { + t.Fatal(err) + } + defer autos.Close() + _, err = autos.Create(automation.Automation{Title: "remind", Schedule: automation.Schedule{At: time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)}, Action: automation.Action{Say: "hello"}, Workspace: "/project"}) + if err != nil { + t.Fatal(err) + } + }}, + {"knowledge page", func(t *testing.T, h string) { + startupSQL(t, h, "knowledge/knowledge.sqlite", `INSERT INTO pages VALUES ('page','My note','profile','{}')`) + }}, + {"titled session", func(t *testing.T, h string) { + startupSQL(t, h, "graph.db", `INSERT INTO sessions(id,title,created_at,last_active_at) VALUES('sent','My work','now','now')`) + }}, + {"unknown file", func(t *testing.T, h string) { startupFile(t, h, "my-work", "keep") }}, + {"unknown catalog filename", func(t *testing.T, h string) { startupFile(t, h, "model-catalog-my-notes.json", "keep my work") }}, + {"unknown graph table", func(t *testing.T, h string) { startupSQL(t, h, "graph.db", `CREATE TABLE future_work (data TEXT)`) }}, + {"unknown knowledge state", func(t *testing.T, h string) { + startupSQL(t, h, "knowledge/knowledge.sqlite", `INSERT INTO state VALUES('future-work','{}')`) + }}, + {"unknown automation table", func(t *testing.T, h string) { + startupSQL(t, h, "v3/automations/automations.db", `CREATE TABLE future_work (data TEXT)`) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + home := firstStart(t) + if ok, err := identity.Joinable(home); err != nil || !ok { + t.Fatalf("first start cannot join: %v %v", ok, err) + } + if tc.change != nil { + tc.change(t, home) + if ok, err := identity.Joinable(home); ok { + t.Fatalf("work was called empty: %v", err) + } + } + }) + } +} + +// L4-H2: Narrow startup rows must not block the base memory merge, and an +// unknown graph event must still refuse a move even when its projections are empty. +func TestL4Fix2GraphMemoryRemainsJoinable(t *testing.T) { + home := firstStart(t) + graph, err := store.Open(filepath.Join(home, "graph.db")) + if err != nil { + t.Fatal(err) + } + _, err = graph.AddMemory(store.Memory{Owner: store.OwnerUser, Text: "My hand-edited memory"}) + if err != nil { + graph.Close() + t.Fatal(err) + } + if err := graph.Close(); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); !ok || err != nil { + t.Fatalf("memory merge refused: %v %v", ok, err) + } + startupSQL(t, home, "graph.db", `INSERT INTO events(ts,node_id,kind,payload) VALUES('now','root','unknown_work','{}')`) + if ok, err := identity.Joinable(home); ok || err != nil { + t.Fatalf("unknown work event: %v %v", ok, err) + } +} diff --git a/internal/identity/l4fix4_picker_test.go b/internal/identity/l4fix4_picker_test.go new file mode 100644 index 0000000000..4f6c0abd40 --- /dev/null +++ b/internal/identity/l4fix4_picker_test.go @@ -0,0 +1,34 @@ +package identity_test + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// L4-A: Only the picker's own cache names are disposable startup state; +// similarly named personal files still keep both pairing checks conservative. +func TestL4Fix4PickerCacheIsStartupState(t *testing.T) { + for _, tc := range []struct { + name string + want bool + }{ + {"models.json", true}, + {"models-" + catalog.CacheKey("ollama", "http://localhost:11434/v1") + ".json", true}, + {"models-my-notes.json", false}, + {"models-0123456789abcdeF.json", false}, + {"models-0123.json", false}, + {"models-0123456789abcdef-more.json", false}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + startupFile(t, home, "v3/"+tc.name, `{}`) + for _, check := range []func(string) (bool, error){identity.Joinable, identity.Pristine} { + if got, err := check(home); err != nil || got != tc.want { + t.Fatalf("startup check = %v, %v; want %v", got, err, tc.want) + } + } + }) + } +} diff --git a/internal/identity/pristine.go b/internal/identity/pristine.go index 96d6a869ef..a5a934ba74 100644 --- a/internal/identity/pristine.go +++ b/internal/identity/pristine.go @@ -32,9 +32,18 @@ var startupRules = rules{ File: never, DeviceFile: never, legacyKeyFile: never, soloFile: never, "vault.enc.lock": never, "pair-sharing.pending": never, "config.json": never, "config.json.lock": never, "notices.json": never, + // The pairing name prompt writes this before checking whether the home can join. + "device-name": never, + "teams.json.lock": never, + "model-catalog-*.json": catalogCache, // Provider catalog caches are fetched again on demand. + "knowledge": within(rules{ + ".worker.lock": never, + "knowledge.sqlite": knowledgeStartup, + "knowledge.sqlite-wal": never, "knowledge.sqlite-shm": never, + }), "catalog.json": never, "model-catalog.json": never, "model-quirks.json": never, "credits.json": never, "logs": never, "bin": never, "cache": never, - "chat.log": empty, "cas": empty, + "chat.log": never, "cas": empty, "graph.db": bareGraph, "graph.db-wal": never, "graph.db-shm": never, "pool": within(rules{ "doc.json": never, "doc.sig": never, "install": never, @@ -42,7 +51,12 @@ var startupRules = rules{ }), "telemetry": within(rules{"first_run": never, "install_id": never, "notice_seen": never}), "v3": within(rules{ - "history.jsonl": onlyCommands, "models.json": never, "lanes.json": never, "lanes.json.lock": never, + "automations": within(rules{ + "automations.db": automationStartup, + "automations.db-wal": never, "automations.db-shm": never, + "clock.lock": never, "clock.log": never, "environment.salt": never, "windows": never, + }), + "history.jsonl": onlyCommands, "models*.json": pickerCache, "lanes.json": never, "lanes.json.lock": never, "lanes.log": never, "lanes": never, // provider facts fetched from the model catalogue, fetched again on demand "draft-*.json": untypedDraft, "hosts": never, "standing": never, "stores": within(rules{ @@ -58,9 +72,9 @@ var started = within(startupRules) // withinSession is one chat folder: its bookkeeping is made again on demand and // its transcript and memories are the chat itself. var withinSession = within(rules{ - "meta.json": never, "presence.json": never, + "meta.json": never, "meta.lock": never, "presence.json": never, ".cell": within(rules{ - "meta.json": never, "session.json": never, "env": empty, + "meta.json": never, "session.json": never, ".furrowpolicy": never, "env": empty, "memories.jsonl": empty, "transcript.jsonl": noTurns, }), }) diff --git a/internal/identity/pristine_judges.go b/internal/identity/pristine_judges.go index 01a526c2e8..5a3cef782a 100644 --- a/internal/identity/pristine_judges.go +++ b/internal/identity/pristine_judges.go @@ -5,6 +5,7 @@ import ( "bytes" "database/sql" "encoding/json" + "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/sqliteuri" "io" "os" @@ -57,6 +58,17 @@ func (r rules) of(name string) judge { // never is for what a start makes again on demand: keys, caches, locks, logs. func never(string) (bool, error) { return false, nil } +// catalogCache delegates cache naming to its owner instead of treating every +// model-catalog-prefixed file as disposable startup state. +func catalogCache(path string) (bool, error) { + return !catalog.IsCacheName(filepath.Base(path)), nil +} + +// pickerCache uses the same filename boundary as the window that writes it. +func pickerCache(path string) (bool, error) { + return !catalog.IsPickerCacheName(filepath.Base(path)), nil +} + // unknown is the judge of a name nobody declared, and it is always use. func unknown(string) (bool, error) { return true, nil } diff --git a/internal/identity/startup_database.go b/internal/identity/startup_database.go new file mode 100644 index 0000000000..2a335a527e --- /dev/null +++ b/internal/identity/startup_database.go @@ -0,0 +1,135 @@ +package identity + +import ( + "database/sql" + "strings" + + "github.com/Agent-Field/codeaf/internal/sqliteuri" + "github.com/Agent-Field/codeaf/internal/store" +) + +// databaseWork reads only named schemas. A new table is evidence we do not know +// what can be lost, even when that table currently has no rows. +func databaseWork(path string, predicates map[string]string, mergeMemory bool) (bool, error) { + if sound, err := soundDatabase(path); !sound || err != nil { + return true, err + } + uri, err := sqliteuri.FromPath(path) + if err != nil { + return true, err + } + uri.RawQuery = "mode=ro" + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + return true, err + } + defer db.Close() + rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table'`) + if err != nil { + return true, err + } + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return true, err + } + names = append(names, name) + } + err = rows.Err() + rows.Close() + if err != nil { + return true, err + } + for _, name := range names { + // Joinable has always merged the graph's memories instead of discarding them. + if mergeMemory && (name == "memories" || strings.HasPrefix(name, "memory_")) { + continue + } + predicate, known := predicates[name] + if !known { + return true, nil + } + var n int + query := `SELECT count(*) FROM "` + strings.ReplaceAll(name, `"`, `""`) + `" WHERE ` + predicate + if err := db.QueryRow(query).Scan(&n); err != nil { + return true, err + } + if n > 0 { + return true, nil + } + } + return false, nil +} + +// startupTables names every ordinary table, with all its rows treated as work. +func startupTables(names ...string) map[string]string { + out := map[string]string{"sqlite_sequence": "0"} + for _, name := range names { + out[name] = "1" + } + return out +} + +// addStartupIndexes names only the FTS tables derived from checked source tables. +// SQLite seeds their configuration and data blocks even before there is content. +func addStartupIndexes(out map[string]string, bases ...string) { + for _, base := range bases { + for _, suffix := range []string{"", "_data", "_idx", "_content", "_docsize", "_config"} { + out[base+suffix] = "0" + } + } +} + +func graphStartupTables() map[string]string { + out := startupTables("events", "nodes", "edges", "messages", "commands", "sessions", "agent_questions", "usage", "usage_turns", "transcript", "surprises", "task_budgets", "role_bindings", "self_receipts", "self_inquiry_lines", "services", "facts", "question_practices", "scope_aliases", "retrospective_watermark", "resident_watermarks", "meta_parameters") + for name, predicate := range workRows { + out[name] = predicate + } + // Store.ensureSpine writes this root event before the person starts work. + out["events"] = "NOT coalesce(kind='" + string(store.EventSpineCreated) + "' AND node_id='" + graphRoot + "' AND json_extract(payload,'$.id')='" + graphRoot + "', 0)" + // These journal rows belong to the memories that Joinable has always merged. + // Every other event kind remains work, including an unknown future kind. + memoryKinds := []store.EventKind{store.EventMemoryAdd, store.EventMemoryUpdate, store.EventMemorySupersede, store.EventMemoryForget, store.EventMemoryRestore, store.EventMemorySkipped, store.EventMemoryWriteFailed, store.EventMemoryRehomed, store.EventMemoryMoved, store.EventMemoryReset, store.EventMemoryInteraction, + // Older graphs retain these retired memory-retrieval telemetry events. + "memory_ranking", "memory_always", "contextual_evidence", + } + quoted := make([]string, len(memoryKinds)) + for i, kind := range memoryKinds { + quoted[i] = "'" + string(kind) + "'" + } + out["events"] += " AND coalesce(kind NOT IN (" + strings.Join(quoted, ",") + "),1)" + addStartupIndexes(out, "messages_fts", "services_fts", "facts_fts", "graph_fts") + return out +} + +func automationStartup(path string) (bool, error) { + out := startupTables("counter", "automations", "runs") + // automation.Open seeds counter id 1; clock sequencing advances it without + // creating an automation. The automations and runs tables decide actual use. + out["counter"] = "id <> 1 OR seq < 0" + // A run's asked mark is work; the walk revision is seeded on open and only + // follows the automations table, which already decides use. + out["run_asked"] = "1" + out["origin_revision"] = "0" + return databaseWork(path, out, false) +} + +func knowledgeStartup(path string) (bool, error) { + out := startupTables("state", "events", "publications", "receipts", "overrides", "pages", "history_errors", "cursors", "proposals", "source_windows", "claim_keys", "proposal_sources", "watch_terms", "watches", "wakes") + // Vault.runPass enables an empty inventory and records its waiting job. No + // other state document, error, source, or person-edited configuration is startup. + out["state"] = `NOT coalesce(json_valid(payload) AND ( + (key='config' AND json_extract(payload,'$.enabled')=1 AND json_extract(payload,'$.schema')=1 + AND NOT EXISTS(SELECT 1 FROM json_each(payload) WHERE key NOT IN ('enabled','schema','created_at'))) + OR (key='job' AND json_extract(payload,'$.phase')='waiting' + AND json_extract(payload,'$.backfill')=0 AND json_extract(payload,'$.sources')=0 + AND json_extract(payload,'$.problems')=0 AND json_extract(payload,'$.error')='' + AND NOT EXISTS(SELECT 1 FROM json_each(payload) WHERE key NOT IN ('backfill','chats','error','imported','inventory_hash','phase','problems','sources')))), 0)` + // Vault.runPass saves offsets even when the history reader admits no sources. + // Every source and publication table above must still be empty. + out["cursors"] = "skip <> 0 OR offset < 0" + addStartupIndexes(out, "page_fts") + return databaseWork(path, out, false) +} diff --git a/internal/identity/store.go b/internal/identity/store.go index ea80cb86b9..b0d0c6fcef 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "strings" + + statehome "github.com/Agent-Field/codeaf/internal/home" ) const ( @@ -21,6 +23,16 @@ const ( // ErrNone reports that no identity exists under the home yet. var ErrNone = errors.New("identity: none on this machine") +// ErrHomeRemoved refuses to make new keys in a home this process has lost. +var ErrHomeRemoved = statehome.ErrRemoved + +func checkHome(home string) error { + if statehome.Removed(home) { + return ErrHomeRemoved + } + return nil +} + func path(home string) string { return filepath.Join(home, File) } // Load reads the identity under home, or ErrNone. @@ -53,11 +65,16 @@ func Ensure(home string) (Identity, error) { // EnsureSolo is Ensure for a computer that needs an identity for itself (a // vault key, a first launch) and has not been asked to share it: an identity // it makes is solo (solo.go). One that already exists is left as it is. -func EnsureSolo(home string) (Identity, error) { +func EnsureSolo(home string) (id Identity, err error) { + defer func() { err = homeWriteError(home, err) }() + if err := checkHome(home); err != nil { + return Identity{}, err + } if _, err := Load(home); errors.Is(err, ErrNone) { - if err := os.MkdirAll(home, 0o700); err != nil { + if err := statehome.MkdirAllUnder(home, home, 0o700); err != nil { return Identity{}, err } + statehome.Witness(home) if err := markSolo(home); err != nil { return Identity{}, err } @@ -69,12 +86,17 @@ func EnsureSolo(home string) (Identity, error) { // that already has a vault.key keeps it as the cell key, so its vault still // opens. Two processes racing to create agree on whichever wrote first. func ensureRoot(home string) (Identity, error) { + if err := checkHome(home); err != nil { + return Identity{}, err + } + statehome.Witness(home) if id, err := Load(home); !errors.Is(err, ErrNone) { return id, err } - if err := os.MkdirAll(home, 0o700); err != nil { + if err := statehome.MkdirAllUnder(home, home, 0o700); err != nil { return Identity{}, err } + statehome.Witness(home) cell, err := inheritedCellKey(home) if err != nil { return Identity{}, err @@ -92,9 +114,13 @@ func ensureRoot(home string) (Identity, error) { // Save replaces the identity under home. func Save(home string, id Identity) error { - if err := os.MkdirAll(home, 0o700); err != nil { + if err := checkHome(home); err != nil { + return err + } + if err := statehome.MkdirAllUnder(home, home, 0o700); err != nil { return err } + statehome.Witness(home) return write(path(home), id.document(), fsreplace.Rename) } @@ -121,11 +147,15 @@ func inheritedCellKey(home string) (key, error) { // write puts v as JSON at target through a private temp file and publish, // which is fsreplace.Rename to replace and os.Link to refuse replacing. -func write(target string, v any, publish func(from, to string) error) error { +func write(target string, v any, publish func(from, to string) error) (err error) { + defer func() { err = homeWriteError(filepath.Dir(target), err) }() raw, err := json.Marshal(v) if err != nil { return err } + if err := checkHome(filepath.Dir(target)); err != nil { + return err + } tmp, err := privateTemp(filepath.Dir(target), raw) if err != nil { return err @@ -152,3 +182,11 @@ func writeSync(f *os.File, data []byte) error { serr := f.Sync() return errors.Join(werr, serr, f.Close()) } + +// homeWriteError preserves the removed-home contract when removal follows a check. +func homeWriteError(home string, err error) error { + if errors.Is(err, fs.ErrNotExist) && statehome.Removed(home) { + return ErrHomeRemoved + } + return err +} diff --git a/internal/identity/teams_relay_test.go b/internal/identity/teams_relay_test.go new file mode 100644 index 0000000000..fb2c5737bd --- /dev/null +++ b/internal/identity/teams_relay_test.go @@ -0,0 +1,23 @@ +package identity + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTeamSyncLedgerDoesNotBlockJoiningAnUnusedHome(t *testing.T) { + home := t.TempDir() + if _, err := EnsureSolo(home); err != nil { + t.Fatal(err) + } + for _, name := range []string{"teams-sync.ledger", "teams-traffic-sync.ledger"} { + if err := os.WriteFile(filepath.Join(home, name), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + } + can, err := Joinable(home) + if err != nil || !can { + t.Fatalf("team sync ledger blocked join: %v %v", can, err) + } +} diff --git a/internal/keys/lock.go b/internal/keys/lock.go index 5ffaa61ce0..ba1ce56247 100644 --- a/internal/keys/lock.go +++ b/internal/keys/lock.go @@ -5,6 +5,7 @@ import ( "path/filepath" "github.com/Agent-Field/codeaf/internal/filelock" + codeafhome "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" ) @@ -12,7 +13,9 @@ import ( // and processes. The state-sync lock cannot serve this purpose: its owner calls // vault transactions while holding it during a network round. func LockHome(home string) (func(), error) { - if err := os.MkdirAll(home, 0700); err != nil { + // A home that was in use and has since been removed is not made again + // ([home.MkdirAllUnder]); a first use still creates it. + if err := codeafhome.MkdirAllUnder(home, home, 0700); err != nil { return nil, err } f, err := os.OpenFile(filepath.Join(home, "vault.enc.lock"), os.O_CREATE|os.O_RDWR, 0600) diff --git a/internal/keys/merge.go b/internal/keys/merge.go index f5d3ccae68..66879ab2c0 100644 --- a/internal/keys/merge.go +++ b/internal/keys/merge.go @@ -78,5 +78,22 @@ func supersedes(a record, ad *vaultDoc, b record, bd *vaultDoc) bool { if sa == sb && a.live() && b.live() && a.Entry == b.Entry && a.Metadata != "" && b.Metadata == "" { return true } - return sa > sb || (sa == sb && a.Deleted && b.live()) + if (a.Scope != "home:keys:teams" && a.Scope != "home:windows:team-traffic") || a.Scope != b.Scope { + return sa > sb || (sa == sb && a.Deleted && b.live()) + } + if sa != sb { + return sa > sb + } + if a.Deleted != b.Deleted { + return a.Deleted + } + // Independent devices can save within one clock tick. Stable content order + // resolves that tie so exchanging the same slots cannot leave two answers. + if a.Scope != b.Scope { + return a.Scope > b.Scope + } + if a.Name != b.Name { + return a.Name > b.Name + } + return a.Value > b.Value } diff --git a/internal/keys/relay_merge_test.go b/internal/keys/relay_merge_test.go new file mode 100644 index 0000000000..eccee4f836 --- /dev/null +++ b/internal/keys/relay_merge_test.go @@ -0,0 +1,76 @@ +package keys + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" +) + +func TestEqualStampFieldsConvergeRegardlessOfMergeDirection(t *testing.T) { + aHome, bHome := t.TempDir(), t.TempDir() + id, err := identity.Ensure(aHome) + if err != nil { + t.Fatal(err) + } + if err := identity.Save(bHome, id); err != nil { + t.Fatal(err) + } + a, err := Open(aHome) + if err != nil { + t.Fatal(err) + } + b, err := Open(bHome) + if err != nil { + t.Fatal(err) + } + at := time.Unix(1700000000, 0) + for v, value := range map[*Vault]string{a: `["x"]`, b: `["y"]`} { + if err := v.PutAt("home:keys:teams/alpha/order", Entry{Name: "alpha/order", Scope: "home:keys:teams", Value: value}, at); err != nil { + t.Fatal(err) + } + } + aRaw, err := a.Export() + if err != nil { + t.Fatal(err) + } + bRaw, err := b.Export() + if err != nil { + t.Fatal(err) + } + if err := a.Merge(bRaw); err != nil { + t.Fatal(err) + } + if err := b.Merge(aRaw); err != nil { + t.Fatal(err) + } + left, err := a.Get("home:keys:teams/alpha/order") + if err != nil { + t.Fatal(err) + } + right, err := b.Get("home:keys:teams/alpha/order") + if err != nil || left != right { + t.Fatalf("equal clock fields diverged: %+v %+v %v", left, right, err) + } + // Embedded separators must not make two distinct records compare equal. + leftRecord := record{Entry: Entry{Scope: "home:keys:teams", Name: "b", Value: "c\x00d"}, Updated: at.UnixMilli()} + rightRecord := record{Entry: Entry{Scope: "home:keys:teams", Name: "b\x00c", Value: "d"}, Updated: at.UnixMilli()} + doc := &vaultDoc{} + if supersedes(leftRecord, doc, rightRecord, doc) == supersedes(rightRecord, doc, leftRecord, doc) { + t.Fatal("distinct equal-time records have no deterministic winner") + } + +} + +func TestExistingScopesKeepTheirEqualStampMergeAnswer(t *testing.T) { + doc := &vaultDoc{} + for _, scope := range []string{"home:keys:preferences", "home:keys:provider-keys", "home:memories", "credentials", "project", "home:keys:teams-future"} { + for _, deleted := range []bool{false, true} { + a := record{Entry: Entry{Scope: scope, Name: "key", Value: "z"}, Updated: 100, Deleted: deleted} + b := record{Entry: Entry{Scope: scope, Name: "key", Value: "a"}, Updated: 100} + if got, want := supersedes(a, doc, b, doc), deleted; got != want { + t.Fatalf("scope %s: got %v want %v", scope, got, want) + } + } + } +} diff --git a/internal/manual/chat/a-dev-box-on-your-own-railway.md b/internal/manual/chat/a-dev-box-on-your-own-railway.md new file mode 100644 index 0000000000..1c589fea71 --- /dev/null +++ b/internal/manual/chat/a-dev-box-on-your-own-railway.md @@ -0,0 +1,212 @@ +# A dev box on your own Railway account + +## Can I run codeaf on my own server or an always-on computer in the cloud? + +Yes. `codeaf devbox up ` creates an always-on computer on **your own Railway +account** and pairs it into your identity. You pay Railway for its compute and +persistent volume. Your laptop can sleep while the box keeps running. + +The box holds your whole identity, including access to chats, memory and saved provider +keys, just like any paired computer. The pairing code joins only that identity: +**one code never gives both** chats and remote access. Terraform separately writes +the box's remote key and admits the laptop that creates it in the box's +configuration. `codeaf chat --at ` on that laptop opens a chat there without +another prompt. `/devices` and Settings → Devices list the chosen name. The box +dials out through the same relay as the laptop; it opens no public port or domain. +Restarts preserve its remote key and any later admissions or revocations. + +For a server you already own, install codeaf there, pair it with `codeaf pair`, +and run `CODEAF_SERVE_SYNC=1 codeaf serve` so it receives your saved keys, or use +`codeaf chat --host ` when ssh works. + +## Set up a dev box with codeaf devbox up + +Install Terraform from https://developer.hashicorp.com/terraform/install. Create +a Railway token at https://railway.com/account/tokens and set `RAILWAY_TOKEN`. +`RAILWAY_API_TOKEN` is also accepted when `RAILWAY_TOKEN` is empty. No token or no +Terraform means nothing is created. + +Run `codeaf devbox up build-box`. Names use lowercase letters, digits and hyphens, +start and end with a letter or digit, and are at most 32 characters. An existing +device name is refused. An existing project is left alone; if its box is reachable +and setup values remain, up finishes removing those values without pairing again. + +The command shows a Terraform plan and asks once: +`Create these on your Railway account? [y/N]`. After approval, codeaf creates the +box without a pairing code, waits up to three minutes for Railway to finish +starting it, then sends a one-time pairing code. It checks deployments every +three seconds; if they have not settled by the limit, it says so and proceeds. +Only the service variables may change when sending the code; a plan changing +other resources is refused without applying it or asking you again. + +Allow a few minutes to install and join. If Railway swaps the deployment while it joins, or +the code expires, codeaf sends a fresh code, up to three codes in total, keeping the same remote key and admission list. Up +probes the pinned key every five seconds while waiting, including when a join +acknowledgement was lost. After acknowledgement it waits at most three minutes +for reachability. It says ` is up` only after a successful connection and +removal of the one-time setup values. Then open `codeaf chat --at build-box`. + +## Dev box up flags for folder, region, workspace and channel + +- `--dir `: keep the Terraform in this folder. +- `--yes`: skip confirmation; required when stdin is not a terminal. +- `--region `: choose a region; empty lets Railway choose. +- `--workspace `: choose a Railway workspace. With several workspaces, + name one; a workspace token that cannot list names can use an id or omit it. +- `--channel stable|staging|dev`: follow that channel. The default follows the + running binary; a source build uses `dev`, and a release candidate uses stable. + Each channel syncs through its own relay by default, so the box is pinned to + the relay of the laptop that created it, whichever channel it installs. + +## How saved provider keys, settings and shared memories reach the box + +The box starts `codeaf serve` with `CODEAF_SERVE_SYNC=1`, so it pulls the identity's saved provider keys, +settings and shared memories at startup and every five seconds by default, +the same interval an open terminal uses; without that variable `codeaf serve` +syncs nothing, as before. `CODEAF_SYNC_INTERVAL_MS` changes that +interval in milliseconds. `codeaf devbox up` sends the +creating computer's saved keys and settings once the box answers, so the first +chat there has them; if that fails it says `Could not send this computer's saved +keys yet` and opening codeaf on that computer sends them. Keys saved on another +computer reach the identity once that computer has opened codeaf. +Keys held only in environment variables are not saved or shared. + +Serving begins while the first round is still running. A `codeaf chat --at ` +started before that round finishes can report a missing provider key. Wait a +moment and open the chat again. + +## Where the Terraform and the always-on computer's identity live + +By default the Terraform is in `/devbox//`. `CODEAF_HOME` moves +that home. `--dir ` selects another folder for up, status or down. You own +the folder: read or edit the module, or run Terraform there yourself with +`RAILWAY_TOKEN` set. Keep using that same folder for later status and down. + +There is one private project `codeaf-`, one service `box`, and one volume +`home` mounted at `/data`. The service runs continuously with one replica. The +volume keeps the identity, installed binary and work folder across restarts and +redeploys. A successful first join writes a marker, so it never reuses that code. +Before its first join, a boot without a code waits without downloading. A failed +join waits for a new code from up. The first install uses the same release as the +computer that ran up when both use the same stable, staging or dev channel, so +it does not depend on GitHub's API limit. If that install fails, it tries the +channel's newest build once in the same boot. Source builds, release candidates +and a different `--channel` install the channel's newest build directly. Later +boots refresh to the channel's newest build and keep the earlier volume copy if +that fails. With no earlier binary, a failed install waits a minute before +exiting so Railway can retry. + +## One-time setup values, Terraform state and private files + +Non-secret choices are in `codeaf.auto.tfvars.json`. The Railway token is passed +only through the Terraform process environment. The pairing code and base64 +remote files are passed only as `TF_VAR_pair_code`, `TF_VAR_remote_key` and `TF_VAR_remote_devices`, never +written to that variables file. The saved plan is deleted on completion or +failure. Once the pinned remote key answers, codeaf says +`Removing the one-time setup values from Railway.` and applies a plan that can +change only the service variables. It asks no second confirmation. This removes +the spent pairing code, the box's remote key and its admission list from Railway +and the folder's current state, then deletes `terraform.tfstate.backup` containing +the previous state. The box restarts and keeps using its seeded volume files. + +Until cleanup succeeds, **state and backups hold those secrets**. Keep the folder +private (0700); state and backups are tightened to 0600 after Terraform writes. +A hand-run apply after joining needs no setup values. +When created with `--region`, change `region` in the variables file to move it. +Without `--region`, the module leaves placement to Railway; to pick one later, +edit the service's `regions` setting in `main.tf`. + +## Check whether my dev box is up and when it was last seen + +`codeaf devbox status ` reads the project's Terraform outputs and your device +list; it needs Terraform but no Railway token. `codeaf devbox status` checks every +folder under the default devbox directory. Both accept `--dir ` and +`--yes` (status has no confirmation). + +The status shows the name, region, project and last seen when available. On the +creating laptop it first opens and immediately closes a remote connection with +the box's pinned key, with a three-second timeout. A successful probe means `up` +and last seen now, including with released boxes that hold no directory watch. +It logs a short connection on the box and may start its engine process, without +opening a chat or making a provider call. + +If that connection fails or this laptop has no alias, status falls back to +directory online presence, then `up (seen recently)` for a record seen within +five minutes when presence is unavailable. Missing readings are omitted. Empty +state says `: not up`. A `codeaf serve` started with `CODEAF_SERVE_SYNC=1`, +as the box's is, holds one shared directory watch and refreshes its name and +capabilities every two minutes when home sync is eligible. Quiet homes, sync off, unavailable cells and pending rotation +publish nothing. A changed identity, device or relay ends that presence worker. + +## Opening the dev box from another computer + +Only the laptop that ran `codeaf devbox up` is admitted for remote access. Pairing +another computer into your chats does not admit it or copy the creating laptop's +local name alias. With the box's key-derived name, a new computer is asked for a +remote pairing code; even a correct code is refused by headless `serve` because +there is no terminal to approve it. It does not wait for a yes/no answer there. +An unadmitted key with a saved pin is also refused. There is no unattended command +to admit another computer yet; use the creating laptop. + +## Send a task to my dev box + +A dev box also takes tasks. It starts `codeaf serve --take-tasks` whenever its +installed build lists that option, so a chat on your laptop can run one task on the +box while the conversation and transcript stay on the laptop: +`/task --on ` names it for one task, and Settings → Run tasks on +sends every task that can go. The box's synced provider keys pay, and the spend +appears on the laptop too. A box whose build predates the option starts plain +`codeaf serve` and takes no tasks; sending one says +` is not taking tasks right now — run codeaf serve --take-tasks on it`, and +the box picks the option up when its channel's build has it and it restarts. See +*Tasks on another computer* for what can go and what happens when a computer +stops answering. + +## How do I remove my dev box or stop paying for it? + +Run `codeaf devbox down ` with the Railway token set. It asks +`Destroy and everything in project codeaf-? [y/N]`. `--yes` skips the +question; `--dir ` selects the Terraform folder used to create it. + +Down destroys the project, service and volume, including the box's work and +identity, then removes its device from your identity. If destruction fails, it +keeps the device. The Terraform folder stays on your laptop; a later up can use +it again. Removing a device in Settings alone does not destroy the paid cloud +resources. + +Down selects the saved joined device id from `codeaf.devbox.json`, so renaming +it does not prevent removal. Without that id it selects the active device by +name, ignoring removed records. If none matches, the receipt says no device was +removed. Reusing the name after down works. + +## Dev box pairing, reachability and setup cleanup failures + +` did not join: ...` means the offer ended without a successful connection. +The box remains on your Railway account. Run the printed `codeaf devbox down +` command, then up again. A lost acknowledgement can leave it running; +this receipt does not claim it is idle. Nothing is destroyed automatically. + +` took its pairing code but is not reachable yet: ...` means the offer +succeeded but its pinned key did not answer within the wait. It remains on your +Railway account. Run the printed `codeaf devbox status ` command to check, +or `codeaf devbox down ` to remove it. + +` is up, but its one-time setup values are still on Railway and in +: ...` means reachability was proved but cleanup did not finish. Secrets +may remain in Railway, state or its backup. Run the printed `codeaf devbox up +` command again to clear them without pairing, or down to remove the box. +When cleanup is already complete, up keeps saying it is already up. + +Recovery commands include `--dir ` for a non-default folder, quoted when +needed. Keep that flag when copying a command. + +## How much does a dev box cost? Does codeaf offer AWS? + +You pay Railway directly for the computer's compute and persistent storage, +according to your Railway plan and usage. codeaf does not set a fixed price or +include that computer in a subscription. Provider calls also spend your own +provider keys as usual. `codeaf devbox down ` removes the paid resources; +closing your laptop or ending a remote chat keeps the computer running. + +AWS: a Terraform module exists in codeaf's source but has never been applied, and +`codeaf devbox` does not offer it yet. diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index 823d734881..f0ca4ec439 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -139,15 +139,9 @@ None of this happens without a folder to weigh in. A run with no `-w`, or a goal no file that exists, is not treated as small — it is treated as unmeasured, and every one of these passes behaves exactly as it did before any of it was read. -## Running one task without the screen — codeaf do, headless, from a script: what flags it takes, what it prints, what its last line and its exit code mean +## What flags does codeaf do take — run one task from a script -``` -codeaf do "" -``` - -One job, nobody watching, then it exits. What you type **is** the goal — it is not reworded -on the way in, and where this conversation would stop and ask, a run with nobody at the -keyboard decides for itself and says on the record that it decided. +Run `codeaf do ""` for one job without the screen. These options control that invocation. | flag | what it does | | --- | --- | @@ -167,6 +161,10 @@ keyboard decides for itself and says on the record that it decided. Flags may come after the task text, and the task itself may be piped in. +## What does the last line of codeaf do mean + +With plain output, `codeaf do` ends with a footer describing the completed work. `--json` prints one machine-readable object instead. + **Standard output is the answer and nothing else**: the deliverable, then `files:` with one absolute path under it per file, then `learned:` — what one worker told the others mid-flight — and last one footer line: @@ -185,6 +183,16 @@ piece of work as it starts and as it lands, and, when half a minute passes with report, a line like `still waiting: 1 task pending, 1 running · last call 40s ago — 4m30s`. +## How do I run codeaf without the screen — headless exit codes and questions + +``` +codeaf do "" +``` + +One job, nobody watching, then it exits. What you type **is** the goal — it is not reworded +on the way in, and where this conversation would stop and ask, a run with nobody at the +keyboard decides for itself and says on the record that it decided. + **The exit code is what a script reads.** `0` — the whole of it stands. `1` — nothing usable: it failed, the price was refused, or it stopped on a question. `2` — partial: the wall came first, the review rejected what was delivered, or parts of it did not land. diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 2ebc6fec40..d9932270f9 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -1,10 +1,10 @@ # Adding a computer to your devices, approving it, and removing it -## How do I add a second device — the Add another device card (add another machine), codeaf pair, a link to paste, and pairing a server with no browser +## How do I add another computer — the Add another device card (add another machine), codeaf pair, a link to paste, and pairing a server with no browser 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. -Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. +Older chats move through an independent copy. The original files stay untouched and remain usable in older codeaf builds. Pairing a computer that has never shared keeps and shares its existing chats, full history, saved keys, memory and preferences. It asks no replacement question. @@ -31,10 +31,22 @@ Click `+ Add another device` or press `alt+d` to open the steps. Click the headi > Paste pairing link ``` +## Paste the new device’s pairing link on Home + 1. On the new device, install codeaf and run `codeaf pair`. It makes a link and waits. -2. On this device, click the card’s link field, type or paste the link, and press Enter. Opening the card also focuses its field. You can edit the link before submitting it; clicking inside the field places the caret there. Esc or Tab returns typing to the normal message box, and clicking the message box does the same. Your chat draft stays separate. You can also paste it anywhere on home when the card’s field is not focused, or type it as your message and press Enter. The link opens the approve screen and is never sent to the model (see "Approve a new device" below). +2. On this device, click the card’s link field, type or paste the link, and press Enter. Opening the card also focuses its field. You can edit the link before submitting it; clicking inside the field places the caret there. Esc or Tab returns typing to the normal message box, and clicking the message box does the same. Your chat draft stays separate. You can also paste it anywhere on home when the card’s field is not focused, or type it as your message and press Enter. The approval replaces the steps on Home; approving, declining or closing it keeps you on Home. The link is never sent to the model (see "Approve a new device" below). + +## Add a device through your own sync service — pairing instructions include --via + +When this computer uses your own sync service, step 1 includes its address: `1. On the new device, install and run: codeaf pair --via http://127.0.0.1:8901`. `/pair`, Settings → Devices → Add another device, and `codeaf pair --code` include the same `--via` argument. + +Approving a self-hosted page's link in a terminal keeps that page's service when it matches this computer's selected service. A link waiting on a different service is refused before its code is sent. -The card goes away once you have two devices. It is not drawn when this chat cannot pair at all, for example a chat that runs over `--host` or `--at`. +The card goes away once you have two devices. After that, `/pair` or Settings → Devices → Add another device still offers a place to paste the new device’s link, alongside the code command. It is not drawn when this chat cannot pair at all, for example a chat that runs over `--host` or `--at`. + +## Do older chats move after pairing? + +Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. ## Pairing a server with no browser — the link, token and check number @@ -53,19 +65,19 @@ Check number: 4821 — the other device shows the same number. Waiting for approval (10 minutes). Press ctrl+c to cancel. ``` -A device with no screen or no browser, such as a server, does the same: run `codeaf pair` there and approve from your laptop. Two things name the same request: the link `https://codeaf.agentfield.ai/p/#`, which opens in a browser and hands over to the installed app (the part after `#` never leaves the browser), and the bare token `.`, for a terminal where nobody can click. `codeaf pair approve` takes either one, and so does pasting into home. When it is approved it says how many workspaces it can now reach: +A device with no screen or no browser, such as a server, does the same: run `codeaf pair` there and approve from your laptop. Two things name the same request: the link (`https://codeaf.agentfield.ai/p/#` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev/p/#` on staging builds, or `https://caf-fabric-dev.instrument-santosh.workers.dev/p/#` on dev and local source builds), which opens in a browser and hands over to the installed app (the part after `#` never leaves the browser), and the bare token `.`, for a terminal where nobody can click. Channels do not sync with each other: pair computers on the same release channel (or with the same `CODEAF_SYNC_URL`). `codeaf pair approve` takes either form, and so does pasting into home. When it is approved, it names one of the computers you joined and tells you how to open your chats: ``` -Paired. 3 workspaces available. +Paired with laptop-a. Run codeaf to open your chats. ``` -(`Paired. 1 workspace available.` when there is one.) +The approving device says `desk-b is now paired. Your chats can continue there.` ## What does codeaf pair --code do 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. -`codeaf pair --code` shows a six-digit code on this computer. On the other computer, run `codeaf pair ` to share this computer's chats. Compare the three words and approve on the computer showing the code. The page *Pairing your chats with a second computer* explains each step. +`codeaf pair --code` shows a code such as `04-063-213` on this computer. On the other computer, run `codeaf pair ` to share this computer's chats. Compare the three words and approve on the computer showing the code. The page *Pairing your chats with a second computer* explains each step. ## The pair words — approve, --code and --via @@ -78,9 +90,9 @@ usage: codeaf pair [--name ] | codeaf pair approve | codeaf - `codeaf pair` with nothing after it is for the NEW device. It asks to join and shows a link. - `codeaf pair approve ` is for a device that is already in. It shows who is asking and asks `a / d`. Only `a` approves; `d`, Esc, Enter and anything else decline. Pasting the link into the chat, or typing `/pair `, does the same thing on a screen. - `--name ` is what your devices call this computer. See the page *Naming a device*. -- `--via
` names the address of the service to go through when it is not the one this device already uses. Pass it to both `codeaf pair` and `codeaf pair approve`. The new device prints the full approve line with `--via` in it when one is needed. +- `--via
` names the address of the sync service to go through when it is not the one this device already uses. Pass it to both `codeaf pair` and `codeaf pair approve`. The new device prints the full approve line with `--via` in it when one is needed. -With a self-hosted address (`CODEAF_SYNC_URL=` or `--via
`), the link opens that service's own `/p/` page, and the terminal approval token includes `--via
`. It never prints a hosted page link for that invitation. The page shows the token command and an app link that keep the same address; the key stays after `#`. An address without a web page uses only the terminal token. Pasting a self-hosted link into a chat configured for another address sends no request and says `this pairing link uses another sync address — approve it in a terminal with the command shown on the new device`. +With a self-hosted address (`CODEAF_SYNC_URL=` or `--via
`), the link opens that sync service’s own `/p/` page, and the terminal approval token includes `--via
`. It never prints a hosted page link for that invitation. The page shows the token command and an app link that keep the same address; the key stays after `#`. An address without a web page uses only the terminal token. Pasting a self-hosted link into a chat configured for another address sends no request and says `that link is waiting on codeaf.agentfield.ai, but this computer syncs through 127.0.0.1:8901 — on the new device run: codeaf pair --via http://127.0.0.1:8901`. 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.` @@ -100,7 +112,7 @@ When you open a link on a device that is already in (paste it, or type `/pair `), move to its row and press `r`. It is removed at once and the row says `removed`: +## Remove a computer — r asks once, y removes, any other key keeps it + +The removable row’s key line says `r remove`. To remove a device (the command line calls this revoking: `codeaf devices revoke `), move to its row and press `r`. The card asks `remove desk? it must pair again to come back · y removes · any other key keeps it`. It uses the shown name, including a duplicate name’s `#tail`. Only `y` removes it; every other key, including `esc`, cancels and keeps that row selected without doing anything else. After `y`, the row says `removed`: ``` desk was removed — it can no longer reach your chats. ``` +## Removed device rows, list order and a missing computer + The list is in a fixed order: this device first, then devices that are online, then the rest, each by name. Two devices with the same name show a short tail, such as `tower #a1b2`, so you can tell them apart. Your own computer has no `r`: it cannot remove itself, and if you press `r` there the list says `this device cannot remove itself — choose another row.` A computer that is already removed does nothing when you press `r`. A removed computer can only come back as a new request that you approve. If you remove a computer from another of your devices while this list is open, its row changes to `removed` here too, without closing the list. Removing a computer does not undo what it already holds: for a lost computer read the page *Locking out a lost computer for good*. ## Devices in the desktop — removal warnings, refresh and re-pairing @@ -188,6 +205,21 @@ After pairing again from a terminal, refresh Devices. The running app engine reads the new identity and device files and drops the previous pairing's list, presence and removal refusal. Restarting the engine is not required. Switching the sync address also drops facts learned from the previous service. +## Devices in /settings — where your computers are on the settings page, add another device, rename this one, or remove (revoke) one from settings + +Open `/settings` (or `ctrl+,`) and walk with `←` and `→` to `Devices`, between `Connections` and `Privacy` — or type `devices`, `remove`, `revoke`, `pair` or `add device` in settings search. + +While loading, the tab says `looking up your devices…`. Loaded, it begins with the same dim `sync service: 127.0.0.1:8901 (your own)` or `sync service: codeaf.agentfield.ai (hosted)` line as `/devices` and the terminal. It lists this computer even before pairing, with the same platform and presence columns and in the same order and words as `/devices` — `●` online, `○` away (`seen 3h ago`, or `offline`), `this device`, and `removed` — followed by `Add another device`. The open tab refreshes on its own when a computer joins, renames itself or is removed. + +## Keys in Settings Devices — add, rename and confirm removal + +In `/settings` → Devices, the key line shrinks to what works on the row (`↑↓ choose` when there is more than one row, `r remove` only on a removable computer, `←→ categories`, `esc close`): + +- `enter add` on `Add another device` leaves `/settings` and starts `/pair`. +- `n rename` on `this device` opens `name this device: ` under the row with `enter save · ctrl+u clear · esc cancel`. While typing, `esc` cancels the edit without closing `/settings`; a second `esc` leaves `/settings`. On another row `n` says `a device renames itself — choose this device's row, or open /devices on the one you mean.` +- `r remove` (called `codeaf devices revoke ` on the CLI) asks `remove desk? it must pair again to come back · y removes · any other key keeps it`. Only `y` removes it, marks its row `removed`, and says `desk was removed — it can no longer reach your chats.` Any other key cancels, keeps the same row selected, and does nothing else. On your own row `r` says `this device cannot remove itself — choose another row.` On `Add another device` or a `removed` row, `r` does nothing. + +Where devices cannot work, the tab shows one sentence and no rows: `pairing is not available here` (a chat over `--host` or `--at`), `moving chats needs a newer furrow engine than this build carries`, or the read error. With `CODEAF_SYNC_URL=off` it lists this computer, and `enter add` answers `sync is off (CODEAF_SYNC_URL=off); pairing needs the sync service`, as `/pair` does. ## A device was removed but refreshing the list failed @@ -197,15 +229,25 @@ After a successful removal, its confirmation stays visible even if refreshing th 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. -If this computer was removed while its window was closed, the explanation appears on home on the next launch too. The launch check and the live check share one notice, so learning it both ways does not repeat it. +Home shows `removed by another of your devices — pair again to sync` on its own dim line with the Add another device card. The line remains visible when this connection cannot offer that card. The footer keeps only key hints. `/pair` repeats that notice, then says `on a device still in your devices, open /pair; enter its code here to pair again` and shows `Code from another device`. Enter the other device’s code and confirm pairing. It cannot offer a code from the removed membership or approve another device’s link. If this computer was removed while its window was closed, the explanation appears on Home on the next launch too. The launch check and the live check share one notice, so learning it both ways does not repeat it. Another of your devices removed this one. The chat says: ``` -this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back +this device was removed by another of your devices, so this chat stays here only — /pair brings it back ``` -`/devices` on the removed computer says the same line in place of the list. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. It appears the moment the other device removes this one, even if codeaf has been open for days, in the open chat and on the home screen alike, and once only. Your chats on this device are safe and stay on it. They no longer follow your other devices. To bring the device back, run `codeaf pair` here, then approve the link it shows from a device that is still in (the steps above). If you did not mean to remove it, that is the whole fix. +## Can a removed computer rejoin — /devices, pairing again and retained local chats + +`/devices` on a removed computer says the same line in place of the list. A terminal refusal uses `this device was removed by another of your devices, so this chat stays here only — run codeaf pair to bring it back`. Home says `removed by another of your devices — pair again to sync`. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. It appears the moment the other device removes this one, even if codeaf has been open for days, in the open chat and on the home screen alike, and once only. Your chats on this device are safe and stay on it. They no longer follow your other devices. To bring the device back, run `codeaf pair` here, then approve the link it shows from a device that is still in (the steps above). If this computer has chats with submitted turns, run `codeaf pair --replace` instead. Its existing chats stay on this computer but no longer sync; shared memories and settings are cleared. Approve the new link from a computer still in your devices. A computer that has only opened codeaf, even with a provider connected, and whose chats contain only startup headers needs no `--replace`; saved knowledge, automations, tracked work and unknown local files still require the explicit replacement road. An active, already-paired computer is refused with the names of its other devices; `codeaf devices` lists them. + +## Pair again in the chat — replace confirmation keeps local chats + +On a removed computer, `/pair` accepts a code from `/pair` on a computer still +in your devices. It uses the same confirmation as `/pair --replace`: +`Pair with your other devices? This computer's existing chats stay here but stop syncing; shared memories and settings are cleared. Provider keys saved on this computer stay here.` +Only `y` continues; `n` or `esc` keeps the existing chats. The retained local +chats no longer sync under the replaced membership. ## The link did not work — a pairing link or token that ran out, was declined or was answered already @@ -213,16 +255,16 @@ Moving chats and pairing are available by default on Linux, macOS and native Win These are the sentences a link pairing says when it stops, in the words the screen uses. Nothing was changed on either device when you read one of them. -- `that is not a codeaf pairing link or code; it looks like https://codeaf.agentfield.ai/p/k7m2q9xd#... or k7m2q9xd.`: what was pasted is neither shape. Paste the whole link or the whole `.` token. A link from any other host is not a codeaf link. +- `that is not a codeaf pairing link or code; it looks like https://codeaf.agentfield.ai/p/k7m2q9xd#... or k7m2q9xd.`: what was pasted is neither shape. Paste the whole link or the whole `.` token. Links from your own sync service are accepted too. - `the link ran out before anyone approved it; run `codeaf pair` again for a new one`: a link is good for 10 minutes. -- `that link is not waiting any more; run `codeaf pair` on the new device for a fresh one`: the link is unknown, already used or gone. Those are one answer on purpose. +- `that link is not waiting any more; run `codeaf pair` on the new device for a fresh one`: the link’s own sync service said it is gone or expired. A link on a different sync service is refused before any request is sent. - `that request was already answered`: another of your devices approved or denied it first. - `the request was declined on your other device; run `codeaf pair` to ask again`: the new device sees this when you decline. - `the check number does not fit that device, so nothing was approved`: the number on the approve screen does not belong to the device that asked. Do not approve; ask again. -- `this device has no codeaf identity yet; start codeaf here once before approving another device`: this computer has never started codeaf, so there is no local identity to share yet. Start codeaf here once, then approve the other computer. +- `open codeaf on this computer once, then approve again`: this computer has never started codeaf, so there is no local identity to share yet. Start codeaf here once, then approve the other computer. - `paired, but your devices could not be read yet; open codeaf again in a moment`: the pairing worked; the device list needs a moment. -Too many requests from one network are refused too: at most 10 new links in an hour and 3 waiting at once from one network. See *Pairing your chats with a second computer* for the older six-digit code and its own limit `too many pairings from this network; wait 7 min`. +Too many requests from one network are refused too: at most 10 new links in an hour and 3 waiting at once from one network. See *Pairing your chats with a second computer* for the code route and its own limit `too many pairings from this network; wait 7 min`. ## I quit on my laptop and opened my desktop after pairing @@ -243,3 +285,68 @@ blocks its later requests; it cannot take back bytes the device already received Once codeaf learns that this machine was removed, it stops asking for chats and stops sending updates. That refusal is remembered across windows and restarts. Your window stays usable for local chats. Home removes its offer to continue a chat from your other devices, its continue chord and the continue action on chat rows. The removal explanation stays visible. Run `codeaf pair` to pair it again. Pairing can still reach the service and gives this machine a new device key. An already open Home then shows your other devices again. + +## Terminal device list — sync service, this computer, platform and duplicate names + +`codeaf devices` begins with `sync service: codeaf.agentfield.ai (hosted)` or `sync service: 127.0.0.1:8901 (your own)`. It lists this computer as well as the other paired computers. A known platform and online state or last-seen time appear beside each name; unknown details are omitted. Removed devices come last under `removed`. + +Duplicate names use the same short suffix as the chat picker, for example `desk-b #8381`. Remove that one with `codeaf devices revoke desk-b#8381` or `codeaf devices revoke "desk-b #8381"`. An ambiguous name lists the choices and an example command. Removal cannot erase anything already on that computer. + + +## Pair from Home or after the second computer — /pair and a link field + +`/devices`, `/pair` and `/settings` from Home open their card or page without +creating a conversation. The device list and pairing approval stay over Home. + +`/pair` and Settings → Devices → Add another device offer both directions: +`paste the link the new device shows, or on it run codeaf pair 43-209-648`. +The command includes `--via` when this computer uses your own sync service. +The card also shows `> Paste pairing link`: paste or type the link there and +press Enter. Closing the card cancels its code; submitting a link switches to +the same approval card as Home, with the same check number and `a` / `d` keys. +A link for a different sync service is refused before looking it up. Your chat +draft stays separate. `c copy code` works until you start editing the link. + + +## Device removal when a list cannot be read or selectors collide + +`codeaf devices revoke ` checks both the devices that sync your chats +and the devices that can use this machine. If either list cannot be read, +`could not read the devices that sync your chats; nothing was removed — try again` +or `could not read the devices that can use this machine; nothing was removed — try again` +is the refusal. No removal happens. A selector that matches multiple devices, +even across the two lists, is refused with their available selectors. + +## This computer before pairing — terminal devices list and sync off + +Before pairing, `codeaf devices` lists this computer's local name, known platform +and `this device`, then says `Run codeaf pair to add another device.` +A sync list with no removable peer also ends with that pairing next step. +If this build cannot move chats, the local row is followed by the existing +remote-access instructions for `codeaf serve` and `codeaf chat --at `; +it does not recommend unavailable chat pairing. +With `CODEAF_SYNC_URL=off`, neither the terminal nor the device cards draws a +`sync service:` line. The local row remains available. + +## Pair again with the same devices after removal + +On a removed computer, enter a current device's code in `/pair` and confirm the +replacement. Approval installs a fresh certified device key even when the +shared account is unchanged. The fresh computer appears as active in your +devices; the old removed entry stays removed. Local chats stay here, shared +memories and settings are cleared, and locally saved provider keys remain. + + +## Sync is off — which address should I set + +A command that needs sync says +`sync is off: set CODEAF_SYNC_URL to your sync service's address`. +Set a service URL to enable syncing again. Device cards omit their service line +while syncing is explicitly off; their local computer row still appears. + +## Pairing links with differently written service URLs + +Pairing compares service URLs with scheme and host case ignored, default +ports omitted (`:443` for HTTPS, `:80` for HTTP) and trailing slashes omitted. +An actual difference in scheme, port, host or path prefix remains a different +service. A link for a different service is refused before any lookup there. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index b601742e4a..fbb20c4fa2 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -341,7 +341,7 @@ command). | `/pair` | `/sync`, `/link`, `/laptop` | `` | on the other computer: uses a code from `/pair` there, shows three words to compare, and keeps and shares this solo computer's existing chats | | `/pair` | `/sync`, `/link`, `/laptop` | ` --replace` | explicit clean slate: asks first and gives up this computer's shared state for the other computer's; ordinary pairing keeps and shares a solo computer's chats | | `/pair` | `/sync`, `/link`, `/laptop` | `` | on a device that is already paired: opens a new device's request from the link it shows, with its name, system, time and a check number to compare, then `a` approves and `d` declines | -| `/devices` | — | — | lists your devices with `●` online and `○` away; `n` renames this device, `r` removes the one under the cursor at once, and a removed device can only return as a new request you approve | +| `/devices` | — | — | lists your devices with `●` online and `○` away; `n` renames this device, `r` asks before removing the computer under the cursor; only `y` removes it and any other key keeps it, and a removed device can only return as a new request you approve (`/settings` → `Devices` holds the same list, including this computer and `Add another device`) | The words to type are `/pair`, `/pair `, `/sync`, `/link` and `/laptop`. All five open the same door. In a terminal `codeaf pair --code` shows a code and `codeaf pair ` uses one; bare `codeaf pair` requests approval by link instead. The page *Pairing your chats with a second computer* has the whole story: the three words, one attempt for each code, and what revoking cannot take back. @@ -491,9 +491,7 @@ re-read from the new agent. the box goes with **you** rather than with the conversation: it is carried into the new one and cleared in the old, along with any message that was waiting for an answer. -It ends with a note that says which of the two happened: `new conversation · ` -when it added one, and `new session · ` — or just `new session` with no file — when -it replaced a fresh empty one. +It ends with `new conversation · ` whether it adds a conversation or replaces a fresh empty one. `` is the folder’s base name, such as `proj-a`; no transcript path appears. With no project name it says `new conversation`. **How many conversations are already open is never a reason to refuse.** The ninth and the fiftieth `/new` open exactly like the first, and the one you were in is left running. @@ -1580,6 +1578,14 @@ weekly update" — and it is proposed on a card. `/standing` and `/orders` are g ## /task — start work you can walk away from +`/task --on ` runs a task in its own copy on a paired computer +running `codeaf serve --take-tasks`, while the chat stays here. Quote shown names containing +spaces: `/task --on "mac mini" Fix the export`. `--best` or `--cheap` may go on +either side of `--on`; `solo` follows the flags. `here` and `this computer` +choose this computer. The flag is offered only where paired tasks are available; +Linux and macOS are supported on both ends, Windows not yet. See *Tasks on +another computer* for placement, the **Run tasks on** default and `run it here`. + `/task ` starts work directly from the words after the command; the brief does not pass through the conversation model. **Nothing is waited for in front of it**: the task exists the moment you press enter, the ordinary started-task row appears @@ -1820,7 +1826,7 @@ this split when it opens; configure the remote profile on that machine. ## /settings, /set, /config — opening and navigating settings `/settings` (or `/set`, `/config`, or ctrl+,) opens a fullscreen page: a tab bar over the -codeaf settings, plus a tab of connected accounts. It was the first of the three fullscreen +codeaf settings, plus tabs for connected accounts and paired devices. It was the first of the three fullscreen pages here — the others are `/history` (the activity page, ctrl+.) and `/home` — and **only one of the three is ever up at a time**: opening any one closes the other two. @@ -1847,7 +1853,7 @@ and saves. Text and number editors offer adjacent Save/Cancel controls. Invalid input keeps the draft and shows its error beside it. Escape cancels an open editor or choice list without saving. -esc backs out one layer at a time: the search first, then anything the `Connections` tab +esc backs out one layer at a time: the search first, then anything the `Connections` or `Devices` tab has open, then the panel. The head line says `esc close` on the right. The bottom legend normally reads `↑↓ move · ←→ tabs · enter change · type to search · esc close`. @@ -1921,7 +1927,7 @@ Refusals inside the panel, exactly as written: they say `that model is chosen where its session is opened`. - Any refusal from the settings registry is shown on the foot line in the registry's own words. Nothing is swallowed. -- A search that matches nothing says `nothing matches`. The `Connections` tab has its own +- A search that matches nothing says `nothing matches`. The `Connections` and `Devices` tabs have their own sentences. ## config.json keys are not read — why codeaf says a setting I wrote is ignored @@ -1946,7 +1952,7 @@ value to the key a settings row names (`settings` lists every one), or delete th Settings now groups controls by what you want to change: -`General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy` +`General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Devices · Privacy` General holds appearance, input and updates. Models holds model selection and advanced routing/context controls. Memory holds `notice things to remember` — @@ -1954,6 +1960,8 @@ whether codeaf may keep a line on its own after a turn you typed — and a link the Memories tab. Tasks controls delegated work; AI teams sets defaults for groups of AI chats. Permissions controls approvals. Spending holds limits. Connections holds accounts, keys, web search and remote connection settings. +Devices lists your paired computers, renames this one, removes another, and starts +`/pair` from `Add another device`. Privacy holds history, drafts and data-sharing choices. Old saved keys and values are unchanged. Search accepts old labels and keys, @@ -1968,7 +1976,7 @@ a scrolling category bar. The selected category remains visible; `…` indicates more categories. Left and right change category and the bar follows. Only visible controls can be clicked. Hover shows row help without changing its value. -Type to search across settings and connection services. Escape clears the +Type to search across settings, connection services and paired devices. Escape clears the search before leaving settings. Advanced controls remain searchable while collapsed. Escape from an editor cancels its unfinished edit. @@ -2017,7 +2025,7 @@ have no provider row behind them, so nothing unfolds there and the legend does n key. While any of those layers is up — the value box, the model picker, the key box on the -Connections tab — the foot drops `tab next place`, because the layer has taken that key. +Connections tab, the device rename box on the Devices tab — the foot drops `tab next place`, because the layer has taken that key. ## Task model seats and their limits in Settings diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index f31695e49a..c5f35379cf 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -8,7 +8,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -## Continue on another computer — why can't I type in this chat, my message did not send and home opened with a question +## Continue on another computer — continuing a chat on another computer, why can't I type in this chat, my message did not send and home opened with a question 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. @@ -18,7 +18,7 @@ A chat can be worked on by one device at a time. If you open a chat here that an ``` Continue this chat here? -running on tower; last saved turn 4s ago +held on tower; last saved turn 4s ago 1 continue here 2 leave it there ``` @@ -29,23 +29,23 @@ An attended window keeps following your other devices while the chat is open, so If you continue it, the other device's window stops taking new turns and says ` continued this chat; this window now only shows it`. The page *Home* has the rest of that card. +## Where an older chat's copy lives — the .moving folder, README.txt, can I delete it + +An older chat's copy is kept in a folder called `.moving` beside the original, in codeaf's state folder for that project. The name is historical: nothing in it is a move in progress. It holds the live copies this build writes to, so deleting it deletes those chats. The folder carries a `README.txt` that says so. The originals beside it stay untouched for older codeaf builds. + ## My typed message after continue here — is it kept, does it send by itself -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. +before showing this question for a send, codeaf checks which computer holds the chat now. an older listing alone cannot raise it. if that check fails, nothing is sent and the whole message stays in the box, including pasted text and attachments. the chat says ` has not answered; your message is still in the box, press enter to send it`. press enter to check again. changing the draft while a check waits keeps the current words unsent; the chat says `draft changed before sending; press enter to send your current words`. -Before showing this question for a send, codeaf checks which computer holds the chat now. An older listing alone cannot raise it. If the check cannot reach your other devices and this window already knows the chat moved away, your words stay in the box and nothing is sent. Continuing a chat this computer already holds keeps its open conversation and your typed words. +a chat this window already holds, or one that has never been shared, sends without asking the service. an ordinary check for a shared copy waits up to five seconds. immediately after this window's own successful move, a message can wait up to 30 seconds for the chat to finish opening, then sends once. a window that lost the chat, or another window holding it, cannot give this window permission to send. -The first Enter waits up to five seconds for a current reading when this window -does not hold the shared chat, including in a freshly opened copy. -`other machines unreachable` means that reading -failed; it never means a reading has not happened yet. Your words stay unsent -while the reading is in progress. +if Enter raised the continue card, a successful move restores those same words, pasted text and attachments and says `your message is still in the box; press enter to send it`. it waits for another enter. choosing `leave it there` or a failed move also keeps the whole draft. -After `continue here` succeeds, those same words, pasted text and attachments are in the arrived chat's box. Press enter again when you want to send them. Choosing `leave it there` or a move that fails also keeps the whole draft. +## which draft comes back after continuing a chat -The move opens one conversation for that chat. Once you send those words, they are cleared from its saved drafts, including matching copies left by an earlier window. Taking the chat back, reopening it or restarting codeaf then shows an empty box unless you typed a new draft. +a move opens one conversation for that chat. sending clears the words from its saved drafts, including matching copies left by an earlier window. taking the chat back, reopening it or restarting codeaf then shows an empty box unless you typed a new draft. -Taking a chat from Home restores that chat's own saved draft, or leaves its box empty. Words typed for a different chat stay with that chat, even when both work in one place on disk. A draft held behind a send's continuation card stays with the chat that raised it. +taking a chat from Home restores that chat's own saved draft, or leaves its box empty. words typed for a different chat stay with that chat, even when both work in one place on disk. a draft held behind a send's continuation card stays with the chat that raised it. continuing a chat this window already holds keeps its open conversation and typed words. ## Read a finished task's worker conversation after moving its chat @@ -61,21 +61,29 @@ On `Continue this chat here?`, `leave it there` closes the question without movi If Enter in a displaced chat raises the continue card, `esc` or `2 leave it there` returns to that same chat window with its full draft still in the box. If you opened the card from a Home row, leaving the card keeps you on Home. +during an automatic continue, the first `esc` withdraws its opening and any waiting choice. the automatic chat is not opened: it finishes arriving and is let go, so it stays on this computer, free to continue from anywhere. a further `esc` navigates as usual while that finishes. if the abandoned move has not answered after two minutes, its request is cancelled before the waiting choice can begin. inspecting another chat's card and choosing `leave it there` or `esc` leaves the automatic arrival alone. + A continue card previews only that chat's unsent words, or nothing when no draft is available. Its project foot belongs to the selected chat. Words typed in another chat never appear on this card. Saved drafts belong to their chat even when a copied chat moves to a different project folder; opening another copy cannot adopt them. -Home says `other machines did not answer in time` when its ownership listing reaches a deadline. Your words stay unsent. This sentence does not establish a network failure. +## slow chat listing — waiting for a service, a timeout or an unreachable host -## Why does continue here say this chat was last held here +at five seconds without a chat-list answer, Home says `waiting for ; still checking` and keeps its rows. the read continues for up to 25 seconds. a deadline says ` did not answer in 25s; still trying`; a failed connection says ` is unreachable; still trying`. background checks continue on their usual schedule. a successful answer clears the line. -When this computer was the chat's last holder and its hold expired, the card says `this chat was last held here; last saved turn 2h ago`, with the age of its saved turn. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. +these are Home's listing sentences. a message left unsent by a failed send check gets its own explanation: ` has not answered; your message is still in the box, press enter to send it`. + +## Why does continue here say this chat was last held here — my only computer asks to continue + +A chat this computer held last, whose hold expired with no other computer holding it, opens without a question: there is nothing to continue from. Enter sends, and turns this computer saved while it could not reach your other devices are sent to them when the chat opens. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. + +The card still says `this chat was last held here; last saved turn 2h ago` when this window's own earlier move failed. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. If unpublished work needs to be kept separately, the number of kept turns counts conversation turns. Intermediate saves and tool records within one question and answer do not become extra turns. -## What does running on tower mean — a row on home and the verb it offers +## What does held on tower mean — a row on home and the verb it offers 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. -Home lists chats from your other devices. A chat that a device is working on now says `running on `. One that a device let go of, or that went off because its lid closed, says ` offline`. +Home lists chats from your other devices. A chat another device still holds says `held on `. The hold alone does not establish that work is running there. One that a device let go of, or that went off because its lid closed, says ` offline`. Press enter on either row to continue it here. The verb on the card depends on the row: **Move here** for a chat another device is running right now, **Continue here** for one it let go of. You already chose to continue, so the cursor starts on `continue here` and enter completes the move; `esc` leaves it there. The card says how fresh the copy is, so you know how many turns may still be on the other device. @@ -93,7 +101,7 @@ alt+c continue here · alt+x not now - `alt+c` continues immediately. The card asks first only when there is a real choice, such as keeping unsaved local edits as a branch. - `alt+x` dismisses it for that device until you open codeaf again. -The line follows the devices row: it stands while the device is offline or has let go of the chat, even if that became true after you opened codeaf, and goes away if the device comes back. A device that is online and still working on the chat is never offered. +The line follows the devices row: it stands while the device is offline or has let go of the chat, even if that became true after you opened codeaf, and goes away if the device comes back. If the service stops supplying fresh presence while the card stands, a dim service-failure sentence stays in its place. The offer returns when fresh evidence supports it again. A device that is online and still working on the chat is never offered. Home never offers this computer's own chat as work to continue from another computer, even when an older record says this computer went offline. @@ -126,7 +134,7 @@ not committed yet: main.go, util.go · last tests: passed got it ``` -## What was not brought along — the lines on the arrival card +## What was not brought along — folders, commands and tests on the arrival card When a chat arrives on a paired computer, its arrival card can include these lines: @@ -168,11 +176,11 @@ Removing this computer from your devices does not change those project labels. Switching away and back keeps the same copy label or ordinary project name. -## This chat is not syncing — other machines unreachable, working offline +## This chat is not syncing — the sync service is not answering, working offline If this computer holds the chat and renewals have failed for longer than one renewal period, one dim line says -`this chat is not syncing because other machines are unreachable; it keeps working here`. +`the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back`. It is said once per outage. When renewals succeed again, one line says `this chat is syncing again`. @@ -184,26 +192,35 @@ only shows it` notice still applies. When unsent turns were kept as a separate copy, reopening that copy checks who holds the copy itself. Another computer may still hold the original chat. The previous window finishes saving and stops syncing before the reopened copy starts its own sync, so a late save cannot change which copy it opens. Taking that copy elsewhere still stops new tool work in its older window. -## Continue here did nothing — another device continued first or the sync service is unreachable +## Continue here does nothing — continue here did nothing, the engine store is busy, or a chat is already continuing here 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. -If nothing happens when you press `Continue here`, the cause is one of these: +while a chat you explicitly chose is moving here, the card says `continuing here…`. another explicit continue during that move says `a chat is already continuing here; wait for it to open` on the card. an automatic startup move instead lets your explicit choice go next; see “my choice while an automatic chat is arriving”. + +every failure stays where you are looking: on Home's card with `1 retry` and `2 leave it there`, or inside the open chat window. a fresh reading decides whether a different computer continued first. if the same computer picked the chat up again, the card asks again with ` picked this chat up again; press enter to continue it here`. it does not blame another device. -- `another device continued this chat first`: another device was faster. The row goes back to `running on `. Nothing changed. -- `other machines unreachable`: this device cannot reach the service. Nothing can be moved until it can. -- `could not continue this chat here`: the move failed. The row stays where it was; try again. -- `could not continue this chat here because other machines are unreachable; your words are still in the box`: the service went away during the move. Nothing was sent and the chat stays where it was; send again once the other machines are reachable. -- A sentence starting `the sync service must be updated before chats can move` means the service is too old to move chats safely. The operators update the hosted service; for your own service, follow the update command in that sentence on the terminal. Pairing, listing and viewing still work. Reopen codeaf after the service is updated. -- With no way to continue set up, an off row has no `continue here`: enter says why. +## why continue here failed — retry or leave it there -## Taking a chat back, and two computers racing for the same chat — who wins, and what the loser sees +a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry confirms the holder and turn of ownership now displayed on that card. + + +- `another device continued this chat first`: another device was faster. The row goes back to `held on `. Nothing changed. +- `the sync service is not answering`: this device cannot reach the service. Nothing can be moved until it can. +- `could not continue this chat here because the engine store is busy` or `could not continue this chat here because the engine store is busy (held by codeaf chat, pid 4321)`: another `codeaf` process holds this computer's engine store writer. Close or finish that process, then choose `retry`. +- `could not continue this chat here`: the move failed. The row stays where it was; choose `retry` or try again. +- A move does not fail because another chat on this computer is saving its secrets to the sync service at that moment. Its last step waits up to 30 seconds for that save to finish, then arrives. +- `could not continue this chat here because the sync service is not answering; your words are still in the box`: the service went away during the move. Nothing was sent and the chat stays where it was; send again once the other machines are reachable. +- `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here` means the chat is open on another computer and the sync service is too old to take it from there safely. Everything else works, including continuing a chat the other computer has closed. Close the chat on that computer, then choose `continue here` again. +- Under `codeaf chat --host`, `continue here` is not offered and a chat the remote host already holds never shows as held elsewhere. With no way to continue set up, an off row has no `continue here`: enter says why. + +## taking a chat back, and two computers continuing the same chat — which one opens 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. A chat has one holder at a time. To take a chat back from the computer that took it, continue it there the same way: press enter on its row on that computer and choose `continue here`. Nothing has to be returned or switched off first. A computer with the chat open when another takes it says ` continued this chat; this window now only shows it`, stops taking new turns, and keeps what it had not yet sent as a branch (` turns from : open separately / discard`). A tool call the model tries there answers with that same line. Taking the chat back leaves a `node_modules` or other install folder that computer already has alone. -The choice covers the holder and turn of ownership shown on the card. If another computer continues the chat before you confirm or while the copy is arriving, your choice cannot take it from that new holder. The slower one sees `another device continued this chat first`, its local copy is not replaced, and its row goes back to `running on `. No turn is lost on either side: whatever the earlier holder had not sent is kept for you as the branch above. This build offers discard, which archives a branch, and opening the branch as a separate chat; merging it into the original chat is unavailable. Pressing `2` (`leave it there`) on the question, or `esc`, changes nothing and leaves the chat where it is. +The choice covers the holder and turn of ownership shown on the card. If another computer continues the chat before you confirm or while the copy is arriving, your choice cannot take it from that new holder. The slower one sees `another device continued this chat first` on the card with `1 retry` and `2 leave it there`; its local copy is not replaced, and its row goes back to `held on `. if the same holder picked it up again, the refreshed card says ` picked this chat up again; press enter to continue it here` and asks for a new confirmation instead. a complete chat already held here gets one silent recovery attempt; another disagreement keeps the retry card and draft. No turn is lost on either side: whatever the earlier holder had not sent is kept for you as the branch above. This build offers discard, which archives a branch, and opening the branch as a separate chat; merging it into the original chat is unavailable. Pressing `2` (`leave it there`) on the question, or `esc`, changes nothing and leaves the chat where it is. ## This window only shows the chat — can I still send here @@ -298,7 +315,11 @@ If Git cannot read the project's ignore rules, saving stops with `cannot read wh A command's result returns after its call log is written, while its file capture finishes in the background. The next tool call waits until the previous capture has finished reading files. Continuing the chat on this computer, uploading it, closing it, quitting codeaf and `codeaf cell gc` also wait for both the call log and the saved files to reach durable storage. A storage failure is reported and stops that boundary. Opening a chat screens its folder in the background without sealing an empty turn. A command submitted before that screen finishes waits for it. A window that only views a chat held elsewhere does not seal turns. A failed capture keeps the finished call in its local log for the next save; codeaf never runs that command again to recover it. -A wait lasts at most 30 seconds. A timeout says `still saving this chat on this computer: context deadline exceeded`; the pending save is not silently treated as done. The saving status reports failures. A successful quit stays quiet and waits up to 30 seconds for its final saving reply. An unanswered reply at that ceiling says the save is still finishing in the background; a definite saving failure reports an error. A final disk-flush failure says `codeaf: could not finish saving this chat:` followed by the cause and exits unsuccessfully. +## Quitting while saving — local confirmation and a slow upload + +A save wait lasts at most 30 seconds. After the screen closes, a pending sync save says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. + +If the turns are saved locally but not uploaded, the final sentence is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service’s host. That is a successful exit. Ctrl+c during this wait leaves promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`; that failure may include the local recovery path. If the last upload fails when you quit, for example because the connection dropped, this computer does not let the chat go. Your other computer keeps showing it as running here and does not open the older save by itself; the unsent turns stay on this computer until an upload succeeds. @@ -306,6 +327,10 @@ If the last upload fails when you quit, for example because the connection dropp Device presence can arrive before its list of chats. If you press `alt+m` early, home says `chats are still arriving from your other computer; this opens when they are ready`. It keeps your request and opens the ordinary confirmation once the chat arrives; it never takes the chat without that confirmation. A recent chat left on another computer is offered when home opens, including one that computer closed cleanly. +## a new chat brings its full saved contents; a returning chat brings its changes + +a chat this computer has never held brings its saved contents in parallel, even after other chats used this store. a returning chat downloads only what changed. either path checks that all saved contents arrived before the move finishes. sharing saved file contents with other chats does not make a new chat count as one this computer already held. + ## Does first start send anything — local identity before pairing The first start may create a local identity for this computer. That file sends nothing: there is no sync traffic or network use until you pair or offer a pairing code. Reading the add-device instructions leaves it quiet. Offering a pairing code or running `codeaf pair` contacts the sync service for that pairing. `CODEAF_SYNC_URL=off` blocks sync traffic even after pairing. @@ -334,9 +359,17 @@ A clean quit lets go after the last save is durable. Opening bare `codeaf` on yo If this computer is also coordinating, a clean terminal quit releases its idle chats and returns coordination to an anchor before stopping. With the anchors reachable, the other computer can continue immediately. Sleep, a crash or a lost connection still use the fallback described on the coordination and workers page. -Once the chat arrives, you can send your next message immediately. The window keeps following changes, including renamed computers and a later move to another computer. +after the chat arrives, enter can wait up to 30 seconds for this window to finish opening the chat before sending once. if the check fails, it says ` has not answered; your message is still in the box, press enter to send it`. the window keeps following changes, including renamed computers and a later move to another computer. + +A running chat on the other computer never moves automatically. Neither does a named chat, `--host`, `--at` or `--session`, a chat with unsaved local edits, or a launch while another local window already has a chat open. if the service cannot be reached, Home names its host and keeps trying. if another computer got there first, the refreshed continue card explains the change and keeps the local chat as it was. -A running chat on the other computer never moves automatically. Neither does a named chat, `--host`, `--at` or `--session`, a chat with unsaved local edits, or a launch while another local window already has a chat open. If the service cannot be reached or another device got there first, Home says `other machines unreachable` or `another device continued this chat first` and keeps the local chat as it was. +## my choice while an automatic chat is arriving — continue a different chat, join it or cancel + +bare startup still chooses the most recently released eligible chat and checks local safety first. input before that check answers cancels the automatic choice. + +if its move is already running, confirming a different chat on Home or using `alt+c` chooses what opens next. Home says `continuing next; is still on its way here`, with the titles from the continue cards. the automatic move settles first and is let go with its files and kept work intact; it never opens over your choice. the next move uses exactly the computer and turn of ownership you confirmed. `esc` cancels the waiting choice and the automatic chat still settles and is let go. + +confirming the automatic chat itself joins its move and opens it once. explicitly opening another local chat or starting a new one also prevents a late automatic arrival from replacing it. moving the cursor, opening Home or inspecting and declining another card does not choose a different chat. an explicit move already in progress still gives the usual busy sentence for another explicit pick. ## Why an older chat could not be copied — symbolic links, home folder @@ -398,3 +431,38 @@ A new chat can send its first message normally. A sleeping computer can also res If something changes the chat's files while a move is putting them in place, codeaf stops the move and lets go of the chat. It says the move could not finish and that your words are still in the box. The window keeps showing the chat or returns to Home, and it cannot send on the old copy. Try **continue here** again. Every failed move keeps the captured draft, including its whitespace, caret, pasted text and attachments, even if the old session closed during the move. Losing a race, a connection failure or a refused move does the same. A failed move never sends those words by itself. + +## Repair sync — codeaf doctor and codeaf doctor --fix for unfinished continues, duplicate rows and a busy engine store + +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. + +Run `codeaf doctor` from a terminal to inspect this computer's engine store and cross-computer sync state without changing anything. When an issue is present, `engine store` and `sync` rows report: + +- `busy · held by , pid `: another live process holds the engine store writer. +- `unfinished continue · chat `: a continue started on this computer never completed or let go. +- `duplicate rows · chat (2 local folders)`: more than one local folder on disk shares one chat id. +- `duplicate rows · chat (branch copy , all turns in main chat)` or `duplicate rows · chat (branch copy , 3 turns not in main chat)`: branch copies exist for a chat. +- `lapsed hold on this computer · chat `: an expired hold from this computer would raise a question on open. + +Run `codeaf doctor --fix` to repair what is safe. It prints one line per repair: + +- `fixed: released unfinished continue for chat ` +- `fixed: retired duplicate branch copy of chat ` + +`codeaf doctor --fix` never touches another computer's live hold, never disturbs a live store writer (quit the program `busy` names, or wait for it, and continue again), and never removes a branch copy or `.taking` scratch folder that holds a turn not already in the main chat. + +## The chat banner says the sync service is not answering — what is offline + +The chat banner names the selected host, for example: +`the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back`. +When its address is unavailable, it says +`the sync service is not answering; this chat keeps working here and catches up when it is back`. +Startup and renewal notices share one notice per outage. +The sync service may be unreachable while your other computers are still +working. The chat stays usable on this computer and catches up when the service +answers. Home’s presence strip disappears when the service cannot provide fresh +presence; it does not guess which computers are online. + +## Sync notices between older and current builds + +A current window understands the earlier outage and removed-computer notices too. It uses one outage line while local work continues, and points to `/pair` for recovery in a chat. A current engine keeps the earlier notice spelling for an older window, so that window's existing outage and removal handling still works. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index be27efbbfd..c0654c65f2 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -99,7 +99,7 @@ A tile reads top to bottom: same as the conversation itself - a small **activity line** on the bottom border while there is activity to show -A conversation this window can only show as a snapshot (over a shared connection only the one +A conversation this window can only show as a saved copy (over a shared connection only the one in front is live) says `seen 6m ago` and draws no spinner. **A tile that needs you** has the amber border, the only amber in the view, and its body ends @@ -449,3 +449,77 @@ when that is the desired handoff. For a teammate, the manager's `team_start` also takes `model`, including for a new sub-team's manager. Omitting it keeps the profile default. Neither tool rewrites the profile or changes the requesting chat's model. + +## Do my teams show on my other computer? + +Team sharing is off by default. On each paired computer, open +**Settings → General → share teams** and choose **on**. +Both paired computers must turn it on. This shares teams, members, handles, the +manager and team settings, plus recent Traffic as read-only history. Team changes +show within a few seconds, even with no chat open. The Teams dropdown, Teams page +and `@` list refresh without a restart. +Both computers need to be running codeaf and able to reach the relay. An +unpaired computer or a computer with sharing off keeps its teams to itself. +Turning sharing off stops sharing immediately and keeps teams already received. + +Open a member whose chat is on another paired computer to see +`Continue this chat here?` on Home. Choose **continue here** to move the chat +and open it. Choose **leave it there** to stay on Home with nothing changed. +A chat that is not shared names the computer that has it: open it there. Until +its name is known, it says `the other computer`. Under `--host` or `--at`, a +member on another computer shows that notice; open it on its computer. + +A send to one member or manager whose chat is known to be on another computer +is refused: it would not reach that chat. Continue the chat here first, then +send again. A room post or send to several members still reaches local members +and names anyone not reached, such as `Not reached: @web (on desktop).` If every +recipient is elsewhere, nothing is written. An unavailable relay never blocks +local messages. + +## Will deleting a team on my laptop delete it on my desktop? + +Once both paired computers share teams (Settings → General → **share teams** set +to **on** on each), a deleted team stays deleted. Sharing is off by default. A desktop that was +offline catches up when it reconnects. Renaming its old copy while offline does +not bring the team back. Deleting a team does not delete its conversations. +An unpaired computer or one with sharing off keeps its teams to itself. + +Edits to different members or different parts of a team can both survive. If +both computers change the same detail, the later change wins. Teams appear in +creation order on both computers. Conflicting handles and parents are repaired +consistently, and a removed member cannot remain the manager. + +## Does Traffic sync between computers? + +Traffic sharing is off by default. Once both paired computers turn on **share teams** +in Settings → General, Traffic from another computer shows its newest history, up to 32 lines per team and at most 16 KiB per team. Across all teams, each computer shares +at most 64 KiB, with the most recently active teams first. It can take up to +about 30 seconds to appear. Text longer than 2 KiB is shortened, with ` …` at +the end. Your original local lines keep their full text. + +This is history only: it cannot be answered or acted on here. It never delivers +a directive or question, starts or stops a chat, or wakes a member here. Replies +within the shared history stay linked. As a computer shares newer lines, its +older lines leave this view. They remain on the computer that wrote them until +its ordinary log rotation. + +A send only to chats known to be elsewhere is refused. A send with local +recipients is written once and names those not reached. Open a remote chat and +choose **continue here** on Home before sending to it. Under `--host` or `--at`, +open it on its computer. Decision details, spending records and working copies +do not travel with Traffic. A decision or escalation whose recipient is +elsewhere is refused before it changes anything. An unpaired computer or one +with sharing off keeps its Traffic to itself. A history link that cannot find its message says it is +on the other computer's history. + +## How do I turn on team sharing? + +Team sharing is **off by default**. Pair your computers, then open +**Settings → General → share teams** and choose **on** on each computer. Both computers must +turn it on to exchange teams, members, manager choices and settings, and recent Traffic as read-only history. +This setting belongs to each computer and does not follow synced preferences. +An unpaired computer or one with sharing off keeps its teams to itself. + +Once both computers share teams, team changes arrive within a few seconds while +codeaf runs and can reach the relay; recent Traffic can take about 30 seconds. +Turn **share teams** to **off** to stop all team sharing immediately. Teams already received stay as they are. diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 58af353b43..dcf7a9408e 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -7,7 +7,7 @@ A relay directory upgrade can briefly pause pairing and chat moves. Retry after ## Can my phone lead, be the leader or coordinate my computers? -There is no codeaf phone app yet, so a phone cannot lead yet. A phone browser's pairing page only helps approve a computer; it does not join the phone to the cluster. A joined laptop or desktop with persistent storage and sync on can lead: run `codeaf cluster lead` on that computer and keep codeaf open. `codeaf cluster status` shows its joined node and the current coordinator. +There is no codeaf phone app yet, so a phone cannot lead yet. A phone browser's pairing page only helps approve a computer; it does not join the phone to the cluster. A joined laptop or desktop with persistent storage and sync on can lead: run `codeaf cluster lead` on that computer and keep codeaf open. `codeaf cluster status` names the current coordinator and the computers deciding changes or keeping copies. A phone would need a supported client, persistent storage, enrollment and a live connection before it could take this role. Those requirements are not a working phone capability today. Leadership coordinates metadata; it does not move a chat or run its tools. @@ -17,11 +17,13 @@ A phone would need a supported client, persistent storage, enrollment and a live storage work. A computer already joined is read from the service without reopening its local member store. A deadline says `the sync service at
did not answer in time — try again`. +The address is the service used for this command, including the service saved +during pairing or selected by `CODEAF_SYNC_URL`. Slow local storage can also use up that allowance; this sentence does not establish a network failure. A refused connection, missing route or failed name lookup instead says -`the sync service at
cannot be reached from here — check this computer's network`. +`nothing is answering at http://127.0.0.1:8901 — if that is your own sync service, start it there with codeaf relay` for your own service, or `cannot reach codeaf.agentfield.ai — check this computer's internet connection` for the hosted service. ## Joined computers, learner capacity and an outdated build @@ -33,13 +35,21 @@ The membership protocol admits at most 32 live learners and retains retired reco ## What is cluster lead — what does codeaf cluster lead do, make this computer the leader -`codeaf cluster status` on the hosted relay, or a self-hosted `codeaf relay` with storage, reports the committed voters and current leader after a fresh majority read. A paired computer joins with persistent storage as a learner before status shows its node. Readiness requires its own durable application through the advertised join position. Status names `this computer`, `learning computers`, `ready computers` and `voting computers` when known. `codeaf cluster lead` on an unjoined computer says `this computer must join the cluster with persistent storage before it can lead`. - On the hosted relay or a self-hosted `codeaf relay` with storage, run `codeaf cluster lead` on the joined computer that should coordinate. It first catches up, replaces the third anchor’s vote through a joint change, durably finishes that change with the third anchor still learning, and transfers leadership. The voters are then two anchors and this computer. Keep codeaf open to keep its member running; a command used with no open terminal runs its member only for that command. Leadership moves coordination metadata, never chat content or chat ownership. -Do not treat pairing or a saved leadership request as a successful transfer. Success requires the selected computer to be elected, commit its current term and pass a fresh majority read; its receipt names the computer, term, revision and read barrier. Retrying `codeaf cluster lead` preserves an already saved signed request. Continue chats through the ordinary continue door. +A successful transfer has a terminal receipt. An interrupted command keeps its saved signed request; retrying resumes that request instead of choosing another computer. + +## Cluster status — coordinating, deciding and keeping copies + +`codeaf cluster status` on the hosted relay, or a self-hosted `codeaf relay` with storage, reports the committed voters and current leader after a fresh majority read. A paired computer joins with persistent storage as a learner before status shows its node. Readiness requires its own durable application through the advertised join position. Status uses device names under `coordinating`, `deciding` and `keeping copies`. When the sync service supplies member labels and a member’s device is absent from your current devices, that member reads `a computer no longer in your devices`. An older sync service that supplies no labels instead shows `another computer`, or a group such as `2 other computers`; missing labels do not mean a device was removed. `--verbose` shows the numeric details for debugging. `codeaf cluster lead` on an unjoined computer says `this computer must join the cluster with persistent storage before it can lead`. + +With `--verbose`, Status names `coordinator`, `voting computers`, `this computer`, `learning computers`, `ready computers` and `finishing the change from`. These are debugging details; ordinary status uses device names. -Three voters tolerate one unavailable member. Losing a majority pauses coordination; saved chats remain. The refusal is `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. +## Did cluster lead finish — leadership receipt and retry + +Do not treat pairing or a saved leadership request as a successful transfer. Success requires the selected computer to be elected, commit its current term and pass a fresh majority read; its terminal receipt says `laptop-a now coordinates your devices`, using that computer’s device name. `--verbose` retains the internal receipt fields for debugging. Retrying `codeaf cluster lead` preserves an already saved signed request. Continue chats through the ordinary continue door. + +Three voters tolerate one unavailable member. Losing a majority pauses coordination; saved chats remain. The terminal says `the sync service at 127.0.0.1:8901 is up but cannot agree on changes right now; it usually recovers within a minute`. The coordination API’s refusal is `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. ## Hosted relay while this computer leads @@ -63,13 +73,15 @@ On the hosted relay and a self-hosted relay with storage, a clean terminal quit ## What happens if my leader goes away — my leading laptop went to sleep or the leader computer was turned off -When a personal coordinator sleeps, crashes, loses its connection or turns sync off, the two remaining anchor voters can elect a coordinator and restore the third anchor after it catches up. The missing computer need not approve its removal. A returning computer catches up as a learner and does not automatically regain leadership, including after a clean quit: request `codeaf cluster lead` again. Losing a majority pauses coordination and keeps saved chats. The refusal is `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. +When a personal coordinator sleeps, crashes, loses its connection or turns sync off, the two remaining anchor voters can elect a coordinator and restore the third anchor after it catches up. The missing computer need not approve its removal. A returning computer catches up as a learner and does not automatically regain leadership, including after a clean quit: request `codeaf cluster lead` again. Losing a majority pauses coordination and keeps saved chats. The terminal says `the sync service at 127.0.0.1:8901 is up but cannot agree on changes right now; it usually recovers within a minute`. The coordination API’s refusal is `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. Coordination ticks every 5 seconds. The provisioned anchors’ ranked election windows are 6–7 ticks (30–35 seconds), 8–9 ticks (40–45 seconds) and 10–11 ticks (50–55 seconds); other computers use 12–13 ticks (60–65 seconds). Healthy fallback targets about 65 seconds. Scheduling delays or a partition can take longer. Fully idle groups stop periodic ticks only after every voter has applied the same idle barrier; a new operation or a leading computer’s disconnection wakes coordination and needs a fresh majority response. +If a majority cannot answer, the coordination API says `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. Restore the third anchor if a leading computer and an anchor are both unavailable. + ## Coordinator and anchor both unavailable, lost majority -While a personal computer leads, the voters are that computer and two anchors. Losing that computer and either voting anchor leaves no majority, even though the third anchor retains its learning copy. The response is `this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here`. codeaf preserves the existing cluster and saved work; it cannot make a writable minority or remove voters to manufacture a majority. +While a personal computer leads, the voters are that computer and two anchors. Losing that computer and either voting anchor leaves no majority, even though the third anchor retains its learning copy. The response is `the sync service at 127.0.0.1:8901 is up but cannot agree on changes right now; it usually recovers within a minute`. codeaf preserves the existing cluster and saved work; it cannot make a writable minority or remove voters to manufacture a majority. ## Does an open connection keep my chat running on this computer? diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index ca7eb7d012..5676b66ff0 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -144,7 +144,7 @@ build if the two disagree), so a tip you saw is on it word for word. has three or more earlier conversations. Retired the first time the automations place opens, by any door (`/automations`, `alt+7`, a row that leads there), or the first time an automation is proposed to you on a card in a conversation. -- `space space takes you back to home` — after the first exchange, in a conversation only +- `space space brings you back to home` — after the first exchange, in a conversation only (never on home itself). Retired the first time two spaces in an empty box open home, from a conversation or from a place; reaching home by `/home` or the tab does not retire it. It is the first tip a conversation says, ahead of `/task`. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index ca32bc2f81..27ec5213e9 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -287,7 +287,7 @@ before choosing the fifteen most recent, so each conversation appears once. The `opt+k` chats menu still lists open tabs; dismissing or reopening a tab updates the tab and the row in Sessions together. -## A chat is running on my other computer — how does home show it, chats on my other machines, "running on tower", "tower offline", why does it say running on build-box offline, merge / discard +## A chat is running on my other computer — how does home show it, chats on my other machines, "held on tower", "tower offline", why does it say held on build-box offline, merge / discard 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. @@ -299,14 +299,14 @@ happened. The row says where the chat is only where that tells you something: | The chat is | The row says | | --- | --- | -| running on another machine | `running on ` | +| held on another machine | `held on ` | | on a machine that went off or to sleep without letting go | ` offline` | | a branch left by a takeover | ` turns from : discard` (this build has no merge) | | held by this machine, or let go by everyone | nothing | -## Why a chat says running on build-box or build-box offline +## Why a chat says held on build-box or build-box offline -For a chat on another paired computer, read the two words apart: `running on build-box` says that computer is working on the chat now, and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A chat on a computer that is `offline` can still be continued here; the card says how many turns may still be there. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows +For a chat on another paired computer, read the two words apart: `held on build-box` says that computer still holds the chat, including while it is idle, and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A chat on a computer that is `offline` can still be continued here; the card says how many turns may still be there. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows `untitled`, and a machine whose name will not open shows the first eight characters of its id. On a narrow frame a branch's sentence shortens to ` turns · `, and where even that will not fit the sentence gives way whole before the chat's name is cut, so a @@ -330,8 +330,7 @@ ten minutes). It asks every few seconds while the list is changing and about onc while it is not. Each row's age is counted from its last saved turn, so it keeps growing between refreshes. -If the other machines cannot be reached, the last list stays on screen and the line -`other machines unreachable` stands under it. With no connection set up at all (sync off, see "Sync your chats between machines" in running-from-the-terminal), nothing +a read still waiting at five seconds keeps the last list and says `waiting for ; still checking`. it continues for up to 25 seconds. a deadline says ` did not answer in 25s; still trying`; a failed connection says ` is unreachable; still trying`. background retries continue, and a successful answer clears the line. With no connection set up at all (sync off, see "Sync your chats between machines" in running-from-the-terminal), nothing extra is drawn and the panel is exactly the local list. `codeaf cell list --all` prints the same rows in a terminal (see "The cell verbs" in running-from-the-terminal). @@ -341,12 +340,12 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -Press enter on a row that says ` offline` or `running on ` and home asks **Continue this chat here?** +Press enter on a row that says ` offline` or `held on ` and home asks **Continue this chat here?** with the cursor on `continue here`, because pressing enter on the row already said you want it; `esc` or `2` leaves it there. The card says how fresh the copy is: `last saved turn ago; up to turns may still be on `. The `up to` clause is left out when nothing is waiting there. On a -`running on ` row the card starts with that: `running on ; last saved turn - ago`. When that device is not online, the card leaves `running on` out and says `last saved turn ago`. Press enter to continue: the chat is fetched, this machine takes it +`held on ` row the card starts with that: `held on ; last saved turn + ago`. When that device is not online, the card leaves `held on` out and says `last saved turn ago`. Press enter to continue: the chat is fetched, this machine takes it over at once and it opens. You do not wait for the other machine to go quiet: a chat that is running there stops taking new turns there the moment you take it, and its window says ` continued this chat; this window now only shows it`. Anything it had not yet sent @@ -359,13 +358,12 @@ When continuing a chat from another paired computer: - If you had unsaved edits to that chat here, they are not overwritten. They are kept as a branch and the chat says `your unsaved edits here were kept as turns from `. -- If another machine took an `offline` chat first, the row goes back to `running on ` with one - line saying another device continued it first. Nothing was changed. -- If the other machines cannot be reached, enter says `other machines unreachable` and asks +- If another machine took an `offline` chat first, the row goes back to `held on ` with a card saying `another device continued this chat first`, `1 retry` and `2 leave it there`. if the same computer picked it up again, the card instead says ` picked this chat up again; press enter to continue it here` and asks for fresh confirmation. +- if the service cannot be reached, enter names its host and says it is still trying, and asks nothing: no chat can be taken without them. - With no way to continue set up, an offline row has no `continue here`: enter says its sentence. -## What comes with a continued chat — the files, binary files, file modes, the executable bit, the .env, where the copy lives, no git needed +## Is my project folder changed when I continue a chat here — what comes with a continued chat, the files, binary files, file modes, the executable bit, the .env, where the copy lives, no git needed Continuing a chat brings all of it: the whole conversation, and every file the chat worked on, byte for byte: text and binary files, files that were never in a git repository, the @@ -525,8 +523,16 @@ them`. Opening separately opens its own chat and workspace copy, with the kept t their files; the main chat stays as it is. **Discard archives** the branch: it leaves this list and is not deleted. **Merge is not offered on this build**. The row says -` turns from : open separately / discard`. If the other -machines cannot be reached, enter says `other machines unreachable` and does nothing. +` turns from : open separately / discard`. if the service cannot be reached, enter names its host and says it is still trying, and does nothing. + +## Why do I see the same chat twice on Home — the same chat twice on Home, one row per chat id and collapsed branch copies + +Home keys every conversation row by the chat's id rather than its transcript path, both directly on this computer and over `codeaf chat --host`. A chat known both locally on disk and through sync appears once on Home instead of twice. Under `codeaf chat --host`, `continue here` is not offered and a chat the remote host already holds never appears a second time as held elsewhere. + +When a chat also has kept branch copies, Home collapses those copies into the main chat's row instead of listing separate duplicate rows for the same chat. The row and its continue card count the copies, say what a copy is, and state whether discarding loses anything: + +- `1 copy (unsent turns set aside; already in this chat, so discarding loses nothing)` — or `1 copy · unsent, loses nothing` in a narrow column — when every turn in the copy is already in the main chat. +- `2 copies (unsent turns set aside; discarding archives 3 turns not in this chat)` — or `2 copies · unsent, 3 turns not here` in a narrow column — when a copy holds turns that are not in the main chat. ## Why does home show a session id for an untitled chat, untitled conversation, new conversation @@ -1541,7 +1547,7 @@ as commands. The folder notice may remain, but the path stays in the box. **Yes. A line that begins with `/` is a command, and `enter` runs it rather than sending it.** Typing `/settings` on home and pressing `enter` opens the settings panel; it does not -start a conversation whose first message is the word `/settings`. +start a conversation whose first message is the word `/settings`. `/devices` and `/pair` also open over Home without starting a conversation. A pasted pairing link shows its approval in the Add another device card, and either decision stays on Home. **The screen says which `enter` you are about to press, before you press it.** With a command in the box the action row reads `+ run /settings` in place of `+ start a new conversation: @@ -1847,7 +1853,7 @@ advertised. (Until 2026-09-17 these words were the right end of the rule above t **It stays when a tip is showing.** A conversation's tip covers the project at the keys row's right end, never the keys. Until 2026-09-24 a tip took the whole row and the door went with it. One of the tips is about the door itself: -`space space takes you back to home`. +`space space brings you back to home`. **You can click it.** A press on the words `space space home` opens home; a press on the rule beside them is a press on a rule. @@ -2718,6 +2724,18 @@ After a clean quit lets a paired computer's chat go, bare `codeaf` on another pa If the laptop only slept or lost its connection, Home offers its latest chat and `alt+c` continues immediately. When local unsaved edits need to be kept as a branch, the continuation card asks first. Nothing moves automatically while the other computer is running that chat, for a named chat or `--host`/`--at`/`--session`, when another local window holds a chat, or when local unsaved edits need a choice. If the move fails, Home stays open and says why. +## my chosen chat opens while a startup chat is still arriving + +if bare startup is already continuing a released chat, confirming a different Home row or using `alt+c` chooses the chat that opens next. Home says `continuing next; is still on its way here`, using the same chat titles as the cards. the automatic move settles and is let go before your confirmed move begins. its files and kept work remain intact, and it never opens over your selection. `esc` cancels the waiting choice; the automatic chat still settles and is let go. + +confirming the arriving chat itself joins the existing move. opening another local chat or starting a new chat also takes precedence over the automatic arrival. moving the cursor, opening Home or inspecting and declining another card does not choose a different chat. input before the startup safety check answers still cancels that automatic choice. a move you explicitly started retains the busy sentence `a chat is already continuing here; wait for it to open` for another explicit pick. + ## Empty untitled chats from your other devices — rows before the first saved turn An empty new chat on one of your other devices stays off Home until it has a saved turn. Opening a window there does not add an `untitled` row here, even while that window remains open and running. Its startup save alone does not make it a conversation you can continue. Once that chat has its first saved turn, it appears with the other chats you can continue. + +## Sync off and my chats are gone — CODEAF_SYNC_URL=off keeps every chat on this computer + +Turning sync off with `CODEAF_SYNC_URL=off` (or leaving sync off before pairing) means "do not talk to the sync service", never "forget my chats". Home still lists every chat on this computer — chats this version saved and chats from older versions alike — each once, and each opens and can be written. + +When an older chat was already copied into this version's store, Home lists and opens that copy even while sync is off, and the untouched original stays on disk beside it. Saved sync hold receipts from an earlier session stay dormant while sync is off, so sending a message in any local chat works without asking the sync service. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 10c273e71d..b45eb885f3 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -1218,6 +1218,11 @@ longer evidence that anybody is looking at *that* conversation. ## How long a task gets before it is stopped +For a task on another computer, the default wall is six hours; a time limit +sent by this chat replaces it. The other computer's spending permission still +applies. See *Tasks on another computer*. The checkpoint clocks below describe +local session-tree work. + Two clocks, and neither is a hard stop. **One hour per checkpoint.** The run, every correction round and every check inside it @@ -2683,7 +2688,13 @@ and stay there. ## Does a task survive a restart? -The graph survives. The running work does not. +For a task on another computer, reopening the chat follows the saved attempt +and brings its answer home once. Restarting `codeaf serve --take-tasks` on that computer +follows a child still running there; a child that died is interrupted, never +automatically run again. See *Tasks on another computer* for `run it here`. + +For the local session-tree tasks described below, the graph survives but the +running worker does not. When a session comes back: @@ -3221,7 +3232,20 @@ pointer — they still point at your turn, never at the parent task's own journa ## The optional arguments on propose_task -Four more arguments, all optional. Every bad value is an ordinary result, not an error. +These arguments are optional. Bad values return a refusal the model can correct. + +**`computer`** — the shown name of a paired computer that runs +`codeaf serve --take-tasks`. This argument and its model guidance appear only +while **Run tasks on** names a paired computer. At the default `this computer`, +the model gets the ordinary local tool. You can still name a computer for one +task with `/task --on`. The task needs its own independent +copy, and both ends need Linux or macOS; Windows is not yet supported. Omitted +uses **Run tasks on**, whose default is `this computer`. An explicit choice wins; +`here` and `this computer` run locally. An unknown name returns +`no paired computer is named "" — your computers are: ` as a tool +error. The chat and transcript stay here. A remote task runs alone in this +conversation; wait for it to finish before proposing more tasks. See *Tasks on +another computer* for the return path, spending and recovery. **`depends_on`** — an array of task ids that must finish first. The task waits for them, and their reports are put in front of it when it starts — every one of them, bounded diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index e01959c219..9f11d7b53b 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -34,7 +34,7 @@ name followed by the same saving-failure sentence, once. ## Could not save on quit — where are the unsaved turns kept -If the final save fails and the transcript location is known, the restored terminal says +If writing on this computer fails and the transcript location is known, the restored terminal says `codeaf could not save this chat; unsaved turns are kept in and return on the next start if this computer still holds the chat.` The path names the transcript on the computer running the chat, including over `--host`. When the location is unknown, it says @@ -49,15 +49,13 @@ A computer removed from your paired devices keeps saving this chat locally. Quitting stays quiet and does not try a final relay save; the removal notice already explains that the chat stays on this computer until you pair it again. +## Quitting while the sync service is slow — waiting for a final save + The screen closes after at most one second while a slow close finishes. The launcher keeps its connection open for the final publish and release before it exits. -A successful quit stays quiet, including while late usage, task cost, naming or -memory records finish saving. The launcher waits up to 30 seconds for the final -saving reply. If that reply is still outstanding at the ceiling, it says -`codeaf is still saving this chat in the background; unsaved turns stay on this computer.` -A save still running is not reported as a failure. A -second ctrl+c while quitting restores the terminal and exits immediately; -unfinished turns stay on the computer running the chat for the next start. +A successful quit with nothing waiting stays quiet. While a sync save waits, the restored terminal says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without that confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. + +The launcher waits up to 30 seconds for the final saving reply. If only upload is outstanding, the successful receipt is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service's host. Ctrl+c during this restored-terminal wait exits promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`. ## One Enter sends — checking which computer holds this chat @@ -580,7 +578,7 @@ second `esc` inside half a second is the rewind's door and `ctrl+c` at rest is t out, so neither is free — and you do not need one. The `esc` you already pressed started the 10-second window, and when it runs out codeaf stops waiting on its own. -**What happens at 10 seconds.** codeaf detaches from the turn: the waits codeaf holds are +**What happens if it will not stop: at 10 seconds** codeaf detaches from the turn: the waits codeaf holds are ended and whatever request was still open to the model is aborted. A wait that ignores being cancelled — a command whose output a grandchild is still holding, say — may run on behind the detached turn; what detaching guarantees is that NOTHING IS WAITING FOR IT any @@ -676,6 +674,10 @@ running. ## Quitting while a task is running — what happens to tasks and background work when the session closes +A task running on another paired computer keeps running there when you close +this chat. Reopening follows its saved attempt and brings its result home once; +it never starts that attempt again automatically. See *Tasks on another computer*. + **A hosted conversation keeps working.** Closing the window, `ctrl+c`, `kill -HUP` and a terminal that went away all detach: the task goes on running in this machine's codeaf service, its questions stay waiting for you, and you rejoin it by opening the same @@ -1968,10 +1970,10 @@ into… picker, typing filters, `↑` `↓` walk, `enter` moves, `esc` cancels. the *teams page* page. **Settings panel** (`ctrl+,`): `esc` backs out one layer at a time — search, then an -open account, then the panel · `left`/`shift+tab` and `right`/`tab` change tab · +open account or device rename, then the panel · `left`/`shift+tab` and `right`/`tab` change tab · `up`/`ctrl+p`, `down`/`ctrl+n`, `pgup`, `pgdown`, `home`, `end` walk · `enter` activates, and `space` activates too — except while a search is on, when it goes into the box, so -a phrase like `shell command` can be written · `backspace`, `ctrl+u`, `ctrl+w` edit the +a phrase like `shell command` can be written · on the **Devices** tab without a search, `n` renames this device and `r` removes the selected computer · `backspace`, `ctrl+u`, `ctrl+w` edit the search · anything else types into it. **Sessions place** (`ctrl+.`, or `/history`, or the one dim door line at the bottom of the task @@ -2730,6 +2732,14 @@ the keyboard, and `esc` there hands it back to the list. ## Keys in the task roster and inside a room +When **Run tasks on** names a computer or a proposal already names one, +`ctrl+d computer` on that proposal over an empty draft cycles `this computer` and eligible paired computers. Accepting sends the +choice shown by `runs on `. When a remote task cannot reach its computer +or was interrupted, its card and page offer `h run it here`; click the card +action or press `h` over an empty draft to start fresh local work. The old +attempt is abandoned and its late result never lands. See *Tasks on another +computer* for limits and the exact recovery sentences. + Opening a task's room gives the keyboard to that room. For a task that accepts notes, type one and press `enter` to send it; you do not need to press `alt+t` again first. Use `alt+t` when you want to navigate the roster again. @@ -3839,3 +3849,28 @@ Its usage counts toward the conversation and any owning factory job budget. It stops at the budget limit or when the conversation closes, and falls back quietly if generation fails or the connected engine lacks this capability. This is a desktop composer feature; terminal Tab behavior is unchanged. +## Quitting before local saving finishes — no upload receipt yet + +The successful `saved here` receipt requires the chat engine to acknowledge that its local transcript and captured work finished saving. A network error alone, or an unanswered closing call, does not prove that. If local closing still has no receipt after about three seconds, it says `still saving on this computer — ctrl+c leaves the written turns here`. If you leave before local saving is confirmed, codeaf says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. A confirmed local save that is still uploading uses the successful sync-service receipt instead. A failed local write keeps the `codeaf could not save this chat` sentence. + + +## Saving between different builds — older engine or older window + +The new saved-here receipt is enabled only when both the window and engine +support it. An older engine's successful close stays successful without a +receipt. An older window receives the ordinary successful reply when the new +engine saved locally but upload is pending. A local write error remains a +failure. + +When an older engine has not answered before the closing wait ends, the window +keeps the earlier `codeaf is still saving this chat in the background; unsaved turns stay on this computer.` +message. A forced interrupt keeps the earlier exit status 130. New progress and +saved-here words require both builds to support the receipt. + +## Quitting several chats — running work and an upload that is pending + +A running hosted chat detaches and keeps working. Its successful departure does +not require a local-close receipt. If another idle chat closed locally and its +upload failed, quitting still reports that chat as saved here and succeeds. +A failure writing this computer's saved chat or its kept copies remains a +failure, including when an upload failed too. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index cfa9960503..9583d8e20f 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1,5 +1,13 @@ # Models, context, and what it costs +## A fallback list naming only the current model — keep the answer on that model + +The `fallback models` row is your list of other models a failed request may try. +If it names only the current model, there is no other model to try: codeaf does +not replace it with a suggestion from the catalog. +Repeating the same model or adding blank rows does not change that choice. +A list containing only blanks leaves the catalog free to suggest another model. + ## Lost internet, Wi-Fi disconnected, DNS errors, and waiting for connection When DNS or a connection attempt fails before the request is accepted, codeaf @@ -261,7 +269,7 @@ your query. `↑`/`↓` do not count as editing. `tab` always opens and closes. **This is one list with two doors.** `/model` opens it, and so does the **your model** row at the top of the Models category in `/settings` — the same rows, the same name search, the same providers under `→`, and `enter` on a provider pins it either way. The media -slots on that tab (**drawing**, **speaking**, **looking** and the rest) open the same +choices on that tab (**drawing**, **speaking**, **looking** and the rest) open the same component over their own models, but they have no provider row behind them, so nothing unfolds under them and the foot does not offer the key. @@ -277,7 +285,7 @@ Type a letter, use `backspace` or `ctrl+w`, or paste: any edit returns the caret Every model list opens on the model it holds. `/model` opens on the model in use, highlighted and on screen on the first frame, so enter with nothing typed confirms. -The settings slots and roles, a task's model word, home's draft, the task composer's +The settings choices and roles, a task's model word, home's draft, the task composer's `alt+o` and a lane list or chip follow the same rule. A refresh returns the cursor to the held model with the typed filter kept; `ctrl+u` emptying the box returns there too. If that model is absent, the first selectable row is used, skipping unavailable @@ -285,13 +293,13 @@ notices. `/model ` and typing a filter start the narrowed list at its fir The model in use is bold in the accent with no band of its own. The band belongs to the cursor, and a pointer hovering another row lifts that row too. Every model list -owns the mouse wheel while it is up: `/model`, settings slots and roles, home's draft +owns the mouse wheel while it is up: `/model`, settings choices and roles, home's draft and the task composer walk three rows a notch, clamped at both ends, while the page beneath stays put. The cursor's row stays on screen with headings and extra lines included in the window's size. **Enter on a model chooses it and closes the list immediately.** This applies to the -conversation, a task's model, settings slots and roles, home's draft and the task composer. +conversation, a task's model, settings choices and roles, home's draft and the task composer. The keyboard returns to the draft or page you opened the list from. To compare another model, reopen the list. With no matching model, Enter leaves the list open and changes nothing. Enter inside a provider fold keeps those controls open, including when it opens @@ -306,8 +314,8 @@ appear; with only OpenRouter connected, only its models appear. With several pro connected, their lists appear together in provider groups. Removing a connection or its key removes its models from subsequent lists. A cold catalog never adds built-in guesses. -This applies to `/model`, task rooms, settings slots and roles, Home drafts and the task -composer. Each slot still keeps only models with the capabilities it needs. A provider +This applies to `/model`, task rooms, settings choices and roles, Home drafts and the task +composer. Each field still keeps only models with the capabilities it needs. A provider that cannot list models has a non-selectable notice; `+ add a provider` opens connection setup and is not a model. That menu includes every built-in provider from initial setup, including OpenRouter, and labels each `connected` or `not connected`. You can add OpenRouter after @@ -415,7 +423,7 @@ their rounded `t/s` rates, with a rate that rounds to zero absent there too. ## Why does the model picker keep jumping -The `/model` list you are reading is a snapshot. Typing in the filter still narrows it; +The `/model` list you are reading is a saved copy. Typing in the filter still narrows it; the `via` and the speeds do not rewrite themselves while you look. A turn running underneath can still update the status line. Close the list and open it again if you want the latest machines. @@ -464,7 +472,7 @@ Where that happens the column is dropped, head and all: | **filming** | `video`, on every row | no | `inputs` survives the same test on most lists because it genuinely varies: on `/model` it -has eleven different values across three hundred-odd models, and in the **filming** slot +has eleven different values across three hundred-odd models, and in the **filming** field some models take a picture to animate and some take a clip. **This is only asked of `inputs` and `outputs`,** because they are the only columns a list @@ -474,9 +482,9 @@ list is a coincidence, not a definition, and those columns are always drawn. **The cells report only what was published — they never read the id.** A model whose name says `vl` or `vision` but whose catalog row lists no modalities draws two blank cells, because a cell is a fact about the catalog and not a guess about a name. The **looking** -slot does fall back to those two words in a name when a row published nothing, so a silent +field does fall back to those two words in a name when a row published nothing, so a silent `…-vl` row can be offered there while showing nothing under `inputs` here. The two are -asking different questions: the cell says what is known, the slot has to decide whether to +asking different questions: the cell says what is known, the field has to decide whether to offer the row at all. **On a line with no heading over it the side is spelled out.** `codeaf models`, a frame too @@ -830,11 +838,10 @@ done so far. The checker's own ceiling still applies inside it. without a routed crew; the flag answers the day's questions and the plan-price question, not this one. -### The selector snapshot — when the crew is picked by the pareto engine +### Saved routing choices — when the crew is picked by the pareto engine The crew is normally routed by the learned router, and **nothing else seats a crew unless -you configured it**. There is one extra hand on the picker: a `selector snapshot` row in the -profile's settings (`models.crew.selector_snapshot`), a path to a fitted snapshot file. +you configured it**. In Settings, you can choose a file of fitted routing choices. Configured, it points the pareto selection engine at the same models your providers reach, and it picks one model per unpinned seat — the worker seat from its worker class, the checker from its checker class, the planner from its planner class. @@ -848,11 +855,11 @@ priced and health-checked, so a demoted route, a turned-off provider and an out- account all hold for it too. What happens when the file is missing or bad: the crew is routed **exactly as if no -snapshot were configured** — no half-crew, no refusal, no note. A path that does not load -is refused when you write it (`that snapshot does not load: …`); one that stops loading +saved copy were configured** — no half-crew, no refusal, no note. A path that does not load +is refused when you choose it; one that stops loading later silently falls back. A seat the engine cannot seat (no allowed model passes its capability gate for that seat) keeps the router's own pick for that seat alone. With no -snapshot row the engine never runs at all, and every crew is byte for byte the crew the +saved copy row the engine never runs at all, and every crew is byte for byte the crew the router picked before it existed. ### How hard to try one task — --best and --cheap @@ -893,7 +900,7 @@ pick is the best crew under it, and the line says `held under the $5.00 task lim changed it. A pin sends exactly the id you wrote when the catalog lists it. An id the catalog lists -only as a variant — a dated snapshot — is sent as that variant, and the line says which: +only as a variant — a dated saved copy — is sent as that variant, and the line says which: `checker ⌖ deepseek-v4-flash → -0731`. The first word is the kind of work the task was read as. The worker's provider is named @@ -1322,7 +1329,7 @@ harnesses" is a sentence codeaf acts on — it looks the row up with `settings` with `change_setting`, into the same `models.roles` row, after asking you. The reflex and small work rows of the Models category, and the crew's worker, checker and planner pins behind its one **seats** row, are writable that way too — a pin outside your allowed models is -refused there as it is on `/crew`; only the role **slots** further down the Models category are not, +refused there as it is on `/crew`; only the role **choices** further down the Models category are not, because those are bindings the running session holds rather than values in your profile. So the ladder for any role, most specific first: **its pin**, then **its row's model**, @@ -1480,7 +1487,7 @@ and your own model rows answer for them (*Automations*). **The crew line names the flag, because the flag is what seats the call.** Under `--one-model` the crew line of `/status` and the phone sheet reads `one model` rather than `auto`, and `/status` answers its crew line with `one model · every call rides the model -you are talking to`. The model picker's hint slot and the welcome line under the wordmark say +you are talking to`. The model picker's hint field and the welcome line under the wordmark say the same word. All of them read one answer, so none of them can disagree with another. **Your crew is not gone, it is overridden.** Your pins are untouched on disk — this flag @@ -1782,7 +1789,7 @@ cannot help there, and giving up would only hand you the retry to do by hand. So in a conversation you are watching, with no fallback model left to move to, **codeaf keeps asking that machine until it answers or you stop it**. It waits before every ask: 1 second, then 2, 4 and 8, then every **10 seconds** from then on. The wait never grows -past ten seconds, because what you are waiting for (weights loading, one busy slot) +past ten seconds, because what you are waiting for (weights loading, one busy field) finishes at a moment nobody can predict. The give-up deadline does not end it either. The status line says how long it has been: @@ -2681,18 +2688,18 @@ same three-second beat every place runs on, and it draws three things: `planning` — read from the settings as they stand right now, and never the auxiliary word one call gave itself. That is the point of the column: seeing that execution is most of the bill sends you to the one row that changes it. A model that is on the bill and is - bound to nothing today draws **no role word at all**, and a model bound to two slots says + bound to nothing today draws **no role word at all**, and a model bound to two choices says both. The rows **open nothing** — a model is not a thing money was spent *on* — so the foot over one keeps `→ the limits` and never promises `enter`; they are stops so that a long table scrolls under the cursor. A model is drawn by the word you say out loud — `claude-opus-4-1`, not `anthropic/claude-opus-4-1` — which is the spelling `/model`, the crew chips and the status line all use; -- **a role slot with nothing bound to it** gets a row of its own under the models — +- **a role field with nothing bound to it** gets a row of its own under the models — `planning · unbound · follows execution` — because "planning costs nothing" and "nothing is bound to planning" are opposite facts about the same blank. There is no figure on that - row: no line in the ledger names a slot, so there is nothing measured to put there. - **Today only the conversation slot is drawn at all.** A window holds a client for the - model you are talking to and for no other; the other slots are answered where their + row: no line in the ledger names a field, so there is nothing measured to put there. + **Today only the conversation field is drawn at all.** A window holds a client for the + model you are talking to and for no other; the other choices are answered where their own session is opened, so this window cannot tell "nothing is bound" from "I cannot ask" — and the emptiness law says an unknown is drawn as nothing rather than guessed at; - **by topic** — the three things money is ever spent on, because the ledger holds @@ -3195,7 +3202,7 @@ Open **Spending** for monetary limits. There is no practice limit: codeaf no lon The settings panel's tab bar reads, in order: ``` -General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy +General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Devices · Privacy ``` Money is on **Spending** and nowhere else. The rows that used to share it are on the two @@ -4524,3 +4531,16 @@ media, model lists, connection probes, credits and provider lane reads. A custom HTTP transport retains its own timeouts and can impose stricter redirects, but cannot allow a request outside the original origin. Same-origin redirects work; a chain ends after ten requests rather than waiting forever. + +## Do my crew settings follow me to my other computer? + +After pairing, crew seat model pins, allowed-model choices, the crew daily cap, +per-task cap and free-route choice follow synced preferences within a few seconds, +even with no chat open. Both computers need to be running codeaf and able to reach +the relay. An unpaired computer keeps these choices to itself. + +Each pin's provider route and providers you turned off stay on each computer. +Choose those separately there. A seat model pin still needs an available provider +and its credentials on the receiving computer; pairing does not install a provider. +The **share teams** switch does not change which crew choices follow preferences. +Desktop app crews are separate. diff --git a/internal/manual/chat/naming-a-device.md b/internal/manual/chat/naming-a-device.md index 48a41b8539..48a4379628 100644 --- a/internal/manual/chat/naming-a-device.md +++ b/internal/manual/chat/naming-a-device.md @@ -6,7 +6,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -Every screen that names a device uses one name for it: the home rows (`running on tower`, `tower offline`), `Continue tower's latest chat here?`, the approve screen, `tower is now paired. Your chats can continue there.`, `/devices`, and the line after a move, `Moved from tower in 4s`. A device is called by its host name, the word your operating system already uses for it, until you give it a name. A name is only a label. It is not a key and it signs nothing, and renaming a device changes nothing about what it can reach. +Every screen that names a device uses one name for it: the home rows (`held on tower`, `tower offline`), `Continue tower's latest chat here?`, the approve screen, `tower is now paired. Your chats can continue there.`, `/devices`, `/settings` → `Devices`, and the line after a move, `Moved from tower in 4s`. A device is called by its host name, the word your operating system already uses for it, until you give it a name. A name is only a label. It is not a key and it signs nothing, and renaming a device changes nothing about what it can reach. Because you never named this device, it kept the default. Name it and every other device shows the new name. @@ -31,7 +31,7 @@ Type this on the device you mean: codeaf devices rename "atlas" ``` -Or open `/devices`, stay on the row marked `this device`, and press `n`. The name is filled in under the list: +Or open `/devices` (or `/settings` → `Devices`), stay on the row marked `this device`, and press `n`. The name is filled in under the list: ``` name this device: atlas▏ @@ -64,4 +64,4 @@ One to 32 characters you can see. Spaces at the start and end are dropped. A nam - `a device name is at most 32 characters` - `a device name cannot hold control characters or line breaks` -Two devices may have the same name. `/devices` then adds a short tail, such as `tower #a1b2`, so you can tell them apart. +Two devices may have the same name. `codeaf devices`, `/devices` and `/settings` → `Devices` then add a short tail, such as `tower #a1b2`, so you can tell them apart. diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index 05a7213ae6..fccdb47a5a 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -121,3 +121,7 @@ goes away when you choose a free model, or when a read finds more than $0.50. It never shows for a model served by Codex, a local model or another connected service, and it never shows a dollar figure. On a window too narrow for the whole sentence it is left out rather than cut. + +When Home also has a message to show, the complete warning stays at the right +of that message row. It appears once; the separate footer keeps the key hints +and project. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index 7ca5f9f885..e41685ebb2 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -1,5 +1,7 @@ # Reaching a machine with a pairing code +For an always-on computer on your own Railway account, see [A dev box](a-dev-box-on-your-own-railway.md) and `codeaf devbox up `. + ## Two codes, two grants — remote access and your chats You learn one thing: **a code shown on a screen you own, typed on the other computer.** A code can grant two different things. They stay two things, because they are two different powers. @@ -9,7 +11,7 @@ You learn one thing: **a code shown on a screen you own, typed on the other comp | What the other computer gets | a key to this machine's tools: a hand on that one machine | your identity: your chats, your saved keys and your sync, on every computer you own | | The code | six digits, shown `715 302`, typed with the name of the machine | a number, then six digits, shown `42-715-302`; the number is the place, so the code carries it | | This machine must | stay on and stay connected | be there only while you pair | -| Who can undo it | this machine alone, with `codeaf devices revoke `, at once | you, with `codeaf devices revoke `, but it cuts the relay only; see the page *Pairing your chats with a second computer* | +| Who can undo it | this machine alone, with `codeaf devices revoke `, at once | you, with `r` in `/devices` or `/settings` → `Devices`, or `codeaf devices revoke `, but it cuts the relay only; see the page *Pairing your chats with a second computer* | **One code never gives both.** A code for your chats does not give a shell on any machine. A code for a machine does not give your chats. @@ -34,23 +36,33 @@ laptop$ codeaf chat --at otter-lamp-42 router setting. That machine dials *out* to a relay and stays connected, which is why firewalls and NAT stop mattering. -**This needs a relay service, and there is not one running yet.** Until one is switched -on, `--at` prints: - -``` -no service address is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a service address, or reach that machine with --host over ssh -``` +**There is nothing to set up.** Both machines go through the relay their chats already +sync through: the hosted relay, unless sync was pointed somewhere else with +`CODEAF_SYNC_URL` or by pairing. `CODEAF_RELAY`, or `--via` on `codeaf serve`, picks a +different relay for this door alone, such as one you run yourself. `--host` works today and is not going anywhere. If you can already reach the machine that way, use that; the two doors are compared at the bottom of this page. ## codeaf serve, and the name a machine gets +`codeaf serve` keeps the identity's saved keys and settings in step the way an open terminal does only when it is started with `CODEAF_SERVE_SYNC=1`, which a dev box sets. Without it, serve syncs nothing while it serves, as it always has. + +Plain `codeaf serve` opens only the machine connection; it does not take tasks. +On a paired Linux or macOS computer, `codeaf serve --take-tasks` also prints +`takes tasks from your paired computers`. Those computers can send a task with +`/task --on ` while keeping the chat and transcript +where they are. This task door uses the shared identity and needs no separate +machine-access grant. Only work with its own independent copy can go; Windows +cannot send or take these tasks yet. See *Tasks on another computer*. The +connection name below is for reaching the whole machine, not choosing a task's +computer. + Run it on the machine that owns the work — the one with the files, the API key and the things you have set up: ``` -codeaf serve [--workspace path] [--via https://…] +codeaf serve [--workspace path] [--via https://…] [--take-tasks] ``` It prints, exactly: @@ -60,24 +72,62 @@ It prints, exactly: let a device use this machine with code 715 302 (valid 10 minutes) ``` -**The name is not chosen, it is derived from that machine's own key.** Two words and two -digits, the same every time, on any relay. You cannot pick a nicer one, and nobody else -can register under yours while your machine is connected. +**The relay name is derived from that machine's own key.** Two words and two +digits, the same every time, on any relay. Nobody else can register under yours +while your machine is connected. A dev box also has the chosen alias saved on +the laptop that created it; `codeaf devices` shows that alias for `--at`. `--workspace` is the directory a connection works in when it does not name one; empty -means the directory you ran the command in. `--via` beats the `CODEAF_RELAY` -environment variable. +means the directory you ran the command in. For `codeaf serve`, the relay is, in order: +`--via`, the `CODEAF_RELAY` environment variable, `~/.codeaf/v3/remote/relay`, then the +relay this machine syncs through. For `codeaf chat --at`, it is `CODEAF_RELAY`, the relay +that machine was paired through, `~/.codeaf/v3/remote/relay`, then the relay this machine +syncs through. Running it twice on one machine is refused, because there is only one of it: ``` -this machine is already connected to the service as otter-lamp-42 — there is only one of it, so close the other `codeaf serve` +this machine is already connected to sync as otter-lamp-42 — there is only one of it, so close the other `codeaf serve` +``` + +## codeaf serve when the network drops or the relay says no + +If the network drops while it runs, it says once: + +``` +lost sync — trying again +``` + +and keeps trying, waiting a little longer each time up to a minute, resetting that wait +only after a connection has held for at least a minute. When it reconnects, it says once: + +``` +back — reachable as otter-lamp-42 again +``` + +It prints its name and code again only if the name or code changed. It does not need +restarting, and devices reach it as soon as it is back. + +If the relay turns registrations away — it allows 10 registrations a minute from one +address — `codeaf serve` backs off the same way and says once: + +``` +the sync service is turning registrations away right now — waiting before trying again +``` + +A relay that answers but cannot connect machines, such as an older sync service, stops +it with: + +``` +the sync service at https://service.example.com syncs chats but does not connect machines — run your own with `codeaf relay` and set CODEAF_RELAY to it ``` -With no relay set up at all it says: +A relay that cannot be reached when it starts stops it with the sentence in *When --at +will not open*. With sync turned off (`CODEAF_SYNC_URL=off`) and no `CODEAF_RELAY`, there +is no relay at all: ``` -no service address is set up on this machine, so there is nowhere to be reachable from — set CODEAF_RELAY to your service address, or let people in over ssh with `codeaf chat --host` from their side +no sync service is set up on this machine, so there is nowhere to be reachable from — set CODEAF_RELAY to your sync service ``` Ctrl+c ends it, and the machine gives its name back on the way out: @@ -98,7 +148,6 @@ It prints, in this order: ``` pairing with otter-lamp-42 -a paired device is a key to that machine: it opens conversations there, runs whatever that machine allows to run, and spends that machine's model key. it is the same weight as an ssh key. enter the code shown on otter-lamp-42: ``` @@ -108,7 +157,9 @@ Type `715 302` or `715302` — spaces and dashes are thrown away. Then this devi waiting for approval on otter-lamp-42; it should show: amber fox dune ``` -The words are the check. The machine shows the same three words and asks the person at its terminal, which is you: +## Approving remote work — the three words and y / n + +The three words are the check. On the serving computer, codeaf first says `a paired device is a key to that machine: it opens conversations there, runs whatever that machine allows to run, and spends that machine's model key. it is the same weight as an ssh key.` Then it shows the same three words and asks the person at its terminal, which is you: ``` "laptop" wants to use this machine. Same three words on that screen: amber fox dune? y / n @@ -303,8 +354,9 @@ A name nothing matches says: no device called "phone" is paired with this machine — `codeaf devices` lists the ones that are ``` -Two devices with the same name are refused rather than guessed at, and `--all` removes every -device answering to that name: +## Two devices with the same name — choose a suffix or remove all + +A bare duplicate name is refused with the choices, such as `laptop #8381` and `laptop #9cf9`. Use `codeaf devices revoke laptop#8381` to choose one, or quote its printed name as `codeaf devices revoke "laptop #8381"`. `--all` removes every device answering to that name: ``` codeaf devices revoke laptop --all @@ -316,6 +368,11 @@ the name or after it; `codeaf devices revoke --help` prints it. It used to be re it came first, so `codeaf devices revoke laptop --all` was refused with a usage line that did not mention `--all` at all. +`--all` reaches every device `codeaf devices` lists under that name, of either kind: a +computer that syncs your chats as well as a device that can use this machine. It used to look +only at devices that can use this machine, so `codeaf devices revoke --all` said `no +device called "" is paired with this machine` for a computer the list showed. + Removing one device with `--all` answers in the ordinary sentence — `laptop was removed — it can no longer open a chat here, and it needs a new pairing code to come back.` Removing several answers `2 devices called laptop were removed — each needs a new pairing code to come @@ -351,24 +408,30 @@ that still has it in the list, which is what revoking removes. ## When --at will not open -There are four different reasons, and each says which one it is, in one sentence. +There are five different reasons, and each says which one it is, in one sentence. -**No relay is set up on this machine:** +**No relay is set up on this machine** (sync is off and `CODEAF_RELAY` is empty): ``` -no service address is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a service address, or reach that machine with --host over ssh +no sync service is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a sync service, or reach that machine with --host over ssh ``` **The relay is set up and not answering:** ``` -the service at https://service.example.com cannot be reached from here — check this machine's network, or reach that machine with --host over ssh +nothing is answering at https://service.example.com — if that is your own sync service, start it there with codeaf relay +``` + +**The relay answers but cannot connect machines:** + +``` +the sync service at https://service.example.com syncs chats but does not connect machines — run your own with `codeaf relay` and set CODEAF_RELAY to it, or reach that machine with --host over ssh ``` **The relay is fine and that machine is not connected to it:** ``` -otter-lamp-42 is not connected to the service right now — run `codeaf serve` on that machine +otter-lamp-42 is not connected to the sync service right now — run `codeaf serve` on that machine ``` **This device has never been let in:** @@ -380,8 +443,12 @@ this device is not paired with otter-lamp-42 — run `codeaf serve` on that mach Two more you may meet. A machine that does not answer the handshake at all: `otter-lamp-42 did not answer this device's handshake, so nothing was sent — if that machine was rebuilt it has a new key and this device has to pair with it again`. And a relay that is turning connections away: -`the service is turning connections away right now — try again in a minute` — it allows -30 connections a minute from one address. +`the sync service is turning connections away right now — try again in a minute` — it allows +30 connections a minute from one address. If a proxy or wrong path at the relay address +refuses the upgrade, it names the full address it tried: +`the sync service at https://service.example.com/wrong-prefix refused this connection (404) — check the address it was given`. + +## The pairing code was right but approval failed — writing access on the serving computer **A pairing that says the code was wrong when you are sure it was right** has one other cause, and it is the machine's own disk. The machine writes a device into its list @@ -464,15 +531,18 @@ sitting at. They differ only in how the two halves reach each other. | | `--host` | `--at` | | --- | --- | --- | -| Needs | `ssh ` already works | a relay service, and one pairing | +| Needs | `ssh ` already works | one pairing; the relay is the one sync uses | | Set up on the far machine | nothing | `codeaf serve` running | | Works through NAT and firewalls | only if ssh does | yes — that machine dials out | | Anything in the middle | nothing of ours | a relay that cannot read the conversation | -| Available today | yes | not until a relay is running | +| Available today | yes | yes | **Prefer `--host` when ssh works.** It has nothing between the two machines, nothing to set up, and no service to depend on. `--at` is for the machine ssh cannot reach. +Both machines must use the same relay. If one syncs through a relay of your own and the +other through the hosted one, set `CODEAF_RELAY` to the same address on both. + Both open the same surface with the same limits — what does and does not work over a connection is a property of the connection, not of which of these two doors opened it. @@ -488,3 +558,25 @@ Every request must be signed by one of that person's devices, each person's data apart from everyone else's, and a restart keeps who holds each chat. Its log lists the first characters of an identity id, the kind of request, the status and the byte counts, never what was sent. Stop it with SIGTERM and requests already under way finish first. + +## The serving computer’s device name and the remote window bar + +When this computer is paired for sync, `codeaf serve` and `codeaf serve --take-tasks` lead with the machine name and show the device name as a hint after it, for example `this machine is reachable as seagull-lichen-63 (desk-b)`. Type the first name: `codeaf chat --at seagull-lichen-63`. The `--at` window’s bar shows `desk-b`. This build cannot look up a machine by its device name. + +## Approve remote work from a device already paired for chats + +Pairing for chats and allowing remote work are separate permissions. On the serving computer, before `y / n`, codeaf says `a paired device is a key to that machine: it opens conversations there, runs whatever that machine allows to run, and spends that machine's model key. it is the same weight as an ssh key.` The asking computer starts by asking for the displayed code. + +The approver always asks `"laptop-a" wants to use this machine. Same three words on that screen: red green blue?`, followed by `y / n`. This question also appears when the asking computer is already paired for chats: this exchange cannot verify its chat pairing. Check the three words on both screens and explicitly approve before it gets access to work here. + +## A served connection ended + +The serving terminal says `the connection to laptop-a ended`. A known timeout adds `— it stopped answering`. Process and pipe details go to the local serving log, keeping the terminal free of raw process errors. + + +## Which computer name appears in an --at window and its connection errors + +An `--at` window uses the serving computer's chosen device name in its bar, +connection errors, reconnecting notices and timeout messages. Its typed +rendezvous name still selects the machine to connect to. If an older engine +has not supplied a chosen name, the window uses the requested name. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index e31833eaba..9578f62b26 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -1,6 +1,6 @@ # Hosted relay or your own relay -## Hosted relay or my own — which one do I use, and where does sync go by default +## Hosted relay or my own — which one do I use, where does sync go by default, and which relay each channel uses 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. @@ -13,7 +13,7 @@ A relay is the small service your computers sync through. There are two kinds. B Either way the relay sees only ciphertext and the short list on "What can the relay see". -**Where sync goes by default.** Unset, a computer syncs through codeaf's hosted relay at `https://codeaf.agentfield.ai/fabric`, once this build carries that address. The address lives in one place in the program, `HostedRelayURL`; a build whose value is empty has no default, and then nothing is copied and pairing says `no sync address is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to a sync address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. +**Where sync goes by default.** Unset, each release channel syncs through its own hosted relay: stable releases and release candidates use `https://codeaf.agentfield.ai/fabric`, staging builds use `https://caf-fabric-staging.instrument-santosh.workers.dev`, and dev builds and local builds from source use `https://caf-fabric-dev.instrument-santosh.workers.dev`. Channels do not sync with each other: computers must run the same channel (or set the same `CODEAF_SYNC_URL`) to pair and share chats. A build whose `HostedRelayURL` is empty has no default, and then nothing is copied and pairing says `no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. ## Point codeaf at a relay — CODEAF_SYNC_URL, url or off @@ -26,7 +26,7 @@ CODEAF_SYNC_URL=https://relay.example.com codeaf ``` - **A web address** like `https://relay.example.com` or `http://host:8787`. Plain `http` is for trying it out inside an ssh tunnel. An address that is not a web address stops with `CODEAF_SYNC_URL is not a web address like http://host:8787`. -- **`off`** turns the relay off. Pairing then says `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`. +- **`off`** turns the relay off. Pairing then says `sync is off (CODEAF_SYNC_URL=off); pairing needs the sync service`. Home still lists every chat on this computer — chats this version saved and chats from older versions alike — each once, and each opens and can be written. - **Unset:** a computer that was paired with a named relay uses the relay the pairing saved. Otherwise it uses the hosted default above, if this build has one, and has none if it does not. Your own computers must all name the same relay and hold the same identity, or they see different chats. @@ -42,7 +42,7 @@ codeaf relay --listen :8787 --store /var/lib/codeaf - `--listen` is the address to listen on. The default is `:8787`. - `--store ` is where the relay keeps the directory and the chunks. **Without `--store` the relay only passes pairing and remote-access traffic. It keeps no chats, and sync does not work through it.** The directory is made if it is not there. - `--quiet` turns the log off. `--status=false` turns off `GET /status`. -- `--trust-proxy` makes the pairing limits count the address in `X-Forwarded-For`. Use it only behind a proxy that sets that header, and never on a relay that faces the internet. +- `--trust-proxy` makes registration, dial and pairing limits count the first address in `X-Forwarded-For`, falling back to the socket address when absent or blank. without it, forwarding headers are ignored. a relay behind Railway, a load balancer or nginx must pass this flag or every caller shares the proxy's allowance. use it only behind a proxy that sets that header. Stop it with SIGTERM or ctrl+c: requests under way finish first. **The relay has no TLS of its own.** Put a proxy that ends TLS in front of it if any computer reaches it over a network you do not own. @@ -64,4 +64,24 @@ Moving chats and pairing are available by default on Linux, macOS and native Win ## My sync service is too old to move chats — update before continuing here -An older service can still pair computers and list or show chats, but this build refuses to move a chat through it. It says `the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service`. The hosted service is updated by codeaf's operators. For your own service, replace the running `codeaf relay` with this build. Reopen codeaf after the update; the service's answer is remembered for the current run. +An older sync service still pairs computers, lists and shows chats, saves every chat you write, and continues a chat another computer has closed. Only taking a chat that is open on another computer waits for it, because only an updated service can promise that the take displaces the holder you saw. This build says `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here`. Close the chat on that computer and continue here. For your own service, replace the running `codeaf relay` with this build; the next attempt uses the update without reopening codeaf. + +A chat you write on this computer is never held back by an older service. Releases of codeaf check that the hosted service answers what they need before they ship. + +## Starting your own sync service — codeaf relay help and the pairing command + +Run `codeaf relay --listen 127.0.0.1:8901 --store `. The first line is `codeaf relay is listening on 127.0.0.1:8901 — on each computer: codeaf pair --via http://127.0.0.1:8901`. Use an address the other computers can reach; 127.0.0.1 reaches only the computer itself. `codeaf relay --help` lists the ordinary options, and `codeaf help env` lists both service variables. + +`CODEAF_SYNC_URL` selects the sync service for chats; `off` disables syncing. `CODEAF_RELAY` is the fallback service override for pairing when `CODEAF_SYNC_URL` is unset, and the remote connection override for `serve` and `chat --at`. An explicit `--via` at pairing selects and remembers the service for later starts. Every instruction for the new computer includes that `--via` argument when the service is your own. + +## The sync service did not answer — unreachable, slow or unable to agree + +A refused connection, failed name lookup or missing route to your own service says `nothing is answering at http://127.0.0.1:8901 — if that is your own sync service, start it there with codeaf relay`. The hosted equivalent is `cannot reach codeaf.agentfield.ai — check this computer's internet connection`. + +A timeout says `the sync service at http://127.0.0.1:8901 did not answer in time — try again`. A server error says `the sync service at 127.0.0.1:8901 is up but cannot agree on changes right now; it usually recovers within a minute`. Each sentence uses the address you selected. + +`codeaf pair`, `codeaf devices` and `codeaf cluster status` say `asking the sync service at 127.0.0.1:8901…` to the error stream after two seconds without an answer, only when that stream is a terminal. + +## A chat keeps working when the sync service is down + +A sync outage says `the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back`, once per outage. Local saves and rewind keep working. A local save by itself does not prove that syncing recovered. After an actual successful sync call, the chat can say `syncing again`; a sustained renewal outage keeps its existing `this chat is syncing again` recovery line. diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md index c2ead41b16..13801d9b0b 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -1,8 +1,8 @@ # Relay limits and refusals -## The sync service must be updated before chats can move +## Taking an open chat from another computer waits for a sync service update -If an old service cannot safely decide which computer continues a chat first, no move is sent. The sentence is `the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service`. Pairing, listing and viewing keep working. codeaf's operators update the hosted service; update your own `codeaf relay` to this build. Reopen codeaf after the update. +An older service cannot promise that taking a chat displaces only the computer you saw holding it, so this build never sends that take to it. The sentence is `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here`. Writing, saving, pairing, listing, viewing and continuing a chat the other computer closed keep working. Close the chat on that computer and continue here; update your own `codeaf relay` to this build. ## Is there a storage limit on the relay — how much can I sync @@ -37,10 +37,10 @@ Pairing has limits on both kinds of relay: 10 new codes in an hour for one netwo A refused upload leaves the saved turn on this computer. These are the other sentences: - **Too many new identities from this network** (`429`, code `too_many_identities`). The chat says `too many new identities from this network today — try again in about 3 hours` at once. The time is the relay's `Retry-After`, rounded up to minutes or hours; when the relay names none, the chat says `this network has started too many new identities today; sync begins when it allows more`. It waits as long as the `Retry-After` says, up to an hour at a time. -- **Another of your computers stopped this one** (`401`, code `revoked`). The chat says `this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back`, and does not try again: only a new pairing brings the computer back. +- **Another of your computers stopped this one** (`401`, code `revoked`). The chat says `this device was removed by another of your devices, so this chat stays here only — /pair brings it back`, and does not try again: only a new pairing brings the computer back. - **The clock is off** (`401`, code `skew`). The chat says `this computer's clock is off by more than 5 minutes`, once, and tries again at each normal turn of sync. -Each sentence is said once for as long as the trouble lasts, and again if the relay answers in between and then refuses again. An unreachable relay is retried with the doubling wait. If this computer holds the chat and renewals fail for longer than one renewal period, it says `this chat is not syncing because other machines are unreachable; it keeps working here` once. A successful renewal or publish then says `this chat is syncing again` once. A displaced or removed computer does not claim that it is syncing again. +Each sentence is said once for as long as the trouble lasts, and again if the relay answers in between and then refuses again. An unreachable relay is retried with the doubling wait. If this computer holds the chat and renewals fail for longer than one renewal period, it says `the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back` once. A successful renewal or publish then says `this chat is syncing again` once. A displaced or removed computer does not claim that it is syncing again. On your own relay the full case is its disk: free space on the machine that runs it, then reopen the chat. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 430d84430b..f240a5184e 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -660,7 +660,7 @@ by default on Linux and macOS on every build. Native Windows is supported too; a ## codeaf pair — show a code, or use one, from the terminal -**`codeaf pair approve `** compares the four-digit check number and asks `a / d`: only `a` approves, and `d`, Esc, Enter or anything else decline. Code pairing keeps its `y / n` question. +**`codeaf pair approve `** compares the four-digit check number and asks `y / n`: only `y` or `yes` approves, and `d`, Esc, Enter or anything else decline. Code pairing keeps its `y / n` question. **`codeaf pair`** on a new computer asks to join your devices and shows a link to approve from a computer that is already in (`codeaf pair approve `); **`codeaf pair --code`** shows a code that gives your chats to another computer. **`codeaf pair `** uses a code that another computer showed, for example `codeaf pair 42-715-302`. `CODEAF_SYNC_URL=` or `--via ` keeps the invitation on that service's own pairing page and includes `--via` in the token command; it never prints the hosted page link for a self-hosted invitation. `--via ` names the sync address to go through, and ordinary pairing keeps and shares a solo computer's own chats, history, saved keys, memory and preferences. Terminal pairing prints “Sharing finishes the next time codeaf runs.” Start codeaf again to share existing chats, saved keys, memory and preferences in the background; reopen after an interruption to resume. A used computer already sharing another set refuses and changes nothing. Only explicit `--replace` chooses a clean slate, with its existing confirmation. The page *Pairing your chats with a second computer* has the whole story. @@ -769,18 +769,22 @@ key the first time the identity is made on that machine. ## List your chats on every machine — codeaf cell list --all, and what its lines mean **`codeaf cell list --all`** prints every chat you have on any of your machines, one line -each: id, title, device, where it runs (`running on `, ` offline`, +each: id, title, device, where it runs (`held on `, ` offline`, ` turns from : discard`, or `-` for one held here or let go), and how long ago its last saved turn was. It needs no terminal UI, so it works over ssh. Without `--all` it prints the usage line. With sync off (`CODEAF_SYNC_URL=off`, or no relay to sync through) it prints -`sync is off: set CODEAF_SYNC_URL to your relay's address` and lists nothing. With a relay +`sync is off: set CODEAF_SYNC_URL to your sync service's address` and lists nothing. With a relay set but no identity on this machine it prints `this machine has no identity yet: codeaf identity import` and lists nothing, and when the -other machines cannot be reached it starts with `other machines unreachable`. +other machines cannot be reached it starts with `the sync service is not answering`. ## A different sync setting on an existing engine -The engine keeps its sync setting from startup. Changing `CODEAF_SYNC_URL` to another address or `off` retires an idle engine before opening a chat and says `the engine restarted to pick up this terminal's sync setting`. If another window or work still uses it, the new window refuses to attach: `the engine for this folder started with a different environment than this terminal and is still in use — close its other windows or let its work finish, or run codeaf engine --stop --workspace `. The command names the exact folder. Let that work finish or stop the engine explicitly before reopening with the new setting. +The engine keeps its sync setting from startup. Changing `CODEAF_SYNC_URL` to another address or `off` retires an idle engine before opening a chat and says `the engine restarted to pick up this terminal's sync setting`. If another window or work still uses it, the new window refuses to attach: `the background engine for this folder started with a different environment than this terminal and is still in use — codeaf chat --no-host opens this window on its own and leaves the other one running; or, after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run codeaf engine --stop --workspace `. The command names the exact folder. + +To use the new setting at once without touching the other window, run `codeaf chat --no-host`, with `--session ` to open a particular chat; for example `CODEAF_SYNC_URL=off codeaf chat --no-host`. That window runs its own engine in this terminal and ends with it. + +Only a terminal that sets `CODEAF_SYNC_URL` itself is asking for a different relay. A window with the variable unset — `codeaf chat --host` reaching this computer over ssh, whose shell usually does not carry what you exported, or a terminal you never set it in — joins the running engine and its relay as it is. It restarts the engine only when the relay a pairing saved on this computer has changed since the engine started. ## Sync your chats between machines — CODEAF_SYNC_URL and CODEAF_SYNC_INTERVAL_MS @@ -819,7 +823,7 @@ show your chats on the other machines once both machines share an identity and t With sync on, the chat you are typing in sends each saved turn to your relay, and holds the chat for this machine while it is open. Closing the chat sends what is left and lets go of it, so your other machine can pick it up at once. If this machine sleeps or loses its connection, presence stops answering; the other computers mark it offline after 25 seconds of silence, within 30 seconds. The lease itself expires after about ninety seconds, and an explicit continue can take it sooner. -You do not have to wait for that: press enter on a chat that says `running on ` on another +You do not have to wait for that: press enter on a chat that says `held on ` on another machine and continue it there, and it is taken at once (see "Continue a chat here that another machine left" in home). @@ -1578,6 +1582,10 @@ own keypair, and `CODEAF_MODEL_POOL_RELAY_URL` with `models.pool.public_key` server, a watcher — one per line, tab separated: the name, the status, how long it has been up, what is watched for health, and where its log is. +A shell command that has already finished keeps any background server or watcher +it left running when the turn ends. Interrupting a command that is still running +stops the command and its child processes. + ``` dev-server running 2h port:5173 /tmp/dev.log ``` @@ -1929,7 +1937,7 @@ a negative count is refused too. The command's own usage follows the line, and t short block of labelled rows. It needs no key and spends nothing. ``` -codeaf doctor [--db path] +codeaf doctor [--db path] [--fix] ``` Every row is labelled in the words a developer would search for: @@ -1961,6 +1969,22 @@ machine that counted, which is the opposite of the truth. That command is about this *install*. `/status` in the chat is about the conversation you are in. They are related and they are not the same reading. +## I deleted my codeaf home while the engine was stopping + +A running process remembers its original home directory. If you remove it or +replace it with a different directory, attempts to make an identity or device +refuse with `codeaf home was removed or replaced; restart codeaf to use the current folder`. +Late saving work does not make a new identity there. If the engine is already +stopping and still closing chats, it exits promptly when its original home is +removed. With the home intact, stopping keeps its usual saving order and limits. +A new process can still create a home on first use. + +## Repair a busy engine store or sync state — codeaf doctor --fix + +When an engine store lock or cross-computer sync issue exists on this computer, `codeaf doctor` adds `engine store` and `sync` rows naming a busy store holder (`busy · held by , pid `), an `unfinished continue · chat `, `duplicate rows · chat `, or a `lapsed hold on this computer · chat `. Healthy stores and clean sync state print nothing. + +Without `--fix`, `codeaf doctor` changes nothing. `codeaf doctor --fix` releases unfinished continues started by this computer, and retires duplicate branch copies whose turns are all already in the main chat, printing one `fixed:` line for each repair. A store lock whose process has ended is already free, so doctor neither reports nor removes it, and a busy store waits for its holder to finish or quit. It never touches another computer's live hold and never deletes a turn that exists only in the copy or scratch folder it inspects. + ## codeaf help, and the verbs that take no flags at all `codeaf help` is a third spelling of `codeaf --help`, beside `-h`, and all three print the diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 365524317c..1efffcb938 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -106,9 +106,7 @@ pkill -f '[s]sh -T .* localhost codeaf engine' ``` The status line grows its `connection` segment, the surface redials itself, and the answer -continues rather than restarting. Send something while it is down and the message is not -lost quietly — it comes back as `submit failed: reconnecting to localhost — try that again -in a moment`, and pressing enter again once it is back sends it. +continues rather than restarting. Enter while it is down sends nothing and keeps the full draft in the box. It says `not sent — still reconnecting to localhost; enter sends it once the link is back`. Press Enter again after it is back to send; reconnecting never sends automatically. ## The far machine says a different version — when the connection cannot be made @@ -944,3 +942,23 @@ resolution. This distinction applies inside tmux too. Windows cannot replace the running bridge in place. When a newer engine needs a different version, this launch stops cleanly and names the installed program’s update command. It leaves the existing conversation and its windows running. + + +## A disconnected message stays in the box — not sent, reconnecting or connection gone + +While `chat --host` reconnects, Enter keeps your words and attachments in the +message box and starts no turn. For localhost it says +`not sent — still reconnecting to localhost; enter sends it once the link is back`. +Press Enter after the link returns; it never sends automatically. + +When `chat --at` has lost its connection, Enter likewise keeps your draft. With +a computer named desk-b it says `not sent — the connection to desk-b is gone; your words are still in the box. Run the same command to pick the conversation back up`. +The computer’s chosen name is used when available. + +## An old latency disappears — ping age in --host and --at + +A measured round trip appears in the connection bar, for example `desk-b · 1ms`. +Pings are five seconds apart. After six seconds without an answered ping, the +latency disappears, including while the next ping is still waiting. The next +answer restores the reading. This is a link measurement, not proof that sync +or chat ownership is available. diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 2ef8cad45f..865a1b75bb 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -2044,7 +2044,7 @@ This works everywhere a path appears in codeaf's own text: attached a picture to, and the `Transcript: file:///…` line an `@task` mention leaves behind. - **in codeaf's dim `·` notes** — `/status`'s `file` row, `/help`'s `session · …`, - `exported · …`, `resumed …`, `new session · …`. + `exported · …`, `resumed …`. A new chat says `new conversation · proj-a`, using the project’s base name without a transcript path. - **on a tool row** — the target of a `read`, an `edit` or a `write`, and the file name above an edit's diff, even when the row was too narrow to show the whole path. - **the dim line under a picture**, which is the picture's whole absolute path. diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 9391378187..2cf6bcadb5 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -1025,8 +1025,8 @@ even after the answer has finished, and the saving-failure status clears. Later bookkeeping does not hide that recovery. Another task saving successfully does not clear this chat's saving failure. -If closing cannot finish saving, the error names the local transcript that keeps -the unsaved turns. Keep that file: after storage becomes reachable again, the +If a local write fails, the error names the local transcript that keeps +the unsaved turns. A sync outage alone leaves the turns saved here and does not produce a local-write failure. Keep that file: after storage becomes reachable again, the turns can be saved and published, or kept separately when you continue the chat. ## Rewind is off when Windows cannot save file history securely diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 08d7c7ef02..265b534323 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -3,13 +3,13 @@ ## Find a setting — categories, search and advanced controls Open `/settings` or press `ctrl+,`. Settings is organized into General, Models, -Memory, Tasks, AI teams, Permissions, Spending, Connections and Privacy. Wide +Memory, Tasks, AI teams, Permissions, Spending, Connections, Devices and Privacy. Wide terminals keep the categories beside the controls; narrow terminals show a category bar. Left and right change category, up and down choose a row, and Enter changes it. The pointer can select the same controls. -Type to search across categories, including advanced controls and connection -services. Old setting names and configuration keys remain searchable. Clear the +Type to search across categories, including advanced controls, connection +services and paired devices. Old setting names and configuration keys remain searchable. Clear the query to return to browsing; Escape clears it first, then closes settings. Model pickers have their own **Filter models** box. Each category keeps uncommon controls behind `Advanced`; search can find them without opening that section first. @@ -82,6 +82,11 @@ positive: on shows contextual tips, off hides them. Keyboard instructions and setting explanations stay visible either way. **Completed tool details** offers **collapsed** or **expanded** without changing the meaning of an existing profile. +**Settings → General → share teams** is off until turned on. Both paired computers +must turn it on to share teams, their members and settings, and recent Traffic as +read-only history. The choice stays on each computer. Turning it off stops sharing +and keeps teams already received. + ## AI teams settings versus an individual AI team **AI teams** contains defaults for groups of AI chats, including questions to an @@ -130,6 +135,17 @@ changed cap to already-open chats. Changing this preference does not stop a running task. An unset task model follows the configured worker, falling back to the current chat model when no worker model is set. +**Run tasks on**, immediately after **Task model** in Tasks, chooses where a +task goes when you do not name a computer. It defaults to `this computer` and +reads eligible paired computers by shown name only when opened. The row +is absent on unpaired, quiet or Windows homes. An explicit `/task --on`, proposal-card choice or `computer` +argument wins. Quick tasks and work without an independent copy still run here. +Both ends need Linux or macOS, and the other computer must run `codeaf serve --take-tasks`. Plain `codeaf serve` +does not take tasks. The model's `computer` argument and a proposal's computer +line and cycle key appear only after the setting names a computer, or (for the +card) the proposal already names one. +See *Tasks on another computer* for limits and `run it here`. + ## Connect a service, change models or manage privacy **Connections** contains service sign-in and provider credentials, web search @@ -138,6 +154,10 @@ Ordinary account connection does not require you to create your own OAuth app. **Models** selects supported models and reasoning effort; advanced controls hold routing, internal role overrides and context limits. +**Devices** lists your paired computers in the same words and order as `/devices`, +renames this device with `n`, removes another with `r`, and starts `/pair` from +`Add another device`. + **Privacy** separates input history, unfinished drafts, anonymous usage reporting and **shared model recommendations** (formerly Model Pool). Recommendations can **use and contribute**, **use only**, or remain **off**. Disabling one privacy diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index 512e31c75c..0b1c6ad9dc 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -274,7 +274,9 @@ ref/index checks continue to protect the destination. The portable apply contract preserves raw file bytes and line endings, file permissions, empty directories and directory permissions. Changed paths with unsupported ownership, hardlinks, extended attributes, native flags or access -control metadata are refused before writing the original. Timestamps are outside +control metadata are refused before writing the original. The one exception is +`com.apple.provenance`, which macOS stamps on new files itself and which the +replacements receive the same way; it never causes a refusal. Timestamps are outside this portable contract. Older copies without a historical metadata baseline stay readable, but this apply door cannot infer their original metadata safely. diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md new file mode 100644 index 0000000000..cadc7495f3 --- /dev/null +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -0,0 +1,385 @@ +# Tasks on another computer + +## Can a task run on my other computer + +Yes. Pair both computers with the same identity and run `codeaf serve --take-tasks` on the +computer that will do the task. Both ends need Linux or macOS; Windows cannot +send or receive these tasks yet. An ordinary open chat window does not take tasks. +Only a task that works in its own copy can go. + +Choose the computer with `/task --on ` or **Run tasks on** +in `/settings` → Tasks. Until you choose, ordinary tasks run here without +contacting other task computers. A proposal already naming a computer, or +one made with a remote default, offers `ctrl+d computer`. The +model gets the optional `computer` argument only while **Run tasks on** names +a paired computer. Use its shown device name, not its connection name. The named choice wins over the setting; the default is +`this computer`. These controls are absent when paired tasks are unavailable. + +The chat, conversation file and transcript stay here. The task's copy goes +encrypted to the other computer, and its change comes home through the ordinary +landing road, including its commits. With private task copies on, use +`/task review work` then `/task apply work` here instead. +That apply is the same one a task that ran here uses, with the same limits: on +a Mac it refuses a project whose files belong to a group other than yours, +saying `portable apply cannot preserve a different owner or group`. +The other computer uses its own provider keys; its spend appears here against +the conversation and task, once, and also stays on that computer's ledger. + +## Run a task on my mac mini + +Pair your Mac mini and this computer, then run `codeaf serve --take-tasks` on the mini. +Use its shown name, quoting names that contain spaces: + +``` +/task --on "mac mini" Write the export regression test +/task --cheap --on "mac mini" solo Fix the export +``` + +`--best` or `--cheap` may come before or after `--on`; `solo` follows those +flags. `here` and `this computer` choose this computer. A proposal's +`ctrl+d computer` cycles through `this computer` and eligible paired computers +over an empty draft; accepting the proposal sends that choice. The line says +`runs on `. This line and cycle key are absent from ordinary local +proposals until the setting or proposal names a computer. The model's +`computer` argument is available only while **Run tasks on** names a paired +computer. Two computers with the same name use their Devices labels, such as +`mac mini #1111` and `mac mini #2222`. Include that label when choosing one. +An ambiguous bare name says +`"" matches more than one paired computer — choose one of: `. +A misspelt name is refused with +`no paired computer is named "" — your computers are: `. + +Both computers must run Linux or macOS, and the task needs its own copy. Sending +a task does not move the chat or transcript. The mini uses its own provider keys. + +## What does running on mean + +On a task, `running on ` means its worker runs on that paired +computer while this chat stays here. Its steps appear here in order as it +works. `ran on ` records where completed work ran; +`interrupted on ` means the worker stopped without leaving an answer. +These words appear on task cards and landing notices, the task rail, +Background work's `open result` row, `/history` task rows and the task page. +They stay with a saved task even in a window opened before pairing or a +window only watching the chat. The initial `single task … started` receipt +does not name the computer. Home has no task row. +A task that ran here gains no computer words. + +The task's change returns to its copy here and follows normal landing. Private +task copies wait for Review and Apply here. Conflicting changes are kept for +your decision. If the task copy here changed while the other computer worked, +codeaf says `the task copy changed here while the other computer worked; both copies were kept`. + +This differs from a whole chat held on another computer: task placement keeps +your conversation and transcript here, and only sends the task's own copy. +The remote worker uses that computer's provider keys; its spend is attributed +to this chat and task on this computer too. + +## Models without thinking on the other computer + +A task worker on the other computer asks for `low` effort unless a task +choice sets another level. When its saved catalog row lists supported +parameters without reasoning, it sends no reasoning effort. + +A stock local Ollama row may list no parameters. If the model answers the +effort with `does not support thinking` (or `does not support reasoning`), the +task worker sends the same request again once without effort or a thinking +budget. Later calls through that task's client omit them too. This stays +within that client; other tasks and processes learn nothing from the refusal. +The worker reads available catalog rows without fetching a catalog for this +choice. + +Plain `codeaf do` and chat keep their existing effort and refusal behavior. + +## My other computer stopped answering + +After about a minute without an answer, the task says +` stopped answering — it may still be working` and offers `run it here`. +The worker may still be running there. codeaf keeps trying; when the connection +returns, the card says `running on ` again and missed steps arrive +in order. Reopening this chat follows the saved attempt and brings its result +home once. Nothing is ever re-run by itself to recover a remote attempt. + +Restarting `codeaf serve --take-tasks` follows a child that is still alive. A child that died +without an answer is interrupted and is never started again automatically. +Stopping the task here asks the other computer to stop; its partial change +comes back through the same landing road as a stopped local task. + +If the computer never took the task, the sentence is +` is not taking tasks right now — run codeaf serve --take-tasks on it`. +An older build says +` runs a different codeaf that cannot take this task — update both`. +A removed device is refused with `this computer was removed from your devices`. + +## Run it here + +When the other computer is unavailable or its task was interrupted, the task +card and task page offer `h run it here`. Click the card action or press `h` +over an empty message box. The action is absent while that computer is still +answering normally; stop the task first in that case. + +Choosing `run it here` abandons the old attempt and starts fresh local work in +the unchanged copy here. A late result from the abandoned attempt never lands. +The placement words disappear and the receipt says +`running task here`. If this copy has changed, codeaf keeps +both copies and refuses to overwrite it. The abandoned task’s record is archived; +the local worker starts with a fresh record of effects in its own copy. A computer that dropped may still be +working; a request to stop it is best effort. + +## Run tasks on — when the model gets the computer argument + +In `/settings` → Tasks, **Run tasks on** follows **Task model**. Its hint is +`where a task goes when you do not name a computer`. It offers `this computer` +(the default), then eligible paired computers by shown name. The row reads the +computer list only when you open it. It is absent on unpaired, quiet or Windows +homes, including from the model’s `settings` and `change_setting` tools. A removed or unsupported saved choice displays +`this computer`. Changing the row affects the next task, not work already running. + +An explicit `/task --on`, proposal-card computer choice, or model-supplied +`computer` wins over the setting. Omitted means the setting decides. Quick +tasks and other work without an independent copy run here even when the +setting names another computer, without a special placement sentence. + +## codeaf serve --take-tasks takes tasks from paired computers + +Plain `codeaf serve` opens only the machine connection and does not take tasks. +Run `codeaf serve --take-tasks` on the paired computer that should take tasks. It prints +`takes tasks from your paired computers`. Pairing with the same identity is +the permission; no separate machine-access grant is needed for tasks. Quiet or +unpaired homes do not offer this door. Linux and macOS are supported on both +ends; Windows is not yet. A named unsupported target says +`<computer> cannot take tasks from other computers yet`. Windows serving says +`this computer cannot take tasks from other computers yet`. + +At most two attempts run on that computer at once. A third is refused with +`<computer> is already running two tasks`. If its provider key is missing, the +task ends incomplete with that computer's explanation. The other computer's +keys pay and its spending permission still applies. Without a time limit from +this chat, a remote attempt gets six hours. A time limit sent by this chat +replaces that wall; it cannot lift the other computer's spending permission. +Restarting serve follows +an existing child; it never repeats an admitted attempt's tools. + +## Why did my task run here instead + +Only a task that works in one independent copy can go to another computer. +Quick tasks, program runs, explicit in-place tasks, reference or scratch work, +folders without a transportable copy, and tasks spanning several roots stay +here. A singleton private task copy can go. Linux and macOS are supported on +both ends; Windows is not yet. + +When only **Run tasks on** selected the computer, ineligible work runs here +without special placement text. When you named it explicitly, codeaf explains, +for example: `a quick task runs here — only a task that works in its own copy can run on <computer>`. +A computer that is not serving does not silently redirect work: it offers +`run it here` for your choice. + +## Can this conversation start other tasks while one runs on another computer + +A task on another computer runs alone in this conversation. You can keep +talking here, but wait for that task to finish before starting more tasks, +including another task on the same computer. The refusal is +`the work already underway uses another computer; let it finish before starting this task`. +This limit applies even though the receiving computer can take two attempts +from separate conversations. Remote tasks cannot join more work into the +instruction already sent. + +## Could not check your devices right now + +`could not check your devices right now` means the task computer could not +refresh your devices list. It refuses new work and ends the connection rather +than trust an old permission. Check sync connectivity on both computers, then +retry. Removing a device also ends an existing task watch when its cached +permission expires, within about a minute; the worker itself may still run. +`this computer was removed from your devices` requires pairing that computer +again before it can send tasks. + +## Could not confirm this is my task computer + +`could not confirm this is <computer>` or `could not confirm this computer` +means the connection could not prove it was the chosen paired computer. +Check its entry in Devices, update codeaf on both ends, and pair again if needed. +Do not substitute another computer just because its name looks the same. +`<computer> runs a different codeaf that cannot take this task — update both` +requires updating both ends before retrying. + +## The task copy could not be sent + +`the task copy changed while it was being sent; try again` keeps the copy here. +Finish or pause the edits, then retry the task. +`task copies need private storage and a relay blob store` means this chat's +task transfer is not set up; reopen a supported paired chat with sync available. +`a home folder or a folder above it cannot be sent as a task copy` requires +choosing a project folder below your home, with an independent task copy. +No task is silently redirected elsewhere; use `run it here` when offered. + +## The task copy cell or head is not readable + +`the task copy's cell is not readable` and +`the task copy's head is not readable` mean the saved transfer information +cannot identify a usable task copy. Keep the copy here and the other computer's +work, update both ends, then retry from the task's own controls. +`the sent task copy's fingerprint is missing` means codeaf cannot safely +compare the returned work with what it sent. Keep both copies and choose which +change to bring home; do not hand-edit the saved comparison. +`the task copy's repository link is not a file` or +`the task copy is missing its independent history` requires keeping the copies +and preparing a new independent task copy before retrying. + +## The task destination already contains files + +`the task's destination already contains files` refuses to replace existing +files while receiving a task copy. Keep that folder intact, inspect the old +task's result, and retry with a fresh task copy. Do not clear a folder while a +worker may still use it. +`the task copy changed here while the other computer worked; both copies were kept` +also covers an edit made just as a returned result was being installed. Your +edit stays here and the other result stays available; inspect both before +choosing what to bring home. + +## Could not put the result in place; the earlier copy is kept + +`could not put the result in place; the earlier copy is kept at <path>: <reason>` +means installation and its rollback could not finish. Preserve the named path: +it holds your earlier copy. Check free disk space and folder permissions, then +inspect both copies before moving files or retrying. Do not delete the named +folder as temporary clutter. +`The result could not be brought from <computer>; the copy here was kept` +means the remote task has no usable returned copy. Keep your local copy, restore +connectivity, and inspect the task on the other computer before choosing +`run it here`. + +## That task attempt id is not readable, still stopping, or already finished + +`that task attempt id is not readable` means the saved attempt cannot be +identified; reopen the chat with an updated build and keep both task copies. +`that task is still stopping` means an abandoned worker has not finished +stopping; wait rather than start the same attempt again. +`that task has already finished` refuses replay of an acknowledged attempt. +Completed attempts keep compact replay protection for the newest 1,000 +acknowledgements, even after restarting the serving computer. Start a new task +if you want the work performed again; resending its old attempt cannot rerun it. +These receipts describe the saved attempt, not a reason to discard its work. + +## That task request is not readable + +`that task request is not readable` means the receiving computer cannot read +the request. Update both ends, reopen the chat, and retry through the normal +task controls. +`choose landed or abandoned for this task` concerns the saved acknowledgement +of a result. Keep both copies and reopen the chat so codeaf can retry that +acknowledgement; do not invent an acknowledgement or resend the old start. +`taskdoor: a frame exceeds the 1048576-byte limit` means a task message exceeded +the transfer limit. Keep the copies, update both builds, and use a smaller +instruction or bounded task output before starting a new attempt. + +## The other computer no longer has this task's answer + +`the other computer no longer has this task's answer` means the saved watch +found no retained attempt. Keep the copy here; inspect the other computer's +task files before choosing `run it here`. +`the other computer sent an unreadable task event` means an event cannot be +used safely. Update both ends and reopen the chat to follow its saved attempt; +do not start another worker while the first may still run. +Reopening follows the original attempt and never repeats its tools by itself. + +## The task's remote record cannot be saved here + +`the task's remote record cannot be saved here` or +`the task's remote record is missing` means the conversation cannot keep the +information needed to follow work elsewhere. Keep both task copies, check +local disk space and permissions, and reopen the saved chat before retrying. +`could not save the task result: <reason>` similarly requires preserving the +other computer's task folder and fixing the named storage problem. +`the returned task copy must be landed by this computer` means the result +must go through the owning chat's ordinary landing or Review and Apply here; +open that chat to bring the work home. + +## The task was interrupted before it left an answer + +`the task was interrupted on this computer before it left an answer`, +`the task ended without an answer`, or `the task ended without a result` +means no complete answer was left by the worker. Its partial work stayed on +the other computer. The sent copy here remains unchanged so `run it here` +can start fresh; inspect partial work on the other computer if you need it. +`The work could not be sent back: <reason>` keeps that work on the other +computer too. Fix the stated transfer problem and keep the copy there until +you have brought the change home. A normal stop still brings partial work +back through the usual landing road. + +## Could not record the task's child + +`could not record the task's child: <reason>` means the serving computer could +not safely remember the worker it started. It stops that worker rather than +lose track of it. Inspect the reason, update both builds, and retry with a new +task after checking the retained copy. +Recovery may instead say +`the recorded process does not name this task's work folder`, +`the recorded process does not name this task's private record`, +`the child's process start is not readable`, +`the recorded process is not this task's child`, or +`this task's child is no longer running`. +These refusals prevent attaching to an unrelated process. Keep the task copy; +an interrupted attempt can be replaced with `run it here` after inspecting it. + +## The task's saved event is too large + +`the task's saved event is too large` means a recorded step or charge cannot +fit the bounded task journal. Keep the other computer's task folder and inspect +its output; reduce oversized output before starting a new task. +Saved journal damage can say +`taskdoor: the saved task events are out of order`, +`taskdoor: saved events follow the task's finish`, +`taskdoor: only task events belong in a journal`, +`taskdoor: unknown task outcome`, or +`taskdoor: that task has already finished`. +Preserve the record, update codeaf, and inspect the retained work before +choosing a fresh local attempt. Do not repair the journal by deleting lines. + +## This computer is already taking tasks + +`this computer is already taking tasks` means a serving process already owns +the task folder. Use that process or close it before starting another one. +`` this machine is already connected to sync as <connection> — there is only one of it, so close the other `codeaf serve` `` +requires closing the duplicate serving process. +`<computer> is already running two tasks` requires waiting for a slot; each +remote task still runs alone in its owning conversation. +Setup errors such as `taskdoor: a door needs a relay, roster and executor`, +`taskdoor: this device does not belong to the identity`, +`taskdoor: an attempt store folder is required`, or +`taskdoor: attempt <id> is not a folder` require checking the paired profile +and its task storage before starting `codeaf serve --take-tasks` again. + +## The task door says empty frame or a frame needs a type + +`taskdoor: empty frame` or `taskdoor: a frame needs a type` means the task +computer sent an unreadable task message. The connection cannot safely start or +follow that request. Update codeaf on both computers, check the chosen computer +in Devices, and reopen the chat before retrying through its task controls. +Keep the task copies while checking whether the original attempt started. +A connection failure never grants permission to repeat an admitted attempt. + +## Why a task appears before its copy is ready + +A task sent to another computer appears here before its local copy is prepared. +Preparing and sending that copy happens in the background, so a large project +does not hold up the start receipt. You can stop the task while it prepares. + +If preparation fails, the task shows the reason instead of silently starting +local work. If the other computer is not serving, it still says +`<computer> is not taking tasks right now — run codeaf serve --take-tasks on it` +and offers `run it here`. Ordinary tasks on this computer keep their existing +start behavior. + +## Reopening a task while its copy is being prepared + +If you close the chat during preparation, reopening keeps the chosen computer +and adopts any copy already prepared. Retained partial copies stay named on +the failed task for inspection; they are never silently replaced. + +## Paired task computers are unavailable here + +`paired task computers are unavailable here` means this chat cannot currently +use its saved remote executor, for example after pairing becomes unavailable +or the home is made quiet. The pending task keeps its chosen computer for +when support returns; reopening never chooses local execution by itself. diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index b46e82c517..f9cb9187d3 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -546,6 +546,8 @@ by its team with nothing from you stops being woken and asks you instead, as a w the traffic. It is woken again after you next say something to it. The ten wakes count ten replies when each member turn replies and then finishes. +## How do I turn team message wakes off? + A team's auto-wake can be turned off. `team messages wake` in `/settings` under **Teams** is the default every team inherits (on), and a team can override it for itself and the teams under it: its entry in the profile's teams store, `teams.db`, carries `"wake": false` (or `true`). With it @@ -560,8 +562,26 @@ membership from before it joined; reusing a removed member's handle does not bri member's brief. A conversation reopened later is handed what was said to it while it was closed. +## Team sharing and Traffic on another computer + The traffic itself is kept in the profile of the machine the conversations run on, in -`teams/<id>/traffic.jsonl`, one line per message, only ever added to. +`teams/<id>/traffic.jsonl`, one line per message, only ever added to. It holds only +messages written on this computer. Team sharing is off by default: on each paired +computer, set **share teams** to **on** in **Settings → General**. An unpaired +computer or one with sharing off keeps teams and Traffic to itself. Turning it +off keeps teams already received. + +Once both computers share teams, Traffic also shows recent history from the other +computer, kept beside it in `teams/<id>/elsewhere.jsonl`. That history +cannot deliver a message, start, stop, answer or wake a chat here. A message to a +member or manager known to be on another computer is refused before writing; +continue that chat here from Home first, then send again. A room post or send to +several members reaches local recipients and names those not reached. If all +recipients are elsewhere, nothing is written. An unavailable relay does not +block local delivery. Decisions and escalations also check their recipients +before changing anything. Over `--host` or `--at`, a remote member shows an +"on the other computer" notice; open it there. A history link whose message +cannot be found says it is on the other computer's history. ## Which approval rules do new members inherit? diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 6b228676b5..7902ed80fa 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -16,7 +16,7 @@ This page is the code way. A new computer can also ask to join and be approved f There are two doors. Pick one. -1. On the computer that has your chats, type `/pair` in the chat. Or run `codeaf pair --code` in a terminal. It shows a code and how to use it: +1. On the computer that has your chats, type `/pair` in the chat (or choose `Add another device` in `/settings` → `Devices`). Or run `codeaf pair --code` in a terminal. It shows a code and how to use it: ``` this shares your chats with the device you pair @@ -45,7 +45,7 @@ Before accepting new work, an open synced chat checks who holds it if its last o If another computer has continued the chat, this window only shows it. Sending, queueing, steering and retrying are refused before saving your words or calling a model. The moved notice names the other computer. You can still answer an existing question or stop work. Desktop and terminal chats share this check. With sync off, an unpaired home, unavailable sync or no sync drive, there is no added network wait. -## What does /laptop do — /pair, /sync and /link +## What does /laptop do — the laptop command pairs chats with my other computer; /pair, /sync and /link 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. @@ -72,7 +72,7 @@ Both screens show the same three words. This is how you know the two computers a The computer that uses the code shows the words first and waits: ``` -waiting for approval on your other device; it should show: amber fox dune +Waiting for approval on your other device; it should show: amber fox dune ``` The computer that has your chats asks you. It shows the name of the other computer and the same words. You answer `y` or `n`: @@ -96,15 +96,18 @@ Nothing is sent. The code is gone. If you were not at the screen, run `/pair` ag When you answer `y`, the computer that has your chats says: ``` -paired: laptop +laptop is now paired. Your chats can continue there. ``` and the other computer says: ``` -Paired. Your chats are kept. Both computers' chats will appear as sharing finishes. +Paired with laptop-a. Your chats are kept. Both computers' chats will appear as sharing finishes. ``` +When the other computer's name cannot be read yet, it says +`Paired. Your chats are kept. Both computers' chats will appear as sharing finishes.` + The code is now dead. It cannot pair a third computer. ## The code did not work — one attempt for each code @@ -282,13 +285,9 @@ There is no phone app for continuing chats. A phone browser can open the `/p/` p 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. -When moving chats is available, `codeaf devices` lists every device linked to this computer, in up to two groups. A group with nothing in it does not show. With nothing of either kind it says: +When moving chats is available, `codeaf devices` lists this computer and every linked device, in up to two groups (in the chat, `/devices` and `/settings` → `Devices` list your paired computers). A group with no entries stays hidden. Before pairing, this computer's local name, platform and `this device` come first, followed by `Run codeaf pair to add another device.` A chat-sync list with no removable peer has that same next step instead of a removal hint. -``` -nothing is paired yet — run `codeaf pair` to add another device, or `codeaf serve` to let a device use this machine. -``` - -When moving chats is unavailable, `codeaf devices` names this machine and its key location. With no remote-access devices, it says `no devices are paired with this machine.` and points to `codeaf serve` here and `codeaf chat --at <name>` on the other computer. It does not recommend unavailable chat pairing. +When moving chats is unavailable, `codeaf devices` lists this computer’s local name and platform, then keeps the remote-access route: its reachable name and key location. With no remote-access devices, it says `no devices are paired with this machine.` and points to `codeaf serve` here and `codeaf chat --at <name>` on the other computer. It does not recommend unavailable chat pairing. The group of computers you paired with `/pair` has this heading: @@ -309,7 +308,7 @@ devices that can use this machine ## Remove a paired computer — revoke and the chats it already downloaded -To remove a computer, run `codeaf devices revoke <name>` with the name from the list. It works on both groups. When you remove a device that holds your chats, it says: +To remove a computer from a terminal, run `codeaf devices revoke <name>` with the name from the list (in the chat, press `r` on its row in `/devices` or `/settings` → `Devices`). In the terminal, `revoke` works on both groups. When you remove a device that holds your chats, it says: ``` desktop was removed — it can no longer sync your chats. @@ -349,7 +348,7 @@ If something reaches for the relay while quiet, it gets the word `sync is waitin The first-run line about the hosted relay is said once, when chats are first copied, after pairing or offering a code. `CODEAF_SYNC_URL=off` keeps even a paired machine from contacting a relay, including pairing with `--via`. -## Where your chats are stored — the hosted relay, turning it off, self-host your own relay, is my code private +## Where are my chats stored — where your chats are stored, the hosted relay, turning it off, self-host your own relay, is my code private 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. @@ -357,14 +356,14 @@ Your chats live on your own computer first. They are also copied, sealed, to a relay, so your other computers can open them. Which relay is one setting, `CODEAF_SYNC_URL`, and there is no sign-in and no account: -- **Unset** uses codeaf's hosted relay (`https://codeaf.agentfield.ai/fabric`) when this build has one, and says so once, in one +- **Unset** uses this build's channel relay when it has one — `https://codeaf.agentfield.ai/fabric` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev` on staging builds, and `https://caf-fabric-dev.instrument-santosh.workers.dev` on dev builds and local builds from source — and says so once, in one line, the first time your chats are copied: `Your chats sync end-to-end encrypted through - codeaf's hosted relay, which only ever sees ciphertext; turn that off with - CODEAF_SYNC_URL=off, or use your own relay with CODEAF_SYNC_URL=<url>.` A computer you - paired with another one uses the relay that pairing saved instead. A build with no - hosted relay has no default, and nothing is copied until you name a relay. + codeaf's fabric (<address>), a sync service that only ever sees ciphertext; turn that off with + CODEAF_SYNC_URL=off, or use your own with CODEAF_SYNC_URL=<url>.` Channels do not sync with each + other. A computer you paired with another one uses the relay that pairing saved instead. A build + with no hosted relay has no default, and nothing is copied until you name a relay. - **`CODEAF_SYNC_URL=off`** turns the copying off. Nothing leaves the computer, and `codeaf pair` - says `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`. + says `sync is off (CODEAF_SYNC_URL=off); pairing needs the sync service`. - **`CODEAF_SYNC_URL=https://relay.example.com`** is your own relay. You run it with `codeaf relay --listen :8787 --store <directory>` (see "Running the relay yourself" on the page *Reaching a machine with a pairing code*). The hosted relay and your own speak the same way, so @@ -384,10 +383,10 @@ A relay is a small service that passes bytes between the two computers. Your cha What the relay does see is short: the number at the start of the code, the time, how long each message is, and the network address of each computer. It never sees the six digits. -Pairing goes through the relay this computer syncs through. The first that answers wins: the one `CODEAF_SYNC_URL` names, else the one an earlier pairing saved, else codeaf's hosted relay (`https://codeaf.agentfield.ai/fabric`) when this build has one. A build with no hosted relay has no default, so there you must name one: set `CODEAF_SYNC_URL` to the address of a relay, or pass it in the command. With no relay at all, `/pair` and `codeaf pair` say: +Pairing goes through the relay this computer syncs through. The first that answers wins: the one `CODEAF_SYNC_URL` names, else the one an earlier pairing saved, else this channel's hosted relay (`https://codeaf.agentfield.ai/fabric` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev` on staging builds, or `https://caf-fabric-dev.instrument-santosh.workers.dev` on dev and local source builds) when this build has one. Channels do not sync with each other, so both computers must run the same channel or point `CODEAF_SYNC_URL` at the same relay. A build with no hosted relay has no default, so there you must name one: set `CODEAF_SYNC_URL` to the address of a relay, or pass it in the command. With no relay at all, `/pair` and `codeaf pair` say: ``` -no sync address is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to a sync address, or pass --via <address> +no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via <address> ``` ## Pair through your own relay, or stop relay traffic with CODEAF_SYNC_URL=off @@ -410,7 +409,7 @@ After the pairing, the other computer saves that relay and syncs through it; whe `CODEAF_SYNC_URL=off` turns sync off. Then pairing is not available. Both commands say so before they send anything: ``` -sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address +sync is off (CODEAF_SYNC_URL=off); pairing needs the sync service ``` ## The pairing relay does not answer, is full or is too old @@ -422,7 +421,7 @@ Other things the relay can say while you pair: The relay cannot be reached. The name is the host you used: ``` -cannot reach relay.example.com — check your internet connection and try again +nothing is answering at https://relay.example.com — if that is your own sync service, start it there with codeaf relay ``` The relay is full. Try again in a few minutes: @@ -519,3 +518,34 @@ A removed computer keeps working locally in a new chat and in a chat it held whe ## Why does codeaf devices fail when the sync service is down If your devices cannot be listed, `codeaf devices` exits with an error explaining that the sync service cannot be reached. It does not say nothing is paired when the listing is unavailable. A connection that times out says the service did not answer in time. + +## Pairing fallback names and other local work + +When the device name cannot be read yet, completion says `Paired with your other device. Run codeaf to open your chats.` Otherwise it names one of your paired computers. + +If a computer with its own saved pairing keys has local work codeaf cannot count as a submitted chat, it keeps that work and says `this computer has local work, so nothing was changed. To pair it anyway, run this again with --replace: local chats stay on this computer but no longer sync; shared memories and settings are cleared`. + + +## Paste a new computer’s link into /pair — adding another device after two + +The chat’s `/pair` card also accepts the link shown on a new computer. It says +`paste the link the new device shows, or on it run codeaf pair 43-209-648` and +shows a `> Paste pairing link` field. The command carries `--via` for your own +sync service. Paste the link and press Enter to see the check number and approve +with `a` or decline with `d`. A link for a different service is refused before +lookup. Settings → Devices → Add another device opens this same card. + +The terminal’s `codeaf pair --code` keeps its code instructions; it has no link +field. Use `codeaf pair approve <link>` there instead. + +## I opened codeaf once without sending a turn — can this computer pair? + +Yes. A computer that has only opened codeaf and quit without sending a turn can pair without `--replace`, including when a provider such as Ollama is connected. Its fetched model lists do not count as local work. Startup logs, locks, the empty chat header, and empty knowledge and automation stores do not count as your work. The name you enter for pairing is kept. + +Once this computer has its own saved pairing keys, a sent turn, tracked work, a chat memory, an automation, a saved knowledge page or a stored conversation makes the computer used. Unknown files or database tables also keep the conservative local-work refusal. Existing personal memories in the graph keep their normal merge behavior. A computer with an unrelated shared account is never silently merged; use the explicit replacement road when appropriate. + +## What happens to chats made with chat --once before the first pairing? + +If this computer has no saved pairing keys yet — for example it has only ever run `codeaf chat --once` — those chats are kept when it first pairs without `--replace`, and pairing does not discard or hide them. They are not sent at the moment of pairing. The next time codeaf's window opens on this computer, each one is saved for syncing and appears on your other computers' Home, where it can be continued. + +A computer that already has its own saved pairing keys follows the local-work refusal and explicit `--replace` rules above. diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 052e7f3b50..fd4385c2b2 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -124,6 +124,19 @@ Shell commands have a narrower signal: pictures declared in `bash`'s `outputs` are already inspected and recorded. Other shell-made files are not discovered automatically; there is no filesystem scan. `share_deliverable` can explicitly hand over an existing file. +## Why did a compact worker reject a write with “write needs text”? + +Chat writes require a string `content` field, including with `append:true`. A +missing or null `content` returns +`Invalid arguments: content is required and must be a string` before changing +files or creating directories. An explicit `content:""` is valid. + +The compact worker toolbelt uses `write` with `path` and `text`, rather than the +chat toolbelt's `path` and `content`. Its `text` field must be a string. A missing, +null or differently typed `text` returns +`write needs text as a string (use text, not content; an empty string is allowed)` +before creating directories or changing a file. Retry with the required `text` +field. An explicit `text:""` intentionally writes an empty file. ## Can you append to a file, or add to the end without rewriting it? diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index 8132011cbf..f5c3701a83 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -649,7 +649,7 @@ the same error is counted against itself. There is no command or panel for it, it is not on the Memories tab, and the noticing setting does not turn it off: the one line on a failed row is the whole of what it says. -## Does /remember work on my other computer — hand edited memories follow my paired computers, memory missing on the new laptop +## Are my memories shared across computers? Does /remember work on my other computer — hand edited memories follow my paired computers, memory missing on the new laptop Person and project memory follow your paired computers through the encrypted vault within one sync round, including hand-edited lines in the Memories place and lines written by older chats. No chat needs to be continued first. Concurrent edits of different lines both survive; for the same line the later edit wins, including forgetting a line. The service receives ciphertext only. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 7997258cff..44b03789b2 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -41,7 +41,46 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // R4 and R3 keep command routes and explicit-choice remedies reachable. {"gh api says not found on Windows", "running-commands-on-windows"}, {"CODEAF_BASH does not exist", "running-commands-on-windows"}, + // Task placement must answer DESIGN contract 16 in the person's words. + {"taskdoor empty frame when starting a task", "tasks-on-another-computer"}, + {"taskdoor a frame needs a type", "tasks-on-another-computer"}, + {"could not check your devices right now", "tasks-on-another-computer"}, + {"could not confirm this is my task computer", "tasks-on-another-computer"}, + {"the task copy could not be sent", "tasks-on-another-computer"}, + {"the task copy cell or head is not readable", "tasks-on-another-computer"}, + {"the task destination already contains files", "tasks-on-another-computer"}, + {"could not put the result in place earlier copy kept", "tasks-on-another-computer"}, + {"the sent task copy fingerprint is missing", "tasks-on-another-computer"}, + {"that task attempt id is not readable", "tasks-on-another-computer"}, + {"that task is already finished", "tasks-on-another-computer"}, + {"that task request is not readable", "tasks-on-another-computer"}, + {"the other computer no longer has this task answer", "tasks-on-another-computer"}, + {"the other computer sent an unreadable task event", "tasks-on-another-computer"}, + {"the task remote record cannot be saved here", "tasks-on-another-computer"}, + {"the task was interrupted before it left an answer", "tasks-on-another-computer"}, + {"could not record the task child", "tasks-on-another-computer"}, + {"the task saved event is too large", "tasks-on-another-computer"}, + {"this computer is already taking tasks", "tasks-on-another-computer"}, + {"can a task run on my other computer", "tasks-on-another-computer"}, + {"why a task appears before its copy is ready", "tasks-on-another-computer"}, + {"paired task computers are unavailable here", "tasks-on-another-computer"}, + {"run a task on my mac mini", "tasks-on-another-computer"}, + {"what does running on mean", "tasks-on-another-computer"}, + {"my other computer stopped answering", "tasks-on-another-computer"}, + {"run it here", "tasks-on-another-computer"}, + {"run tasks on", "tasks-on-another-computer"}, + {"codeaf serve --take-tasks takes tasks", "tasks-on-another-computer"}, + {"does plain codeaf serve take tasks", "tasks-on-another-computer"}, + {"when does the model get the computer argument", "tasks-on-another-computer"}, + {"why did my task run here instead", "tasks-on-another-computer"}, + {"send a task to my dev box", "a-dev-box-on-your-own-railway"}, + {"can this conversation start other tasks while one runs on another computer", "tasks-on-another-computer"}, {"why didn't my task's changes show up in my folder", "how-tasks-run"}, + {"can I run codeaf on my own server", "a-dev-box-on-your-own-railway"}, + {"set up a dev box", "a-dev-box-on-your-own-railway"}, + {"always-on computer in the cloud", "a-dev-box-on-your-own-railway"}, + {"how do I remove my dev box", "a-dev-box-on-your-own-railway"}, + {"how much does a dev box cost", "a-dev-box-on-your-own-railway"}, // R6/R7/R8/R15/R30 keep leadership, fallback and worker limits reachable. {"can my phone lead", "coordination-and-workers"}, {"can my phone be the leader", "coordination-and-workers"}, @@ -58,6 +97,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // R25/R29 keep older-client refusals and failed quit saves reachable. {"why can an older computer list chats but not continue a chat that is still running", "continuing-a-chat-on-another-computer"}, {"what happens if the final save fails during quit", "continuing-a-chat-on-another-computer"}, + // #2070 keeps continue-here troubleshooting, duplicate Home rows and sync repair reachable. + {"continue here does nothing", "continuing-a-chat-on-another-computer"}, + {"the same chat twice on Home", "home"}, + {"why do I see the same chat twice on Home", "home"}, + {"repair sync", "continuing-a-chat-on-another-computer"}, + {"how do I repair sync", "continuing-a-chat-on-another-computer"}, // C29.4/C30.2/C31.5 must be reachable in the person's own words. {"can I rewind an old chat on my new laptop", "continuing-a-chat-on-another-computer"}, {"I quit on my laptop and opened my desktop", "continuing-a-chat-on-another-computer"}, @@ -104,6 +149,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I switch teams from the tab strip", "conversations-and-teams"}, {"does deleting a team close its conversations", "conversations-and-teams"}, {"where are my teams saved", "conversations-and-teams"}, + {"do my teams show on my other computer?", "conversations-and-teams"}, + {"how do I turn on team sharing", "conversations-and-teams"}, + {"will deleting a team on my laptop delete it on my desktop?", "conversations-and-teams"}, + {"does Traffic sync between computers?", "conversations-and-teams"}, + {"do my crew settings follow me to my other computer?", "models-and-cost"}, {"create an empty team and add several saved conversations", "teams-page"}, {"how do I choose another manager before deleting its conversation", "teams-page"}, {"where is the model name and working spinner on team cards", "teams-page"}, @@ -3233,6 +3283,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"what does leave it there do", "continuing-a-chat-on-another-computer"}, {"when was a device last seen", "add-or-remove-a-computer"}, {"what does the removed computer see after I remove it", "add-or-remove-a-computer"}, + {"can I remove a device from settings", "add-or-remove-a-computer"}, + {"where are my computers in the settings page", "add-or-remove-a-computer"}, + {"how do I revoke a computer in settings", "add-or-remove-a-computer"}, + {"add another device from settings", "add-or-remove-a-computer"}, {"what happens to node_modules when I move a chat", "home"}, {"are my job logs moved with the chat", "home"}, {"does the task history come with the chat to another computer", "home"}, @@ -4571,6 +4625,10 @@ func TestTheDeviceQuestionsReachTheirPages(t *testing.T) { {"what is the check number on the new device", add, "Check number 4821. Approve only if"}, {"what does codeaf pair --code do", add, "codeaf pair --code"}, {"how do I remove a computer", add, "r remove"}, + {"can I remove a device from settings", add, "between `Connections` and `Privacy`"}, + {"where are my computers in the settings page", add, "between `Connections` and `Privacy`"}, + {"how do I revoke a computer in settings", add, "between `Connections` and `Privacy`"}, + {"add another device from settings", add, "`enter add` on `Add another device` leaves `/settings`"}, {"what does the dot next to a computer mean", add, "○ desk (offline)"}, {"I was told this computer was removed", add, "this device was removed by another of your devices"}, {"how do I rename this computer", naming, "codeaf devices rename \"atlas\""}, @@ -4591,9 +4649,35 @@ func TestTheDeviceQuestionsReachTheirPages(t *testing.T) { } } +func TestTheTeamSharingQuestionExplainsTheLocalOptIn(t *testing.T) { + for _, section := range Chat().Search("do my teams show on my other computer?", DefaultResults) { + if section.Page != "conversations-and-teams" { + continue + } + complete := true + for _, fact := range []string{ + "Settings → General → share teams", "off by default", + "Both paired computers must turn it on", "teams, members", + "team settings", "recent Traffic as read-only history", + } { + complete = complete && strings.Contains(section.Body, fact) + } + if complete { + return + } + } + t.Fatal("the team sharing question must reach one section explaining the local opt-in and what it shares") +} + // C30.5 replaces the old chat-only memory promise with independent encrypted sync. func TestTheCrossComputerQuestionsReachTheFactThatAnswersThem(t *testing.T) { for _, probe := range []struct{ asked, page, says string }{ + {"do my teams show on my other computer?", "conversations-and-teams", "within a few seconds"}, + {"how do I turn on team sharing", "conversations-and-teams", "off by default"}, + {"how do I turn on team sharing", "conversations-and-teams", "Settings → General → share teams"}, + {"will deleting a team on my laptop delete it on my desktop?", "conversations-and-teams", "a deleted team stays deleted"}, + {"does Traffic sync between computers?", "conversations-and-teams", "32 lines per team"}, + {"do my crew settings follow me to my other computer?", "models-and-cost", "provider route and providers you turned off stay on each computer"}, {"is my code sent anywhere before I pair", "use-this-on-another-computer", "no device record reaches the relay"}, {"what does sync is waiting until another machine is added mean", "use-this-on-another-computer", "That is not a fault."}, {"how do I pair a server with no browser", "add-or-remove-a-computer", "the bare token `<code>.<key>`"}, @@ -4610,6 +4694,9 @@ func TestTheCrossComputerQuestionsReachTheFactThatAnswersThem(t *testing.T) { {"how do I take a chat back from my other computer", "continuing-a-chat-on-another-computer", "Nothing has to be returned or switched off first."}, {"what happens if two computers continue the same chat at once", "continuing-a-chat-on-another-computer", "The slower one sees `another device continued this chat first`"}, {"where does sync go by default", "relay-hosted-or-your-own", "https://codeaf.agentfield.ai/fabric"}, + {"which relay does a dev build or staging build use", "relay-hosted-or-your-own", "https://caf-fabric-dev.instrument-santosh.workers.dev"}, + {"do dev and stable builds sync with each other", "relay-hosted-or-your-own", "Channels do not sync with each other"}, + {"sync off and my chats are gone", "home", "Home still lists every chat on this computer"}, } { found := false for _, section := range Chat().Search(probe.asked, DefaultResults) { @@ -4668,3 +4755,41 @@ func TestWindowsManualQuotesTheShellRefusal(t *testing.T) { } } } + +// TestSyncOffManualQuotesExactSentences pins the retrieval probe and exact +// person-facing sentences for #2072 (sync off keeps every chat). +func TestSyncOffManualQuotesExactSentences(t *testing.T) { + for _, probe := range []struct { + asked string + page string + heading string + quotes []string + }{ + { + asked: "sync off and my chats are gone", + page: "home", + heading: "Sync off and my chats are gone", + quotes: []string{ + "CODEAF_SYNC_URL=off", + "chats from older versions alike", + "each once, and each opens and can be written", + }, + }, + } { + t.Run(probe.asked, func(t *testing.T) { + results := Chat().Search(probe.asked, DefaultResults) + for _, section := range results { + if section.Page != probe.page || !strings.HasPrefix(section.Title, probe.heading) { + continue + } + for _, quote := range probe.quotes { + if !strings.Contains(section.Body, quote) { + t.Errorf("section %q on %s missing exact quote %q", section.Title, section.Page, quote) + } + } + return + } + t.Fatalf("%q did not retrieve %q on %s within %d results", probe.asked, probe.heading, probe.page, DefaultResults) + }) + } +} diff --git a/internal/manual/chatdisplaytruth_test.go b/internal/manual/chatdisplaytruth_test.go index e781211276..5b8967fc35 100644 --- a/internal/manual/chatdisplaytruth_test.go +++ b/internal/manual/chatdisplaytruth_test.go @@ -83,6 +83,8 @@ func TestNoSectionRepeatsStaleHelpSettingsGreetingOrManagerClaims(t *testing.T) "The nine tabs need", "walk the nine one at a time", "Tasks · Providers · Connections", + "Connections · Privacy", + "Spending, Connections and Privacy", "a plain launch opens straight on home", "deterministic `@manager-...` fallback", } @@ -150,7 +152,7 @@ func TestManualStatesHelpSettingsGreetingAndManagerDisplayTruth(t *testing.T) { }{ {"commands", "codeaf <command> --help — asking one command what it takes, which is not a failure", []string{"run `codeaf --help` for every command, `codeaf help env` for the variables."}}, {"commands", "codeaf --help, and --help on any command — what does this command take, what are its flags, how do I see the usage", []string{"`codeaf help env`", "every variable and its default"}}, - {"commands", "Settings categories — where did Session, Context, Workspace, Display and Providers go", []string{"General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Privacy", "Old saved keys and values are unchanged"}}, + {"commands", "Settings categories — where did Session, Context, Workspace, Display and Providers go", []string{"General · Models · Memory · Tasks · AI teams · Permissions · Spending · Connections · Devices · Privacy", "Old saved keys and values are unchanged"}}, {"models-and-cost", "Where are the spending limits — the Spending tab, and every door onto it", []string{"Tasks · AI teams · Permissions"}}, {"getting-started", "Getting started — first time setup, what happens the first time I run codeaf", []string{"nothing elsewhere", "What would you like to work on?", "home", "`--no-host`"}}, {"team-manager", "Making a manager", []string{"team's name", "`@second`", "`@lead`", "All teams"}}, diff --git a/internal/manual/devbox_test.go b/internal/manual/devbox_test.go new file mode 100644 index 0000000000..b00903237c --- /dev/null +++ b/internal/manual/devbox_test.go @@ -0,0 +1,31 @@ +package manual + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/packed" +) + +// Contract 7: the shipped compression path preserves answers to dev box questions. +func TestDevboxPackedManualAnswersTheSameQuestions(t *testing.T) { + archive, err := packed.Pack("chat") + if err != nil { + t.Fatal(err) + } + if err := packed.Verify(archive, "chat"); err != nil { + t.Fatal(err) + } + corpus := newCorpus(packed.New(archive), "chat/*.md") + const page = "a-dev-box-on-your-own-railway" + for _, question := range []string{"can I run codeaf on my own server", "set up a dev box", "always-on computer in the cloud", "how do I remove my dev box", "how much does a dev box cost"} { + found := false + for _, section := range corpus.Search(question, DefaultResults) { + if section.Page == page { + found = true + } + } + if !found { + t.Errorf("packed lookup for %q misses %s", question, page) + } + } +} diff --git a/internal/manual/relay_defects_test.go b/internal/manual/relay_defects_test.go new file mode 100644 index 0000000000..7562df1a87 --- /dev/null +++ b/internal/manual/relay_defects_test.go @@ -0,0 +1,47 @@ +package manual + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/packed" +) + +func TestRelayDefectAnswersSurvivePacking(t *testing.T) { + archive, err := packed.Pack("chat") + if err != nil { + t.Fatal(err) + } + if err := packed.Verify(archive, "chat"); err != nil { + t.Fatal(err) + } + compressed := newCorpus(packed.New(archive), "chat/*.md") + for _, corpus := range []*Corpus{Chat(), compressed} { + for _, probe := range []struct{ query, page, words string }{ + {"my chosen chat while a startup chat is still arriving", "home", "continuing <Y> next; <X> is still on its way here"}, + {"slow chat listing timeout waiting for a service host", "continuing-a-chat-on-another-computer", "<host> did not answer in 25s; still trying"}, + {"my typed message after continue here is kept does it send by itself", "continuing-a-chat-on-another-computer", "your message is still in the box; press enter to send it"}, + {"continue here another device continuing first picked this chat up again", "continuing-a-chat-on-another-computer", "<holder> picked this chat up again; press enter to continue it here"}, + {"relay trust proxy registration dial pairing X-Forwarded-For", "relay-hosted-or-your-own", "registration, dial and pairing limits"}, + } { + found := false + for _, section := range corpus.Search(probe.query, DefaultResults) { + if section.Page == probe.page && strings.Contains(section.Body, probe.words) { + found = true + } + } + if !found { + t.Errorf("%q did not reach %s saying %q", probe.query, probe.page, probe.words) + } + } + page, _ := corpus.Page("continuing-a-chat-on-another-computer") + for _, part := range strings.Split(page, "\n## ")[1:] { + heading, _, _ := strings.Cut(part, "\n") + if strings.HasPrefix(heading, "my choice while") || strings.HasPrefix(heading, "My typed message") || strings.HasPrefix(heading, "slow chat listing") || strings.HasPrefix(heading, "Continue here does nothing") || strings.HasPrefix(heading, "why continue here failed") { + if len([]rune(part)) > 2000 { + t.Errorf("section exceeds 2000 characters: %s", heading) + } + } + } + } +} diff --git a/internal/manual/task_refusals_test.go b/internal/manual/task_refusals_test.go new file mode 100644 index 0000000000..70d072972a --- /dev/null +++ b/internal/manual/task_refusals_test.go @@ -0,0 +1,37 @@ +package manual + +import ( + "os" + "strings" + "testing" +) + +// R7-16 and contract 16 keep the door's actionable refusals in the shipped chat manual. +func TestTaskComputerRefusalsExplainWhatToDo(t *testing.T) { + raw, err := os.ReadFile("chat/tasks-on-another-computer.md") + if err != nil { + t.Fatal(err) + } + for _, sentence := range []string{ + "taskdoor: empty frame", "taskdoor: a frame needs a type", "could not check your devices right now", "could not confirm this computer", "could not confirm this is <computer>", + "this computer is already taking tasks", "that task attempt id is not readable", "that task is still stopping", "that task has already finished", + "that task request is not readable", "choose landed or abandoned for this task", + "the task copy's cell is not readable", "the task copy's head is not readable", "task copies need private storage and a relay blob store", + "the task copy changed while it was being sent; try again", "the task's destination already contains files", + "could not put the result in place; the earlier copy is kept at <path>: <reason>", "the sent task copy's fingerprint is missing", + "a home folder or a folder above it cannot be sent as a task copy", + "the task's remote record cannot be saved here", "the task's remote record is missing", "the other computer no longer has this task's answer", + "the other computer sent an unreadable task event", "The result could not be brought from <computer>; the copy here was kept", + "the task was interrupted on this computer before it left an answer", "the task ended without an answer", "the task ended without a result", + "could not record the task's child: <reason>", "the task's saved event is too large", "The work could not be sent back: <reason>", + "the recorded process does not name this task's work folder", "the recorded process does not name this task's private record", + "the child's process start is not readable", "the recorded process is not this task's child", "this task's child is no longer running", + "could not save the task result: <reason>", "the returned task copy must be landed by this computer", + } { + t.Run(sentence, func(t *testing.T) { + if !strings.Contains(string(raw), "`"+sentence+"`") { + t.Errorf("missing exact refusal and recovery guidance: %s", sentence) + } + }) + } +} diff --git a/internal/pair/book.go b/internal/pair/book.go index 01f90198bb..118a42fd70 100644 --- a/internal/pair/book.go +++ b/internal/pair/book.go @@ -16,10 +16,13 @@ package pair // Deleting it costs a person nothing except having to pair again. import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/fsreplace" "os" "path/filepath" @@ -31,8 +34,10 @@ import ( // Known is one machine this device has paired with, as the surface remembers it. type Known struct { - // Name is what a person types after --at. + // Name is the key-derived name registered with the relay. Name string `json:"name"` + // Alias is a chosen dev box name pinned by its Terraform configuration. + Alias string `json:"alias,omitempty"` // Key is the machine's long-term public key, base64 raw-url. THIS IS THE // PINNED THING: a connection is to this key, and the name is only how the // relay finds it. @@ -92,7 +97,7 @@ func (b *Book) Machine(name string) (Known, bool, error) { return Known{}, false, err } for _, one := range known { - if one.Name == name { + if one.Name == name || (one.Alias != "" && one.Alias == name) { return one, true, nil } } @@ -110,7 +115,7 @@ func (b *Book) Remember(one Known) error { return b.update(func(known []Known) []Known { kept := known[:0] for _, existing := range known { - if existing.Name != one.Name { + if existing.Name != one.Name && (one.Alias == "" || existing.Alias != one.Alias) { kept = append(kept, existing) } } @@ -182,7 +187,7 @@ func (b *Book) Revoke(label string) (Paired, error) { } var hits []Paired for _, one := range paired { - if strings.EqualFold(one.Label, label) { + if devname.MatchesDevice(label, one.Label, one.ID()) { hits = append(hits, one) } } @@ -191,7 +196,11 @@ func (b *Book) Revoke(label string) (Paired, error) { return Paired{}, fmt.Errorf("no device called %q is paired with this machine — `codeaf devices` lists the ones that are", label) case 1: default: - return Paired{}, fmt.Errorf("%d devices are called %q — this build can only stop one by name, so revoke them all with `codeaf devices revoke --all %s`", len(hits), label, label) + choices := make([]string, len(hits)) + for i, one := range hits { + choices[i] = devname.DeviceLabel(one.Label, one.ID(), true) + } + return Paired{}, devname.Ambiguous(label, choices) } gone := hits[0] err = b.updateDevices(func(paired []Paired) []Paired { @@ -306,3 +315,10 @@ func since(at, now time.Time) string { return at.Format("2 Jan 2006") } } + +// ID supplies a stable selector for a remote-access key without printing the key. +func (p Paired) ID() string { + raw, _ := base64.RawURLEncoding.DecodeString(p.Key) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:16]) +} diff --git a/internal/pair/boxlink.go b/internal/pair/boxlink.go index c92c772573..1b3b9475d1 100644 --- a/internal/pair/boxlink.go +++ b/internal/pair/boxlink.go @@ -11,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/pair/cpace" "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // chatProtocol labels the wire that carries a person's chats. It is mixed into @@ -142,7 +143,7 @@ func phraseBox(err error, host string) error { case errors.Is(err, context.Canceled): return err case errors.As(err, &netErr): - return CannotReachHost(host) + return cannotReachError{syncwords.Explain(err, host).Error()} } - return err + return syncwords.Explain(err, host) } diff --git a/internal/pair/channel_link_test.go b/internal/pair/channel_link_test.go new file mode 100644 index 0000000000..c76336f31d --- /dev/null +++ b/internal/pair/channel_link_test.go @@ -0,0 +1,62 @@ +package pair_test + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// Each channel prints pairing links on its own hosted origin, treats links from +// another channel as a foreign service, and still obeys a self-hosted Via. +func TestPairingLinksFollowTheReleaseChannel(t *testing.T) { + key, err := pair.NewLinkKey() + if err != nil { + t.Fatal(err) + } + ref := pair.LinkRef{Code: "k7m2q9xd", Key: key} + cases := []struct { + name string + revision string + want string + foreign string + }{ + {"stable", "v0.3.0", syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL), syncsetup.DevRelayURL}, + {"rc", "v0.3.0-rc.1", syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL), syncsetup.StagingRelayURL}, + {"staging", "staging-20261030-abcdef012345", syncsetup.StagingRelayURL, syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL)}, + {"dev", "dev-20261030-abcdef012345", syncsetup.DevRelayURL, syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL)}, + {"untagged", "deadbeef", syncsetup.DevRelayURL, syncsetup.LinkOriginOf(syncsetup.ProductionRelayURL)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + origin := syncsetup.HostedLinkOriginFor(tc.revision) + if origin != tc.want { + t.Fatalf("HostedLinkOriginFor(%q) = %q, want %q", tc.revision, origin, tc.want) + } + was := pair.LinkOrigin + pair.LinkOrigin = origin + t.Cleanup(func() { pair.LinkOrigin = was }) + + if !strings.HasPrefix(ref.URL(), origin+"/p/"+ref.Code+"#") { + t.Fatalf("URL() = %q, want prefix %q", ref.URL(), origin+"/p/"+ref.Code+"#") + } + lines := pair.InviteLines(pair.Invite{Ref: ref, Check: "4821", ExpiresIn: 10 * time.Minute}) + if !strings.Contains(lines, ref.URL()) { + t.Fatalf("InviteLines lacks channel link %q:\n%s", ref.URL(), lines) + } + if got := pair.LinkRelay(ref.URL()); got != "" { + t.Fatalf("LinkRelay(%q) = %q, want empty for own channel", ref.URL(), got) + } + foreignLink := ref.URLAt(tc.foreign) + if got := pair.LinkRelay(foreignLink); got != tc.foreign { + t.Fatalf("LinkRelay(%q) = %q, want foreign origin %q", foreignLink, got, tc.foreign) + } + custom := pair.InviteLines(pair.Invite{Ref: ref, Via: "https://own.example/relay", Check: "4821", ExpiresIn: 10 * time.Minute}) + if strings.Contains(custom, origin) || !strings.Contains(custom, "https://own.example/relay/p/"+ref.Code) { + t.Fatalf("custom Via did not override channel origin:\n%s", custom) + } + }) + } +} diff --git a/internal/pair/code.go b/internal/pair/code.go index 4397d7a09f..4530da2936 100644 --- a/internal/pair/code.go +++ b/internal/pair/code.go @@ -209,13 +209,17 @@ func codeLines(what, how string) string { return fmt.Sprintf(" %s\n %s (valid %d minutes)\n", what, how, int(CodeValidFor/time.Minute)) } +const shareChatsWord = "this shares your chats with the device you pair" + // ChatLines is what a device that is sharing its chats shows: the grant on the // line above the code, then the command that takes it. A relay that is not the // default is named in the command, so the other device never has to guess it. func ChatLines(code *Code, relay string) string { - command := "codeaf pair " + code.Shown() - if relay != "" { - command += " --via " + relay - } - return codeLines("this shares your chats with the device you pair", "on it run "+command) + command := Command(relay, code.Shown()) + return codeLines(shareChatsWord, "on it run "+command) +} + +// ChatCardLines offers the chat's two directions; terminals have no link field. +func ChatCardLines(code *Code, relay string) string { + return codeLines(shareChatsWord, "paste the link the new device shows, or on it run "+Command(relay, code.Shown())) } diff --git a/internal/pair/command.go b/internal/pair/command.go new file mode 100644 index 0000000000..34261e310d --- /dev/null +++ b/internal/pair/command.go @@ -0,0 +1,80 @@ +package pair + +import ( + "fmt" + "net" + "net/url" + "strings" + + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// Command builds every instruction to pair a new device through this service. +func Command(address, code string) string { + command := "codeaf pair" + if code != "" { + command += " " + code + } + if address != "" && !syncwords.Hosted(address) { + command += " --via " + address + } + return command +} + +// CheckLinkService refuses before reading a request on another service. Tokens +// name no service and deliberately use the one selected on this computer. +func CheckLinkService(typed, address string) error { + via := linkService(typed) + if via == "" { + return nil + } + if canonicalService(via) == canonicalService(address) { + return nil + } + return fmt.Errorf("that link is waiting on %s, but this computer syncs through %s — on the new device run: %s", syncwords.Host(via), syncwords.Host(address), Command(address, "")) +} + +// linkService is the service a pasted link waits on. LinkRelay leaves this +// build's own hosted service unnamed, because a hosted computer keeps its +// configured service; a computer on its own service must still see that a +// hosted link is waiting somewhere else, however the origin was spelled. +func linkService(typed string) string { + u, err := url.Parse(strings.TrimSpace(typed)) + if err != nil { + return "" + } + if u.Scheme == "codeaf" { + if via := u.Query().Get("via"); via != "" { + return via + } + return syncwords.HostedURL + } + if webRelay(u) && strings.HasPrefix(u.Path, "/p/") && LinkOrigin != "" && + canonicalService(u.Scheme+"://"+u.Host) == canonicalService(LinkOrigin) { + return syncwords.HostedURL + } + return LinkRelay(typed) +} + +// canonicalService normalizes URL spelling without erasing a service's path or protocol. +func canonicalService(address string) string { + u, err := url.Parse(strings.TrimSpace(address)) + if err != nil || u.Host == "" { + return strings.TrimRight(address, "/") + } + u.Scheme = strings.ToLower(u.Scheme) + host, port := strings.ToLower(u.Hostname()), u.Port() + if u.Scheme == "https" && port == "443" || u.Scheme == "http" && port == "80" { + port = "" + } + u.Host = host + if strings.Contains(host, ":") { + u.Host = "[" + host + "]" + } + if port != "" { + u.Host = net.JoinHostPort(host, port) + } + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = strings.TrimRight(u.RawPath, "/") + return u.String() +} diff --git a/internal/pair/deadline.go b/internal/pair/deadline.go index 71dcb701ba..906a91c4e4 100644 --- a/internal/pair/deadline.go +++ b/internal/pair/deadline.go @@ -2,7 +2,7 @@ package pair import ( "errors" - "fmt" + "github.com/Agent-Field/codeaf/internal/syncwords" "net/url" ) @@ -24,7 +24,7 @@ type serviceTimeout struct { } func (e serviceTimeout) Error() string { - return fmt.Sprintf("the sync service at %s did not answer in time — try again", e.address) + return syncwords.Explain(e.cause, e.address).Error() } func (e serviceTimeout) Unwrap() error { return e.cause } diff --git a/internal/pair/devbox.go b/internal/pair/devbox.go new file mode 100644 index 0000000000..4f49ab545d --- /dev/null +++ b/internal/pair/devbox.go @@ -0,0 +1,64 @@ +package pair + +import ( + "encoding/base64" + "os" + "path/filepath" + "time" +) + +// DevboxRemote prepares released serve's key and admissions as Terraform configuration. +// Running the existing writers in a private temporary folder keeps their formats identical. +func DevboxRemote(name, service string, client Device, label string) (key, devices []byte, known Known, err error) { + dir, err := os.MkdirTemp("", "codeaf-devbox-remote-*") + if err != nil { + return nil, nil, Known{}, err + } + defer os.RemoveAll(dir) + keeper := FileKeeper{Path: filepath.Join(dir, "device.key")} + machine, err := ThisDevice(keeper) + if err != nil { + return nil, nil, Known{}, err + } + now := time.Now() + bookPath := filepath.Join(dir, "devices.json") + if err := BookAt(bookPath).Admit(Paired{Label: label, Key: base64.RawURLEncoding.EncodeToString(client.Public()), Since: now}); err != nil { + return nil, nil, Known{}, err + } + key, err = os.ReadFile(keeper.Path) + if err != nil { + return nil, nil, Known{}, err + } + devices, err = os.ReadFile(bookPath) + if err != nil { + return nil, nil, Known{}, err + } + return key, devices, Known{Name: machine.Name(), Alias: name, Key: base64.RawURLEncoding.EncodeToString(machine.Public()), Service: service, Since: now}, nil +} + +// ForgetAlias drops the creating laptop's pin after its dev box is destroyed. +func (b *Book) ForgetAlias(name string) error { + known, err := b.Machines() + if err != nil { + return err + } + found := false + for _, one := range known { + if one.Alias == name { + found = true + break + } + } + if !found { + return nil + } + return b.update(func(known []Known) []Known { + kept := known[:0] + for _, one := range known { + if one.Alias != name { + kept = append(kept, one) + } + } + return kept + }) +} diff --git a/internal/pair/devbox_alias_test.go b/internal/pair/devbox_alias_test.go new file mode 100644 index 0000000000..d5340f4240 --- /dev/null +++ b/internal/pair/devbox_alias_test.go @@ -0,0 +1,216 @@ +package pair + +import ( + "context" + "encoding/base64" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/relay" +) + +// Contract 3: --at resolves the chosen alias to a pinned key through a real relay. +func TestDevboxAliasOpensItsPinnedRemoteComputer(t *testing.T) { + service := devboxTestRelay(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := ThisDevice(FileKeeper{Path: filepath.Join(t.TempDir(), "client.key")}) + if err != nil { + t.Fatal(err) + } + key, devices, known, err := DevboxRemote("build-box", service, client, "laptop") + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + dir := filepath.Join(home, "v3", "remote") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + for name, raw := range map[string][]byte{"device.key": key, "devices.json": devices} { + if err := os.WriteFile(filepath.Join(dir, name), raw, 0o600); err != nil { + t.Fatal(err) + } + } + machine, err := ThisDevice(FileKeeper{Path: filepath.Join(home, "v3", "remote", "device.key")}) + if err != nil { + t.Fatal(err) + } + known.Service = service + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(known); err != nil { + t.Fatal(err) + } + reachable := make(chan struct{}, 1) + desk := &Desk{} + host := &Host{Service: service, Device: machine, Devices: BookAt(filepath.Join(home, "v3", "remote", "devices.json")), Desk: desk, Say: func(string) { + select { + case reachable <- struct{}{}: + default: + } + }, Open: func(conn io.ReadWriteCloser) { defer conn.Close(); _, _ = io.WriteString(conn, "hello from the box") }} + ended := make(chan error, 1) + go func() { ended <- host.Run(ctx) }() + defer func() { + cancel() + select { + case err := <-ended: + if err != nil { + t.Error(err) + } + case <-time.After(time.Second): + t.Error("host did not stop") + } + }() + select { + case <-reachable: + case <-ctx.Done(): + t.Fatal("host never registered") + } + tunnel, err := (Reach{Name: "build-box", Device: client, Machines: book, Label: "laptop"}).Open(ctx) + if err != nil { + t.Fatal(err) + } + defer tunnel.Close() + message, err := io.ReadAll(tunnel) + if err != nil || string(message) != "hello from the box" { + t.Fatalf("remote message %q %v", message, err) + } + stranger := aDevice(t) + // Knowing the host's key does not admit a different client key. + if conn, err := (Reach{Name: "build-box", Device: stranger, Machines: book, Label: "other"}).Open(ctx); err == nil { + conn.Close() + t.Fatal("unknown client was admitted") + } + code, err := desk.Offer() + if err != nil { + t.Fatal(err) + } + otherBook := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if conn, err := (Reach{Name: machine.Name(), Sync: service, Device: stranger, Machines: otherBook, Label: "other", AskCode: func(string) (string, error) { return code.Shown(), nil }}).Open(ctx); err == nil { + conn.Close() + t.Fatal("headless host admitted a new client") + } else if !strings.Contains(err.Error(), "was refused there") { + t.Fatalf("headless admission: %v", err) + } +} + +func devboxTestRelay(t *testing.T, handlers ...http.Handler) string { + t.Helper() + var listener net.Listener + for port := 8900; port <= 8999; port++ { + opened, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err == nil { + listener = opened + break + } + } + if listener == nil { + t.Fatal("no allowed test relay port is free") + } + var handler http.Handler = &relay.Server{} + if len(handlers) > 0 { + handler = handlers[0] + } + server := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second} + t.Cleanup(func() { _ = server.Close() }) + go func() { _ = server.Serve(listener) }() + return "http://" + listener.Addr().String() + +} + +// Round 2: the status probe's short context also bounds an established but silent handshake. +func TestDevboxAliasProbeHonorsCancellationDuringHandshake(t *testing.T) { + service := devboxTestRelay(t) + machine, client := aDevice(t), aDevice(t) + registration, err := relay.Register(context.Background(), service, machine.Private()) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(Known{Name: machine.Name(), Alias: "box", Service: service, Key: base64.RawURLEncoding.EncodeToString(machine.Public())}); err != nil { + t.Fatal(err) + } + accepted := make(chan struct{}) + go func() { + conn, err := registration.Accept() + if err == nil { + defer conn.Close() + var intent [1]byte + if _, err := io.ReadFull(conn, intent[:]); err != nil { + return + } + if _, err := readRecord(conn); err != nil { + return + } + close(accepted) + io.Copy(io.Discard, conn) + } + }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + tunnel, err := (Reach{Name: "box", Device: client, Machines: book}).Open(ctx) + if tunnel != nil { + tunnel.Close() + } + done <- err + }() + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("probe never dialed") + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("silent machine answered") + } + case <-time.After(time.Second): + t.Fatal("probe ignored cancellation") + } +} + +// Round 2: a legacy relay's raw HTTP upgrade must obey the same short probe deadline. +func TestDevboxAliasProbeBoundsLegacyRelayUpgrade(t *testing.T) { + service := devboxTestRelay(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + w.WriteHeader(http.StatusUpgradeRequired) + return + } + <-r.Context().Done() + })) + machine, client := aDevice(t), aDevice(t) + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(Known{Name: machine.Name(), Alias: "box", Service: service, Key: base64.RawURLEncoding.EncodeToString(machine.Public())}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { + tunnel, err := (Reach{Name: "box", Device: client, Machines: book}).Open(ctx) + if tunnel != nil { + tunnel.Close() + } + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("silent relay answered") + } + case <-time.After(time.Second): + t.Fatal("legacy relay ignored probe deadline") + } +} diff --git a/internal/pair/devbox_test.go b/internal/pair/devbox_test.go new file mode 100644 index 0000000000..690122da7f --- /dev/null +++ b/internal/pair/devbox_test.go @@ -0,0 +1,54 @@ +package pair + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +// Round 2: Terraform receives the exact files released serve loads, independently of identity pairing. +func TestDevboxRemoteFilesLoadWithServeReaders(t *testing.T) { + client, err := ThisDevice(FileKeeper{Path: filepath.Join(t.TempDir(), "client.key")}) + if err != nil { + t.Fatal(err) + } + key, devices, known, err := DevboxRemote("build-box", "https://relay.example", client, "laptop") + if err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + if err := os.MkdirAll(Dir(), 0o700); err != nil { + t.Fatal(err) + } + for path, raw := range map[string][]byte{DeviceKeyPath(): key, filepath.Join(Dir(), "devices.json"): devices} { + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + } + machine, err := ThisDevice(OpenKeeper()) + if err != nil { + t.Fatal(err) + } + if machine.Name() != known.Name || base64.RawURLEncoding.EncodeToString(machine.Public()) != known.Key || known.Alias != "build-box" || known.Service != "https://relay.example" { + t.Fatalf("wrong pin: %+v", known) + } + allowed, ok, err := DeviceBook().Allows(client.Public()) + if err != nil || !ok || allowed.Label != "laptop" { + t.Fatalf("admission: %+v %v %v", allowed, ok, err) + } + if raw, err := os.ReadFile(DeviceKeyPath()); err != nil || !bytes.Equal(raw, key) { + t.Fatal("serve regenerated its key", err) + } + book := BookAt(filepath.Join(t.TempDir(), "machines.json")) + if err := book.Remember(known); err != nil { + t.Fatal(err) + } + if err := book.ForgetAlias("build-box"); err != nil { + t.Fatal(err) + } + if _, found, err := book.Machine("build-box"); err != nil || found { + t.Fatalf("destroyed alias retained: %v %v", found, err) + } +} diff --git a/internal/pair/dial.go b/internal/pair/dial.go index f7ad76784e..c8f7f5f095 100644 --- a/internal/pair/dial.go +++ b/internal/pair/dial.go @@ -21,13 +21,14 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // The three lines a pairing shows, kept here so that the code that prints them // and the manual page that quotes them cannot drift apart. -// PairingWeight is what a person reads BEFORE they type a code. It is the whole -// security posture of this feature in one sentence, in a person's words. +// PairingWeight tells the approver what accepting a device permits, before the +// decision. That permission belongs beside the choice that grants it. // // A PAIRED DEVICE IS A HAND ON THAT MACHINE'S TOOLS. Saying so at the moment of // the decision is not a disclaimer; it is the only moment at which the sentence @@ -72,6 +73,12 @@ type Reach struct { AskCode func(name string) (string, error) // Say is where the pairing's own lines go. Nil says nothing. Say func(string) + // WeightOnApprover selects the terminal presentation whose approver explains + // the permission. Older callback consumers retain their original notice sequence. + WeightOnApprover bool + // Sync is the relay this device syncs through, used when nothing more + // particular names one; see [Relay]. + Sync string // Now is the clock, swapped by tests. Now func() time.Time } @@ -95,14 +102,17 @@ func (r Reach) say(line string) { // ways this fails that a person cannot tell apart by looking, and errors.go // keeps one sentence for each. func (r Reach) Open(ctx context.Context) (*Tunnel, error) { - if !relay.ValidName(r.Name) { - return nil, fmt.Errorf("%q is not the shape of a machine name — they look like otter-lamp-42, and `codeaf serve` prints the one for a machine", r.Name) - } known, found, err := r.Machines.Machine(r.Name) if err != nil { return nil, err } - service := RelayFor(known, found) + if found && known.Alias == r.Name { + r.Name = known.Name + } + if !relay.ValidName(r.Name) { + return nil, fmt.Errorf("%q is not the shape of a machine name — they look like otter-lamp-42, and `codeaf serve` prints the one for a machine", r.Name) + } + service := RelayFor(known, found, r.Sync) if strings.TrimSpace(service) == "" { return nil, NoRelay(r.Name) } @@ -127,10 +137,20 @@ func (r Reach) Open(ctx context.Context) (*Tunnel, error) { if err != nil { return nil, err } - withDeadline(stream, r.now().Add(HandshakeWithin)) + // A status probe may cancel after dialing; the stream otherwise outlives its dial context. + stopClosing := context.AfterFunc(ctx, func() { _ = stream.Close() }) + defer stopClosing() + deadline := r.now().Add(HandshakeWithin) + if until, ok := ctx.Deadline(); ok && until.Before(deadline) { + deadline = until + } + withDeadline(stream, deadline) tunnel, err := connectAsSurface(stream, machineKey, r.Device, r.Label) if err != nil { _ = stream.Close() + if ctx.Err() != nil { + return nil, ctx.Err() + } if errors.Is(err, ErrNotThatMachine) { return nil, NotThatMachine(r.Name) } @@ -152,7 +172,9 @@ func (r Reach) pair(ctx context.Context, service string) (Known, error) { defer func() { _ = stream.Close() }() r.say(PairingPreamble(r.Name)) - r.say(PairingWeight) + if !r.WeightOnApprover { + r.say(PairingWeight) + } typed, err := r.AskCode(r.Name) if err != nil { return Known{}, err @@ -203,11 +225,13 @@ func (r Reach) dial(ctx context.Context, service string) (io.ReadWriteCloser, er case errors.Is(err, relay.ErrNoMachine): return nil, NotConnected(r.Name) case errors.Is(err, relay.ErrUnreachable): - return nil, Unreachable(service) + return nil, syncwords.Explain(err, service) + case errors.Is(err, relay.ErrNoDoor): + return nil, NoDoor(service) case errors.Is(err, relay.ErrTooMany): return nil, Busy() default: - return nil, err + return nil, syncwords.Explain(err, service) } } diff --git a/internal/pair/edge_relay_test.go b/internal/pair/edge_relay_test.go index 4c0cb00305..8336c3b4e6 100644 --- a/internal/pair/edge_relay_test.go +++ b/internal/pair/edge_relay_test.go @@ -499,7 +499,7 @@ func TestRelayTooOld(t *testing.T) { // A RELAY THAT DOES NOT ANSWER IS NAMED BY ITS HOST, and the offering device // shows no code it could not serve. func TestRelayUnreachable(t *testing.T) { - const want = "cannot reach relay.test \u2014 check your internet connection and try again" + const want = "nothing is answering at https://relay.test — if that is your own sync service, start it there with codeaf relay" t.Run("at the first question", func(t *testing.T) { r, hostile := hostileRig(t) hostile.failLimits(unreachable()) diff --git a/internal/pair/edge_serve_test.go b/internal/pair/edge_serve_test.go index 088eea0be0..7c0009de94 100644 --- a/internal/pair/edge_serve_test.go +++ b/internal/pair/edge_serve_test.go @@ -8,12 +8,19 @@ package pair import ( "context" "fmt" + "io" + "net" + "net/http" + "net/http/httptest" "path/filepath" "regexp" "strings" "sync" + "sync/atomic" "testing" "time" + + "github.com/Agent-Field/codeaf/internal/relay" ) // screenLog is everything the machine has said, in order. @@ -267,3 +274,478 @@ func TestServeApproveGetsTheDeviceLabel(t *testing.T) { }) } } + +// A DROPPED NETWORK IS ONE LINE, NOT ONE LINE A RETRY. While the relay is gone +// the machine says once that it lost sync and is trying again, keeps trying +// without being restarted, and is reachable again when the relay is back +// without reprinting a banner whose name and code have not changed. +func TestServeSaysOnceThatItLostSyncAndComesBack(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + var down atomic.Bool + door := &relay.Server{} + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if down.Load() { + http.Error(w, "error code: 502", http.StatusBadGateway) + return + } + door.ServeHTTP(w, r) + })) + wire := &cuttable{Listener: server.Listener} + server.Listener = wire + server.Start() + t.Cleanup(server.Close) + screen := &screenLog{} + host := &Host{Service: server.URL, Device: aDevice(t), Devices: BookAt(filepath.Join(t.TempDir(), "devices.json")), Desk: &Desk{}, Say: screen.say} + ctx, stop := context.WithCancel(context.Background()) + ended := make(chan struct{}) + go func() { _ = host.Run(ctx); close(ended) }() + t.Cleanup(func() { stop(); <-ended }) + screen.until(t, "its first code", func(l []string) bool { return len(codes(l)) == 1 }) + + down.Store(true) + wire.cut() + time.Sleep(4 * time.Second) // the first retries come at one and two seconds + down.Store(false) + waitForCondition(t, "the machine to register again", func() bool { + return len(door.Ledgers()) == 1 + }) + lines := screen.all() + lost := 0 + for _, line := range lines { + if line == LostSyncLine { + lost++ + } + } + if lost != 1 { + t.Fatalf("the machine said it lost sync %d times over one outage; want once: %q", lost, lines) + } + if got := len(codes(lines)); got != 1 { + t.Fatalf("the unchanged banner was printed %d times across one brief outage; want 1: %q", got, lines) + } + // The lost line is answered by exactly one back line, so the screen of a + // machine that recovered never ends on "lost sync". + screen.until(t, "the back line", func(l []string) bool { + for _, line := range l { + if line == BackLine(host.Device.Name()) { + return true + } + } + return false + }) + back := 0 + for _, line := range screen.all() { + if line == BackLine(host.Device.Name()) { + back++ + } + } + if back != 1 { + t.Fatalf("the machine said it was back %d times after one outage; want once: %q", back, screen.all()) + } +} + +// A RELAY THAT STILL HOLDS A LIVE SOCKET FOR THIS NAME IS WAITED OUT. While +// another live carrier holds this machine's name after a drop, a machine that +// was already serving keeps trying instead of quitting, and comes back as soon +// as that carrier ends. +func TestServeWaitsOutItsOwnStaleName(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + var connected atomic.Int32 + door := &relay.Server{ + Note: func(_, what string) { + if what == "connected" { + connected.Add(1) + } + }, + } + server := httptest.NewUnstartedServer(door) + wire := &cuttable{Listener: server.Listener} + server.Listener = wire + server.Start() + t.Cleanup(server.Close) + screen := &screenLog{} + device := aDevice(t) + host := &Host{Service: server.URL, Device: device, Devices: BookAt(filepath.Join(t.TempDir(), "devices.json")), Desk: &Desk{}, Say: screen.say} + ctx, stop := context.WithCancel(context.Background()) + ended := make(chan struct{}) + var runErr error + go func() { runErr = host.Run(ctx); close(ended) }() + t.Cleanup(func() { stop(); <-ended }) + screen.until(t, "its first code", func(l []string) bool { return len(codes(l)) == 1 }) + + wire.cut() + stale, err := relay.Register(context.Background(), server.URL, device.Private()) + if err != nil { + t.Fatal(err) + } + time.Sleep(3 * time.Second) + _ = stale.Close() + select { + case <-ended: + t.Fatalf("serve quit while its own old name was still held: %v", runErr) + default: + } + waitForCondition(t, "the machine to retake its name", func() bool { + return connected.Load() >= 3 && len(door.Ledgers()) == 1 + }) +} + +// Contract item 1: SILENT DIALS END AT THE HANDSHAKE DEADLINE. Sixteen silent +// dials fill a served machine's stream ceiling only until the handshake +// deadline fires; once it does, the serving machine closes all sixteen and a +// real `--at` dial succeeds. +func TestSilentDialsCannotHoldAMachine(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + door, address := liveRelay(t) + t.Setenv(RelayEnv, address) + + screen := &screenLog{} + devices := BookAt(filepath.Join(t.TempDir(), "devices.json")) + machines := BookAt(filepath.Join(t.TempDir(), "machines.json")) + host := &Host{ + Service: address, + Device: aDevice(t), + Devices: devices, + Desk: &Desk{}, + Approve: func(string, string) bool { return true }, + Say: screen.say, + handshake: 60 * time.Millisecond, + Open: func(pipe io.ReadWriteCloser) { + defer func() { _ = pipe.Close() }() + _, _ = io.Copy(pipe, pipe) + }, + } + ctx, stop := context.WithCancel(context.Background()) + ended := make(chan struct{}) + go func() { _ = host.Run(ctx); close(ended) }() + t.Cleanup(func() { stop(); <-ended }) + first := codes(screen.until(t, "its first code", func(l []string) bool { return len(codes(l)) == 1 }))[0] + + // Pair one device first so the post-flood check exercises an ordinary + // paired `--at` dial. + reach := Reach{ + Name: host.Device.Name(), + Device: aDevice(t), + Machines: machines, + Label: "laptop", + AskCode: func(string) (string, error) { return first, nil }, + } + warm, err := reach.Open(context.Background()) + if err != nil { + t.Fatalf("initial pairing failed: %v", err) + } + _ = warm.Close() + + // Open 16 silent streams (relay.MaxStreams) and send zero bytes on each. + silent := make([]io.ReadWriteCloser, 0, relay.MaxStreams) + t.Cleanup(func() { + for _, s := range silent { + _ = s.Close() + } + }) + for i := 0; i < relay.MaxStreams; i++ { + s, err := relay.Dial(context.Background(), address, host.Device.Name()) + if err != nil { + t.Fatalf("silent dial %d: %v", i, err) + } + silent = append(silent, s) + } + + // Without waiting thirty real seconds, the short handshake deadline closes + // every silent stream and frees the machine's slots on the relay. + waitForCondition(t, "all 16 silent streams to close at the handshake deadline", func() bool { + ledgers := door.Ledgers() + return len(ledgers) == 1 && ledgers[0].Streams == 0 + }) + + tunnel, err := reach.Open(context.Background()) + if err != nil { + t.Fatalf("real --at dial after 16 silent dials failed: %v", err) + } + defer func() { _ = tunnel.Close() }() + const want = "still reachable" + if _, err := tunnel.Write([]byte(want)); err != nil { + t.Fatal(err) + } + buf := make([]byte, len(want)) + if _, err := io.ReadFull(tunnel, buf); err != nil { + t.Fatal(err) + } + if string(buf) != want { + t.Fatalf("echoed %q, want %q", string(buf), want) + } +} + +// Contract item 2: A DIAL THAT ONLY SAYS "PAIR" CANNOT FREEZE PAIRING. Sending +// the single `P` intent byte and then nothing spends the live code before the +// exchange (preserving the one-attempt rule), hits the handshake deadline, +// retires that code, and prints a fresh code that pairs a real device. +func TestHalfStartedPairIntentRotatesTheCode(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + _, address := liveRelay(t) + t.Setenv(RelayEnv, address) + + screen := &screenLog{} + devices := BookAt(filepath.Join(t.TempDir(), "devices.json")) + machines := BookAt(filepath.Join(t.TempDir(), "machines.json")) + host := &Host{ + Service: address, + Device: aDevice(t), + Devices: devices, + Desk: &Desk{}, + Approve: func(string, string) bool { return true }, + Say: screen.say, + handshake: 60 * time.Millisecond, + Open: func(pipe io.ReadWriteCloser) { + defer func() { _ = pipe.Close() }() + _, _ = io.Copy(pipe, pipe) + }, + } + ctx, stop := context.WithCancel(context.Background()) + ended := make(chan struct{}) + go func() { _ = host.Run(ctx); close(ended) }() + t.Cleanup(func() { stop(); <-ended }) + first := codes(screen.until(t, "its first code", func(l []string) bool { return len(codes(l)) == 1 }))[0] + + half, err := relay.Dial(context.Background(), address, host.Device.Name()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = half.Close() }) + if _, err := half.Write([]byte{intentPair}); err != nil { + t.Fatal(err) + } + + lines := screen.until(t, "the next code after a stalled P intent", func(l []string) bool { + return len(codes(l)) >= 2 + }) + if firstWith(lines, burnLine) < 0 { + t.Fatalf("the machine never reported the burned attempt: %q", lines) + } + second := codes(lines)[1] + if second == first { + t.Fatalf("the rotated code %q matched the spent code", second) + } + + reach := Reach{ + Name: host.Device.Name(), + Device: aDevice(t), + Machines: machines, + Label: "laptop", + AskCode: func(string) (string, error) { return second, nil }, + } + tunnel, err := reach.Open(context.Background()) + if err != nil { + t.Fatalf("pairing with the rotated code failed: %v", err) + } + _ = tunnel.Close() + if list, _ := devices.Devices(); len(list) != 1 { + t.Fatalf("the machine admitted %v, want 1 paired device", list) + } +} + +// Contract item 5: A DROPPED REGISTRATION BACKS OFF, PRINTS AN UNCHANGED BANNER +// ONCE, AND PHRASES RATE LIMITS FOR A PERSON. +func TestDroppedRegistrationBacksOffAndPrintsBannerOnce(t *testing.T) { + t.Run("ten immediate drops grow the wait each time up to the cap and print the banner once", func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + var clockMu sync.Mutex + now := time.Now() + clock := func() time.Time { + clockMu.Lock() + defer clockMu.Unlock() + return now + } + advance := func(d time.Duration) { + clockMu.Lock() + now = now.Add(d) + clockMu.Unlock() + } + + connected := make(chan struct{}, 32) + door := &relay.Server{ + Now: clock, + Note: func(_, what string) { + if what == "connected" { + connected <- struct{}{} + } + }, + } + server := httptest.NewUnstartedServer(door) + wire := &cuttable{Listener: server.Listener} + server.Listener = wire + server.Start() + t.Cleanup(server.Close) + + slept := make(chan time.Duration, 32) + screen := &screenLog{} + host := &Host{ + Service: server.URL, + Device: aDevice(t), + Devices: BookAt(filepath.Join(t.TempDir(), "devices.json")), + Desk: &Desk{Now: clock}, + Say: screen.say, + Now: clock, + sleep: func(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + advance(d) + slept <- d + return nil + }, + } + ctx, stop := context.WithCancel(context.Background()) + ended := make(chan struct{}) + go func() { _ = host.Run(ctx); close(ended) }() + t.Cleanup(func() { stop(); <-ended }) + + screen.until(t, "its first code", func(l []string) bool { return len(codes(l)) == 1 }) + awaitStep(t, connected, "initial registration") + + var waits []time.Duration + for i := 0; i < 10; i++ { + wire.cut() + waits = append(waits, awaitStep(t, slept, fmt.Sprintf("backoff %d", i+1))) + awaitStep(t, connected, fmt.Sprintf("reconnect %d", i+1)) + } + + wantWaits := []time.Duration{ + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, + 8 * time.Second, + 16 * time.Second, + 32 * time.Second, + time.Minute, + time.Minute, + time.Minute, + time.Minute, + } + for i, want := range wantWaits { + if waits[i] != want { + t.Fatalf("backoff %d was %v, want %v (all: %v)", i+1, waits[i], want, waits) + } + } + if got := len(codes(screen.all())); got != 1 { + t.Fatalf("banner printed %d times across 10 immediate drops; want 1: %q", got, screen.all()) + } + + // Confirm Host.Run has entered hold (and recorded `since := h.now()`) + // before advancing the clock, by sending an unknown intent byte that + // Host.answer immediately closes without touching the code or screen. + probe, err := relay.Dial(context.Background(), server.URL, host.Device.Name()) + if err != nil { + t.Fatal(err) + } + _, _ = probe.Write([]byte{'?'}) + _, _ = io.Copy(io.Discard, probe) + _ = probe.Close() + + // Once a registration stays up for at least one minute, a later drop + // resets the backoff to 1s. + advance(steadyAfter + time.Second) + wire.cut() + if reset := awaitStep(t, slept, "backoff after steady registration"); reset != time.Second { + t.Fatalf("backoff after holding >= %v was %v, want 1s", steadyAfter, reset) + } + }) + + t.Run("ErrTooMany gets a person-facing sentence and backs off without leaking the sentinel", func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + throttled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "too many registrations from here — wait a minute", http.StatusTooManyRequests) + })) + t.Cleanup(throttled.Close) + + ctx, stop := context.WithCancel(context.Background()) + defer stop() + var waits []time.Duration + screen := &screenLog{} + host := &Host{ + Service: throttled.URL, + Device: aDevice(t), + Devices: BookAt(filepath.Join(t.TempDir(), "devices.json")), + Desk: &Desk{}, + Say: screen.say, + sleep: func(ctx context.Context, d time.Duration) error { + waits = append(waits, d) + if len(waits) == 3 { + stop() + return context.Canceled + } + return nil + }, + } + if err := host.Run(ctx); err != nil { + t.Fatalf("Host.Run under 429 returned %v, want nil on context cancel", err) + } + lines := screen.all() + if len(lines) != 1 || lines[0] != TooManyRegistrationsLine { + t.Fatalf("screen under 429 said %q; want [%q] once", lines, TooManyRegistrationsLine) + } + for _, line := range lines { + if strings.Contains(line, "relay:") { + t.Fatalf("raw package sentinel leaked to screen: %q", line) + } + } + want := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second} + for i := range want { + if waits[i] != want[i] { + t.Fatalf("429 backoff %d was %v, want %v (all: %v)", i+1, waits[i], want[i], waits) + } + } + }) +} + +func waitForCondition(t *testing.T, what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if ok() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func awaitStep[T any](t *testing.T, ch <-chan T, what string) T { + t.Helper() + select { + case v := <-ch: + return v + case <-time.After(5 * time.Second): + var zero T + t.Fatalf("timed out waiting for %s", what) + return zero + } +} + +// cuttable is a listener whose connections a test can drop, hijacked ones +// included, the way a network that goes away drops them. +type cuttable struct { + net.Listener + mu sync.Mutex + conns []net.Conn +} + +func (c *cuttable) Accept() (net.Conn, error) { + conn, err := c.Listener.Accept() + if err == nil { + c.mu.Lock() + c.conns = append(c.conns, conn) + c.mu.Unlock() + } + return conn, err +} + +func (c *cuttable) cut() { + c.mu.Lock() + defer c.mu.Unlock() + for _, conn := range c.conns { + _ = conn.Close() + } + c.conns = nil +} diff --git a/internal/pair/endtoend_test.go b/internal/pair/endtoend_test.go index 916b62c3b5..dbfe651afd 100644 --- a/internal/pair/endtoend_test.go +++ b/internal/pair/endtoend_test.go @@ -224,7 +224,7 @@ func openWhenReady(ctx context.Context, reach Reach) (*Tunnel, error) { if err == nil { return tunnel, nil } - if !strings.Contains(err.Error(), "not connected to the service") || time.Now().After(deadline) { + if !errors.Is(err, ErrNotConnected) || time.Now().After(deadline) { return nil, err } time.Sleep(10 * time.Millisecond) diff --git a/internal/pair/errors.go b/internal/pair/errors.go index c3b4533dcc..f03c97070e 100644 --- a/internal/pair/errors.go +++ b/internal/pair/errors.go @@ -17,13 +17,14 @@ package pair import ( "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/syncwords" "time" ) // The facts, so a caller can tell them apart before phrasing them. var ( // ErrNoRelay is this machine having no relay address at all. - ErrNoRelay = errors.New("pair: no sync address is configured") + ErrNoRelay = errors.New("pair: no sync service is configured") // ErrNotPaired is this device never having been let in to that machine. ErrNotPaired = errors.New("pair: this device is not paired with that machine") // ErrWrongCode is the pairing code not matching. @@ -31,23 +32,31 @@ var ( // ErrNotThatMachine is a completed dial to something that does not hold // the key this device pinned. ErrNotThatMachine = errors.New("pair: that is not the machine this device paired with") + // ErrNotConnected means the service answered but the machine has not registered. + ErrNotConnected = errors.New("pair: machine is not connected") // ErrNoAnswer is a handshake that got no reply at all. ErrNoAnswer = errors.New("pair: that machine did not answer the handshake") ) // NoRelay is the first sentence: nothing is configured. func NoRelay(name string) error { - return fmt.Errorf("no service address is set up on this machine, so --at has nowhere to look for %s — set %s to a service address, or reach that machine with --host over ssh", name, RelayEnv) + return fmt.Errorf("no sync service is set up on this machine, so --at has nowhere to look for %s — set %s to a sync service, or reach that machine with --host over ssh", name, RelayEnv) } // Unreachable is the second: something is configured and it is not answering. func Unreachable(service string) error { - return fmt.Errorf("the service at %s cannot be reached from here — check this machine's network, or reach that machine with --host over ssh", service) + return errors.New(syncwords.Unreachable(service)) +} + +// NoDoor is a service that answers but cannot connect machines: one that only +// syncs chats, or one from before machines could register with it. +func NoDoor(service string) error { + return fmt.Errorf("%s, or reach that machine with --host over ssh", NoDoorForServe(service)) } // NotConnected is the third: the relay is fine and that machine is not there. func NotConnected(name string) error { - return fmt.Errorf("%s is not connected to the service right now — run `codeaf serve` on that machine", name) + return notConnectedError{name: name} } // NotPaired is the fourth: this device has never been let in. @@ -80,7 +89,7 @@ func NoAnswer(name string) error { // Busy is the relay turning connections away. func Busy() error { - return errors.New("the service is turning connections away right now — try again in a minute") + return errors.New("the sync service is turning connections away right now — try again in a minute") } // Stopped is what a machine says to a device it no longer lets in. It travels @@ -132,9 +141,9 @@ var ( // ErrBadGrant is what arrived as a grant and could not be used. ErrBadGrant = errors.New("what the other device sent could not be read; nothing was changed") // ErrSyncOff is a person who has turned the relay off. - ErrSyncOff = errors.New("sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address") + ErrSyncOff = errors.New("sync is off (CODEAF_SYNC_URL=off); pairing needs the sync service") // ErrNoSync is a person with no relay set at all. - ErrNoSync = errors.New("no sync address is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to a sync address, or pass --via <address>") + ErrNoSync = errors.New("no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via <address>") // ErrRelayBusy is a relay at one of its global caps. ErrRelayBusy = errors.New("sync is full right now; try again in a few minutes") // ErrRelayTooOld is a relay that does not know pairing yet. @@ -157,7 +166,7 @@ func NothingWaitingUnder(plate string) error { // CannotReachHost names the relay that did not answer. func CannotReachHost(host string) error { - return fmt.Errorf("%w %s \u2014 check your internet connection and try again", ErrCannotReach, host) + return cannotReachError{syncwords.Unreachable(host)} } // TooManyFor says how long until a network may ask again. @@ -183,5 +192,23 @@ var ( // ErrPairedNotRead is a pairing that worked and a list of devices that did not load. ErrPairedNotRead = errors.New("paired, but your devices could not be read yet; open codeaf again in a moment") // ErrNotJoined is an approver that has no device of its own to answer with. - ErrNotJoined = errors.New("this device has no codeaf identity yet; start codeaf here once before approving another device") + ErrNotJoined = errors.New("open codeaf on this computer once, then approve again") ) + +type cannotReachError struct{ line string } + +func (e cannotReachError) Error() string { return e.line } +func (e cannotReachError) Unwrap() error { return ErrCannotReach } + +// NoDoorForServe gives the machine operator a remedy on this machine. +func NoDoorForServe(service string) error { + return fmt.Errorf("the sync service at %s syncs chats but does not connect machines — run your own with `codeaf relay` and set %s to it", service, RelayEnv) +} + +// notConnectedError keeps readiness checks independent of the visible sentence. +type notConnectedError struct{ name string } + +func (e notConnectedError) Error() string { + return fmt.Sprintf("%s is not connected to the sync service right now — run `codeaf serve` on that machine", e.name) +} +func (e notConnectedError) Unwrap() error { return ErrNotConnected } diff --git a/internal/pair/join.go b/internal/pair/join.go index de3306db69..649b0449a6 100644 --- a/internal/pair/join.go +++ b/internal/pair/join.go @@ -46,6 +46,8 @@ type Joining struct { // Joined is how a pairing ended on the joining device. type Joined struct { + // With is an existing computer in the newly joined set of devices. + With string // Already is true when this computer held these chats before, so nothing // was changed. Already bool @@ -105,7 +107,7 @@ func phraseJoin(err error, route Mailbox) error { if errors.Is(err, ErrWrongCode) { return ErrCodeDidNotWork } - return phraseBox(err, route.Host) + return phraseBox(err, route.URL) } // joinOffer is what this device sends in message 3: its name, then random bytes @@ -139,7 +141,7 @@ func adoptWithDevice(j Joining, grant Grant, dev *identity.Dev) (Joined, error) if err != nil && !errors.Is(err, identity.ErrNone) { return Joined{}, err } - if err == nil && held.ID() == grant.Identity.ID() { + if err == nil && !j.Replace && held.ID() == grant.Identity.ID() { return Joined{Already: true}, nil } if err == nil && !j.Replace && !identity.Solo(j.Home) && !slices.Contains(grant.Replaces, held.ID()) { @@ -158,7 +160,12 @@ func adoptWithDevice(j Joining, grant Grant, dev *identity.Dev) (Joined, error) held, err = identity.Load(j.Home) switch { case err == nil && held.ID() == grant.Identity.ID(): - return Joined{Already: true}, nil + if !j.Replace { + return Joined{Already: true}, nil + } + // Explicit replacement after approval renews this computer's key even + // when the shared account is unchanged, because its old key may be + // revoked: the replacing merge below mints a new device. case err == nil && slices.Contains(grant.Replaces, held.ID()): return Joined{Successor: true}, follow(j, held, grant) } diff --git a/internal/pair/l4_join_test.go b/internal/pair/l4_join_test.go new file mode 100644 index 0000000000..51698d3cab --- /dev/null +++ b/internal/pair/l4_join_test.go @@ -0,0 +1,85 @@ +package pair + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// L4-H2a: looking around creates bookkeeping, but no chat to lose on either road. +func TestL4LookedAroundCanPair(t *testing.T) { + for _, link := range []bool{false, true} { + h := t.TempDir() + if _, err := identity.EnsureSolo(h); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(h, "desk-b"); err != nil { + t.Fatal(err) + } + p := filepath.Join(h, "v3/projects/project/chat/.cell") + if err := os.MkdirAll(p, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p, "transcript.jsonl"), []byte("{\"type\":\"session\",\"id\":\"chat\"}\n"), 0600); err != nil { + t.Fatal(err) + } + // Starting a chat writes its policy, while state.json records tool-created work. + if err := os.WriteFile(filepath.Join(p, ".furrowpolicy"), nil, 0600); err != nil { + t.Fatal(err) + } + other, err := identity.Ensure(t.TempDir()) + if err != nil { + t.Fatal(err) + } + j := Joining{Home: h} + if link { + ready, err := (LinkJoining{Joining: j}).free() + if err != nil { + t.Fatal(err) + } + j = ready.Joining + } + if _, err := adopt(j, Grant{Identity: other}); err != nil { + t.Fatalf("link=%v: %v", link, err) + } + } +} + +// L4-H2d: the approver's startup instruction has no key-management vocabulary. +func TestL4ApprovalNeedsOneStart(t *testing.T) { + if ErrNotJoined.Error() != "open codeaf on this computer once, then approve again" { + t.Fatal(ErrNotJoined) + } +} + +// A solo computer with chats of its own may join: app/v3 merges its chats into +// the shared set instead of refusing or replacing them (1c4ec8a25, #2031), and +// the joined line says so. Dev's count-and-refuse wording (#2087) does not +// apply on this branch. +func TestL4PairKeepsAComputersOwnChats(t *testing.T) { + for _, chats := range []int{1, 3} { + h := t.TempDir() + if _, err := identity.EnsureSolo(h); err != nil { + t.Fatal(err) + } + for i := 0; i < chats; i++ { + p := filepath.Join(h, "v3/projects/project", string(rune('a'+i)), ".cell") + if err := os.MkdirAll(p, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p, "transcript.jsonl"), []byte("{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}\n"), 0600); err != nil { + t.Fatal(err) + } + } + if _, err := (LinkJoining{Joining: Joining{Home: h}}).free(); err != nil { + t.Fatalf("%d chats: a computer with its own chats was refused: %v", chats, err) + } + if !strings.Contains(JoinedLine, "Your chats are kept") { + t.Fatal(JoinedLine) + } + } +} diff --git a/internal/pair/l4_words_test.go b/internal/pair/l4_words_test.go new file mode 100644 index 0000000000..3a0b02e40e --- /dev/null +++ b/internal/pair/l4_words_test.go @@ -0,0 +1,122 @@ +package pair + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// L4-H1a: every instruction uses the selected sync service, even without a flag. +func TestL4PairCommand(t *testing.T) { + if got := Command("http://127.0.0.1:8901", "04-063-213"); got != "codeaf pair 04-063-213 --via http://127.0.0.1:8901" { + t.Fatal(got) + } + if got := Command("https://codeaf.agentfield.ai/fabric", ""); got != "codeaf pair" { + t.Fatal(got) + } +} + +// L4-H1b, L4-H1c: a hosted link must not be mistaken for an expired local link. +func TestL4LinkServiceMismatch(t *testing.T) { + for _, tc := range []struct{ link, own, want string }{ + {"https://codeaf.agentfield.ai/p/k7m2q9xd", "http://127.0.0.1:8901", "that link is waiting on codeaf.agentfield.ai, but this computer syncs through 127.0.0.1:8901 — on the new device run: codeaf pair --via http://127.0.0.1:8901"}, + {"http://127.0.0.1:8901/p/k7m2q9xd", "https://codeaf.agentfield.ai/fabric", "that link is waiting on 127.0.0.1:8901, but this computer syncs through codeaf.agentfield.ai — on the new device run: codeaf pair"}, + } { + err := CheckLinkService(tc.link, tc.own) + if err == nil || err.Error() != tc.want || strings.Contains(err.Error(), "not waiting") { + t.Fatalf("%v", err) + } + } + if err := CheckLinkService("k7m2q9xd", "http://127.0.0.1:8901"); err != nil { + t.Fatal(err) + } +} + +// L4-M12a through L4-M12c: both roads name the peer and end with the same +// kept-chats sentence. +func TestL4PairCompletionVoice(t *testing.T) { + if got := (Joined{With: "laptop-a"}).Sentence(); got != "Paired with laptop-a. Your chats are kept. Both computers' chats will appear as sharing finishes." { + t.Fatal(got) + } + if got := PairedChatsLine("desk-b"); got != "desk-b is now paired. Your chats can continue there." { + t.Fatal(got) + } + if got := WaitingChatsLine("a b c"); got != "Waiting for approval on your other device; it should show: a b c" { + t.Fatal(got) + } +} + +// L4-M1a, L4-FIX5-a: the first serving name is accepted by --at; the device name is a hint. +func TestL4ServeUsesDeviceName(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv(RelayEnv, "") + const name = "seagull-lichen-63" + for _, display := range []string{"desk-b", ""} { + for _, codeErr := range []error{nil, ErrOffering} { + h := Host{DisplayName: display} + code := &Code{digits: "715302"} + got := h.banner(name, code, codeErr) + first, rest, _ := strings.Cut(got, "\n") + want := " this machine is reachable as " + name + if display != "" { + want += " (desk-b)" + } + if first != want { + t.Errorf("display=%q, code error=%v: first line %q; want %q", display, codeErr, first, want) + } + target := strings.Fields(first)[5] + if !relay.ValidName(target) { + t.Errorf("first name %q cannot be used with --at", target) + continue + } + if codeErr == nil { + _, plain, _ := strings.Cut(strings.TrimRight(Lines(name, code), "\n"), "\n") + if rest != plain { + t.Errorf("instructions changed with the hint: %q; want %q", rest, plain) + } + } + reach := Reach{Name: target, Machines: BookAt(filepath.Join(t.TempDir(), "machines.json"))} + if _, err := reach.Open(t.Context()); err == nil || err.Error() != NoRelay(name).Error() { + t.Errorf("--at %s should pass the shape check and reach the no-service check: %v", target, err) + } + } + } + if got := BackLine(name); got != "back — reachable as seagull-lichen-63 again" { + t.Fatal(got) + } +} + +// L4-M16a: process failure yields one named connection sentence without raw detail. +func TestL4ConnectionEnded(t *testing.T) { + if got := ConnectionEndedLine("laptop-a", errors.New("write /dev/stdout: splice: connection reset by peer; exit status 1")); got != "the connection to laptop-a ended" { + t.Fatal(got) + } +} + +// L4-M7a: CLI connection words use the sync service vocabulary. +func TestL4SyncVocabulary(t *testing.T) { + for _, err := range []error{NoRelay("desk-b"), NoDoor("https://example.com"), NotConnected("desk-b"), Busy(), ErrSyncOff, ErrNoSync} { + if strings.Contains(err.Error(), "sync address") || strings.Contains(err.Error(), "the service") { + t.Fatal(err) + } + } +} + +// L4-H6e: the serving machine is never asked to reach itself through ssh. +func TestL4ServeRemedyIsLocal(t *testing.T) { + if got := NoDoorForServe("http://127.0.0.1:8901").Error(); strings.Contains(got, "--host") || !strings.Contains(got, "codeaf relay") { + t.Fatal(got) + } +} + +// L4-H6d: code-pairing server failures use the same sentence as devices and cluster. +func TestL4CodePairServiceFailure(t *testing.T) { + got := phraseBox(wireauth.ErrUnavailable, "http://127.0.0.1:8901") + if got.Error() != "the sync service at 127.0.0.1:8901 is up but cannot agree on changes right now; it usually recovers within a minute" { + t.Fatal(got) + } +} diff --git a/internal/pair/l4fix2_weight_test.go b/internal/pair/l4fix2_weight_test.go new file mode 100644 index 0000000000..cad8dd3c8e --- /dev/null +++ b/internal/pair/l4fix2_weight_test.go @@ -0,0 +1,24 @@ +package pair + +import ( + "strings" + "testing" + "time" +) + +// L4-M3, L4-B7 stability: The terminal opts into the approver's explanation, +// while the unchanged conversation integration checks the base callback sequence. +func TestL4Fix2WeightOnApprover(t *testing.T) { + rig := newServeRig(t, func(string, string) bool { return true }) + reach, screen := rig.reach("laptop", rig.latest) + reach.WeightOnApprover = true + tunnel, err := reach.Open(bounded(t, 5*time.Second)) + if err != nil { + t.Fatal(err) + } + defer tunnel.Close() + said := screen.all() + if len(said) != 3 || said[0] != PairingPreamble(reach.Name) || !strings.HasPrefix(said[1], WaitingLine(reach.Name, "")) || said[2] != PairedLine(reach.Name) { + t.Fatalf("terminal notices: %q", said) + } +} diff --git a/internal/pair/l4fix4_preflight_test.go b/internal/pair/l4fix4_preflight_test.go new file mode 100644 index 0000000000..869cb7b47d --- /dev/null +++ b/internal/pair/l4fix4_preflight_test.go @@ -0,0 +1,82 @@ +package pair + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pairbox" +) + +type preflightMailbox struct { + pairbox.Box + calls int + err error +} + +func (b *preflightMailbox) Limits(context.Context) (pairbox.Limits, error) { + b.calls++ + return pairbox.Limits{}, b.err +} + +func preflightChat(t *testing.T, home string) string { + t.Helper() + path := filepath.Join(home, "v3/projects/project/chat/transcript.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{\"type\":\"session\",\"id\":\"chat\"}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"keep this turn\"}\n"), 0600); err != nil { + t.Fatal(err) + } + return path +} + +// A solo home with chats of its own keeps and shares them, so it is never +// refused before the mailbox: every road reaches it, and nothing local changes +// until the other device has said yes. +func TestSoloJoinWithChatsReachesTheMailboxAndChangesNothing(t *testing.T) { + for _, tc := range []struct { + name string + used, paired, replace bool + }{ + {name: "used", used: true}, + {name: "local work"}, + {name: "unused"}, + {name: "replace", used: true, replace: true}, + {name: "paired", used: true, paired: true}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + original, err := identity.EnsureSolo(home) + if err != nil { + t.Fatal(err) + } + if tc.used { + preflightChat(t, home) + } + if tc.name == "local work" { + if err := os.WriteFile(filepath.Join(home, "my-notes"), []byte("keep me"), 0600); err != nil { + t.Fatal(err) + } + } + if tc.paired { + if err := identity.EndSolo(home); err != nil { + t.Fatal(err) + } + } + before := filesIn(t, home) + box := &preflightMailbox{err: errors.New("mailbox reached")} + _, err = Join(t.Context(), Mailbox{Box: box}, Joining{Home: home, Replace: tc.replace}, "42-715-302", newScreen()) + if !errors.Is(err, box.err) || box.calls != 1 { + t.Fatalf("join = %v; mailbox calls = %d", err, box.calls) + } + same(t, before, filesIn(t, home)) + if held, err := identity.Load(home); err != nil || held.ID() != original.ID() { + t.Fatal("preflight changed the local account", err) + } + }) + } +} diff --git a/internal/pair/l4fix4_preidentity_test.go b/internal/pair/l4fix4_preidentity_test.go new file mode 100644 index 0000000000..c850517b9e --- /dev/null +++ b/internal/pair/l4fix4_preidentity_test.go @@ -0,0 +1,70 @@ +package pair + +import ( + "os" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" +) + +// L4-B: Chats written before an identity exists survive either pairing road +// byte for byte and belong to the newly joined account without replacement. +func TestL4Fix4PreIdentityChatsSurvivePairing(t *testing.T) { + for _, link := range []bool{false, true} { + t.Run(map[bool]string{false: "code", true: "link"}[link], func(t *testing.T) { + var home string + var account identity.Identity + var join func() + if link { + r := newLinkRig(t) + home = r.new + account, _ = identity.Load(r.old) + join = func() { + ui, done := r.start(t, t.Context()) + in := waitInvite(t, ui) + asking, err := r.approver.Look(t.Context(), in.Ref) + if err != nil { + t.Fatal(err) + } + if err := r.approver.Approve(t.Context(), asking); err != nil { + t.Fatal(err) + } + if result := waitResult(t, done); result.err != nil { + t.Fatal(result.err) + } + } + } else { + r := newChatRig(t) + home, account = r.homeB, r.a + join = func() { + ui := newScreen() + offer := offerAs(t, r, ui) + if _, err := r.join(t.Context(), home, ui.nextCode(t).Shown(), newScreen()); err != nil { + t.Fatal(err) + } + if end := offer.wait(t); end.err != nil { + t.Fatal(end.err) + } + } + } + journal := preflightChat(t, home) + before, err := os.ReadFile(journal) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Load(home); err != identity.ErrNone { + t.Fatalf("fixture already has an identity: %v", err) + } + join() + if held, err := identity.Load(home); err != nil || held.ID() != account.ID() || identity.Solo(home) { + t.Fatalf("did not join the account: %v", err) + } + if after, err := os.ReadFile(journal); err != nil || string(after) != string(before) { + t.Fatalf("pairing disturbed the original chat: %v", err) + } + if count, err := identity.ChatCount(home); err != nil || count != 1 { + t.Fatalf("ChatCount = %d, %v", count, err) + } + }) + } +} diff --git a/internal/pair/l4fix_rejoin_test.go b/internal/pair/l4fix_rejoin_test.go new file mode 100644 index 0000000000..aadb36da28 --- /dev/null +++ b/internal/pair/l4fix_rejoin_test.go @@ -0,0 +1,82 @@ +package pair + +import ( + "context" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// putOwnDevice writes the record a chat start writes for its own device. It is +// spelled here rather than borrowed from internal/syncsetup, which imports this +// package, so the record matches syncsetup's deviceRecord field for field. +func putOwnDevice(ctx context.Context, dir directory.Client, id identity.Identity, dev identity.Dev, name string) error { + sealed, err := directory.SealName(directory.MetadataKey(id.CellKey()), name) + if err != nil { + return err + } + return dir.PutDevice(ctx, dev.ID(), directory.Device{ + V: 1, Name: sealed, AddedBy: id.ID(), Platform: runtime.GOOS, + Caps: directory.Caps{OS: runtime.GOOS, Arch: runtime.GOARCH, Cow: "none"}, + }) +} + +// L4-R2: Revoking and rejoining with a current code replaces the removed device key. +func TestL4FixRemovedComputerRejoins(t *testing.T) { + r := newChatRig(t) + ctx := t.Context() + ui := newScreen() + offering := r.offer(ctx, ui) + if _, err := Join(ctx, r.route, Joining{Home: r.homeB, Label: "desk-b"}, ui.nextCode(t).Shown(), newScreen()); err != nil { + t.Fatal(err) + } + if end := within(t, offering); end.err != nil { + t.Fatal(end.err) + } + old, err := identity.Device(r.homeB) + if err != nil { + t.Fatal(err) + } + a, err := identity.Device(r.homeA) + if err != nil { + t.Fatal(err) + } + dir := directory.NewMemory(time.Now) + if err := putOwnDevice(ctx, dir.For(a.ID()), r.a, a, "laptop-a"); err != nil { + t.Fatal(err) + } + if err := putOwnDevice(ctx, dir.For(old.ID()), r.a, old, "desk-b"); err != nil { + t.Fatal(err) + } + if err := dir.For(a.ID()).Revoke(ctx, old.ID()); err != nil { + t.Fatal(err) + } + ui = newScreen() + offering = r.offer(ctx, ui) + if _, err := Join(ctx, r.route, Joining{Home: r.homeB, Label: "desk-b", Replace: true}, ui.nextCode(t).Shown(), newScreen()); err != nil { + t.Fatal(err) + } + if end := within(t, offering); end.err != nil { + t.Fatal(end.err) + } + fresh, err := identity.Device(r.homeB) + if err != nil { + t.Fatal(err) + } + if fresh.ID() == old.ID() { + t.Fatal("rejoin retained the revoked key") + } + if err := fresh.Cert.Verify(r.a.PublicKey()); err != nil { + t.Fatal(err) + } + if err := putOwnDevice(ctx, dir.For(fresh.ID()), r.a, fresh, "desk-b"); err != nil { + t.Fatal(err) + } + list, err := dir.For(fresh.ID()).List(ctx) + if err != nil || list.Devices[fresh.ID()].Revoked || list.Devices[fresh.ID()].Name == "" || !list.Devices[old.ID()].Revoked { + t.Fatalf("list=%+v err=%v", list, err) + } +} diff --git a/internal/pair/l4fix_serve_test.go b/internal/pair/l4fix_serve_test.go new file mode 100644 index 0000000000..0c6ccecb68 --- /dev/null +++ b/internal/pair/l4fix_serve_test.go @@ -0,0 +1,54 @@ +package pair + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/relay" +) + +// L4-R7: Repeated struggling registrations share one outage and one recovery notice. +func TestL4FixServeStrugglingOnce(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + var attempts atomic.Int32 + door := &relay.Server{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) <= 2 { + http.Error(w, "struggling", http.StatusServiceUnavailable) + return + } + door.ServeHTTP(w, r) + })) + defer server.Close() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + log := &screenLog{} + host := &Host{Service: server.URL, Device: aDevice(t), Desk: &Desk{}, Devices: BookAt(filepath.Join(t.TempDir(), "devices.json")), Say: func(line string) { + log.say(line) + if strings.Contains(line, "reachable as") { + cancel() + } + }} + host.sleep = func(ctx context.Context, _ time.Duration) error { return ctx.Err() } + if err := host.Run(ctx); err != nil { + t.Fatalf("serve stopped on recoverable service failure: %v", err) + } + lost, back := 0, 0 + for _, line := range log.all() { + if strings.Contains(line, "cannot agree on changes") { + lost++ + } + if strings.Contains(line, "back") { + back++ + } + } + if attempts.Load() != 3 || lost != 1 || back != 1 { + t.Fatalf("attempts=%d lost=%d back=%d lines=%q", attempts.Load(), lost, back, log.all()) + } +} diff --git a/internal/pair/l4fix_service_test.go b/internal/pair/l4fix_service_test.go new file mode 100644 index 0000000000..b9bc139e6d --- /dev/null +++ b/internal/pair/l4fix_service_test.go @@ -0,0 +1,42 @@ +package pair + +import ( + "net/url" + "testing" + + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// L4-R6: Equivalent URLs approve on this service, while meaningful differences refuse. +func TestL4FixCanonicalServices(t *testing.T) { + // internal/syncsetup sets both at startup in the binary; this package's tests + // do not link it, so the stable channel's values stand in here. + origin, hosted := LinkOrigin, syncwords.HostedURL + LinkOrigin = "https://codeaf.agentfield.ai" + syncwords.UseHosted("https://codeaf.agentfield.ai/fabric", []string{"codeaf.agentfield.ai"}) + t.Cleanup(func() { LinkOrigin = origin; syncwords.UseHosted(hosted, nil) }) + if err := CheckLinkService("https://CODEAF.AGENTFIELD.AI:443/p/k7m2q9xd", "https://codeaf.agentfield.ai/fabric"); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + via, own string + equal bool + }{ + {"https://SYNC.EXAMPLE:443/fabric///", "https://sync.example/fabric", true}, + {"HTTPS://sync.example/fabric", "https://sync.example/fabric/", true}, + {"http://SYNC.EXAMPLE:80/", "http://sync.example", true}, + {"https://CODEAF.AGENTFIELD.AI:443/fabric/", "https://codeaf.agentfield.ai/fabric", true}, + {"https://sync.example", "https://elsewhere.example", false}, + {"https://sync.example:444", "https://sync.example", false}, + {"http://sync.example", "https://sync.example", false}, + {"https://sync.example/a", "https://sync.example/b", false}, + } { + t.Run(tc.via+"_"+tc.own, func(t *testing.T) { + link := "codeaf://pair/k7m2q9xd?via=" + url.QueryEscape(tc.via) + err := CheckLinkService(link, tc.own) + if (err == nil) != tc.equal { + t.Fatalf("CheckLinkService = %v; equal=%v", err, tc.equal) + } + }) + } +} diff --git a/internal/pair/lines.go b/internal/pair/lines.go index fb8baee0fa..0b9beabbb9 100644 --- a/internal/pair/lines.go +++ b/internal/pair/lines.go @@ -15,6 +15,9 @@ import ( // stray Enter never lets a device in. const AskChoice = "y / n" +// ExampleChatCode keeps the help on both terminal doors aligned with the code shape. +const ExampleChatCode = "04-063-213" + // AskChatsLine is the question the device that shares its chats puts to a person. func AskChatsLine(label, words string) string { return `"` + label + `" wants your chats. Same three words on that screen: ` + words + `?` @@ -33,18 +36,22 @@ const BurnLine = "someone typed a wrong code. New code:" // WaitingChatsLine is what the joining device shows while a person decides on // the other one: the words that screen should be showing. func WaitingChatsLine(words string) string { - return "waiting for approval on your other device; it should show: " + words + return "Waiting for approval on your other device; it should show: " + words } // JoinedLine is what the joining device says when it worked. -const JoinedLine = "Paired. Your chats are kept. Both computers' chats will appear as sharing finishes." +// keptChatsLine is the shared tail of every successful join: a solo computer's +// own chats stay, and the other computers' chats arrive as sharing finishes. +const keptChatsLine = ". Your chats are kept. Both computers' chats will appear as sharing finishes." + +const JoinedLine = "Paired" + keptChatsLine // AlreadyPairedLine is the no-change result on every pairing surface. const AlreadyPairedLine = "already paired; nothing changed" // FollowedLine is what the joining device says when it was on an identity that // was rotated and has moved to the one that replaced it. -const FollowedLine = "Paired. This device now follows your new identity and keeps its chats and its saved keys." +const FollowedLine = JoinedLine // Sentence is how a finished join reads: a computer that held these chats // already is told nothing changed, one that followed a rotation says so, and any @@ -54,13 +61,13 @@ func (j Joined) Sentence() string { case j.Already: return ErrAlreadyPaired.Error() case j.Successor: - return FollowedLine + return PairedWithLine(j.With) } - return JoinedLine + return PairedWithLine(j.With) } // PairedChatsLine is what the sharing device says when it worked. -func PairedChatsLine(label string) string { return "paired: " + label } +func PairedChatsLine(label string) string { return ApprovedLine(label) } // ── joining by link ────────────────────────────────────────────────────────── @@ -106,13 +113,7 @@ func ApproveCommand(ref LinkRef, via string) string { // JoinedFleetLine is what the new device says when it is in. No workspaces // yet says nothing about them, as a zero is never printed. func JoinedFleetLine(workspaces int) string { - switch workspaces { - case 0: - return "Paired." - case 1: - return "Paired. 1 workspace available." - } - return fmt.Sprintf("Paired. %d workspaces available.", workspaces) + return PairedWithLine("") } // platformWords are the names a person knows their system by, keyed by the @@ -153,8 +154,7 @@ func ApprovedLine(name string) string { return name + " is now paired. Your chat // next chat start writes the record anyway, so this is one dim line and not a // failure. func RecordUnsaidLine(err error) string { - return "paired, but this device could not write its own record: " + err.Error() + - " — it is written at the next chat start." + return "paired; this computer will appear on your other devices after the next chat starts" } // DeclinedLine is what the approving device says after a no. @@ -192,3 +192,14 @@ func NamePrompt(name string) string { // NameTried is the sentence for a name that was refused, then the question again. func NameTried(err error) string { return err.Error() + " \u2014 try another." } + +// PlatformWord omits unknown platform values instead of inventing a reading. +func PlatformWord(platform string) string { return platformWords[platform] } + +// PairedWithLine gives both joining roads the same next step. +func PairedWithLine(name string) string { + if name == "" { + return JoinedLine + } + return "Paired with " + name + keptChatsLine +} diff --git a/internal/pair/lines_test.go b/internal/pair/lines_test.go index f4f706ec36..45f4c43670 100644 --- a/internal/pair/lines_test.go +++ b/internal/pair/lines_test.go @@ -6,7 +6,7 @@ import "testing" // zero is never printed. func TestJoinedFleetLineNeverPrintsZero(t *testing.T) { for workspaces, want := range map[int]string{ - 0: "Paired.", 1: "Paired. 1 workspace available.", 3: "Paired. 3 workspaces available.", + 0: JoinedLine, 1: JoinedLine, 3: JoinedLine, } { if got := JoinedFleetLine(workspaces); got != want { t.Errorf("JoinedFleetLine(%d) = %q, want %q", workspaces, got, want) diff --git a/internal/pair/linkjoin.go b/internal/pair/linkjoin.go index a968c608b3..7ca2a6e07c 100644 --- a/internal/pair/linkjoin.go +++ b/internal/pair/linkjoin.go @@ -15,6 +15,7 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/Agent-Field/codeaf/internal/wireauth" ) @@ -25,7 +26,10 @@ type LinkUI interface { } // Fleet is what a device sees of the identity it just joined. -type Fleet struct{ Devices, Workspaces int } +type Fleet struct { + Devices, Workspaces int + With string +} // LinkJoining is this computer as the device that asks to join. type LinkJoining struct { @@ -217,7 +221,7 @@ func phraseLink(err error, host string) error { case errors.Is(err, directory.ErrTooOld): return ErrRelayTooOld case errors.Is(err, directory.ErrUnreachable): - return CannotReachHost(host) + return syncwords.Explain(err, host) } - return err + return syncwords.Explain(err, host) } diff --git a/internal/pair/linkjoin_test.go b/internal/pair/linkjoin_test.go index eefea36d90..931c8afa06 100644 --- a/internal/pair/linkjoin_test.go +++ b/internal/pair/linkjoin_test.go @@ -124,7 +124,7 @@ func TestJoinByLinkApproved(t *testing.T) { ui, done := r.start(t, context.Background()) in := waitInvite(t, ui) - if !strings.HasPrefix(in.Ref.URL(), "https://codeaf.agentfield.ai/p/"+in.Ref.Code+"#") || len(in.Check) != 4 { + if !strings.HasPrefix(in.Ref.URL(), LinkOrigin+"/p/"+in.Ref.Code+"#") || len(in.Check) != 4 { t.Fatalf("the invite looks wrong: %+v", in) } ref, err := ReadLink(in.Ref.Token()) diff --git a/internal/pair/linkkey.go b/internal/pair/linkkey.go index ac08a1af56..45d13b224b 100644 --- a/internal/pair/linkkey.go +++ b/internal/pair/linkkey.go @@ -18,9 +18,11 @@ import ( "golang.org/x/crypto/nacl/secretbox" ) -// LinkOrigin is where a short link points. The page there hands a visitor to the -// installed app, or shows what to run. -const LinkOrigin = "https://codeaf.agentfield.ai" +// LinkOrigin is where a short link points for this binary's release channel, +// set at startup from internal/syncsetup/relayurl.go so the hosted addresses +// are spelled in one place. The page there hands a visitor to the installed +// app, or shows what to run. +var LinkOrigin string // linkKeySize is the secret in the link's fragment, which no server ever sees. const linkKeySize = 16 diff --git a/internal/pair/list.go b/internal/pair/list.go index 65d9a1a5a7..c21a3734ac 100644 --- a/internal/pair/list.go +++ b/internal/pair/list.go @@ -14,9 +14,11 @@ package pair // only ever draw a table that has rows. import ( + "cmp" "encoding/base64" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/charmbracelet/x/ansi" "strings" "time" @@ -36,7 +38,7 @@ func decodeStoredKey(stored string) ([]byte, error) { // DevicesList is what `codeaf devices` prints about the devices that can use // this machine. Its caller has already checked that paired is not empty. -func DevicesList(name string, paired []Paired, keeper Keeper, now time.Time) string { +func DevicesList(name string, paired []Paired, keeper Keeper, now time.Time, duplicates ...map[string]bool) string { var out strings.Builder fmt.Fprintf(&out, "this machine is reachable as %s\n", name) fmt.Fprintf(&out, "its key is kept in %s\n\n", keeper.Where()) @@ -54,7 +56,17 @@ func DevicesList(name string, paired []Paired, keeper Keeper, now time.Time) str // The emptiness law, one row at a time: a device that has never // connected shows nothing where its last connection would be, rather // than a zero time or the word "never". - line := fmt.Sprintf(" %s paired %s", padded(one.Label, widest), since(one.Since, now)) + duplicate := false + for _, other := range paired { + if other.Key != one.Key && strings.EqualFold(other.Label, one.Label) { + duplicate = true + } + } + if len(duplicates) > 0 { + duplicate = duplicates[0][strings.ToLower(one.Label)] + } + label := devname.DeviceLabel(one.Label, one.ID(), duplicate) + line := fmt.Sprintf(" %s paired %s", padded(label, widest), since(one.Since, now)) if seen := since(one.Seen, now); seen != "" { line += " · last here " + seen } @@ -76,10 +88,10 @@ func MachinesList(known []Known, now time.Time) string { out.WriteString("\nmachines this device can reach\n\n") widest := 0 for _, one := range known { - widest = max(widest, width(one.Name)) + widest = max(widest, width(cmp.Or(one.Alias, one.Name))) } for _, one := range known { - fmt.Fprintf(&out, " %s paired %s\n", padded(one.Name, widest), since(one.Since, now)) + fmt.Fprintf(&out, " %s paired %s\n", padded(cmp.Or(one.Alias, one.Name), widest), since(one.Since, now)) } out.WriteString("\nopen one with `codeaf chat --at <name>`.\n") return out.String() @@ -97,28 +109,62 @@ func RevokedLine(label string) string { return label + " was removed — it can no longer open a chat here, and it needs a new pairing code to come back." } +// NextPairing offers the next step after this computer's local row. +const NextPairing = "Run codeaf pair to add another device." + // NoDevices is the whole answer of `codeaf devices` when nothing of either kind // is paired: one sentence, and the two ways to change that. const NoDevices = "nothing is paired yet — run `codeaf pair` to add another device, or `codeaf serve` to let a device use this machine." // ChatsDevice is one device that holds the person's chats, as a row. type ChatsDevice struct { - Name string - This bool // the computer the command is typed on - Stopped bool // removed: the relay no longer answers it + Name string + This bool // the computer the command is typed on + Stopped bool // removed: the relay no longer answers it + Online bool + Platform string + LastSeen time.Time } // ChatsDevicesList is the table of computers that hold the person's chats. Its // caller has already checked that rows is not empty. -func ChatsDevicesList(rows []ChatsDevice) string { +func ChatsDevicesList(rows []ChatsDevice) string { return ChatsDevicesListAt(rows, time.Now()) } + +// ChatsDevicesListAt keeps last-seen wording reproducible under the caller's clock. +// A caller without chat-moving capability suppresses the pairing next step. +func ChatsDevicesListAt(rows []ChatsDevice, now time.Time, canPair ...bool) string { var out strings.Builder out.WriteString("your devices\n\n") widest := 0 for _, one := range rows { widest = max(widest, width(one.Name)) } + removed := false for _, one := range rows { - fmt.Fprintf(&out, " %s%s\n", padded(one.Name, widest), chatsMarks(one)) + if one.Stopped && !removed { + out.WriteString("\nremoved\n\n") + removed = true + } + details := chatsMarks(one) + if platform := PlatformWord(one.Platform); platform != "" { + details += " " + platform + } + if !one.Stopped && one.Online { + details += " online" + } else if !one.Stopped && !one.LastSeen.IsZero() { + details += " last seen " + since(one.LastSeen, now) + } + fmt.Fprintf(&out, " %s%s\n", padded(one.Name, widest), details) + } + removable := false + for _, row := range rows { + removable = removable || !row.This && !row.Stopped + } + if !removable { + if len(canPair) == 0 || canPair[0] { + out.WriteString("\n" + NextPairing + "\n") + } + return out.String() } out.WriteString("\nremove one with `codeaf devices revoke <name>` — it stops syncing your chats, but keeps what it already holds.\n") return out.String() @@ -131,9 +177,6 @@ func chatsMarks(one ChatsDevice) string { if one.This { marks = append(marks, "this device") } - if one.Stopped { - marks = append(marks, "removed") - } if len(marks) == 0 { return "" } diff --git a/internal/pair/manual_test.go b/internal/pair/manual_test.go index 094eb42a3b..19a6a6fe71 100644 --- a/internal/pair/manual_test.go +++ b/internal/pair/manual_test.go @@ -21,6 +21,7 @@ import ( // // The bar is the same one the other gates set: the page must SAY the sentence, // word for word, not describe it well. +// L4-H6 and L4-M7 keep connection words identical to the compiled manual. func TestEverySentenceThisPackageShowsIsInTheManual(t *testing.T) { code := &Code{digits: "715302"} said := []string{ @@ -34,6 +35,7 @@ func TestEverySentenceThisPackageShowsIsInTheManual(t *testing.T) { // The four ways `--at` fails, each naming its own cause. NoRelay("otter-lamp-42").Error(), Unreachable("https://service.example.com").Error(), + NoDoor("https://service.example.com").Error(), NotConnected("otter-lamp-42").Error(), NotPaired("otter-lamp-42").Error(), // And the ones a person meets less often. @@ -43,6 +45,9 @@ func TestEverySentenceThisPackageShowsIsInTheManual(t *testing.T) { Busy().Error(), Stopped(), RevokedLine("laptop"), + LostSyncLine, + BackLine("otter-lamp-42"), + TooManyRegistrationsLine, // The join words on the machine being opened, and what its own screen // says about the attempts it turned away. WaitingLine("otter-lamp-42", "amber fox dune"), @@ -64,6 +69,7 @@ func TestEverySentenceThisPackageShowsIsInTheManual(t *testing.T) { // shows for it is quoted there whole. The page is named rather than searched // for, so a sentence that moved to some other page still fails: a person who // asks how to use codeaf on a second computer is sent to this one. +// L4-H1, L4-H2 and L4-M12 keep pairing sentences on their shipped manual page. func TestEverySentenceOfPairingChatsIsOnItsOwnPage(t *testing.T) { const name = "use-this-on-another-computer" page, ok := manual.Chat().Page(name) @@ -98,7 +104,7 @@ func TestEverySentenceOfPairingChatsIsOnItsOwnPage(t *testing.T) { ErrRelayTooOld.Error(), ErrDifferentChats.Error(), ErrAlreadyPaired.Error(), - CannotReachHost("relay.example.com").Error(), + CannotReachHost("https://relay.example.com").Error(), TooManyFor(7 * time.Minute).Error(), } for _, sentence := range said { @@ -117,11 +123,13 @@ func TestTheManualQuotesTheLimitsThisPackageActuallyApplies(t *testing.T) { t.Fatal("the page reaching-this-machine-without-ssh is not in the corpus") } for _, quoted := range []string{ - "10 minutes", // CodeValidFor - "1 attempt", // CodeAttempts - "up to 16", // relay.MaxStreams - "30 connections a minute", // relay.DialsPerMinute + "10 minutes", // CodeValidFor + "1 attempt", // CodeAttempts + "up to 16", // relay.MaxStreams + "30 connections a minute", // relay.DialsPerMinute + "10 registrations a minute", // relay.RegistrationsPerMinute "~/.codeaf/v3/remote/device.key", + "~/.codeaf/v3/remote/relay", } { if !strings.Contains(page, quoted) { t.Errorf("the page does not quote %q", quoted) @@ -133,6 +141,9 @@ func TestTheManualQuotesTheLimitsThisPackageActuallyApplies(t *testing.T) { if relay.DialsPerMinute != 30 { t.Fatalf("relay.DialsPerMinute is %d and the page says 30", relay.DialsPerMinute) } + if relay.RegistrationsPerMinute != 10 { + t.Fatalf("relay.RegistrationsPerMinute is %d and the page says 10", relay.RegistrationsPerMinute) + } if int(CodeValidFor.Minutes()) != 10 { t.Fatalf("CodeValidFor is %v and the page says 10 minutes", CodeValidFor) } diff --git a/internal/pair/offer.go b/internal/pair/offer.go index 4087ec251a..d8db0a4a4d 100644 --- a/internal/pair/offer.go +++ b/internal/pair/offer.go @@ -67,7 +67,7 @@ func Offer(ctx context.Context, route Mailbox, grant Grant, ui OfferUI) (string, // relay that cannot serve a code is never allowed to display one. func checkRelay(ctx context.Context, route Mailbox) error { if _, err := route.Box.Limits(ctx); err != nil { - return phraseBox(err, route.Host) + return phraseBox(err, route.URL) } return nil } @@ -83,7 +83,7 @@ func offerOnce(ctx context.Context, route Mailbox, grant Grant, ui OfferUI, burn } made, err := createApart(ctx, route.Box, key, burned) if err != nil { - return "", "", phraseBox(err, route.Host) + return "", "", phraseBox(err, route.URL) } code = code.WithPlate(made.Nameplate) defer discard(route.Box, made.Nameplate, key) @@ -96,7 +96,7 @@ func offerOnce(ctx context.Context, route Mailbox, grant Grant, ui OfferUI, burn key: key, gone: map[stage]error{awaitStart: ErrDidNotFinish, awaitOffer: ErrDidNotFinish}, sendGone: ErrDidNotFinish} joiner, err := answer(link, chatScheme(made.Nameplate), code.secret()) if err != nil { - return "", made.Nameplate, phraseBox(err, route.Host) + return "", made.Nameplate, phraseBox(err, route.URL) } label, err := conclude(ctx, joiner, grant, ui) return label, made.Nameplate, err diff --git a/internal/pair/pair_test.go b/internal/pair/pair_test.go index f15c621aa9..8775d99ad8 100644 --- a/internal/pair/pair_test.go +++ b/internal/pair/pair_test.go @@ -535,7 +535,7 @@ func TestEveryWayThisFailsSaysWhichWayItFailed(t *testing.T) { if err == nil { t.Fatal("--at opened with no relay set up") } - if !strings.Contains(err.Error(), "no service address is set up on this machine") { + if !strings.Contains(err.Error(), "no sync service is set up on this machine") { t.Fatalf("with no relay, --at said %q", err) } if !strings.Contains(err.Error(), "--host over ssh") { @@ -551,7 +551,7 @@ func TestEveryWayThisFailsSaysWhichWayItFailed(t *testing.T) { t.Run("cannot reach the relay", func(t *testing.T) { t.Setenv(RelayEnv, "http://127.0.0.1:1") _, err := asking.Open(context.Background()) - if err == nil || !strings.Contains(err.Error(), "cannot be reached from here") { + if err == nil || !strings.Contains(err.Error(), "nothing is answering at http://127.0.0.1:1") { t.Fatalf("with an unreachable relay, --at said %v", err) } }) @@ -560,7 +560,7 @@ func TestEveryWayThisFailsSaysWhichWayItFailed(t *testing.T) { _, address := liveRelay(t) t.Setenv(RelayEnv, address) _, err := asking.Open(context.Background()) - if err == nil || !strings.Contains(err.Error(), "is not connected to the service right now") { + if err == nil || !strings.Contains(err.Error(), "is not connected to the sync service right now") { t.Fatalf("with nothing registered, --at said %v", err) } if !strings.Contains(err.Error(), "codeaf serve") { diff --git a/internal/pair/serve.go b/internal/pair/serve.go index 581dfd4fae..203179739a 100644 --- a/internal/pair/serve.go +++ b/internal/pair/serve.go @@ -24,12 +24,15 @@ import ( "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // Host is the machine side. type Host struct { // Service is the relay's address. Service string + // DisplayName is the sync device name, when this computer is paired for chats. + DisplayName string // Device is this machine's key. Device Device // Devices is the book of devices this machine lets in. @@ -50,10 +53,39 @@ type Host struct { // TUNNEL IS AN io.ReadWriteCloser AND NOTHING MORE, which is what lets the // caller hand it straight to internal/remote. Open func(io.ReadWriteCloser) + // OpenNamed returns a connection outcome for one plain terminal sentence. + OpenNamed func(string, io.ReadWriteCloser) error // Now is the clock, swapped by tests. Now func() time.Time + + // handshake overrides [HandshakeWithin] in tests so silent-stream + // deadlines can be checked without waiting thirty real seconds. + handshake time.Duration + // sleep overrides the retry wait in tests so backoff schedules can be + // checked with an injected clock. + sleep func(context.Context, time.Duration) error + + bannerMu sync.Mutex + shownBanner string } +// steadyAfter is how long a registration must stay up before a later drop is +// counted as a fresh outage rather than the same flapping connection. +const steadyAfter = time.Minute + +// TooManyRegistrationsLine is what `codeaf serve` prints when the relay turns +// registrations away at its per-minute ceiling. +const TooManyRegistrationsLine = "the sync service is turning registrations away right now — waiting before trying again" + +// LostSyncLine is what `codeaf serve` prints, once per outage, when its +// connection to the relay is lost and it starts trying again. +const LostSyncLine = "lost sync — trying again" + +// BackLine is what `codeaf serve` prints when it is reachable again after +// saying [LostSyncLine]. It names the machine because that is the fact a +// person checking the screen wants: devices can reach it under this name. +func BackLine(name string) string { return "back — reachable as " + name + " again" } + func (h *Host) now() time.Time { if h.Now != nil { return h.Now() @@ -61,12 +93,59 @@ func (h *Host) now() time.Time { return time.Now() } +func (h *Host) handshakeWithin() time.Duration { + if h.handshake > 0 { + return h.handshake + } + return HandshakeWithin +} + +func (h *Host) wait(ctx context.Context, d time.Duration) error { + if h.sleep != nil { + return h.sleep(ctx, d) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + func (h *Host) say(line string) { if h.Say != nil { h.Say(line) } } +func (h *Host) banner(name string, code *Code, codeErr error) string { + var line string + if codeErr == nil { + line = strings.TrimRight(Lines(name, code), "\n") + } else { + line = " this machine is reachable as " + name + } + if h.DisplayName != "" { + // A device name is only a hint; instructions must keep the machine name. + first, rest, more := strings.Cut(line, "\n") + line = first + " (" + h.DisplayName + ")" + if more { + line += "\n" + rest + } + } + return line +} + +func (h *Host) sayBanner(line string, force bool) { + h.bannerMu.Lock() + same := line == h.shownBanner + h.shownBanner = line + h.bannerMu.Unlock() + if force || !same { + h.say(line) + } +} + // Run holds the registration until the context is cancelled, redialling when // the connection to the relay is lost. // @@ -77,52 +156,100 @@ func (h *Host) say(line string) { // again cannot fix. func (h *Host) Run(ctx context.Context) error { if strings.TrimSpace(h.Service) == "" { - return errors.New("no sync address is set up on this machine, so there is nowhere to be reachable from — set " + RelayEnv + " to your sync address") + return errors.New("no sync service is set up on this machine, so there is nowhere to be reachable from — set " + RelayEnv + " to your sync service") + } + if h.Desk != nil && h.Desk.Now == nil && h.Now != nil { + h.Desk.Now = h.Now } wait := time.Second announced := false + // told and throttled are each said once per spell, not once per retry. + // told is set when the person has been told sync was lost and not yet + // that it came back, so the two lines always arrive as a pair. + told := false + throttled := false + tellLost := func() { + if !told { + h.say(LostSyncLine) + } + told = true + } for { registration, err := relay.Register(ctx, h.Service, h.Device.Private()) switch { case err == nil: + case errors.Is(err, relay.ErrTooMany): + if !throttled { + h.say(TooManyRegistrationsLine) + } + throttled = true + case errors.Is(err, relay.ErrNameTaken) && announced: + // This machine held the name a moment ago, so what holds it now is + // its own dead connection, which the relay forgets when it times + // out. That is the outage still going on, and is waited out. + tellLost() case errors.Is(err, relay.ErrNameTaken): // RULE THREE OF THE NAME POLICY, MET FROM THIS SIDE. Something is // already connected under this machine's name, which in practice // means a second `codeaf serve` on this same machine. Trying again // forever would be two processes fighting over one name. return fmt.Errorf("this machine is already connected to sync as %s — there is only one of it, so close the other `codeaf serve`", h.Device.Name()) + case errors.Is(err, relay.ErrUnreachable) && announced: + tellLost() + case syncwords.IsStruggling(err): + if !told { + h.say(syncwords.Explain(err, h.Service).Error()) + } + told = true case errors.Is(err, relay.ErrUnreachable): if !announced { - return Unreachable(h.Service) + return syncwords.Explain(err, h.Service) } - h.say("lost sync — trying again") + tellLost() + case errors.Is(err, relay.ErrNoDoor) && announced: + // The door was there a moment ago, so this is a relay being + // redeployed, and is waited out like any other outage. + tellLost() + case errors.Is(err, relay.ErrNoDoor): + // Trying again cannot add a door the service does not have. + return NoDoorForServe(h.Service) default: if !announced { - return err + return syncwords.Explain(err, h.Service) } - h.say("sync refused this machine: " + err.Error()) + h.say(syncwords.Explain(err, h.Service).Error()) } if err == nil { - wait = time.Second - if !announced { - announced = true - } - if code, codeErr := h.Desk.Offer(); codeErr == nil { - h.say(strings.TrimRight(Lines(registration.Name(), code), "\n")) - } else { - h.say(" this machine is reachable as " + registration.Name()) + announced = true + // A LOST LINE IS ANSWERED BY A BACK LINE. The banner is reprinted only + // when the name or code changed, so without this the last thing on + // the screen of a machine that recovered would still say it was lost. + if told { + h.say(BackLine(registration.Name())) + told = false } + code, codeErr := h.Desk.Offer() + // Reprints happen only when the name or pairing code actually + // changed, so a brief network blip neither spams the screen nor + // disguises a flapping carrier as a fresh start. + h.sayBanner(h.banner(registration.Name(), code, codeErr), false) + since := h.now() h.hold(ctx, registration) + // Backoff resets only after the carrier stayed up for a meaningful + // stretch; an upgrade that drops right after Register keeps + // backing off instead of hammering the relay every second. + if h.now().Sub(since) >= steadyAfter { + wait = time.Second + throttled = false + } } - select { - case <-ctx.Done(): + if err := h.wait(ctx, wait); err != nil { return nil - case <-time.After(wait): } - if wait < time.Minute { - wait *= 2 + if wait *= 2; wait > time.Minute { + wait = time.Minute } } } @@ -135,9 +262,14 @@ func (h *Host) Run(ctx context.Context) error { // fails; waiting for them is what makes "Run returned" mean that nothing of // this machine is still writing its book or speaking to its screen. func (h *Host) hold(ctx context.Context, registration *relay.Registration) { + done := make(chan struct{}) + defer close(done) go func() { - <-ctx.Done() - _ = registration.Close() + select { + case <-ctx.Done(): + _ = registration.Close() + case <-done: + } }() var workers sync.WaitGroup defer workers.Wait() @@ -158,7 +290,7 @@ func (h *Host) hold(ctx context.Context, registration *relay.Registration) { // answer takes one arriving stream through whichever of the two exchanges it // asked for. func (h *Host) answer(stream io.ReadWriteCloser) { - withDeadline(stream, h.now().Add(HandshakeWithin)) + withDeadline(stream, time.Now().Add(h.handshakeWithin())) var intent [1]byte if _, err := io.ReadFull(stream, intent[:]); err != nil { _ = stream.Close() @@ -196,7 +328,7 @@ func (h *Host) pair(stream io.ReadWriteCloser) { // one: a person who watched a code burn must not have to ask for another. h.Desk.Retire() if next, err := h.Desk.Offer(); err == nil { - h.say(strings.TrimRight(Lines(h.Device.Name(), next), "\n")) + h.sayBanner(h.banner(h.Device.Name(), next, nil), true) } } @@ -205,8 +337,8 @@ func (h *Host) pair(stream io.ReadWriteCloser) { // nobody is sitting at never admits anything. func (h *Host) approver(stream io.ReadWriteCloser) func(label, words string) error { return func(label, words string) error { - withDeadline(stream, h.now().Add(ConfirmWithin+HandshakeWithin)) - defer withDeadline(stream, h.now().Add(HandshakeWithin)) + withDeadline(stream, time.Now().Add(ConfirmWithin+h.handshakeWithin())) + defer withDeadline(stream, time.Now().Add(h.handshakeWithin())) if h.Approve == nil || !h.Approve(label, words) { return ErrRefused } @@ -271,12 +403,17 @@ func (h *Host) connect(stream io.ReadWriteCloser) { withDeadline(stream, time.Time{}) h.Devices.Touch(key, h.now()) h.say(who.Label + " connected") + if h.OpenNamed != nil { + err := h.OpenNamed(who.Label, tunnel) + h.say(ConnectionEndedLine(who.Label, err)) + return + } if h.Open == nil { _ = tunnel.Close() return } h.Open(tunnel) - h.say(who.Label + " left") + h.say(ConnectionEndedLine(who.Label, nil)) } // ThisMachineLabel is what this device calls itself when it pairs: the name a @@ -284,3 +421,12 @@ func (h *Host) connect(stream io.ReadWriteCloser) { // they already use for their laptop. It is only ever a label — a connection is // checked against a key. func ThisMachineLabel() string { return readableLabel(devname.Name(home.Dir())) } + +// ConnectionEndedLine keeps process and pipe internals in the serving log. +func ConnectionEndedLine(name string, err error) string { + line := "the connection to " + name + " ended" + if errors.Is(err, context.DeadlineExceeded) { + line += " — it stopped answering" + } + return line +} diff --git a/internal/pair/service.go b/internal/pair/service.go index a1801ea64c..f0baa641c2 100644 --- a/internal/pair/service.go +++ b/internal/pair/service.go @@ -2,12 +2,12 @@ package pair // Which relay this machine uses. // -// THERE IS NO BUILT-IN DEFAULT, AND THAT IS DELIBERATE. The relay service is -// not deployed; a default pointing at an address that does not answer would -// turn "nothing is set up" — which has a next step — into "the network is -// broken", which does not. So an unset relay is its own state with its own -// sentence ([NoRelay]), and the day the service goes live is the day a default -// belongs in this file and not before. +// The relay a machine is reached through is the one it syncs through, unless +// somebody said otherwise. Sync already found an address — the person's +// variable, the relay a pairing saved, or the hosted default — and a second +// answer here would be a machine whose chats sync while `codeaf serve` says +// there is nowhere to be reachable from. So the caller hands in sync's answer +// and it is the last word, after the overrides this package has always read. // // A MACHINE REMEMBERS THE RELAY IT WAS PAIRED THROUGH, so a device that has // used two of them reaches each machine through the right one without anybody @@ -30,27 +30,27 @@ const RelayEnv = "CODEAF_RELAY" // a shell. func RelayFile() string { return filepath.Join(Dir(), "relay") } -// Relay is the address this machine uses when nothing else says otherwise. +// Relay is the address this machine uses: the override, the saved file, then +// sync, the address this machine syncs through (empty when sync is off). // Empty means none is set up. -func Relay() string { +func Relay(sync string) string { if set := strings.TrimSpace(env.Get(RelayEnv)); set != "" { return set } - raw, err := os.ReadFile(RelayFile()) - if err != nil { - return "" + if raw, err := os.ReadFile(RelayFile()); err == nil && strings.TrimSpace(string(raw)) != "" { + return strings.TrimSpace(string(raw)) } - return strings.TrimSpace(string(raw)) + return strings.TrimSpace(sync) } // RelayFor is the address to reach one machine through: the override, then the -// relay that machine was paired through, then the configured one. -func RelayFor(known Known, found bool) string { +// relay that machine was paired through, then [Relay]. +func RelayFor(known Known, found bool, sync string) string { if set := strings.TrimSpace(env.Get(RelayEnv)); set != "" { return set } if found && strings.TrimSpace(known.Service) != "" { return strings.TrimSpace(known.Service) } - return Relay() + return Relay(sync) } diff --git a/internal/pair/state.go b/internal/pair/state.go index 10eb0226ad..b05463bd39 100644 --- a/internal/pair/state.go +++ b/internal/pair/state.go @@ -3,6 +3,7 @@ package pair import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/store" + "github.com/Agent-Field/codeaf/internal/teams" "os" "path/filepath" ) @@ -52,7 +53,14 @@ func replaceState(home, profile string) error { return err } } - for _, name := range []string{"preferences-sync.ledger", "memory-sync.ledger", "provider-keys.ledger", "vault.enc", "vault.enc.next"} { + names := []string{"preferences-sync.ledger", "memory-sync.ledger", "provider-keys.ledger", "vault.enc", "vault.enc.next"} + if config.TeamsShareAt(profile) { + if _, err := teams.MoveAside(profile); err != nil && !os.IsNotExist(err) { + return err + } + names = append(names, "teams-sync.ledger", "teams-traffic-sync.ledger") + } + for _, name := range names { if err := os.Remove(filepath.Join(home, name)); err != nil && !os.IsNotExist(err) { return err } diff --git a/internal/pair/task_tunnel_test.go b/internal/pair/task_tunnel_test.go new file mode 100644 index 0000000000..681c98ccb2 --- /dev/null +++ b/internal/pair/task_tunnel_test.go @@ -0,0 +1,159 @@ +package pair + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "io" + "net" + "testing" + "time" +) + +// S6 leaves the machine handshake on its original protocol without recording +// the channel binding that only the separately opted-in task door needs. +func TestPairingAndTheMachineDoorKeepTheirOriginalHandshake(t *testing.T) { + a, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + b, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + type answer struct { + tunnel *Tunnel + err error + } + result := make(chan answer, 1) + go func() { + var intent [1]byte + if _, err := io.ReadFull(far, intent[:]); err != nil { + result <- answer{err: err} + return + } + server, _, err := acceptAsMachine(far, Device{private: b}, func(key []byte, label string) error { + if !bytes.Equal(key, a.PublicKey().Bytes()) || label != "chat computer" { + return io.ErrUnexpectedEOF + } + return nil + }) + result <- answer{server, err} + }() + client, err := connectAsSurface(near, b.PublicKey().Bytes(), Device{private: a}, "chat computer") + if err != nil { + t.Fatal(err) + } + server := <-result + if server.err != nil { + t.Fatal(server.err) + } + if len(client.ChannelBinding()) != 0 || len(server.tunnel.ChannelBinding()) != 0 { + t.Fatal("the original machine handshake recorded a task channel binding") + } +} + +// Contract 14 depends on an encrypted task channel whose binding both devices +// can sign; the separate prologue prevents a machine door accepting a task. +func TestTaskTunnelBindsBothEndsAndSeparatesMachineDoors(t *testing.T) { + key := func() *ecdh.PrivateKey { + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return k + } + a, b := key(), key() + t.Run("binding and plaintext", func(t *testing.T) { + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + result := make(chan *Tunnel, 1) + failed := make(chan error, 1) + go func() { + tunnel, err := AcceptWithKey(far, b) + if err != nil { + failed <- err + return + } + result <- tunnel + _, err = tunnel.Write([]byte("only the endpoints can read this")) + failed <- err + }() + client, err := ConnectWithKey(near, b.PublicKey().Bytes(), a) + if err != nil { + t.Fatal(err) + } + var server *Tunnel + select { + case server = <-result: + case err := <-failed: + t.Fatal(err) + } + binding := client.ChannelBinding() + if len(binding) == 0 || !bytes.Equal(binding, server.ChannelBinding()) { + t.Fatal("ends do not share the completed handshake binding") + } + binding[0] ^= 1 + if bytes.Equal(binding, client.ChannelBinding()) { + t.Fatal("a caller could mutate the binding") + } + if !bytes.Equal(server.Peer(), a.PublicKey().Bytes()) { + t.Fatal("server did not authenticate the initiator static") + } + data := make([]byte, len("only the endpoints can read this")) + if _, err := io.ReadFull(client, data); err != nil { + t.Fatal(err) + } + if string(data) != "only the endpoints can read this" { + t.Fatalf("plaintext = %q", data) + } + if err := <-failed; err != nil { + t.Fatal(err) + } + }) + t.Run("task cannot enter a machine door", func(t *testing.T) { + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + machine := Device{private: b} + finished := make(chan error, 1) + go func() { + defer far.Close() + var intent [1]byte + _, _ = io.ReadFull(far, intent[:]) + _, _, err := acceptAsMachine(far, machine, func([]byte, string) error { t.Error("task reached machine authorization"); return nil }) + finished <- err + }() + if _, err := ConnectWithKey(near, b.PublicKey().Bytes(), a); err == nil { + t.Fatal("task handshake completed against a machine door") + } + if err := <-finished; err == nil { + t.Fatal("machine accepted task handshake") + } + }) + t.Run("machine cannot enter a task door", func(t *testing.T) { + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + finished := make(chan error, 1) + go func() { defer far.Close(); _, err := AcceptWithKey(far, b); finished <- err }() + if _, err := connectAsSurface(near, b.PublicKey().Bytes(), Device{private: a}, "laptop"); err == nil { + t.Fatal("machine handshake completed against a task door") + } + if err := <-finished; err == nil { + t.Fatal("task door accepted machine handshake") + } + }) +} diff --git a/internal/pair/teams_relay_test.go b/internal/pair/teams_relay_test.go new file mode 100644 index 0000000000..6913de5990 --- /dev/null +++ b/internal/pair/teams_relay_test.go @@ -0,0 +1,810 @@ +package pair_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/teams/teamstest" +) + +type relayTeamsRig struct { + a, b *syncsetup.Sync + requests *atomic.Int64 +} + +// Pair through an actual loopback mailbox, then use the authenticated directory +// and sealed store on that same relay. Neither side shares a home or vault file. +func setRelayTeamSharing(t *testing.T, dir, value string) { + t.Helper() + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTeamsShare) + if !ok { + t.Fatal("missing sharing setting") + } + if err := row.Apply(value); err != nil { + t.Fatal(err) + } +} + +func newRelayTeamsRig(t *testing.T) relayTeamsRig { return relayTeamsRigSharing(t, true) } + +func relayTeamsRigSharing(t *testing.T, share bool) relayTeamsRig { + t.Helper() + celltest.Available(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_PROFILE_DIR", "") + aHome, bHome := t.TempDir(), t.TempDir() + t.Setenv("CODEAF_HOME", aHome) + own, err := identity.Ensure(aHome) + if err != nil { + t.Fatal(err) + } + var mu sync.Mutex + dirs := map[string]directory.Directory{} + stores := map[string]blobstore.Store{} + auth := reqsign.AuthenticateAt(time.Now) + // Both exact and subtree paths must use one mailbox's generation and state. + box := pairbox.NewMemory(pairbox.DefaultLimits, nil) + boxHandler := pairbox.Handler(box, pairbox.SocketPeer) + root := http.NewServeMux() + root.Handle(pairbox.Path, boxHandler) + root.Handle(pairbox.Path+"/", boxHandler) + root.Handle("/v1/dir/", directory.Handler(auth, func(id string) (directory.Directory, error) { + mu.Lock() + defer mu.Unlock() + if dirs[id] == nil { + dirs[id] = directory.NewMemory(time.Now) + } + return dirs[id], nil + })) + root.Handle("/v1/store/", blobstore.Handler(auth, func(id string) (blobstore.Store, error) { + mu.Lock() + defer mu.Unlock() + if stores[id] == nil { + stores[id] = blobstore.NewMemory() + } + return stores[id], nil + })) + requests := new(atomic.Int64) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); root.ServeHTTP(w, r) })) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + _ = server.Listener.Close() + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + t.Setenv(syncsetup.URLVar, server.URL) + t.Setenv(syncsetup.IntervalVar, "") + route := pair.Mailbox{Box: pairbox.NewHTTP(server.URL, server.Client()), URL: server.URL, Host: "127.0.0.1"} + ui := newTeamPairScreen() + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := pair.Offer(ctx, route, pair.Grant{Identity: own, SyncURL: server.URL}, ui) + done <- err + }() + code := ui.nextCode(t) + if _, err := pair.Join(ctx, route, pair.Joining{Home: bHome, Label: "desktop"}, code.Shown(), newTeamPairScreen()); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + // pair.Offer's CLI normally ends solo; the fixture uses the same explicit door. + if err := identity.EndSolo(aHome); err != nil { + t.Fatal(err) + } + open := func(home string) *syncsetup.Sync { + s, ok, err := syncsetup.Open(home) + if err != nil || !ok { + t.Fatalf("open: %v %v", ok, err) + } + return s + } + a, b := open(aHome), open(bHome) + if share { + setRelayTeamSharing(t, aHome, "on") + setRelayTeamSharing(t, bHome, "on") + } + for i, s := range []*syncsetup.Sync{a, b} { + if err := syncsetup.PutOwnDevice(t.Context(), s.Dir, s.Identity, s.Device, []string{"laptop", "desktop"}[i]); err != nil { + t.Fatal(err) + } + } + return relayTeamsRig{a, b, requests} +} +func (r relayTeamsRig) round(t *testing.T) { + t.Helper() + for _, s := range []*syncsetup.Sync{r.a, r.b, r.a, r.b} { + if err := s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + } +} +func relayTeamEdit(t *testing.T, s *syncsetup.Sync, fn func(*teams.File)) { + t.Helper() + if err := teams.Update(s.Home, func(f *teams.File) error { fn(f); return nil }); err != nil { + t.Fatal(err) + } +} +func relayTeamRead(t *testing.T, s *syncsetup.Sync) *teams.File { + t.Helper() + f, err := teams.Load(s.Home) + if err != nil { + t.Fatal(err) + } + return f +} +func relayTeamCreate(t *testing.T, r relayTeamsRig) { + t.Helper() + var members []teams.Member + for _, title := range []string{"storage", "network"} { + c, err := cell.Create(r.a.Home, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(c.Root, cell.TranscriptPath) + if err := os.WriteFile(file, []byte("journal\n"), 0600); err != nil { + t.Fatal(err) + } + members = append(members, teams.Member{Key: file, File: file, Word: title, JoinedAt: time.Unix(100, 0)}) + } + relayTeamEdit(t, r.a, func(f *teams.File) { + f.Teams = append(f.Teams, teams.Team{ID: "alpha", Name: "alpha", Members: members, Made: time.Unix(10, 0).UTC()}) + }) + r.round(t) +} + +func TestTeamsPairCreateRenameAndOfflineDeleteWinsOverLaterRename(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + a, b := relayTeamRead(t, r.a), relayTeamRead(t, r.b) + if len(b.Teams) != 1 || b.Teams[0].Name != "alpha" || len(b.Teams[0].Members) != 2 { + t.Fatalf("received %+v", b) + } + for i, m := range b.Teams[0].Members { + if m.Handle != a.Teams[0].Members[i].Handle { + t.Fatal("handles differ") + } + if _, remote := teams.RemoteCell(m.Key); !remote || m.File != "" { + t.Fatal("foreign path escaped into local opener") + } + } + relayTeamEdit(t, r.b, func(f *teams.File) { f.Teams[0].Name = "renamed" }) + r.round(t) + if got := relayTeamRead(t, r.a); len(got.Teams) != 1 || got.Teams[0].Name != "renamed" { + t.Fatal("rename did not replace old name") + } + relayTeamEdit(t, r.a, func(f *teams.File) { f.Teams = nil }) + if err := r.a.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + relayTeamEdit(t, r.b, func(f *teams.File) { f.Teams[0].Name = "offline rename after delete" }) + r.round(t) + if len(relayTeamRead(t, r.a).Teams) != 0 || len(relayTeamRead(t, r.b).Teams) != 0 { + t.Fatal("offline rename resurrected team") + } +} + +func TestTeamsConcurrentMembersKeepBothHandlesAndOrderInEitherSyncOrder(t *testing.T) { + for _, reverse := range []bool{false, true} { + t.Run(map[bool]string{false: "a-first", true: "b-first"}[reverse], func(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + for i, s := range []*syncsetup.Sync{r.a, r.b} { + relayTeamEdit(t, s, func(f *teams.File) { + f.Teams[0].Members = append(f.Teams[0].Members, teams.Member{Key: []string{"x", "y"}[i], Word: []string{"lexer", "parser"}[i], JoinedAt: time.Unix(200, int64(i))}) + }) + } + if reverse { + r.a, r.b = r.b, r.a + } + r.round(t) + a, b := relayTeamRead(t, r.a).Teams[0], relayTeamRead(t, r.b).Teams[0] + portable := func(t teams.Team) []string { + var out []string + for _, m := range t.Members { + out = append(out, m.Word+"/"+m.Handle) + } + return out + } + if len(a.Members) != 4 || !reflect.DeepEqual(portable(a), portable(b)) { + t.Fatalf("concurrent membership: %+v %+v", a, b) + } + }) + } +} + +// Both homes use a real paired relay. Only the display mirror receives the +// other writer's window; an older local-log reader therefore sees none of it. +func TestPairedTrafficMirrorsOnceWithoutWritingTheReceivingLog(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + for _, kind := range []string{teams.KindDirective, teams.KindStart, teams.KindQuestion, teams.KindStop} { + if err := teams.AppendTraffic(r.a.Home, "alpha", teams.Entry{Kind: kind, From: teams.FromManager, To: "storage", Text: "history " + kind}); err != nil { + t.Fatal(err) + } + } + first, err := teams.AppendTrafficID(r.a.Home, "alpha", teams.Entry{Kind: teams.KindQuestion, From: "storage", To: teams.ToManager, Text: "question"}) + if err != nil { + t.Fatal(err) + } + if err := teams.AppendTraffic(r.a.Home, "alpha", teams.Entry{Kind: teams.KindAnswer, From: teams.FromManager, To: "storage", Text: "answer", Reply: first, Answers: first}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(teams.TrafficPath(r.a.Home, "alpha")) + for range 5 { + r.round(t) + } + local, err := teams.ReadTraffic(r.b.Home, "alpha", "", 0) + if err != nil || len(local) != 0 { + t.Fatalf("remote instruction in delivery log: %+v %v", local, err) + } + if _, err := os.Stat(teams.TrafficPath(r.b.Home, "alpha")); !os.IsNotExist(err) { + t.Fatal("receiving local log was created") + } + display, err := teams.ReadHistory(r.b.Home, "alpha", 0) + if err != nil || len(display) != 6 { + t.Fatalf("mirror: %+v %v", display, err) + } + if display[5].Reply != display[4].ID || display[5].Answers != display[4].ID { + t.Fatal("mirror lost its thread") + } + for _, line := range display { + if line.ID != line.Origin { + t.Fatal("mirror id is not portable") + } + } + after, _ := os.ReadFile(teams.TrafficPath(r.a.Home, "alpha")) + if !bytes.Equal(before, after) { + t.Fatal("local bytes changed") + } +} + +func TestTeamsConcurrentParentLoopHandleCollisionAndDeletedManagerConverge(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + relayTeamEdit(t, r.a, func(f *teams.File) { f.Teams = append(f.Teams, teams.Team{ID: "beta", Name: "beta"}) }) + r.round(t) + relayTeamEdit(t, r.a, func(f *teams.File) { + i := teams.Index(f.Teams, "alpha") + f.Teams[i].Parent = "beta" + f.Teams[i].Manager = f.Teams[i].Members[0].Key + f.Teams[i].Members = append(f.Teams[i].Members, teams.Member{Key: "x", Word: "lexer", Handle: "shared", JoinedAt: time.Unix(200, 0)}) + }) + relayTeamEdit(t, r.b, func(f *teams.File) { + i := teams.Index(f.Teams, "beta") + f.Teams[i].Parent = "alpha" + i = teams.Index(f.Teams, "alpha") + f.Teams[i].Members = f.Teams[i].Members[1:] + f.Teams[i].Members = append(f.Teams[i].Members, teams.Member{Key: "y", Word: "parser", Handle: "shared", JoinedAt: time.Unix(201, 0)}) + }) + r.round(t) + a, b := relayTeamRead(t, r.a), relayTeamRead(t, r.b) + for _, f := range []*teams.File{a, b} { + alpha, _ := f.Team("alpha") + beta, _ := f.Team("beta") + if alpha.Parent != "" || beta.Parent != "alpha" || alpha.Manager != "" || len(alpha.Members) != 3 { + t.Fatalf("merged laws: %+v", f.Teams) + } + handles := map[string]bool{} + for _, member := range alpha.Members { + if handles[member.Handle] { + t.Fatal("duplicate handle survived") + } + handles[member.Handle] = true + if member.Word == "lexer" && member.Handle != "shared" { + t.Fatal("earlier join lost handle") + } + } + } + for i := range a.Teams { + if a.Teams[i].ID != b.Teams[i].ID || a.Teams[i].Parent != b.Teams[i].Parent { + t.Fatal("parent repair differed") + } + for j := range a.Teams[i].Members { + am, bm := a.Teams[i].Members[j], b.Teams[i].Members[j] + if am.Word != bm.Word || am.Handle != bm.Handle || am.Home != bm.Home { + t.Fatal("membership repair differed") + } + } + } +} + +func TestTeamsIdleProfileRoundSharesEditsWithoutPublishingAChat(t *testing.T) { + r := newRelayTeamsRig(t) + r.a.Interval = 25 * time.Millisecond + r.b.Interval = 25 * time.Millisecond + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}, 2) + for _, s := range []*syncsetup.Sync{r.a, r.b} { + go func() { s.RunState(ctx); done <- struct{}{} }() + } + defer func() { cancel(); <-done; <-done }() + wait := func(want string) { + t.Helper() + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + for { + f := relayTeamRead(t, r.b) + if want == "" && len(f.Teams) == 0 { + return + } + if len(f.Teams) == 1 && f.Teams[0].Name == want { + return + } + select { + case <-deadline.C: + t.Fatalf("idle round did not reach %q", want) + case <-tick.C: + } + } + } + relayTeamEdit(t, r.a, func(f *teams.File) { f.Teams = []teams.Team{{ID: "alpha", Name: "alpha"}} }) + wait("alpha") + relayTeamEdit(t, r.a, func(f *teams.File) { f.Teams[0].Name = "renamed while idle" }) + wait("renamed while idle") + relayTeamEdit(t, r.a, func(f *teams.File) { f.Teams = nil }) + wait("") +} + +func TestUnpairedTeamsAndTrafficKeepOriginalBytesAndMakeNoRequest(t *testing.T) { + r := newRelayTeamsRig(t) + home := t.TempDir() + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + s, ok, err := syncsetup.Open(home) + if err != nil || !ok { + t.Fatalf("unpaired setup: %v %v", ok, err) + } + cursorPath := filepath.Join(home, "chat", ".cell", "team.json") + if err := os.MkdirAll(filepath.Dir(cursorPath), 0700); err != nil { + t.Fatal(err) + } + cursors := []byte(`{"read":{"alpha":"000000000007"}}`) + if err := os.WriteFile(cursorPath, cursors, 0600); err != nil { + t.Fatal(err) + } + beforeRequests := r.requests.Load() + // These wire shapes predate relay teams. Fixed timestamps make the byte + // comparison independent of clocks and random team ids. + for _, name := range []string{"alpha", "renamed"} { + relayTeamEdit(t, s, func(f *teams.File) { + f.Teams = []teams.Team{{ID: "alpha", Name: name, Made: time.Unix(10, 0).UTC(), Members: []teams.Member{{Key: "chat", File: "/local/chat", Where: "/local", Word: "storage", Handle: "storage"}}}} + }) + before, _ := os.ReadFile(teams.Path(home)) + // app/v3 keeps teams in an SQLite store (#2066), so the old file's + // shape is checked on the document the store answers. + var expected any + legacy := `{"version":2,"teams":[{"id":"alpha","name":"` + name + `","parent":"","members":[{"key":"chat","file":"/local/chat","where":"/local","word":"storage","handle":"storage"}],"manager":"","made":"1970-01-01T00:00:10Z"}]}` + // Compare both semantic shape and byte identity across an idle sync round. + if err := json.Unmarshal(teamstest.JSON(t, home), &expected); err != nil { + t.Fatal(err) + } + var oldShape any + if err := json.Unmarshal([]byte(legacy), &oldShape); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, oldShape) { + t.Fatalf("unpaired shape changed: %s", teamstest.JSON(t, home)) + } + if err := s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(teams.Path(home)) + if !bytes.Equal(before, after) { + t.Fatal("unpaired sync changed teams bytes") + } + } + relayTeamEdit(t, s, func(f *teams.File) { + f.Teams[0].Members = append(f.Teams[0].Members, teams.Member{Key: "second", Word: "network"}) + }) + if err := teams.AppendTraffic(home, "alpha", teams.Entry{At: time.Unix(11, 0).UTC(), Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: "original bytes"}); err != nil { + t.Fatal(err) + } + traffic, _ := os.ReadFile(teams.TrafficPath(home, "alpha")) + golden := "{\"id\":\"000000000001\",\"at\":\"1970-01-01T00:00:11Z\",\"kind\":\"note\",\"from\":\"you\",\"to\":\"room\",\"text\":\"original bytes\"}\n" + if string(traffic) != golden { + t.Fatalf("unpaired traffic bytes changed: %s", traffic) + } + relayTeamEdit(t, s, func(f *teams.File) { f.Teams = nil }) + if err := s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(teams.TrafficPath(home, "alpha")) + if !bytes.Equal(traffic, after) || r.requests.Load() != beforeRequests { + t.Fatal("unpaired sync touched history or made a request") + } + afterCursors, _ := os.ReadFile(cursorPath) + if !bytes.Equal(afterCursors, cursors) { + t.Fatal("unpaired sync changed chat cursors") + } + if _, err := os.Stat(filepath.Join(home, "teams-sync.ledger")); !os.IsNotExist(err) { + t.Fatal("unpaired teams ledger created") + } +} + +func TestTrafficMirrorsKeepConcurrentLocalSequenceCollisionsSeparate(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + for i, s := range []*syncsetup.Sync{r.a, r.b} { + id, err := teams.AppendTrafficID(s.Home, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: []string{"left", "right"}[i]}) + if err != nil || id != "000000000001" { + t.Fatalf("fixture did not mint colliding ids: %s %v", id, err) + } + } + for range 3 { + r.round(t) + } + for _, s := range []*syncsetup.Sync{r.a, r.b} { + lines, err := teams.ReadHistory(s.Home, "alpha", 0) + if err != nil || len(lines) != 2 { + t.Fatalf("concurrent origin union: %+v %v", lines, err) + } + texts := map[string]bool{} + imported := 0 + for _, line := range lines { + texts[line.Text] = true + if line.Origin != "" { + imported++ + } + } + if !texts["left"] || !texts["right"] || imported != 1 { + t.Fatal("one origin overwrote the other") + } + } +} + +// Saturating many teams must not make the shared object grow with local log +// size. Compare the actual encoded Traffic entries and the encrypted file. +func TestPairedTrafficAcrossTenTeamsRespectsEveryBudgetAndQuietRoundsWriteNothing(t *testing.T) { + r := newRelayTeamsRig(t) + for _, s := range []*syncsetup.Sync{r.a, r.b} { + if err := teams.Update(s.Home, func(f *teams.File) error { + for i := 0; i < 10; i++ { + f.Teams = append(f.Teams, teams.Team{ID: fmt.Sprintf("team-%02d", i), Name: fmt.Sprintf("team-%02d", i)}) + } + return nil + }); err != nil { + t.Fatal(err) + } + for i := 0; i < 10; i++ { + for j := 0; j < 80; j++ { + if err := teams.AppendTraffic(s.Home, fmt.Sprintf("team-%02d", i), teams.Entry{At: time.Unix(int64(i*100+j+1), 0), Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: strings.Repeat("界", 1800)}); err != nil { + t.Fatal(err) + } + } + } + } + r.round(t) + for _, s := range []*syncsetup.Sync{r.a, r.b} { + v, err := keys.Open(s.Home) + if err != nil { + t.Fatal(err) + } + entries, err := v.Entries("home:windows:team-traffic") + if err != nil { + t.Fatal(err) + } + totals := map[string]int{} + recent := map[string]bool{} + for _, e := range entries { + _, device, _ := strings.Cut(e.Name, "/") + encoded, _ := json.Marshal(e) + totals[device] += len(encoded) + if strings.HasPrefix(e.Name, "team-09/") { + recent[device] = true + } + if strings.HasPrefix(e.Name, "team-00/") { + t.Fatal("least-active team displaced a recent window") + } + var lines []teams.Entry + if err := json.Unmarshal([]byte(e.Value), &lines); err != nil { + t.Fatal(err) + } + if len(lines) > teams.TrafficSyncLines || len(e.Value) > teams.TrafficSyncBytes || len(lines) == 0 { + t.Fatal("window over bound", e.Name) + } + for _, line := range lines { + if len(line.Text) > teams.TrafficSyncTextBytes+len(" …") || !strings.HasSuffix(line.Text, " …") { + t.Fatal("long line omitted or not shortened") + } + } + } + if len(recent) != 2 { + t.Fatal("newest team did not receive the device budget", recent) + } + if len(totals) != 2 { + t.Fatal("missing writer", totals) + } + total := 0 + for _, n := range totals { + if n > teams.TrafficSyncDeviceBytes { + t.Fatalf("device share %d", n) + } + total += n + } + if total > teams.TrafficSyncDeviceBytes*2 { + t.Fatal("aggregate budget exceeded") + } + } + beforeA, _ := os.ReadFile(filepath.Join(r.a.Home, "vault.enc")) + beforeB, _ := os.ReadFile(filepath.Join(r.b.Home, "vault.enc")) + r.round(t) + afterA, _ := os.ReadFile(filepath.Join(r.a.Home, "vault.enc")) + afterB, _ := os.ReadFile(filepath.Join(r.b.Home, "vault.enc")) + if !bytes.Equal(beforeA, afterA) || !bytes.Equal(beforeB, afterB) { + t.Fatal("unchanged round rewrote vault") + } +} + +func TestPairedMemberLocationUsesTheCurrentHolderEvenWithALocalTranscript(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + f, _ := teams.Snapshot(r.a.Home) + member := f.Teams[0].Members[0] + c, err := cell.OpenAt(filepath.Dir(filepath.Dir(member.File)), filepath.Base(filepath.Dir(filepath.Dir(member.File)))) + if err != nil { + t.Fatal(err) + } + created, err := r.a.Dir.Create(t.Context(), c.ID, directory.CellInit{Head: strings.Repeat("1", 64), Class: string(cell.HostBound)}) + if err != nil { + t.Fatal(err) + } + if name, err := r.b.TeamComputer(t.Context(), teams.Member{Key: teams.RemoteKey(c.ID), SyncID: "cell:" + c.ID}); err != nil || name != "laptop" { + t.Fatal(name, err) + } + _, err = r.b.Dir.Acquire(t.Context(), c.ID, directory.AcquireOpts{Force: true, Expect: &directory.LeaseExpectation{Device: r.a.Device.ID(), Fence: created.Cell.Lease.Fence}}) + if err != nil { + t.Fatal(err) + } + if _, err := r.a.Rows(t.Context()); err != nil { + t.Fatal(err) + } + name, err := r.a.TeamComputer(t.Context(), member) + if err != nil || name != "desktop" { + t.Fatalf("stale transcript hid remote holder: %q %v", name, err) + } +} + +// Display failures cannot hold unrelated profile changes behind the mirror. +func TestUnwritableTrafficMirrorStillSharesChangedPreferences(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + path := teams.TrafficMirrorPath(r.a.Home, "alpha") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + changes := map[string]string{config.KeyEffort: `"low"`} + if err := config.ApplyPreferences(r.a.Home, changes); err != nil { + t.Fatal(err) + } + r.round(t) + got, err := config.ReadPreferences(r.b.Home) + if err != nil || got[config.KeyEffort] != changes[config.KeyEffort] { + t.Fatal("mirror blocked preference", got, err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := teams.AppendTraffic(r.b.Home, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "retry mirror"}); err != nil { + t.Fatal(err) + } + r.round(t) + lines, err := teams.ReadHistory(r.a.Home, "alpha", 0) + if err != nil || len(lines) != 1 || lines[0].Text != "retry mirror" { + t.Fatal("mirror did not retry", lines, err) + } +} + +func TestDamagedTeamsStillSharesChangedPreferences(t *testing.T) { + r := newRelayTeamsRig(t) + relayTeamCreate(t, r) + if err := os.WriteFile(teams.Path(r.a.Home), []byte("damaged"), 0600); err != nil { + t.Fatal(err) + } + changes := map[string]string{config.KeyEffort: `"low"`} + if err := config.ApplyPreferences(r.a.Home, changes); err != nil { + t.Fatal(err) + } + r.round(t) + got, err := config.ReadPreferences(r.b.Home) + if err != nil || got[config.KeyEffort] != changes[config.KeyEffort] { + t.Fatal("damaged team blocked unrelated state", got, err) + } +} + +// The external-package fixture uses only the public pairing screen contract. +type teamPairScreen struct{ codes chan *pair.Code } + +func newTeamPairScreen() *teamPairScreen { return &teamPairScreen{codes: make(chan *pair.Code, 8)} } +func (s *teamPairScreen) Show(code *pair.Code, _ string) { s.codes <- code } +func (*teamPairScreen) Burned() {} +func (*teamPairScreen) Waiting(string) {} +func (*teamPairScreen) Ask(context.Context, string, string) bool { return true } +func (s *teamPairScreen) nextCode(t *testing.T) *pair.Code { + t.Helper() + select { + case code := <-s.codes: + return code + case <-time.After(10 * time.Second): + t.Fatal("no pairing code shown") + return nil + } +} + +func relayTeamVaultValues(t *testing.T, s *syncsetup.Sync) map[string]string { + t.Helper() + vault, err := keys.Open(s.Home) + if err != nil { + t.Fatal(err) + } + values := map[string]string{} + for _, scope := range []string{"home:keys:teams", "home:windows:team-traffic"} { + entries, err := vault.Entries(scope) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + values[scope+"/"+entry.Name] = entry.Value + } + removed, err := vault.Removed(scope + "/") + if err != nil { + t.Fatal(err) + } + for _, id := range removed { + values["removed/"+id] = "" + } + + } + return values +} + +func TestTeamSharingNeedsBothLocalSwitchesAndOffRoundsPreserveForeignValues(t *testing.T) { + r := relayTeamsRigSharing(t, false) + setRelayTeamSharing(t, r.a.Home, "on") + relayTeamEdit(t, r.a, func(f *teams.File) { f.Teams = []teams.Team{{ID: "alpha", Name: "from laptop"}} }) + relayTeamEdit(t, r.b, func(f *teams.File) { f.Teams = []teams.Team{{ID: "private", Name: "desktop only"}} }) + if err := teams.AppendTraffic(r.a.Home, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: "from laptop"}); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(teams.Path(r.b.Home)) + if err != nil { + t.Fatal(err) + } + if err := r.a.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + values := relayTeamVaultValues(t, r.a) + if len(values) == 0 { + t.Fatal("enabled computer published no teams") + } + r.round(t) + after, err := os.ReadFile(teams.Path(r.b.Home)) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("off round changed teams", err) + } + if got := relayTeamVaultValues(t, r.b); !reflect.DeepEqual(values, got) { + t.Fatal("off round changed foreign team values", values, got) + } + for _, name := range []string{"teams-sync.ledger", "teams-traffic-sync.ledger", "teams/alpha/elsewhere.jsonl"} { + if _, err := os.Stat(filepath.Join(r.b.Home, name)); !os.IsNotExist(err) { + t.Fatal("off round created team sharing file", name, err) + } + } + // The existing handle already knows this vault. Enabling must restore it + // without waiting for an unrelated remote change or a restart. + setRelayTeamSharing(t, r.b.Home, "on") + r.round(t) + f := relayTeamRead(t, r.b) + if _, ok := f.Team("alpha"); !ok { + t.Fatal("enabling did not receive the existing shared team") + } + received, err := os.ReadFile(teams.Path(r.b.Home)) + if err != nil { + t.Fatal(err) + } + mirror, err := os.ReadFile(filepath.Join(r.b.Home, "teams/alpha/elsewhere.jsonl")) + if err != nil { + t.Fatal(err) + } + setRelayTeamSharing(t, r.b.Home, "off") + relayTeamEdit(t, r.a, func(f *teams.File) { f.Teams[0].Name = "changed while desktop sharing off" }) + r.round(t) + after, err = os.ReadFile(teams.Path(r.b.Home)) + if err != nil || !bytes.Equal(received, after) { + t.Fatal("disabling changed received teams", err) + } + afterMirror, err := os.ReadFile(filepath.Join(r.b.Home, "teams/alpha/elsewhere.jsonl")) + if err != nil || !bytes.Equal(mirror, afterMirror) { + t.Fatal("disabling rewrote mirror", err) + } +} + +func TestPairedSharingOffKeepsLocalBytesEmptyTeamVaultAndExistingPolling(t *testing.T) { + r := relayTeamsRigSharing(t, false) + // Match the existing hosted polling fixture's ordinary preference vault, + // so every quiet round includes both its directory and object reads. + if err := config.ApplyPreferences(r.a.Home, map[string]string{config.KeyEffort: `"low"`}); err != nil { + t.Fatal(err) + } + relayTeamEdit(t, r.a, func(f *teams.File) { + f.Teams = []teams.Team{{ID: "alpha", Name: "local", Members: []teams.Member{{Key: "local-chat", File: "/local/chat", Word: "worker"}}}} + }) + relayTeamEdit(t, r.b, func(f *teams.File) { f.Teams = []teams.Team{{ID: "beta", Name: "private"}} }) + for _, s := range []*syncsetup.Sync{r.a, r.b} { + if err := teams.AppendTraffic(s.Home, relayTeamRead(t, s).Teams[0].ID, teams.Entry{At: time.Unix(11, 0).UTC(), Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: "original bytes"}); err != nil { + t.Fatal(err) + } + } + before := map[string]string{} + for _, s := range []*syncsetup.Sync{r.a, r.b} { + for _, path := range []string{teams.Path(s.Home), teams.TrafficPath(s.Home, relayTeamRead(t, s).Teams[0].ID)} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + before[path] = string(raw) + } + } + r.round(t) + start := r.requests.Load() + r.round(t) + if got := r.requests.Load() - start; got != 8 { + t.Fatalf("four unchanged rounds made %d requests, want existing two-per-round polling", got) + } + for path, want := range before { + raw, err := os.ReadFile(path) + if err != nil || string(raw) != want { + t.Fatal("off round changed local file", path, err) + } + } + for _, s := range []*syncsetup.Sync{r.a, r.b} { + if len(relayTeamVaultValues(t, s)) != 0 { + t.Fatal("default-off vault contains team values") + } + } + raw, _ := os.ReadFile(teams.Path(r.a.Home)) + for _, field := range []string{"sync_id", "foreign_start"} { + if strings.Contains(string(raw), field) { + t.Fatal("new field while sharing off", field) + } + } + traffic, _ := os.ReadFile(teams.TrafficPath(r.a.Home, "alpha")) + const golden = "{\"id\":\"000000000001\",\"at\":\"1970-01-01T00:00:11Z\",\"kind\":\"note\",\"from\":\"you\",\"to\":\"room\",\"text\":\"original bytes\"}\n" + if string(traffic) != golden { + t.Fatalf("local Traffic format changed: %s", traffic) + } +} diff --git a/internal/pair/teams_replace_test.go b/internal/pair/teams_replace_test.go new file mode 100644 index 0000000000..33fc4f5e69 --- /dev/null +++ b/internal/pair/teams_replace_test.go @@ -0,0 +1,91 @@ +package pair + +import ( + "bytes" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/teams/teamstest" + "os" + "path/filepath" + "testing" +) + +func TestTeamsReplaceMovesLocalTeamsAsideAndKeepsTraffic(t *testing.T) { + home := t.TempDir() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: home}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if err := teams.Update(home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "alpha", Name: "local"}}; return nil }); err != nil { + t.Fatal(err) + } + before := teamstest.JSON(t, home) + if err := teams.AppendTraffic(home, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: "keep history"}); err != nil { + t.Fatal(err) + } + traffic, _ := os.ReadFile(teams.TrafficPath(home, "alpha")) + if err := os.WriteFile(filepath.Join(home, "teams-sync.ledger"), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "teams-traffic-sync.ledger"), []byte(`"2026-01-01T00:00:00Z"`), 0600); err != nil { + t.Fatal(err) + } + if err := replaceState(home, home); err != nil { + t.Fatal(err) + } + aside, _ := filepath.Glob(teams.Path(home) + ".unreadable-*[0-9]") + if len(aside) != 1 { + t.Fatal("local teams were not moved aside") + } + // app/v3 keeps teams in an SQLite store (#2066): what was set aside is + // read back as a store, with its WAL beside it, in a profile of its own. + kept := t.TempDir() + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.Rename(aside[0]+suffix, teams.Path(kept)+suffix); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } + after, _ := os.ReadFile(teams.TrafficPath(home, "alpha")) + if !bytes.Equal(before, teamstest.JSON(t, kept)) || !bytes.Equal(traffic, after) { + t.Fatal("replace discarded local history") + } + if _, err := os.Stat(teams.Path(home)); !os.IsNotExist(err) { + t.Fatal("replace kept the store in place") + } + if _, err := os.Stat(filepath.Join(home, "teams-traffic-sync.ledger")); !os.IsNotExist(err) { + t.Fatal("replace retained Traffic rate clock") + } + if _, err := os.Stat(filepath.Join(home, "teams-sync.ledger")); !os.IsNotExist(err) { + t.Fatal("ledger survived replace") + } +} + +func TestTeamsReplacementWithSharingOffPreservesTeamsAndLedgers(t *testing.T) { + home := t.TempDir() + if err := teams.Update(home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "alpha", Name: "local"}}; return nil }); err != nil { + t.Fatal(err) + } + paths := []string{teams.Path(home), filepath.Join(home, "teams-sync.ledger"), filepath.Join(home, "teams-traffic-sync.ledger")} + for _, path := range paths[1:] { + if err := os.WriteFile(path, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + } + before := map[string]string{} + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + before[path] = string(raw) + } + if err := replaceState(home, home); err != nil { + t.Fatal(err) + } + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil || string(raw) != before[path] { + t.Fatalf("replacement changed %s: %v", path, err) + } + } +} diff --git a/internal/pair/tunnel.go b/internal/pair/tunnel.go index 30d1ad7258..bc1cd5e59d 100644 --- a/internal/pair/tunnel.go +++ b/internal/pair/tunnel.go @@ -28,6 +28,7 @@ package pair // cannot see a word of it. import ( + "crypto/ecdh" "errors" "io" "sync" @@ -41,7 +42,8 @@ type Tunnel struct { conn io.ReadWriteCloser // peer is the other end's long-term public key, as the completed handshake // proved it — not as anybody claimed it. - peer []byte + peer []byte + binding []byte sendMu sync.Mutex send *noise.CipherState @@ -56,6 +58,41 @@ type Tunnel struct { // Peer is the key on the other end, proved by the handshake. func (t *Tunnel) Peer() []byte { return t.peer } +// ChannelBinding is the completed handshake hash. Returning a copy lets a +// higher protocol sign it without allowing a caller to change this tunnel's +// identity proof. +func (t *Tunnel) ChannelBinding() []byte { return append([]byte(nil), t.binding...) } + +// taskTunnelPrologue separates identity task doors from machine-access doors, +// even if a caller accidentally supplies the same static key to both. +const taskTunnelPrologue = "codeaf task door v1 connect" + +// ConnectWithKey opens an identity task tunnel with an explicit static key. +// The responder's key must already be pinned by the caller. +func ConnectWithKey(conn io.ReadWriteCloser, peer []byte, key *ecdh.PrivateKey) (*Tunnel, error) { + if key == nil { + return nil, errors.New("pair: a task tunnel needs a static key") + } + return connectTaskTunnel(conn, peer, Device{private: key}.noiseKey(), "", taskTunnelPrologue) +} + +// AcceptWithKey answers an identity task tunnel. Device authorization follows +// inside the tunnel, where certificates can be bound to ChannelBinding. +func AcceptWithKey(conn io.ReadWriteCloser, key *ecdh.PrivateKey) (*Tunnel, error) { + if key == nil { + return nil, errors.New("pair: a task tunnel needs a static key") + } + var intent [1]byte + if _, err := io.ReadFull(conn, intent[:]); err != nil { + return nil, err + } + if intent[0] != intentConnect { + return nil, errors.New("this door takes task connections") + } + tunnel, _, err := acceptTaskTunnel(conn, Device{private: key}.noiseKey(), func([]byte, string) error { return nil }, taskTunnelPrologue) + return tunnel, err +} + // Read hands back plaintext. A record arrives whole and is drained across as // many Reads as the caller needs, which is what makes this an ordinary stream // rather than a message queue. @@ -207,9 +244,102 @@ func acceptAsMachine(conn io.ReadWriteCloser, me Device, allow func(key []byte, return &Tunnel{conn: conn, peer: deviceKey, send: second, recv: first}, deviceKey, nil } -// withDeadline puts a bound on a handshake when the thing underneath can carry -// one. A relay stream cannot — it is a queue, not a socket — so this is a -// courtesy on the transports that can and a silence on the ones that cannot. +func connectTaskTunnel(conn io.ReadWriteCloser, machineKey []byte, static noise.DHKey, label, prologue string) (*Tunnel, error) { + if _, err := conn.Write([]byte{intentConnect}); err != nil { + return nil, err + } + handshake, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: suite, + Pattern: noise.HandshakeIK, + Initiator: true, + Prologue: []byte(prologue), + StaticKeypair: static, + PeerStatic: machineKey, + }) + if err != nil { + return nil, err + } + // THE PAYLOAD IS WHAT PROVES THIS DEVICE HOLDS ITS KEY. In IK the first + // message carries this device's static key and a static-static exchange, + // and the tag over this payload is under a key that exchange produced — so + // a machine that opens it has proof, and a device replaying somebody else's + // captured static key produces a message that does not open. + message, _, _, err := handshake.WriteMessage(nil, []byte(label)) + if err != nil { + return nil, err + } + if err := writeRecord(conn, message); err != nil { + return nil, err + } + reply, err := readRecord(conn) + if err != nil { + // NOTHING CAME BACK, which is what a machine that could not read the + // first message looks like from here: IK encrypts this device's own + // static key to the key it pinned, so a machine holding a different key + // cannot open the message and has nothing to answer with. + return nil, ErrNoAnswer + } + said, first, second, err := handshake.ReadMessage(nil, reply) + if err != nil { + return nil, ErrNotThatMachine + } + if first == nil || second == nil { + return nil, errors.New("that machine answered with a handshake this build cannot finish") + } + if err := hearVerdict(said); err != nil { + return nil, err + } + return &Tunnel{conn: conn, peer: machineKey, binding: append([]byte(nil), handshake.ChannelBinding()...), send: first, recv: second}, nil +} + +// acceptAsMachine answers a connection, and asks `allow` whether the device on +// the other end may open a conversation here. +// +// THE VERDICT TRAVELS INSIDE THE ENCRYPTION. A device that has been stopped +// gets a sentence saying so, and it gets it from the machine itself — the relay +// neither writes it nor reads it. +func acceptTaskTunnel(conn io.ReadWriteCloser, static noise.DHKey, allow func(key []byte, label string) error, prologue string) (*Tunnel, []byte, error) { + handshake, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: suite, + Pattern: noise.HandshakeIK, + Initiator: false, + Prologue: []byte(prologue), + StaticKeypair: static, + }) + if err != nil { + return nil, nil, err + } + message, err := readRecord(conn) + if err != nil { + return nil, nil, err + } + said, _, _, err := handshake.ReadMessage(nil, message) + if err != nil { + return nil, nil, errors.New("a connection arrived that this machine could not make sense of") + } + deviceKey := append([]byte{}, handshake.PeerStatic()...) + verdict := "" + if err := allow(deviceKey, readableLabel(string(said))); err != nil { + verdict = err.Error() + } + reply, first, second, err := handshake.WriteMessage(nil, sayVerdict(verdict)) + if err != nil { + return nil, deviceKey, err + } + if err := writeRecord(conn, reply); err != nil { + return nil, deviceKey, err + } + if verdict != "" { + return nil, deviceKey, errors.New(verdict) + } + // The responder writes with the second cipher state and reads with the + // first, which is Noise's own ordering: the first is the initiator's. + return &Tunnel{conn: conn, peer: deviceKey, binding: append([]byte(nil), handshake.ChannelBinding()...), send: second, recv: first}, deviceKey, nil +} + +// withDeadline puts a bound on a handshake when the stream underneath carries +// deadlines, as both TCP sockets and relay mux streams do, so an unpaired +// caller that opens a stream and says nothing cannot hold a slot forever. type deadliner interface{ SetDeadline(time.Time) error } func withDeadline(conn io.ReadWriteCloser, when time.Time) { diff --git a/internal/pairbox/http.go b/internal/pairbox/http.go index a847172d12..23811f3c7c 100644 --- a/internal/pairbox/http.go +++ b/internal/pairbox/http.go @@ -154,6 +154,14 @@ func answer(resp *http.Response, want int, into any) error { if resp.StatusCode == http.StatusNoContent { return errNoContent } + if resp.StatusCode >= http.StatusInternalServerError { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + var body errorBody + if resp.StatusCode == http.StatusServiceUnavailable && json.Unmarshal(raw, &body) == nil && body.Err == "full" { + return ErrRelayFull + } + return wireauth.ErrUnavailable + } err, ok := errorFor(resp.StatusCode) switch { case !ok: diff --git a/internal/pairbox/l4_http_test.go b/internal/pairbox/l4_http_test.go new file mode 100644 index 0000000000..bf1c5fe93f --- /dev/null +++ b/internal/pairbox/l4_http_test.go @@ -0,0 +1,25 @@ +package pairbox + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// L4-H6d: a generic server failure is not a capacity refusal on the code road. +func TestL4PairServerFailure(t *testing.T) { + for _, status := range []int{500, 503} { + err := answer(&http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("temporarily unavailable"))}, 200, nil) + if !errors.Is(err, wireauth.ErrUnavailable) { + t.Fatal(err) + } + } + err := answer(&http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader(`{"err":"full"}`))}, 200, nil) + if !errors.Is(err, ErrRelayFull) { + t.Fatal(err) + } +} diff --git a/internal/provider/client.go b/internal/provider/client.go index 19b497a2eb..87928e8b38 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -50,6 +50,9 @@ type Config struct { // explicitly accepts an empty key. The ordinary keyless client remains the // first-run state and refuses before the wire. KeyOptional bool + // DropRefusedEffort lets a task child repair an explicit thinking refusal + // without teaching other clients or processes on this home. + DropRefusedEffort bool // BillingDoor is the person-facing name of a bound road. Empty is a service // with one road and preserves every older status line. BillingDoor string @@ -144,8 +147,10 @@ type Config struct { type Client struct { // Unsupported stream-usage options are remembered per endpoint, never per model. streamUsageUnsupported sync.Map - config Config - http *http.Client + // A task's refusal is local to this client and never enters persisted quirks. + refusedEffort sync.Map + config Config + http *http.Client // stream is the same client with the total deadline removed. A streamed // answer is bounded by silence, not by duration — see send. stream *http.Client @@ -818,12 +823,24 @@ func (c *Client) sendRepaired(ctx context.Context, request *ai.Request, knobs ca // The compatible usage option earns only its own repair. Peeking at an // unrelated refusal must not admit it to the older learning road. repairable := response.StatusCode == http.StatusBadRequest && c.repairable(model, knobs) - if !foreign && !repairable && !(streamUsage && usageRefusalStatus) { + dropEffort := c.config.DropRefusedEffort && c.refusedEffortRepairable(model, knobs, response.StatusCode) + if !foreign && !repairable && !dropEffort && !(streamUsage && usageRefusalStatus) { return response, nil } peek, readErr := io.ReadAll(io.LimitReader(response.Body, maxErrorPeek)) switch { case readErr != nil: + case dropEffort && !repaired["refused_effort"] && refusesRequestedEffort(peek): + c.record(recordFacts{ + ctx: ctx, request: request, knobs: knobs, stream: stream, + attempt: c.attemptsSoFar(knobs), began: began, + status: response.StatusCode, err: apiError(response.StatusCode, peek), responseBody: peek, + }) + c.refusedEffort.Store(normalizeModel(model), true) + repaired["refused_effort"] = true + began = logNow() + response, err = c.resend(ctx, request, knobs, stream, response) + continue case foreign && !repaired["foreign_reasoning"] && refusesForeignReasoning(peek): // THE WORDS STILL GO; ONLY THE OTHER MODEL'S THINKING STAYS HOME. A // sidecar tagged with its model never gets here (reasoning.go's @@ -881,6 +898,17 @@ func (c *Client) sendRepaired(ctx context.Context, request *ai.Request, knobs ca } } +// A task child may repair only an effort it actually sent, on the model's +// precise refusal. Other 400s remain the provider's own error. +func (c *Client) refusedEffortRepairable(model string, knobs callKnobs, status int) bool { + return status == http.StatusBadRequest && !knobs.relaxed.has(relaxReasoning) && c.resolveEffort(model, knobs.effort) != EffortNone +} + +func refusesRequestedEffort(body []byte) bool { + text := strings.ToLower(string(body)) + return strings.Contains(text, "does not support thinking") || strings.Contains(text, "does not support reasoning") +} + // resend closes a refused answer and sends the request again as the knobs now // say to shape it. The refusal generated no tokens, so the retry is free. func (c *Client) resend(ctx context.Context, request *ai.Request, knobs callKnobs, stream bool, refused *http.Response) (*http.Response, error) { @@ -909,7 +937,9 @@ func (c *Client) repairable(model string, knobs callKnobs) bool { // first match winning, because a single 400 can name more than one field. func (c *Client) learn(model string, knobs callKnobs, payload []byte) []string { var learned []string - if c.resolveEffort(model, knobs.effort) != EffortNone && refusesReasoning(payload) { + // Only an effort that was on the wire can have been refused. A request the + // endpoint ladder had already stripped teaches nothing about the model. + if !knobs.relaxed.has(relaxReasoning) && c.resolveEffort(model, knobs.effort) != EffortNone && refusesReasoning(payload) { quirks.record(func(q *quirksStore) map[string]time.Time { return q.noReasoning }, model, time.Now()) learned = append(learned, learnedReasoningUnsupported) } diff --git a/internal/provider/endpoints.go b/internal/provider/endpoints.go index ddfe4d790d..978e84a02f 100644 --- a/internal/provider/endpoints.go +++ b/internal/provider/endpoints.go @@ -890,6 +890,18 @@ func (c *Client) FallbackModels(model string) []string { return carried } +// fallbacksWritten reports whether the configured list names any model. A list +// naming only the failing model is still an explicit answer, even though that +// model is removed from the chain; blank rows leave the catalog free to help. +func (c *Client) fallbacksWritten() bool { + for _, model := range c.config.Fallbacks { + if strings.TrimSpace(model) != "" { + return true + } + } + return false +} + // fallbackChain is the models to try after the ladder, in order. // // The operator's own list wins outright, and the catalog is only asked when @@ -913,7 +925,7 @@ func (c *Client) fallbackChain(model string) []string { } } add(c.config.Fallbacks) - if len(c.config.Fallbacks) == 0 && c.config.NearestModels != nil { + if !c.fallbacksWritten() && c.config.NearestModels != nil { add(c.config.NearestModels(model)) } if len(chain) > maxFallbackModels { diff --git a/internal/provider/fallback_self_test.go b/internal/provider/fallback_self_test.go new file mode 100644 index 0000000000..40d5dc1357 --- /dev/null +++ b/internal/provider/fallback_self_test.go @@ -0,0 +1,26 @@ +package provider + +import "testing" + +func TestAFallbackListNamingOnlyTheFailingModelIsAPin(t *testing.T) { + guessed := func(string) []string { return []string{"guessed/one", "guessed/two"} } + for name, written := range map[string][]string{ + "the pinned model alone": {"sim/model"}, + "the pinned model repeated": {"sim/model", " SIM/model "}, + "blank rows around the pin": {"", "sim/model"}, + } { + client, _ := refusingClient(t, func(map[string]any) bool { return false }, Config{ + Fallbacks: written, NearestModels: guessed, + }) + if got := client.FallbackModels("sim/model"); len(got) != 0 { + t.Fatalf("%s: FallbackModels = %v, want none: a pin sends no call to another model", name, got) + } + } + // A row of blanks is no answer at all, and the catalog still helps. + client, _ := refusingClient(t, func(map[string]any) bool { return false }, Config{ + Fallbacks: []string{" ", ""}, NearestModels: guessed, + }) + if got := client.FallbackModels("sim/model"); len(got) != 2 { + t.Fatalf("FallbackModels = %v, want the catalog's two for a row that said nothing", got) + } +} diff --git a/internal/provider/refused_effort_test.go b/internal/provider/refused_effort_test.go new file mode 100644 index 0000000000..5b25f1d847 --- /dev/null +++ b/internal/provider/refused_effort_test.go @@ -0,0 +1,200 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" +) + +// R10(a) repairs stock Ollama's explicit refusal only for the receiving task +// client, including its later calls; a new client must learn for itself. +func TestATaskChildResendsWithoutEffortWhenTheModelSaysItCannotThink(t *testing.T) { + for _, rung := range []effort.Rung{effort.Low, effort.Max} { + t.Run(string(rung), func(t *testing.T) { + fixture := noThinkingEndpoint(t) + client := fixture.client(t, true) + ctx := WithConfiguredEffortRung(context.Background(), rung) + for i := 0; i < 2; i++ { + if _, err := client.CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil { + t.Fatalf("task's refused effort was not repaired: %v", err) + } + } + bodies := fixture.requests() + if len(bodies) != 3 || bodies[0]["reasoning"] == nil { + t.Fatalf("want one refused request, its repair, then one memoized call: %+v", bodies) + } + for _, body := range bodies[1:] { + if body["reasoning"] != nil || body["reasoning_effort"] != nil || body["thinking"] != nil { + t.Errorf("repaired request retained effort or budget: %+v", body) + } + } + if _, err := os.Stat(filepath.Join(fixture.profile, quirksFile)); !os.IsNotExist(err) { + t.Fatalf("the refusal escaped the client into the quirks file: %v", err) + } + // The task client's memo stays its own: another client sends effort + // again. app/v3 then repairs an ordinary client's precise refusal too + // and remembers it for the process (f9c0f1a1c, landed in #2009), so + // the ordinary client costs one refusal and its repair, and a later + // task client sends without effort at once. + fresh := fixture.client(t, false) + if _, err := fresh.CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil { + t.Fatalf("an ordinary client's refusal was not repaired: %v", err) + } + if bodies := fixture.requests(); len(bodies) != 5 || bodies[3]["reasoning"] == nil { + t.Fatal("another client inherited the task client's memo") + } + freshTask := fixture.client(t, true) + if _, err := freshTask.CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil || len(fixture.requests()) != 6 { + t.Fatalf("a later task client did not use the remembered refusal: %v", err) + } + if _, err := client.CompleteWithMessages(ctx, userMessages("write KIWI"), ai.WithModel("another-model")); err != nil || len(fixture.requests()) != 8 { + t.Fatalf("one model's memo leaked into another model: %v", err) + } + }) + } +} + +// R10(a) bounds repair to the precise 400, a sent effort, and one resend. +func TestTaskEffortRepairMatchesOnlyTheExplicit400(t *testing.T) { + for _, tc := range []struct { + name, message string + status, requests int + sentEffort, succeeds bool + }{ + {"reasoning spelling", "DOES NOT SUPPORT REASONING", 400, 2, true, true}, + {"unrelated refusal", "context length is too large", 400, 1, true, false}, + {"other status", "does not support thinking", 422, 1, true, false}, + {"no effort sent", "does not support thinking", 400, 1, false, false}, + {"second refusal", "does not support thinking", 400, 2, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := calls.Add(1) + w.Header().Set("Content-Type", "application/json") + if call > 1 && tc.succeeds { + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"KIWI"},"finish_reason":"stop"}]}`)) + return + } + w.WriteHeader(tc.status) + json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"message": tc.message}}) + })) + defer server.Close() + client, err := NewClient(Config{APIKey: "test-key", BaseURL: server.URL, Model: "fixture/no-thinking", Direct: true, DropRefusedEffort: true}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if tc.sentEffort { + ctx = WithConfiguredReasoningEffort(ctx, EffortLow) + } + _, err = client.CompleteWithMessages(ctx, userMessages("write KIWI")) + if (err == nil) != tc.succeeds || int(calls.Load()) != tc.requests { + t.Fatalf("requests=%d, want %d; error=%v", calls.Load(), tc.requests, err) + } + }) + } +} + +// An ordinary client also repairs the model's precise thinking refusal once: +// app/v3 treats that 400 as permanent (f9c0f1a1c, landed in #2009). The memo +// stays in the process and never reaches the quirks file. +func TestWithoutTheFlagARefusedEffortIsRepairedOnceForTheProcess(t *testing.T) { + fixture := noThinkingEndpoint(t) + ctx := WithConfiguredReasoningEffort(context.Background(), EffortLow) + if _, err := fixture.client(t, false).CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil { + t.Fatalf("ordinary refusal was not repaired: %v", err) + } + bodies := fixture.requests() + if len(bodies) != 2 || bodies[0]["reasoning"] == nil || bodies[1]["reasoning"] != nil { + t.Fatalf("want the refused request and one repair without effort: %+v", bodies) + } + if _, err := os.Stat(filepath.Join(fixture.profile, quirksFile)); !os.IsNotExist(err) { + t.Fatalf("the refusal escaped the process into the quirks file: %v", err) + } +} + +// R10(a) requires an effort on the actual wire. The endpoint ladder can +// already have removed it before this refusal loop gets the request. +func TestAnAlreadyRemovedEffortDoesNotTeachTheTaskClient(t *testing.T) { + var recorded capture + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorded.record(r) + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":{"message":"does not support thinking"}}`)) + })) + defer server.Close() + client, err := NewClient(Config{APIKey: "test-key", BaseURL: server.URL, Model: "fixture/no-thinking", Direct: true, DropRefusedEffort: true}) + if err != nil { + t.Fatal(err) + } + request := &ai.Request{Model: "fixture/no-thinking", Messages: userMessages("write KIWI")} + response, err := client.sendRepaired(context.Background(), request, callKnobs{effort: effortRequest{effort: EffortLow, explicit: true}, relaxed: relaxReasoning}, false) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 400 || recorded.count() != 1 || recorded.body(0)["reasoning"] != nil { + t.Errorf("an already effortless request earned a repair: status %d, requests %d", response.StatusCode, recorded.count()) + } + ctx := WithConfiguredReasoningEffort(context.Background(), EffortLow) + _, err = client.CompleteWithMessages(ctx, userMessages("write KIWI")) + if err == nil || recorded.count() != 3 || recorded.body(1)["reasoning"] == nil { + t.Errorf("an effortless refusal changed the later ordinary attempt: requests %d, error %v", recorded.count(), err) + } +} + +type noThinkingService struct { + server *httptest.Server + profile string + mu sync.Mutex + bodies []map[string]any +} + +func noThinkingEndpoint(t *testing.T) *noThinkingService { + t.Helper() + forgetLanes(t) + f := &noThinkingService{profile: quirksAt(t, "llama3.2:latest")} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + f.mu.Lock() + f.bodies = append(f.bodies, body) + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if body["reasoning"] != nil || body["reasoning_effort"] != nil || body["thinking"] != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":{"message":"\"llama3.2:latest\" does not support thinking"}}`)) + return + } + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"KIWI"},"finish_reason":"stop"}]}`)) + })) + t.Cleanup(f.server.Close) + return f +} + +func (f *noThinkingService) client(t *testing.T, repair bool) *Client { + t.Helper() + c, err := NewClient(Config{APIKey: "test-key", BaseURL: f.server.URL, Model: "llama3.2:latest", Direct: true, DropRefusedEffort: repair}) + if err != nil { + t.Fatal(err) + } + return c +} + +func (f *noThinkingService) requests() []map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return append([]map[string]any(nil), f.bodies...) +} diff --git a/internal/provider/wire.go b/internal/provider/wire.go index 483a753393..29c58b78b7 100644 --- a/internal/provider/wire.go +++ b/internal/provider/wire.go @@ -505,6 +505,11 @@ func (c *Client) resolveEffort(model string, requested effortRequest) Effort { if quirks.recorded(func(q *quirksStore) map[string]time.Time { return q.noReasoning }, model) { return EffortNone } + if c.config.DropRefusedEffort { + if _, refused := c.refusedEffort.Load(normalizeModel(model)); refused { + return EffortNone + } + } if c.config.SupportsParameter != nil { if supported, known := c.config.SupportsParameter(model, "reasoning"); known && !supported { return EffortNone diff --git a/internal/raftdirectory/control.go b/internal/raftdirectory/control.go index f037313a8a..57d481ae1b 100644 --- a/internal/raftdirectory/control.go +++ b/internal/raftdirectory/control.go @@ -31,7 +31,7 @@ func (g *Native) PersonalStatus(ctx context.Context, actor, certificate string) if code != cs.OK { return directory.ErrUnauthorized } - out = clusterctl.Status{Ready: status.Ready, LeadershipUnavailable: status.Self == 0, Cluster: status.Cluster, Self: status.Self, Leader: status.Leader, Membership: status.Membership, Configuration: status.Configuration} + out = clusterctl.Status{Members: status.Members, Ready: status.Ready, LeadershipUnavailable: status.Self == 0, Cluster: status.Cluster, Self: status.Self, Leader: status.Leader, Membership: status.Membership, Configuration: status.Configuration} return nil }) diff --git a/internal/relay/client.go b/internal/relay/client.go index dcb5a4334a..e10de96022 100644 --- a/internal/relay/client.go +++ b/internal/relay/client.go @@ -26,6 +26,8 @@ import ( "net/url" "strings" "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" ) // dialWithin is replaceable by the dead-relay test because WSL2 mirrored @@ -37,6 +39,8 @@ var dialWithin = HandshakeWithin var ( // ErrUnreachable is the relay itself not answering. ErrUnreachable = errors.New("relay: cannot be reached") + // ErrStruggling means the service answered but could not finish the request. + ErrStruggling = errors.New("relay: service unavailable") // ErrNoMachine is the relay answering that nothing is registered under // that name right now. ErrNoMachine = errors.New("relay: no machine connected under that name") @@ -45,6 +49,9 @@ var ( ErrNameTaken = errors.New("relay: that name is already connected") // ErrTooMany is a rate limit, either door. ErrTooMany = errors.New("relay: too many connections from here") + // ErrNoDoor is a relay that answers but offers no machine door: a sync + // service that only stores, or one from before machines could register. + ErrNoDoor = errors.New("relay: that service does not offer machine connections") ) // Registration is a machine's held connection to the relay and the surfaces @@ -93,6 +100,9 @@ func Register(ctx context.Context, service string, private *ecdh.PrivateKey) (*R carrier.onFrame = func() { _ = conn.SetReadDeadline(time.Now().Add(IdleAfter)) } _ = conn.SetReadDeadline(time.Now().Add(IdleAfter)) go carrier.pump() + if alive, ok := conn.(pinger); ok { + go proveAlive(carrier, alive) + } return &Registration{name: name, carrier: carrier, conn: conn}, nil } @@ -139,29 +149,61 @@ func Dial(ctx context.Context, service, name string) (io.ReadWriteCloser, error) } // upgrade dials the service and takes the connection over. +// +// The carrier rides a WebSocket first, because a WebSocket is the one upgrade +// every front end passes: the hosted relay is a Cloudflare Worker, which +// answers no other. A relay from before that door answers a WebSocket with +// 426, and this end then takes the raw upgrade it always spoke, so a +// self-hosted relay keeps working without being upgraded. func upgrade(ctx context.Context, service, path string, header http.Header) (net.Conn, error) { address, err := url.Parse(strings.TrimSpace(service)) if err != nil || address.Host == "" || (address.Scheme != "http" && address.Scheme != "https") { - return nil, fmt.Errorf("%q is not a relay address — it looks like https://relay.example.com", service) + return nil, fmt.Errorf("%q is not a sync service URL — it looks like https://relay.example.com", service) + } + // The relay address may carry a path of its own (https://host/fabric), and + // the door is beneath it, as every other client's route is. Refusals name + // the full base address with that path intact so a mistyped prefix is + // visible in the error. + base := (&url.URL{ + Scheme: address.Scheme, + Host: address.Host, + Path: strings.TrimRight(address.Path, "/"), + }).String() + door := *address + door.Path = wireauth.Endpoint(address.Path, path) + door.RawPath, door.RawQuery, door.Fragment = "", "", "" + + conn, err := upgradeWebSocket(ctx, &door, base, path, header) + if !errors.Is(err, errRawOnly) { + return conn, err } + return upgradeRaw(ctx, &door, base, path, header) +} +// upgradeRaw is the original carrier: an HTTP upgrade to [Protocol] on one TCP +// connection. +func upgradeRaw(ctx context.Context, address *url.URL, base, path string, header http.Header) (net.Conn, error) { dialer := &net.Dialer{Timeout: dialWithin} conn, err := dialer.DialContext(ctx, "tcp", hostPort(address)) if err != nil { - return nil, fmt.Errorf("%w: %s", ErrUnreachable, address.Host) + return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) } + // A caller can stop a probe while an older relay is silent during HTTP upgrade. + raw := conn + stopClosing := context.AfterFunc(ctx, func() { _ = raw.Close() }) + defer stopClosing() if address.Scheme == "https" { secure := tls.Client(conn, &tls.Config{ServerName: address.Hostname()}) if err := secure.HandshakeContext(ctx); err != nil { _ = conn.Close() - return nil, fmt.Errorf("%w: %s", ErrUnreachable, address.Host) + return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) } conn = secure } request := &http.Request{ Method: http.MethodGet, - URL: &url.URL{Path: path}, + URL: &url.URL{Path: address.Path}, Host: address.Host, Header: header.Clone(), Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, @@ -169,31 +211,25 @@ func upgrade(ctx context.Context, service, path string, header http.Header) (net request.Header.Set("Upgrade", Protocol) request.Header.Set("Connection", "Upgrade") - _ = conn.SetDeadline(time.Now().Add(HandshakeWithin)) + deadline := time.Now().Add(HandshakeWithin) + if until, ok := ctx.Deadline(); ok && until.Before(deadline) { + deadline = until + } + _ = conn.SetDeadline(deadline) if err := request.Write(conn); err != nil { _ = conn.Close() - return nil, fmt.Errorf("%w: %s", ErrUnreachable, address.Host) + return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) } reader := bufio.NewReader(conn) response, err := http.ReadResponse(reader, request) if err != nil { _ = conn.Close() - return nil, fmt.Errorf("%w: %s", ErrUnreachable, address.Host) + return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) } if response.StatusCode != http.StatusSwitchingProtocols { said := strings.TrimSpace(readBounded(response.Body)) _ = conn.Close() - switch response.StatusCode { - case http.StatusNotFound: - return nil, ErrNoMachine - case http.StatusTooManyRequests: - return nil, ErrTooMany - default: - if said == "" { - said = response.Status - } - return nil, errors.New(said) - } + return nil, refusal(base, path, response.StatusCode, said) } _ = conn.SetDeadline(time.Time{}) // A 101 has no body, so there should be nothing left in the reader — but @@ -205,6 +241,48 @@ func upgrade(ctx context.Context, service, path string, header http.Header) (net return conn, nil } +// refusal turns a relay's non-101 answer into the fact a caller above can act +// on. ONLY A RELAY'S OWN SENTENCE IS PASSED ON: an upstream proxy, WAF or +// web server answering with HTML or its own 4xx/5xx body becomes a one-sentence +// fact naming the relay address with its base path, never the raw body. +func refusal(base, path string, status int, said string) error { + switch { + case status == http.StatusNotFound && path != EnginePath && said == "no machine is connected under that name": + return ErrNoMachine + case status == http.StatusNotFound && (strings.HasPrefix(said, "{") || said == "not a relay door"): + // The relay answers, but not at a machine door: the hosted wire's JSON + // not_found, or a self-hosted relay hit outside its two paths. + return fmt.Errorf("%w: %s", ErrNoDoor, base) + case status == http.StatusTooManyRequests: + return ErrTooMany + case status == http.StatusServiceUnavailable && strings.Contains(said, "connections open, which is as many as the relay carries at once"): + // Older self-hosted relays answered 503 when a machine reached its + // stream ceiling; that machine is busy, not unreachable. + return ErrTooMany + case status >= 500: + return upstreamFailure{base: base, status: status} + case relaySentence(said): + return errors.New(said) + default: + return fmt.Errorf("the sync service at %s refused this connection (%d) — check the address it was given", base, status) + } +} + +// relaySentence reports whether said is one of the plain-text validation +// sentences our own relay doors write before upgrading. Anything else — HTML +// from a proxy, a captive portal, or an upstream web server — is withheld. +func relaySentence(said string) bool { + switch said { + case "that is not a machine name", + "that name does not belong to that key", + "that key is not readable", + "that key is not a usable machine key": + return true + } + return strings.HasPrefix(said, "a machine key is 32 bytes and that one is ") || + strings.HasPrefix(said, "this door speaks ") +} + // joinedConn is a connection whose first bytes were already read into a buffer. type joinedConn struct { net.Conn @@ -230,3 +308,16 @@ func readBounded(body io.ReadCloser) string { said, _ := io.ReadAll(io.LimitReader(body, 512)) return string(said) } + +// upstreamFailure preserves the base unreachable class and records that an HTTP +// response arrived, so presentation can choose service-recovery guidance. +type upstreamFailure struct { + base string + status int +} + +func (e upstreamFailure) Error() string { + return fmt.Sprintf("%s: %s answered %d", ErrUnreachable, e.base, e.status) +} +func (e upstreamFailure) Unwrap() error { return ErrUnreachable } +func (e upstreamFailure) HTTPStatus() int { return e.status } diff --git a/internal/relay/hosted_test.go b/internal/relay/hosted_test.go new file mode 100644 index 0000000000..65c76b1da2 --- /dev/null +++ b/internal/relay/hosted_test.go @@ -0,0 +1,91 @@ +package relay + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "io" + "os" + "testing" + "time" +) + +// hostedRelayVar names a hosted relay to hold these tests to: `wrangler dev` of +// relay/hosted, or a deployment. Unset, they skip. +const hostedRelayVar = "CODEAF_RELAY_HOSTED_URL" + +func hostedRelay(t *testing.T) string { + t.Helper() + address := os.Getenv(hostedRelayVar) + if address == "" { + t.Skip(hostedRelayVar + " is not set") + } + return address +} + +// The hosted relay carries a machine the way `codeaf relay` does: a round trip, +// a payload bigger than one frame, and several surfaces on one carrier. +func TestAHostedRelayCarriesAMachine(t *testing.T) { + address := hostedRelay(t) + reachAndEcho(t, address) + + registration, err := Register(context.Background(), address, aKey(t)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = registration.Close() }() + big := make([]byte, 3*MaxPayload+17) + _, _ = rand.Read(big) + for i := 0; i < 3; i++ { + surface, err := Dial(context.Background(), address, registration.Name()) + if err != nil { + t.Fatal(err) + } + engine, err := registration.Accept() + if err != nil { + t.Fatal(err) + } + go func() { _, _ = io.Copy(engine, engine) }() + go func() { _, _ = surface.Write(big) }() + got := make([]byte, len(big)) + if _, err := io.ReadFull(surface, got); err != nil || !bytes.Equal(got, big) { + t.Fatalf("surface %d: echo of %d bytes came back wrong: %v", i, len(big), err) + } + _ = surface.Close() + } +} + +// Rule three holds on the hosted relay: a live registration is not taken over, +// and once it ends the name is free again at once. +func TestAHostedRelayKeepsANameForItsLiveMachine(t *testing.T) { + address := hostedRelay(t) + key := aKey(t) + first, err := Register(context.Background(), address, key) + if err != nil { + t.Fatal(err) + } + if _, err := Register(context.Background(), address, key); !errors.Is(err, ErrNameTaken) { + t.Fatalf("a second registration under a live name gave %v; want ErrNameTaken", err) + } + _ = first.Close() + var again *Registration + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(200 * time.Millisecond) { + if again, err = Register(context.Background(), address, key); err == nil { + break + } + } + if err != nil { + t.Fatalf("the name was not free after its machine left: %v", err) + } + _ = again.Close() + + if _, err := Dial(context.Background(), address, NameFor(aKey(t).PublicKey().Bytes())); !errors.Is(err, ErrNoMachine) { + t.Fatalf("dialling a name nothing holds gave %v; want ErrNoMachine", err) + } +} + +// The stream ceiling reads the same on the hosted relay: busy, not unreachable. +func TestAHostedRelayTurnsAFullMachineAwayAsBusy(t *testing.T) { + fullMachineIsBusy(t, hostedRelay(t)) +} diff --git a/internal/relay/mux.go b/internal/relay/mux.go index 716c7c939c..daba8b3d0f 100644 --- a/internal/relay/mux.go +++ b/internal/relay/mux.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "io" + "os" "sync" "time" ) @@ -46,25 +47,21 @@ const frameHeader = 1 + 8 + 4 // surface's read and a surface's write agree about what happened. var errCarrierGone = errors.New("the connection to the relay ended") -// writeFrame puts one frame on a carrier. Callers hold the carrier's write -// mutex; the mutex is not taken here because a frame is sometimes written as -// part of a longer critical section. -func writeFrame(w io.Writer, op byte, stream uint64, payload []byte) error { +// writeFrame puts one frame on a carrier in one write, so a carrier that is a +// WebSocket carries each frame as one message (see transport.go). The frame is +// assembled in scratch, which is handed back for the next frame so a carrier +// does not allocate one per write. Callers hold the carrier's write mutex, +// which also guards scratch. +func writeFrame(w io.Writer, scratch []byte, op byte, stream uint64, payload []byte) ([]byte, error) { if len(payload) > MaxPayload { - return fmt.Errorf("relay: frame of %d bytes is over the %d-byte limit", len(payload), MaxPayload) + return scratch, fmt.Errorf("relay: frame of %d bytes is over the %d-byte limit", len(payload), MaxPayload) } - var header [frameHeader]byte - header[0] = op - binary.BigEndian.PutUint64(header[1:9], stream) - binary.BigEndian.PutUint32(header[9:13], uint32(len(payload))) - if _, err := w.Write(header[:]); err != nil { - return err - } - if len(payload) == 0 { - return nil - } - _, err := w.Write(payload) - return err + frame := append(scratch[:0], op) + frame = binary.BigEndian.AppendUint64(frame, stream) + frame = binary.BigEndian.AppendUint32(frame, uint32(len(payload))) + frame = append(frame, payload...) + _, err := w.Write(frame) + return frame, err } // readFrame takes one frame off a carrier. The payload is freshly allocated @@ -96,7 +93,9 @@ func readFrame(r io.Reader) (op byte, stream uint64, payload []byte, err error) const streamDepth = 8 // muxStream is one surface's byte stream, presented as the [io.ReadWriteCloser] -// everything above this package deals in. +// everything above this package deals in. It carries real read and write +// deadlines so a handshake above this package can evict a silent caller instead +// of letting it hold one of a machine's [MaxStreams] slots forever. type muxStream struct { id uint64 carrier *carrier @@ -104,6 +103,9 @@ type muxStream struct { incoming chan []byte rest []byte + readDeadline streamDeadline + writeDeadline streamDeadline + // closed is THIS END letting go, and incoming being closed is the FAR END // letting go. THE TWO ARE DELIBERATELY NOT THE SAME SIGNAL, and conflating // them was a real bug: a reader selecting on both picks between two ready @@ -119,14 +121,40 @@ type muxStream struct { func newMuxStream(id uint64, c *carrier) *muxStream { return &muxStream{ - id: id, - carrier: c, - incoming: make(chan []byte, streamDepth), - closed: make(chan struct{}), + id: id, + carrier: c, + incoming: make(chan []byte, streamDepth), + readDeadline: newStreamDeadline(), + writeDeadline: newStreamDeadline(), + closed: make(chan struct{}), } } +func (s *muxStream) SetDeadline(t time.Time) error { + s.readDeadline.set(t) + s.writeDeadline.set(t) + return nil +} + +func (s *muxStream) SetReadDeadline(t time.Time) error { + s.readDeadline.set(t) + return nil +} + +func (s *muxStream) SetWriteDeadline(t time.Time) error { + s.writeDeadline.set(t) + return nil +} + +func (s *muxStream) stopDeadlines() { + s.readDeadline.set(time.Time{}) + s.writeDeadline.set(time.Time{}) +} + func (s *muxStream) Read(b []byte) (int, error) { + if isClosedChan(s.readDeadline.wait()) { + return 0, os.ErrDeadlineExceeded + } for len(s.rest) == 0 { select { case chunk, ok := <-s.incoming: @@ -139,6 +167,8 @@ func (s *muxStream) Read(b []byte) (int, error) { s.rest = chunk case <-s.closed: return 0, io.EOF + case <-s.readDeadline.wait(): + return 0, os.ErrDeadlineExceeded } } n := copy(b, s.rest) @@ -152,10 +182,20 @@ func (s *muxStream) Write(b []byte) (int, error) { return 0, io.ErrClosedPipe default: } + if isClosedChan(s.writeDeadline.wait()) { + return 0, os.ErrDeadlineExceeded + } // A write is cut into carrier-sized frames, because a caller above knows // nothing about this framing and must not have to. written := 0 for len(b) > 0 { + select { + case <-s.closed: + return written, io.ErrClosedPipe + case <-s.writeDeadline.wait(): + return written, os.ErrDeadlineExceeded + default: + } chunk := b if len(chunk) > MaxPayload { chunk = chunk[:MaxPayload] @@ -172,12 +212,72 @@ func (s *muxStream) Write(b []byte) (int, error) { func (s *muxStream) Close() error { s.closeOnce.Do(func() { close(s.closed) + s.stopDeadlines() _ = s.carrier.send(opClose, s.id, nil) s.carrier.forget(s.id) }) return nil } +// streamDeadline signals when a stream's read or write deadline has passed, in +// the shape of net.Pipe's pipeDeadline so blocked callers wake at once and +// cleared deadlines hold no timer. +type streamDeadline struct { + mu sync.Mutex + timer *time.Timer + cancel chan struct{} +} + +func newStreamDeadline() streamDeadline { + return streamDeadline{cancel: make(chan struct{})} +} + +func (d *streamDeadline) set(t time.Time) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.timer != nil && !d.timer.Stop() { + <-d.cancel + } + d.timer = nil + + closed := isClosedChan(d.cancel) + if t.IsZero() { + if closed { + d.cancel = make(chan struct{}) + } + return + } + if dur := time.Until(t); dur > 0 { + if closed { + d.cancel = make(chan struct{}) + } + cancel := d.cancel + d.timer = time.AfterFunc(dur, func() { + close(cancel) + }) + return + } + if !closed { + close(d.cancel) + } +} + +func (d *streamDeadline) wait() chan struct{} { + d.mu.Lock() + defer d.mu.Unlock() + return d.cancel +} + +func isClosedChan(c <-chan struct{}) bool { + select { + case <-c: + return true + default: + return false + } +} + // deliver hands a frame to this stream's reader, or drops it on the floor once // the stream is closed — which is not a loss, because a closed stream has no // reader and the peer will be told. @@ -203,9 +303,11 @@ type carrier struct { conn io.ReadWriteCloser writeMu sync.Mutex + scratch []byte // the frame being written, reused under writeMu mu sync.Mutex streams map[uint64]*muxStream + probes map[chan struct{}]struct{} nextID uint64 gone bool @@ -247,7 +349,9 @@ func (c *carrier) send(op byte, stream uint64, payload []byte) error { if gone { return errCarrierGone } - if err := writeFrame(c.conn, op, stream, payload); err != nil { + scratch, err := writeFrame(c.conn, c.scratch, op, stream, payload) + c.scratch = scratch + if err != nil { return err } return nil @@ -329,12 +433,53 @@ func (c *carrier) pump() { case opPing: _ = c.send(opPong, id, nil) case opPong: - // Nothing to do: the frame arriving is the whole of the answer, - // and [IdleAfter] is measured against ANY byte on the carrier. + c.mu.Lock() + for ch := range c.probes { + close(ch) + delete(c.probes, ch) + } + c.mu.Unlock() } } } +// probe tests whether an older carrier under the same key is still answering, +// waiting at most within for its pong. A live machine answers in one round +// trip; one whose network vanished says nothing and lets its successor in. +func (c *carrier) probe(within time.Duration) bool { + ch := make(chan struct{}) + c.mu.Lock() + if c.gone { + c.mu.Unlock() + return false + } + if c.probes == nil { + c.probes = map[chan struct{}]struct{}{} + } + c.probes[ch] = struct{}{} + c.mu.Unlock() + + defer func() { + c.mu.Lock() + delete(c.probes, ch) + c.mu.Unlock() + }() + + if err := c.send(opPing, 0, nil); err != nil { + return false + } + timer := time.NewTimer(within) + defer timer.Stop() + select { + case <-ch: + return true + case <-c.done: + return false + case <-timer.C: + return false + } +} + // shutdown ends the carrier and everything on it, once. func (c *carrier) shutdown(cause error) { c.closeOnce.Do(func() { @@ -356,7 +501,10 @@ func (c *carrier) shutdown(cause error) { // without the race. close(c.done) for _, stream := range streams { - stream.closeOnce.Do(func() { close(stream.closed) }) + stream.closeOnce.Do(func() { + close(stream.closed) + stream.stopDeadlines() + }) } _ = c.conn.Close() }) diff --git a/internal/relay/proxy_limits_test.go b/internal/relay/proxy_limits_test.go new file mode 100644 index 0000000000..53b1e809ec --- /dev/null +++ b/internal/relay/proxy_limits_test.go @@ -0,0 +1,43 @@ +package relay + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestForwardedAddressesCannotResetUntrustedLimits(t *testing.T) { + for _, socket := range []string{"192.0.2.1:1234", "[2001:db8::1]:1234"} { + for _, door := range []struct { + path string + limit int + }{{EnginePath, RegistrationsPerMinute}, {DialPrefix + "otter-lamp-42", DialsPerMinute}} { + t.Run(socket+door.path, func(t *testing.T) { + server := &Server{Now: func() time.Time { return time.Unix(100000, 0) }} + for i := 0; i <= door.limit; i++ { + req := httptest.NewRequest(http.MethodGet, door.path, nil) + req.RemoteAddr = socket + req.Header.Set("Upgrade", Protocol) + switch i % 5 { + case 0: + case 1: + req.Header.Set("X-Forwarded-For", "") + case 2: + req.Header.Set("X-Forwarded-For", " ") + case 3: + req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i)) + case 4: + req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d, 203.0.113.1", i)) + } + out := httptest.NewRecorder() + server.ServeHTTP(out, req) + if (i == door.limit) != (out.Code == http.StatusTooManyRequests) { + t.Fatalf("request %d status=%d, header=%q", i+1, out.Code, req.Header.Get("X-Forwarded-For")) + } + } + }) + } + } +} diff --git a/internal/relay/relay.go b/internal/relay/relay.go index e41ae3ced7..902b7f1511 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -70,18 +70,22 @@ const ( // The tuning, all in one place so that a relay operator changes a number here // and every sentence in this package that quotes it changes with it. ONE SOURCE -// OF TRUTH: nothing below re-states a limit as a literal. +// OF TRUTH: nothing below re-states a limit as a literal, and the hosted Worker +// mirrors these constants in relay/hosted/src/engine/carrier.js (and its +// front-door rate-limit bindings in relay/hosted/wrangler.toml). const ( - // MaxPayload is the largest body one framed message may carry. It is a - // little over 64 KiB because the tunnel above frames its ciphertext at - // Noise's own 65535-byte ceiling and adds a small header; a limit under - // that would cut a legal message in half. + // MaxPayload is the largest body one framed message may carry (MAX_PAYLOAD + // in relay/hosted/src/engine/carrier.js). It is a little over 64 KiB + // because the tunnel above frames its ciphertext at Noise's own 65535-byte + // ceiling and adds a small header; a limit under that would cut a legal + // message in half. MaxPayload = 1 << 17 - // MaxStreams is how many surfaces may be attached to one machine at once. - // It is a limit on the relay's memory and on nothing else — the engine - // machine has its own opinion about how many surfaces a session will have, - // and this is only the number the pipe will carry. + // MaxStreams is how many surfaces may be attached to one machine at once + // (MAX_STREAMS in relay/hosted/src/engine/carrier.js). It is a limit on + // the relay's memory and on nothing else — the engine machine has its own + // opinion about how many surfaces a session will have, and this is only + // the number the pipe will carry. MaxStreams = 16 // PingEvery is how often the relay pokes an idle carrier, and IdleAfter is @@ -91,15 +95,25 @@ const ( PingEvery = 45 * time.Second IdleAfter = 2 * PingEvery - // HandshakeWithin bounds the registration exchange. A connection that has - // claimed a name but not yet proved it holds the key is a connection that - // could hold a name for free, so it is given one round trip's worth of time - // and no more. + // HandshakeWithin bounds the registration exchange (HANDSHAKE_MS in + // relay/hosted/src/engine/carrier.js). A connection that has claimed a name + // but not yet proved it holds the key is a connection that could hold a + // name for free, so it is given one round trip's worth of time and no more. HandshakeWithin = 20 * time.Second - // DialsPerMinute and RegistrationsPerMinute are the per-address ceilings. - // They are deliberately generous for a person and stingy for a script: a - // person dials once and stays, and reconnects a handful of times a day. + // ProbeWithin is how long a registration waits for an older carrier under + // the same key to answer a ping before replacing it (PROBE_MS in + // relay/hosted/src/engine/carrier.js). A machine whose network dropped can + // come back before IdleAfter runs out; a live carrier answers in one round + // trip and keeps its name. + ProbeWithin = 5 * time.Second + + // DialsPerMinute and RegistrationsPerMinute are the per-address ceilings + // (DIALS_PER_MINUTE and REGISTRATIONS_PER_MINUTE in + // relay/hosted/src/engine/carrier.js, and MACHINE_DIAL_LIMIT / + // MACHINE_REGISTER_LIMIT in relay/hosted/wrangler.toml). They are + // deliberately generous for a person and stingy for a script: a person + // dials once and stays, and reconnects a handful of times a day. DialsPerMinute = 30 RegistrationsPerMinute = 10 ) diff --git a/internal/relay/server.go b/internal/relay/server.go index 7c7f4c5f86..86f15a7892 100644 --- a/internal/relay/server.go +++ b/internal/relay/server.go @@ -18,6 +18,8 @@ package relay // test asserts the payloads never appear in it. import ( + "context" + "crypto/subtle" "errors" "io" "net" @@ -25,6 +27,9 @@ import ( "strings" "sync" "time" + + "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/coder/websocket" ) // Ledger is everything the relay knows about one connected machine. IT IS THE @@ -47,6 +52,9 @@ type Ledger struct { // limit it enforces comes from the constants in relay.go, so an operator who // changes one changes it in the one place the sentences quote. type Server struct { + // Peer selects the network counted by registration and dial limits. Nil + // trusts only the socket; forwarding headers require an explicit proxy policy. + Peer func(*http.Request) string // Now is the clock, swapped by tests. Nil is [time.Now]. Now func() time.Time // Note is where a relay operator's log line goes, if they want one. It is @@ -54,10 +62,15 @@ type Server struct { // no payload in this package to hand it. Note func(name, what string) - mu sync.Mutex - live map[string]*registration - dials map[string]*bucket - regs map[string]*bucket + // probeWithin overrides [ProbeWithin] in tests so a silent older carrier + // can be replaced without waiting five real seconds. + probeWithin time.Duration + + mu sync.Mutex + live map[string]*registration + claiming map[string]bool + dials map[string]*bucket + regs map[string]*bucket } // registration is one machine's held carrier. @@ -78,6 +91,13 @@ func (s *Server) now() time.Time { return time.Now() } +func (s *Server) probeTimeout() time.Duration { + if s.probeWithin > 0 { + return s.probeWithin + } + return ProbeWithin +} + func (s *Server) note(name, what string) { if s.Note != nil { s.Note(name, what) @@ -118,11 +138,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // serveEngine takes a machine's registration and then holds its carrier. func (s *Server) serveEngine(w http.ResponseWriter, r *http.Request) { - if !strings.EqualFold(r.Header.Get("Upgrade"), Protocol) { + if !upgradable(r) { http.Error(w, "this door speaks "+Protocol, http.StatusUpgradeRequired) return } - from := callerAddress(r) + from := s.callerAddress(r) if !s.allow(&s.regs, from, RegistrationsPerMinute) { http.Error(w, "too many registrations from here — wait a minute", http.StatusTooManyRequests) return @@ -142,19 +162,15 @@ func (s *Server) serveEngine(w http.ResponseWriter, r *http.Request) { return } - conn, err := hijack(w) + conn, err := takeOver(w, r, messageLimit) if err != nil { - http.Error(w, "this server cannot hold a connection open", http.StatusInternalServerError) return } defer func() { _ = conn.Close() }() - if err := writeUpgraded(conn); err != nil { - return - } // RULE TWO: possession is proved. A machine that cannot answer the // challenge never reaches the table. - _ = conn.SetDeadline(s.now().Add(HandshakeWithin)) + _ = conn.SetDeadline(time.Now().Add(HandshakeWithin)) if err := challengeMachine(conn, name, key); err != nil { _ = writeVerdict(conn, err.Error()) s.note(name, "registration refused") @@ -165,18 +181,36 @@ func (s *Server) serveEngine(w http.ResponseWriter, r *http.Request) { carrier := newCarrier(conn, false) reg := ®istration{name: name, key: key, carrier: carrier, since: s.now()} - // RULE THREE: a live registration is never taken over. Whoever is - // connected under this name stays connected under it. + // RULE THREE: a live registration is never taken over. A different key is + // refused outright even if the current socket is stalled; the SAME key may + // probe the older socket and replace it only when it has gone silent. s.mu.Lock() if s.live == nil { s.live = map[string]*registration{} } - if _, taken := s.live[name]; taken { + if s.claiming == nil { + s.claiming = map[string]bool{} + } + older := s.live[name] + if s.claiming[name] || (older != nil && subtle.ConstantTimeCompare(older.key, key) != 1) { s.mu.Unlock() _ = writeVerdict(conn, "that machine is already connected to the relay under this name") s.note(name, "registration refused, name already connected") return } + if older != nil { + s.claiming[name] = true + s.mu.Unlock() + alive := older.carrier.probe(s.probeTimeout()) + s.mu.Lock() + delete(s.claiming, name) + if alive { + s.mu.Unlock() + _ = writeVerdict(conn, "that machine is already connected to the relay under this name") + s.note(name, "registration refused, name already connected") + return + } + } s.live[name] = reg s.mu.Unlock() @@ -193,6 +227,9 @@ func (s *Server) serveEngine(w http.ResponseWriter, r *http.Request) { if err := writeVerdict(conn, ""); err != nil { return } + if older != nil { + older.carrier.shutdown(nil) + } s.note(name, "connected") // THE CARRIER IS ONLY ALIVE WHILE IT IS SAYING SOMETHING. The relay pings a @@ -200,19 +237,19 @@ func (s *Server) serveEngine(w http.ResponseWriter, r *http.Request) { // pushed out by an ARRIVING frame, so a connection that has stopped // answering — a NAT that forgot the mapping, a machine that was unplugged — // ends at [IdleAfter] instead of being held for ever. - carrier.onFrame = func() { _ = conn.SetReadDeadline(s.now().Add(IdleAfter)) } - _ = conn.SetReadDeadline(s.now().Add(IdleAfter)) + carrier.onFrame = func() { _ = conn.SetReadDeadline(time.Now().Add(IdleAfter)) } + _ = conn.SetReadDeadline(time.Now().Add(IdleAfter)) go carrier.keepAlive() carrier.pump() } // serveDial matches a surface to a machine and then gets out of the way. func (s *Server) serveDial(w http.ResponseWriter, r *http.Request, name string) { - if !strings.EqualFold(r.Header.Get("Upgrade"), Protocol) { + if !upgradable(r) { http.Error(w, "this door speaks "+Protocol, http.StatusUpgradeRequired) return } - if !s.allow(&s.dials, callerAddress(r), DialsPerMinute) { + if !s.allow(&s.dials, s.callerAddress(r), DialsPerMinute) { http.Error(w, "too many connections from here — wait a minute", http.StatusTooManyRequests) return } @@ -235,20 +272,18 @@ func (s *Server) serveDial(w http.ResponseWriter, r *http.Request, name string) stream, err := reg.carrier.open() if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) + // 429, as a rate limit is: the machine is full now, and a surface + // that tries again in a minute may find room. + http.Error(w, err.Error(), http.StatusTooManyRequests) return } defer func() { _ = stream.Close() }() - conn, err := hijack(w) + conn, err := takeOver(w, r, MaxPayload) if err != nil { - http.Error(w, "this server cannot hold a connection open", http.StatusInternalServerError) return } defer func() { _ = conn.Close() }() - if err := writeUpgraded(conn); err != nil { - return - } s.note(name, "a surface arrived") defer s.note(name, "a surface left") @@ -279,6 +314,57 @@ func (r *registration) add(up, down int64) { r.mu.Unlock() } +// upgradable is a request for either carrier: the raw upgrade, or a WebSocket +// offering this protocol. +func upgradable(r *http.Request) bool { + if strings.EqualFold(r.Header.Get("Upgrade"), Protocol) { + return true + } + if !wantsWebSocket(r) { + return false + } + for _, offered := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") { + if strings.TrimSpace(offered) == Protocol { + return true + } + } + return false +} + +func wantsWebSocket(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +// takeOver answers the upgrade and hands back the connection the carrier +// reads, whichever of the two the client asked for. On failure the answer has +// already been written. +func takeOver(w http.ResponseWriter, r *http.Request, maxWrite int) (net.Conn, error) { + if wantsWebSocket(r) { + socket, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{Protocol}, + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return nil, err + } + // websocket.NetConn clears the read limit on entry, so SetReadLimit has + // to come after it or an oversized frame is buffered without bound. + nc := websocket.NetConn(context.Background(), socket, websocket.MessageBinary) + socket.SetReadLimit(messageLimit) + return &wsConn{Conn: nc, socket: socket, maxWrite: maxWrite}, nil + } + conn, err := hijack(w) + if err != nil { + http.Error(w, "this server cannot hold a connection open", http.StatusInternalServerError) + return nil, err + } + if err := writeUpgraded(conn); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + // hijack takes the raw connection out from under the HTTP server. func hijack(w http.ResponseWriter) (net.Conn, error) { hijacker, ok := w.(http.Hijacker) @@ -307,22 +393,13 @@ func writeUpgraded(conn net.Conn) error { return err } -// callerAddress is who a rate limit is counted against. It reads -// X-Forwarded-For only when it is there, because a relay in production sits -// behind something that terminates TLS, and counting every request against the -// load balancer would rate-limit the world as one caller. -func callerAddress(r *http.Request) string { - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - if comma := strings.IndexByte(forwarded, ','); comma >= 0 { - forwarded = forwarded[:comma] - } - return strings.TrimSpace(forwarded) - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr +// callerAddress uses the same peer policy as pairing, so a caller cannot +// mint a fresh allowance by supplying its own forwarding header. +func (s *Server) callerAddress(r *http.Request) string { + if s.Peer != nil { + return s.Peer(r) } - return host + return pairbox.SocketPeer(r) } // bucket is one caller's allowance, refilled by the minute. It is a plain diff --git a/internal/relay/server_test.go b/internal/relay/server_test.go index 042e63cdd6..061133a711 100644 --- a/internal/relay/server_test.go +++ b/internal/relay/server_test.go @@ -1,13 +1,17 @@ package relay import ( + "bytes" "context" "crypto/ecdh" "crypto/rand" + "encoding/binary" "errors" "fmt" "io" + "net" "net/http/httptest" + "os" "strings" "sync" "testing" @@ -161,23 +165,175 @@ func TestAKeySomebodyOnlyCopiedCannotRegister(t *testing.T) { } } -// RULE THREE: a live registration is never taken over. -func TestALiveNameIsNotHandedToASecondMachine(t *testing.T) { +// RULE THREE: a live registration is never taken over, and only the SAME key +// may replace a registration whose socket went silent. +func TestOnlyTheSameKeyMayReplaceASilentRegistration(t *testing.T) { + owner, squatter := collidingKeys(t) + name := NameFor(owner.PublicKey().Bytes()) + if got := NameFor(squatter.PublicKey().Bytes()); got != name { + t.Fatalf("colliding keys derived %q and %q", name, got) + } + + // registerSilent completes the registration proof by hand and then never + // runs carrier.pump(), so the socket holds the name without answering opPing. + registerSilent := func(t *testing.T, address string, key *ecdh.PrivateKey) net.Conn { + t.Helper() + conn, err := upgrade(context.Background(), address, EnginePath, header(name, EncodeKey(key.PublicKey().Bytes()))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := proveToRelay(conn, name, key); err != nil { + t.Fatal(err) + } + return conn + } + + t.Run("different key with silent old is refused", func(t *testing.T) { + service, address := live(t) + service.probeWithin = 30 * time.Millisecond + _ = registerSilent(t, address, owner) + + impostor, err := Register(context.Background(), address, squatter) + if err == nil { + _ = impostor.Close() + t.Fatal("a colliding key took over a silent registration") + } + if !errors.Is(err, ErrNameTaken) { + t.Fatalf("colliding key got %v; want ErrNameTaken", err) + } + if ledgers := service.Ledgers(); len(ledgers) != 1 || ledgers[0].Name != name { + t.Fatalf("silent owner lost its registration: %+v", ledgers) + } + }) + + t.Run("same key with live old is refused", func(t *testing.T) { + service, address := live(t) + service.probeWithin = 30 * time.Millisecond + first, err := Register(context.Background(), address, owner) + if err != nil { + t.Fatal(err) + } + defer func() { _ = first.Close() }() + + second, err := Register(context.Background(), address, owner) + if err == nil { + _ = second.Close() + t.Fatal("a second registration replaced a live machine") + } + if !errors.Is(err, ErrNameTaken) { + t.Fatalf("live replacement got %v; want ErrNameTaken", err) + } + }) + + t.Run("same key with silent old replaces it", func(t *testing.T) { + service, address := live(t) + service.probeWithin = 30 * time.Millisecond + _ = registerSilent(t, address, owner) + + replacement, err := Register(context.Background(), address, owner) + if err != nil { + t.Fatalf("same key could not replace its own silent socket: %v", err) + } + defer func() { _ = replacement.Close() }() + + surface, err := Dial(context.Background(), address, name) + if err != nil { + t.Fatal(err) + } + defer func() { _ = surface.Close() }() + engine, err := replacement.Accept() + if err != nil { + t.Fatal(err) + } + defer func() { _ = engine.Close() }() + if _, err := surface.Write([]byte("ok")); err != nil { + t.Fatal(err) + } + if got := readN(t, engine, 2); got != "ok" { + t.Fatalf("replacement read %q", got) + } + }) +} + +// muxStream carries real read and write deadlines so handshakes above this +// package can time out silent peers and clear the deadline afterward. +func TestMuxStreamEnforcesAndClearsDeadlines(t *testing.T) { _, address := live(t) - key := aKey(t) - first, err := Register(context.Background(), address, key) + registration, err := Register(context.Background(), address, aKey(t)) if err != nil { t.Fatal(err) } - defer func() { _ = first.Close() }() + defer func() { _ = registration.Close() }() - second, err := Register(context.Background(), address, key) - if err == nil { - _ = second.Close() - t.Fatal("a second machine took a name that was already connected") + surface, err := Dial(context.Background(), address, registration.Name()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = surface.Close() }() + + engine, err := registration.Accept() + if err != nil { + t.Fatal(err) + } + defer func() { _ = engine.Close() }() + + deadliner, ok := engine.(interface { + SetDeadline(time.Time) error + SetReadDeadline(time.Time) error + SetWriteDeadline(time.Time) error + }) + if !ok { + t.Fatalf("%T does not implement stream deadlines", engine) + } + + if err := deadliner.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + var one [1]byte + if _, err := engine.Read(one[:]); !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("silent read returned %v; want os.ErrDeadlineExceeded", err) + } + + if err := deadliner.SetWriteDeadline(time.Now().Add(-time.Millisecond)); err != nil { + t.Fatal(err) + } + if _, err := engine.Write([]byte("x")); !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expired write returned %v; want os.ErrDeadlineExceeded", err) + } + + if err := deadliner.SetDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + if _, err := surface.Write([]byte("hi")); err != nil { + t.Fatal(err) } - if !errors.Is(err, ErrNameTaken) { - t.Fatalf("the refusal was %v, which a caller cannot tell apart from any other", err) + if got := readN(t, engine, 2); got != "hi" { + t.Fatalf("after clearing deadline, read %q", got) + } + if _, err := engine.Write([]byte("ok")); err != nil { + t.Fatal(err) + } + if got := readN(t, surface, 2); got != "ok" { + t.Fatalf("after clearing deadline, surface read %q", got) + } +} + +func collidingKeys(t *testing.T) (*ecdh.PrivateKey, *ecdh.PrivateKey) { + t.Helper() + seen := map[string]*ecdh.PrivateKey{} + var seed [32]byte + for i := uint64(1); ; i++ { + binary.LittleEndian.PutUint64(seed[:8], i) + key, err := ecdh.X25519().NewPrivateKey(seed[:]) + if err != nil { + t.Fatal(err) + } + name := NameFor(key.PublicKey().Bytes()) + if first, ok := seen[name]; ok && !bytes.Equal(first.PublicKey().Bytes(), key.PublicKey().Bytes()) { + return first, key + } + seen[name] = key } } diff --git a/internal/relay/transport.go b/internal/relay/transport.go new file mode 100644 index 0000000000..bf54ca8e74 --- /dev/null +++ b/internal/relay/transport.go @@ -0,0 +1,170 @@ +package relay + +// The WebSocket carrier. It carries exactly the bytes the raw upgrade carries — +// the registration proof, then the frames of mux.go — so nothing above the +// connection knows which one it got. Each frame is one message, because +// [writeFrame] writes a frame in one call; that lets a relay that sees +// messages and not a byte stream, such as the hosted one, read a frame without +// buffering half of one across a wake-up. + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coder/websocket" + "golang.org/x/net/http/httpproxy" +) + +// errRawOnly is a relay that answered a WebSocket with 426: one from before +// this door, which only speaks the raw upgrade. +var errRawOnly = errors.New("relay: that relay only speaks the raw upgrade") + +// messageLimit is the largest message either end accepts: one full frame. +const messageLimit = frameHeader + MaxPayload + +// wsConn is a WebSocket presented as the byte stream the carrier reads, with +// the protocol-level ping a carrier uses to keep a quiet connection proven. +type wsConn struct { + net.Conn + socket *websocket.Conn + maxWrite int +} + +// Write cuts surface writes larger than [MaxPayload] into relay-sized WebSocket +// messages so a caller above [Dial] cannot trip a front end's per-message cap +// or the peer's [messageLimit]. Carrier frames on [EnginePath] set maxWrite to +// [messageLimit] so each frame stays one message. +func (w *wsConn) Write(b []byte) (int, error) { + if w.maxWrite <= 0 || len(b) <= w.maxWrite { + return w.Conn.Write(b) + } + written := 0 + for len(b) > 0 { + chunk := b + if len(chunk) > w.maxWrite { + chunk = chunk[:w.maxWrite] + } + n, err := w.Conn.Write(chunk) + written += n + if err != nil { + return written, err + } + b = b[n:] + } + return written, nil +} + +// ping is answered by the far end's WebSocket layer, never by the relay's own +// code, so a hosted relay that is asleep answers it without waking. +func (w *wsConn) ping(ctx context.Context) error { return w.socket.Ping(ctx) } + +// pinger is a carrier connection that can prove itself alive from this end. +type pinger interface { + ping(ctx context.Context) error +} + +// proxyFromEnvironment resolves HTTP_PROXY, HTTPS_PROXY and NO_PROXY with the +// same rules [http.ProxyFromEnvironment] uses, reading the current environment +// so process-lifetime changes and tests are honoured. +func proxyFromEnvironment(req *http.Request) (*url.URL, error) { + return httpproxy.FromEnvironment().ProxyFunc()(req.URL) +} + +func upgradeWebSocket(ctx context.Context, address *url.URL, base, path string, header http.Header) (net.Conn, error) { + dialer := &net.Dialer{Timeout: dialWithin} + transport := &http.Transport{ + Proxy: proxyFromEnvironment, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: false, + DisableKeepAlives: true, + } + upgraded := false + defer func() { + // Any non-101 response (including every 426 fallback to an older + // self-hosted relay) must release its socket and transport goroutines + // before returning rather than leaving idle state behind. + if !upgraded { + transport.CloseIdleConnections() + } + }() + client := &http.Client{Transport: transport} + handshake, cancel := context.WithTimeout(ctx, HandshakeWithin) + defer cancel() + socket, response, err := websocket.Dial(handshake, address.String(), &websocket.DialOptions{ + HTTPClient: client, + HTTPHeader: header.Clone(), + Subprotocols: []string{Protocol}, + CompressionMode: websocket.CompressionDisabled, // the payloads are ciphertext + }) + if err != nil { + // Always drain and close a non-101 body first — including on 426 + // fallbacks — so net/http closes the underlying TCP socket instead of + // holding an active response open. + said := "" + if response != nil && response.Body != nil { + said = strings.TrimSpace(readBounded(response.Body)) + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if response == nil { + return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) + } + if response.StatusCode == http.StatusUpgradeRequired { + return nil, errRawOnly + } + if response.StatusCode == http.StatusSwitchingProtocols { + return nil, fmt.Errorf("%w: %s", ErrUnreachable, base) + } + return nil, refusal(base, path, response.StatusCode, said) + } + // coder/websocket permits a server that omitted Sec-WebSocket-Protocol; a + // generic WebSocket endpoint is not a relay door and must fail closed here. + if socket.Subprotocol() != Protocol { + _ = socket.CloseNow() + return nil, fmt.Errorf("%w: %s", ErrNoDoor, base) + } + upgraded = true + // The connection outlives the handshake's context: it ends when the + // carrier closes it, not when the dial's caller returns. SetReadLimit must + // follow websocket.NetConn because NetConn resets the limit to -1 on entry. + nc := websocket.NetConn(context.Background(), socket, websocket.MessageBinary) + socket.SetReadLimit(messageLimit) + maxWrite := MaxPayload + if path == EnginePath { + maxWrite = messageLimit + } + return &wsConn{Conn: nc, socket: socket, maxWrite: maxWrite}, nil +} + +// proveAlive pings a carrier from this end every [PingEvery] and counts an +// answer as an arrival. A relay that never sends its own pings — the hosted one +// stays asleep rather than wake to send them — is then still a carrier whose +// silence is tested. +func proveAlive(c *carrier, p pinger) { + ticker := time.NewTicker(PingEvery) + defer ticker.Stop() + for { + select { + case <-c.done: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), HandshakeWithin) + err := p.ping(ctx) + cancel() + if err != nil { + c.shutdown(errCarrierGone) + return + } + if c.onFrame != nil { + c.onFrame() + } + } + } +} diff --git a/internal/relay/transport_test.go b/internal/relay/transport_test.go new file mode 100644 index 0000000000..18519bd957 --- /dev/null +++ b/internal/relay/transport_test.go @@ -0,0 +1,470 @@ +package relay + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" +) + +// mounted serves a relay beneath base, the way the hosted relay lives at +// https://host/fabric, and refuses every path outside it as that relay does. +func mounted(t *testing.T, base string, door http.Handler) string { + t.Helper() + mux := http.NewServeMux() + mux.Handle(base+"/", http.StripPrefix(base, door)) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + t.Cleanup(server.CloseClientConnections) + return server.URL + base +} + +// reachAndEcho registers a machine at address, dials it, and checks one round +// trip, which is the whole of what a person needs from the relay. +func reachAndEcho(t *testing.T, address string) { + t.Helper() + registration, err := Register(context.Background(), address, aKey(t)) + if err != nil { + t.Fatalf("Register(%s): %v", address, err) + } + defer func() { _ = registration.Close() }() + surface, err := Dial(context.Background(), address, registration.Name()) + if err != nil { + t.Fatalf("Dial(%s): %v", address, err) + } + defer func() { _ = surface.Close() }() + engine, err := registration.Accept() + if err != nil { + t.Fatal(err) + } + defer func() { _ = engine.Close() }() + if _, err := surface.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + if got := readN(t, engine, 4); got != "ping" { + t.Fatalf("the machine read %q", got) + } + if _, err := engine.Write([]byte("pong")); err != nil { + t.Fatal(err) + } + if got := readN(t, surface, 4); got != "pong" { + t.Fatalf("the surface read %q", got) + } +} + +// A relay address keeps its path, as every other client's does: a relay at +// https://host/fabric is called at /fabric/v1/engine and /fabric/v1/dial/<name>. +func TestARelayAddressKeepsItsPath(t *testing.T) { + for _, base := range []string{"/fabric", "/a/relay"} { + for _, slash := range []string{"", "/"} { + reachAndEcho(t, mounted(t, base, &Server{})+slash) + } + } + _, root := live(t) + reachAndEcho(t, root) + reachAndEcho(t, root+"/") +} + +// rawOnly is a relay from before the WebSocket door: it answers a WebSocket +// upgrade with 426, as every earlier `codeaf relay` does. +func rawOnly(door http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "this door speaks "+Protocol, http.StatusUpgradeRequired) + return + } + door.ServeHTTP(w, r) + }) +} + +// wsOnly is a front end that passes WebSocket upgrades and nothing else, which +// is what a Cloudflare Worker is. +func wsOnly(door http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "error code: 522", 522) + return + } + door.ServeHTTP(w, r) + }) +} + +// The carrier rides a WebSocket, which is the one upgrade every front end +// passes, and still reaches a relay that only speaks the raw upgrade. +func TestTheCarrierCrossesAWebSocketOnlyFrontAndAnOlderRelay(t *testing.T) { + t.Run("websocket only", func(t *testing.T) { + reachAndEcho(t, mounted(t, "/fabric", wsOnly(&Server{}))) + }) + t.Run("raw only", func(t *testing.T) { + reachAndEcho(t, mounted(t, "/fabric", rawOnly(&Server{}))) + }) +} + +// An upstream proxy's body is not the relay's sentence. A relay that is down +// is unreachable; one that has no machine door says so; neither is printed as +// "error code: 522". +func TestUpstreamFailuresBecomeFacts(t *testing.T) { + down := mounted(t, "/fabric", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "error code: 522", 522) + })) + _, err := Register(context.Background(), down, aKey(t)) + if !errors.Is(err, ErrUnreachable) || strings.Contains(err.Error(), "error code") { + t.Fatalf("a relay answering 522: %v; want ErrUnreachable without the proxy's body", err) + } + + noDoor := mounted(t, "/fabric", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"err":"not_found"}`)) + })) + _, err = Register(context.Background(), noDoor, aKey(t)) + if !errors.Is(err, ErrNoDoor) || strings.Contains(err.Error(), "not_found") { + t.Fatalf("a relay with no machine door: %v; want ErrNoDoor", err) + } +} + +// A machine at its stream ceiling is busy, not unreachable: the relay answers +// 429 and the surface hears that it is being turned away for now. +func TestAFullMachineIsBusyNotUnreachable(t *testing.T) { + _, address := live(t) + fullMachineIsBusy(t, address) +} + +func fullMachineIsBusy(t *testing.T, address string) { + t.Helper() + registration, err := Register(context.Background(), address, aKey(t)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = registration.Close() }() + for i := 0; i < MaxStreams; i++ { + surface, err := Dial(context.Background(), address, registration.Name()) + if err != nil { + t.Fatalf("surface %d: %v", i, err) + } + defer func() { _ = surface.Close() }() + } + if _, err := Dial(context.Background(), address, registration.Name()); !errors.Is(err, ErrTooMany) { + t.Fatalf("a dial past %d streams gave %v; want ErrTooMany", MaxStreams, err) + } +} + +// Contract item 6 (part 1): WebSocket upgrades honour HTTP_PROXY and NO_PROXY. +func TestWebSocketUpgradeHonoursProxyEnvironment(t *testing.T) { + upstream := httptest.NewServer(http.StripPrefix("/fabric", wsOnly(&Server{}))) + t.Cleanup(upstream.Close) + t.Cleanup(upstream.CloseClientConnections) + upstreamHost := strings.TrimPrefix(upstream.URL, "http://") + + var proxied atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxied.Add(1) + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "no hijack", http.StatusInternalServerError) + return + } + backend, err := net.Dial("tcp", upstreamHost) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + clientConn, buffered, err := hijacker.Hijack() + if err != nil { + _ = backend.Close() + return + } + out := r.Clone(r.Context()) + out.RequestURI = "" + out.URL.Scheme = "" + out.URL.Host = "" + if err := out.Write(backend); err != nil { + _ = backend.Close() + _ = clientConn.Close() + return + } + go func() { + if buffered.Reader.Buffered() > 0 { + _, _ = io.CopyN(backend, buffered, int64(buffered.Reader.Buffered())) + } + _, _ = io.Copy(backend, clientConn) + _ = backend.Close() + }() + go func() { + _, _ = io.Copy(clientConn, backend) + _ = clientConn.Close() + }() + })) + t.Cleanup(proxy.Close) + t.Cleanup(proxy.CloseClientConnections) + + t.Setenv("HTTP_PROXY", proxy.URL) + t.Setenv("NO_PROXY", "") + // Non-loopback host name so httpproxy does not apply its loopback bypass. + reachAndEcho(t, "http://relay.example.test/fabric") + if got := proxied.Load(); got < 2 { + t.Fatalf("proxy saw %d requests; want both Register and Dial routed through HTTP_PROXY", got) + } + + // NO_PROXY bypasses the proxy for the named host. + before := proxied.Load() + t.Setenv("NO_PROXY", "unproxied.invalid") + previous := dialWithin + dialWithin = 50 * time.Millisecond + t.Cleanup(func() { dialWithin = previous }) + _, _ = Dial(context.Background(), "http://unproxied.invalid/fabric", "otter-lamp-42") + if proxied.Load() != before { + t.Fatalf("NO_PROXY host still hit HTTP_PROXY (%d -> %d)", before, proxied.Load()) + } +} + +// trackedListener counts every accepted TCP socket — hijacked or plain HTTP — +// until its Close method runs, because http.Server.ConnState stops reporting +// once a connection transitions to StateHijacked. +type trackedListener struct { + net.Listener + open atomic.Int32 +} + +type trackedServerConn struct { + net.Conn + once sync.Once + open *atomic.Int32 +} + +func (l *trackedListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + l.open.Add(1) + return &trackedServerConn{Conn: c, open: &l.open}, nil +} + +func (c *trackedServerConn) Close() error { + err := c.Conn.Close() + c.once.Do(func() { c.open.Add(-1) }) + return err +} + +// Contract item 6 (part 2): 20 attempts over the 426 fallback leave no open +// connections on the server and leak no transport goroutines on the client. +func TestFallback426DoesNotGrowOpenConnectionsOverTwentyAttempts(t *testing.T) { + service := &Server{} + server := httptest.NewUnstartedServer(rawOnly(service)) + tracked := &trackedListener{Listener: server.Listener} + server.Listener = tracked + server.Start() + t.Cleanup(server.Close) + t.Cleanup(server.CloseClientConnections) + + countOpen := func() int { + return int(tracked.open.Load()) + } + + registration, err := Register(context.Background(), server.URL, aKey(t)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = registration.Close() }() + + // Warm one dial so baseline goroutines and the one held registration settle. + warm, err := Dial(context.Background(), server.URL, registration.Name()) + if err != nil { + t.Fatal(err) + } + accepted, err := registration.Accept() + if err != nil { + t.Fatal(err) + } + _ = warm.Close() + _ = accepted.Close() + baselineGoroutines := runtime.NumGoroutine() + + const attempts = 20 + for i := 0; i < attempts; i++ { + surface, err := Dial(context.Background(), server.URL, registration.Name()) + if err != nil { + t.Fatalf("attempt %d: %v", i+1, err) + } + engine, err := registration.Accept() + if err != nil { + t.Fatalf("attempt %d accept: %v", i+1, err) + } + _ = surface.Close() + _ = engine.Close() + } + + // Only the single held registration connection may remain open once the 20 + // 426-fallback dials have closed. + deadline := time.Now().Add(2 * time.Second) + for countOpen() > 1 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := countOpen(); got > 1 { + t.Fatalf("after %d 426 fallbacks, %d server connections remained open; want at most 1 (the held registration)", attempts, got) + } + if grew := runtime.NumGoroutine() - baselineGoroutines; grew > 6 { + t.Fatalf("goroutines grew by %d across %d 426 fallbacks (baseline %d)", grew, attempts, baselineGoroutines) + } +} + +// Contract item 7 (Findings 7 & 8): upstream HTML/non-JSON bodies never leak +// into errors, wrong-prefix 404s are not misreported as ErrNoMachine, the base +// path is preserved in refusal messages, and an older relay's 503 stream +// ceiling maps to ErrTooMany. +func TestUpstreamHTMLAndLegacy503BecomeCleanFactsWithBasePath(t *testing.T) { + t.Run("upstream 403 HTML becomes one sentence with base path and no HTML", func(t *testing.T) { + waf := mounted(t, "/wrong-prefix", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("<!DOCTYPE html><html><body><h1>403 Forbidden</h1>\nAccess denied</body></html>")) + })) + _, err := Dial(context.Background(), waf, "otter-lamp-42") + if err == nil { + t.Fatal("403 succeeded") + } + said := err.Error() + if strings.Contains(said, "<!DOCTYPE") || strings.Contains(said, "Forbidden") || strings.Contains(said, "\n") { + t.Fatalf("raw upstream HTML leaked into error: %q", said) + } + if !strings.Contains(said, waf) || !strings.Contains(said, "(403)") { + t.Fatalf("refusal %q does not name full relay address %q and status", said, waf) + } + }) + + t.Run("upstream HTML 404 at bad base path is not ErrNoMachine", func(t *testing.T) { + nginx := mounted(t, "/wrong-prefix", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("<html><head><title>404 Not Found")) + })) + _, err := Dial(context.Background(), nginx, "otter-lamp-42") + if err == nil || errors.Is(err, ErrNoMachine) { + t.Fatalf("HTML 404 at bad base path returned %v; must not report ErrNoMachine", err) + } + if strings.Contains(err.Error(), " ./third_party/raft", "replace go.etcd.io/raft/v3 => "+filepath.Join(root, "third_party", "raft"), 1) + mod += "\nrequire github.com/Agent-Field/codeaf v0.0.0\n\nreplace github.com/Agent-Field/codeaf => " + root + "\n" if err = os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0600); err != nil { t.Fatal(err) } + if err = os.WriteFile(filepath.Join(dir, "go.sum"), rootSum, 0600); err != nil { + t.Fatal(err) + } binary := filepath.Join(dir, "client") - build := exec.Command("go", "build", "-mod=mod", "-p=1", "-o", binary, ".") + build := exec.Command("go", "build", "-buildvcs=false", "-mod=readonly", "-p=1", "-o", binary, ".") build.Dir = dir build.Env = append(os.Environ(), "GOPROXY=off", "GOSUMDB=off", "GOWORK=off") if out, err := build.CombinedOutput(); err != nil { diff --git a/internal/relayserve/compatibility_refusal_test.go b/internal/relayserve/compatibility_refusal_test.go index d1483cabc8..1e59eb641f 100644 --- a/internal/relayserve/compatibility_refusal_test.go +++ b/internal/relayserve/compatibility_refusal_test.go @@ -13,7 +13,7 @@ func TestR25CompatibilityRefusalSentences(t *testing.T) { for _, one := range []struct{ got, want string }{ {cs.EnrollmentMessage(cs.UpdateRequired), "this codeaf build must be updated before it can join this cluster; your saved chats are still here"}, {cs.LeadershipUnavailable, "this cluster cannot coordinate right now; reconnect enough of its voting computers and try again; your saved chats are still here"}, - {directory.ErrConditionalTake.Error(), "the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service"}, + {directory.ErrConditionalTake.Error(), "taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here"}, {clusterctl.CommittedRefusal{Code: cs.Conflict, Sequence: 1}.Error(), "the leadership request could not complete; refresh cluster status and try again"}, } { if one.got != one.want { diff --git a/internal/relayserve/control_test.go b/internal/relayserve/control_test.go index 5e7020df96..0689783881 100644 --- a/internal/relayserve/control_test.go +++ b/internal/relayserve/control_test.go @@ -38,7 +38,7 @@ func TestR6R9R30NativeControlReportsCommittedAnchorsAndRefusesLead(t *testing.T) t.Fatal(err) } c.Signature = a.dev.Sign(b) - if _, err = client.Lead(bg, c); !errors.Is(err, clusterctl.ErrUnsupported) || err.Error() != "this relay does not offer cluster leadership yet; your saved chats are still here" { + if _, err = client.Lead(bg, c); !errors.Is(err, clusterctl.ErrUnsupported) || err.Error() != clusterctl.ErrUnsupported.Error() { t.Fatalf("R6 unsupported lead: %v", err) } after, err := client.Status(bg) diff --git a/internal/relayserve/l4_help_test.go b/internal/relayserve/l4_help_test.go new file mode 100644 index 0000000000..d4aa103a67 --- /dev/null +++ b/internal/relayserve/l4_help_test.go @@ -0,0 +1,24 @@ +package relayserve + +import ( + "bytes" + "strings" + "testing" +) + +// L4-M8c, L4-M8d: help and the first startup line hide protocol and test machinery. +func TestL4RelayWords(t *testing.T) { + var out bytes.Buffer + RelayHelp(&out) + if !strings.HasPrefix(out.String(), "usage: codeaf relay") { + t.Fatal(out.String()) + } + for _, bad := range []string{"test hook", "aforge", "min-grace", "sweep-every", "Usage of"} { // legacy-name + if strings.Contains(out.String(), bad) { + t.Fatal(out.String()) + } + } + if got := relayStartLine("127.0.0.1:8901"); got != "codeaf relay is listening on 127.0.0.1:8901 — on each computer: codeaf pair --via http://127.0.0.1:8901" { + t.Fatal(got) + } +} diff --git a/internal/relayserve/main.go b/internal/relayserve/main.go index 94288fbfec..14fef83784 100644 --- a/internal/relayserve/main.go +++ b/internal/relayserve/main.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "io" "log" "net" "os" @@ -12,7 +13,6 @@ import ( "syscall" "github.com/Agent-Field/codeaf/internal/directory" - "github.com/Agent-Field/codeaf/internal/relay" ) // Main is the relay program, shared by cmd/relay and `codeaf relay`: @@ -20,15 +20,16 @@ import ( // relay --listen :8787 [--store /var/lib/codeaf] [--quiet] [--status=false] [--trust-proxy] // // It runs until SIGINT or SIGTERM and then lets in-flight requests finish. -// In front of it in production goes whatever already terminates TLS; the pipe -// reads X-Forwarded-For when it is there, so its rate limits count the caller. +// In front of it in production goes whatever already terminates TLS; pass --trust-proxy +// so registration, dial and pairing limits count callers behind that proxy. func Main(args []string) error { flags := flag.NewFlagSet("relay", flag.ContinueOnError) + flags.Usage = func() { RelayHelp(flags.Output()) } listen := flags.String("listen", ":8787", "address to listen on") store := flags.String("store", "", "directory that keeps the directory and blob stores; empty runs the blind pipe only") quiet := flags.Bool("quiet", false, "do not log arrivals, departures and requests") status := flags.Bool("status", true, "answer GET /status with what is connected right now") - trust := flags.Bool("trust-proxy", false, "count the address in X-Forwarded-For as the caller's network in the pairing limits; only for a relay behind a proxy that sets it") + trust := flags.Bool("trust-proxy", false, "count the first X-Forwarded-For address for registration, dial and pairing limits; required behind a proxy that sets it") minGrace := flags.Duration("min-grace", 0, "shortest grace period a rotation may ask for (default 1h); a test hook") sweepEvery := flags.Duration("sweep-every", 0, "how often replaced identities whose grace has ended are deleted (default 1h); a test hook") watchers := flags.Int("max-watchers", 0, "directory watch sockets one identity may hold (default 1000); a test hook") @@ -60,11 +61,28 @@ func Main(args []string) error { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + fmt.Fprintln(os.Stdout, relayStartLine(ln.Addr().String())) svc := New(cfg) defer svc.Close() - log.Printf("relay on %s, speaking %s", ln.Addr(), relay.Protocol) if err := Serve(ctx, ln, svc.Handler); err != nil { return fmt.Errorf("relay: %w", err) } return nil } + +// RelayHelp hides test-only switches while keeping the ordinary subcommand shape. +func RelayHelp(out io.Writer) { + fmt.Fprintln(out, `usage: codeaf relay [--listen host:port] [--store path] + +Run your own sync service. + + --listen host:port address to listen on (default :8787) + --store path keep your devices and saved chats here; empty connects machines only + --quiet do not log arrivals, departures or requests + --status=false turn off the service status endpoint + --trust-proxy use the caller address supplied by a trusted proxy`) +} + +func relayStartLine(address string) string { + return "codeaf relay is listening on " + address + " — on each computer: codeaf pair --via http://" + address +} diff --git a/internal/relayserve/proxy_limits_test.go b/internal/relayserve/proxy_limits_test.go new file mode 100644 index 0000000000..59ab49f213 --- /dev/null +++ b/internal/relayserve/proxy_limits_test.go @@ -0,0 +1,88 @@ +package relayserve_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/relayserve" +) + +func TestTrustProxyAppliesToEveryRelayDoor(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + for _, store := range []bool{false, true} { + for _, trust := range []bool{false, true} { + for _, door := range []struct { + name, path, method string + limit int + }{ + {"registration", relay.EnginePath, http.MethodGet, relay.RegistrationsPerMinute}, + {"dial", relay.DialPrefix + "otter-lamp-42", http.MethodGet, relay.DialsPerMinute}, + {"mailbox", pairbox.Path, http.MethodPost, pairbox.DefaultLimits.CreatePerHour}, + {"link", directory.LinkPath + "/requests/AAAAAAAA", http.MethodGet, directory.DefaultLinkLimits.MissPerMinute}, + } { + if !store && door.name == "link" { + continue + } + t.Run(fmt.Sprintf("store=%v/trust=%v/%s", store, trust, door.name), func(t *testing.T) { + path := "" + if store { + path = t.TempDir() + } + svc := relayserve.New(relayserve.Config{Store: path, TrustProxy: trust, Now: func() time.Time { return time.Unix(100000, 0) }}) + defer svc.Close() + request := func(header string) int { + req := httptest.NewRequest(door.method, door.path, nil) + req.RemoteAddr = "192.0.2.1:1234" + req.Header.Set("X-Forwarded-For", header) + req.Header.Set("Upgrade", relay.Protocol) + if door.name == "mailbox" { + key, err := pairbox.NewKey() + if err != nil { + t.Fatal(err) + } + req.Header.Set(pairbox.KeyHeader, key.String()) + } + out := httptest.NewRecorder() + svc.Handler.ServeHTTP(out, req) + return out.Code + } + // Changing only the second entry never changes the counted network. + for i := 0; i < door.limit; i++ { + if code := request(fmt.Sprintf(" 198.51.100.1 , 203.0.113.%d", i)); code == http.StatusTooManyRequests { + t.Fatalf("early limit at %d", i) + } + } + if code := request("198.51.100.1, 203.0.113.99"); code != http.StatusTooManyRequests { + t.Fatalf("same first address: %d", code) + } + code := request("198.51.100.2, 198.51.100.1") + if (code != http.StatusTooManyRequests) != trust { + t.Fatalf("new forwarded address: trust=%v status=%d", trust, code) + } + if trust { + // Absent and blank headers fall back to the same socket allowance. + for i := 0; i < door.limit; i++ { + header := "" + if i%2 == 1 { + header = " , 203.0.113.1" + } + if code := request(header); code == http.StatusTooManyRequests { + t.Fatalf("early fallback limit at %d", i) + } + } + if code := request(" "); code != http.StatusTooManyRequests { + t.Fatalf("blank did not share fallback allowance: %d", code) + } + } + }) + } + } + } +} diff --git a/internal/relayserve/service.go b/internal/relayserve/service.go index 1fdb1a1b53..66d633ada7 100644 --- a/internal/relayserve/service.go +++ b/internal/relayserve/service.go @@ -32,7 +32,7 @@ type Config struct { Logf func(string, ...any) Note func(name, what string) // the pipe's arrival and departure line; nil says nothing - // TrustProxy makes the pairing mailbox's per-network limits count the + // TrustProxy makes registration, dial and pairing limits count the // address in X-Forwarded-For. Only a relay behind a proxy that sets it may // say so; anywhere else a caller could name any network it liked. TrustProxy bool @@ -108,7 +108,7 @@ func New(cfg Config) *Service { if now == nil { now = time.Now } - mux := pipeMux(&relay.Server{Note: cfg.Note}, cfg.Status) + mux := pipeMux(&relay.Server{Note: cfg.Note, Peer: peerOf(cfg)}, cfg.Status) mountPairing(mux, cfg, now) mountCluster(mux, cfg.Cluster) mux.HandleFunc("/p/", servePairPage) @@ -141,9 +141,9 @@ func New(cfg Config) *Service { return svc } -// peerOf names the network of a link-pairing request: the proxy's word when -// the relay sits behind one, the socket otherwise. -func peerOf(cfg Config) directory.PeerFunc { +// peerOf keeps registrations, dials and both pairing paths on one explicit +// proxy policy. An untrusted forwarding header can never select an allowance. +func peerOf(cfg Config) func(*http.Request) string { if cfg.TrustProxy { return pairbox.ForwardedPeer } @@ -166,10 +166,7 @@ func mountPairing(mux *http.ServeMux, cfg Config, now func() time.Time) { if limits == (pairbox.Limits{}) { limits = pairbox.DefaultLimits } - peer := pairbox.SocketPeer - if cfg.TrustProxy { - peer = pairbox.ForwardedPeer - } + peer := peerOf(cfg) h := logged(cfg.Logf, pairbox.Handler(pairbox.NewMemory(limits, now), peer)) mux.Handle(pairbox.Path, h) mux.Handle(pairbox.Path+"/", h) diff --git a/internal/release/promotion_workflow_test.go b/internal/release/promotion_workflow_test.go index 343efcdb4e..9c8cb0fd13 100644 --- a/internal/release/promotion_workflow_test.go +++ b/internal/release/promotion_workflow_test.go @@ -9,8 +9,10 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "testing" + "time" "gopkg.in/yaml.v3" ) @@ -52,33 +54,69 @@ func stringField(t *testing.T, value any) string { return result } -// C16: Promotion requires an explicit dispatch and retains safe concurrency. +// C16: The schedule and the dispatch are the only triggers, and the parsed cron follows the parsed cutoff in both Toronto offsets. func TestC16PromotionTriggerAndCutoff(t *testing.T) { doc := workflowDocument(t, "promote-staging.yml") on := obj(t, doc["on"]) - if len(on) != 1 { - t.Fatalf("triggers = %v; want explicit dispatch only", on) + if len(on) != 2 { + t.Fatalf("triggers = %v", on) + } + schedule := list(t, on["schedule"]) + if len(schedule) != 1 { + t.Fatalf("schedule = %v", schedule) + } + cron := strings.Fields(stringField(t, obj(t, schedule[0])["cron"])) + if len(cron) != 5 { + t.Fatalf("cron = %v", cron) + } + minute, e1 := strconv.Atoi(cron[0]) + hour, e2 := strconv.Atoi(cron[1]) + if e1 != nil || e2 != nil || cron[2] != "*" || cron[3] != "*" { + t.Fatalf("cron = %v", cron) + } + dispatch := obj(t, on["workflow_dispatch"]) + inputs := obj(t, dispatch["inputs"]) + if len(inputs) != 3 { + t.Fatalf("inputs = %v", inputs) } - inputs := obj(t, obj(t, on["workflow_dispatch"])["inputs"]) for _, name := range []string{"target", "dry_run", "signal"} { if _, ok := inputs[name]; !ok { t.Errorf("missing %s", name) } } + env := obj(t, doc["env"]) + zone := stringField(t, env["CUTOFF_ZONE"]) + weekday := stringField(t, env["CUTOFF_WEEKDAY"]) + clock := stringField(t, env["CUTOFF_CLOCK"]) + loc, err := time.LoadLocation(zone) + if err != nil { + t.Fatal(err) + } + cutParts := strings.Split(clock, ":") + cutHour, _ := strconv.Atoi(cutParts[0]) + cutMinute, _ := strconv.Atoi(cutParts[1]) + for _, day := range []time.Time{time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC), time.Date(2026, 11, 6, 0, 0, 0, 0, time.UTC)} { + run := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, time.UTC) + local := run.In(loc) + cut := time.Date(local.Year(), local.Month(), local.Day(), cutHour, cutMinute, 0, 0, loc) + if local.Weekday().String() != weekday || cron[4] != strconv.Itoa(int(local.Weekday())) || !run.After(cut) { + t.Errorf("cron %v run %s before cutoff %s", cron, run, cut) + } + } concurrency := obj(t, doc["concurrency"]) if stringField(t, concurrency["group"]) != "promote-staging-${{ github.event_name }}" || concurrency["cancel-in-progress"] != false { t.Fatalf("concurrency = %v", concurrency) } } -// F8: The workflow documents the validation and push safety boundaries. +// F8: The workflow explains why a late cron is safe and why each job exists. func TestPromotionWorkflowExplainsItsSafetyLaws(t *testing.T) { raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", "promote-staging.yml")) if err != nil { t.Fatal(err) } text := string(raw) - for _, want := range []string{"Validate the selected dev commit", "PROMOTION_TOKEN", "SLACK_RELEASE_WEBHOOK", "forward-only", "persist-credentials"} { + for _, want := range []string{"# THE CUTOFF IS A COMMIT TIME, NOT THE RUN TIME.", "12:59Z and 15:46Z", "PROMOTION_TOKEN", "SLACK_RELEASE_WEBHOOK", "forward-only", "persist-credentials"} { if !strings.Contains(text, want) { t.Errorf("workflow lacks explanation %q", want) } @@ -91,13 +129,13 @@ func TestPromotionWorkflowExplainsItsSafetyLaws(t *testing.T) { } } -// Promotion instructions must preserve candidate validation and forward-only updates. +// Promotion instructions must preserve the Friday cutoff schedule, candidate validation and forward-only updates. func TestPromotionDocsDescribeLiveSetupAndConditionalCadence(t *testing.T) { raw, err := os.ReadFile(filepath.Join(repositoryRoot(t), "docs/rules/promotion.md")) if err != nil { t.Fatal(err) } - for _, want := range []string{"dry_run=true", "Full check", "exact candidate", "git merge-base --is-ancestor", "git push origin $SHA:staging", "Never\nforce a promotion"} { + for _, want := range []string{"every Friday after a 17:00 America/Toronto cutoff", "dry_run=true", "Full check", "exact candidate", "git merge-base --is-ancestor", "git push origin $SHA:staging", "Never\nforce a promotion"} { if !strings.Contains(string(raw), want) { t.Errorf("runbook lacks %q", want) } diff --git a/internal/release/workflow_test.go b/internal/release/workflow_test.go index 11321cccfb..765f90ae49 100644 --- a/internal/release/workflow_test.go +++ b/internal/release/workflow_test.go @@ -77,7 +77,7 @@ func TestReleaseWorkflowKeepsTheChannelContract(t *testing.T) { // carry one, and it must still refuse a red predecessor. for job, want := range map[string]string{ "build": " if: always() && needs.prepare.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')", - "publish": " if: always() && needs.prepare.result == 'success' && needs.build.result == 'success'", + "publish": " if: always() && needs.prepare.result == 'success' && needs.build.result == 'success' && needs.relay.result == 'success'", } { block := regexp.MustCompile(`(?ms)^ ` + job + `:\n needs: [^\n]+\n( if: [^\n]+)`).FindStringSubmatch(workflow) if block == nil || block[1] != want { @@ -106,7 +106,7 @@ func TestReleaseWorkflowKeepsTheChannelContract(t *testing.T) { `if [ -s .github/known-red.txt ]; then`, `skip="$(awk '!/^#/ && NF {print}' .github/known-red.txt | paste -sd'|' -)"`, `test_args=()`, - `go test "${test_args[@]}"`, + `go test ${test_args[@]+"${test_args[@]}"}`, } { if !strings.Contains(workflow, source) { t.Errorf("the release-surface test is not safe for an empty or absent known-red ledger: missing %q", source) @@ -183,7 +183,7 @@ func releaseSurfacePrologue(t *testing.T) string { continue } if strings.HasPrefix(trimmed, "go test ") { - script = append(script, `printf 'ARG:%s\n' "${test_args[@]}"`) + script = append(script, `printf 'ARG:%s\n' ${test_args[@]+"${test_args[@]}"}`) return strings.Join(script, "\n") + "\n" } script = append(script, trimmed) @@ -268,3 +268,213 @@ func TestPublishedPinExamplesGiveVersionToBash(t *testing.T) { } } } + +func TestRelayJobRunsAfterPrepareAndBlocksPublish(t *testing.T) { + root := repositoryRoot(t) + rawWorkflow, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "release.yml")) + if err != nil { + t.Fatal(err) + } + workflowText := string(rawWorkflow) + + doc := workflowDocument(t, "release.yml") + jobs := obj(t, doc["jobs"]) + relay := obj(t, jobs["relay"]) + if relay["needs"] != "prepare" { + t.Fatalf("relay needs = %v, want prepare", relay["needs"]) + } + perms := obj(t, relay["permissions"]) + if len(perms) != 1 || perms["contents"] != "read" { + t.Fatalf("relay permissions = %v, want only contents: read", perms) + } + concurrency := obj(t, relay["concurrency"]) + wantGroup := "relay-${{ (needs.prepare.outputs.channel == 'stable' || needs.prepare.outputs.channel == 'rc') && 'production' || needs.prepare.outputs.channel }}" + if concurrency["group"] != wantGroup || concurrency["cancel-in-progress"] != false { + t.Fatalf("relay concurrency = %v, want group %q and cancel-in-progress false", concurrency, wantGroup) + } + + publish := obj(t, jobs["publish"]) + publishNeeds := list(t, publish["needs"]) + for _, want := range []string{"prepare", "build", "relay"} { + if !containsNeed(publishNeeds, want) { + t.Errorf("publish needs %v lacks %s", publishNeeds, want) + } + } + if !strings.Contains(stringField(t, publish["if"]), "needs.relay.result == 'success'") { + t.Fatalf("publish condition does not block on relay success: %v", publish["if"]) + } + + steps := list(t, relay["steps"]) + checkedOut := false + pinnedAction := regexp.MustCompile(`^actions/[a-z0-9-]+@v[0-9]+$`) + for i, rawStep := range steps { + step := obj(t, rawStep) + if i > 0 && step["if"] != "steps.gate.outputs.deploy == 'true'" { + t.Errorf("relay step %d lacks gate condition: %v", i, step["if"]) + } + if uses, ok := step["uses"].(string); ok { + if !pinnedAction.MatchString(uses) { + t.Errorf("relay step %d uses unpinned action %q", i, uses) + } + if strings.HasPrefix(uses, "actions/checkout@") { + checkedOut = true + if obj(t, step["with"])["ref"] != "${{ needs.prepare.outputs.sha }}" { + t.Errorf("relay checkout does not check out needs.prepare.outputs.sha: %v", step["with"]) + } + } + } + if run, ok := step["run"].(string); ok && strings.Contains(run, "scripts/") && !checkedOut { + t.Errorf("relay step %d runs a repository script before checkout", i) + } + } + + deployStep := obj(t, steps[len(steps)-1]) + deployEnv := obj(t, deployStep["env"]) + if deployEnv["CLOUDFLARE_ACCOUNT_ID"] != "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" || + deployEnv["CLOUDFLARE_API_TOKEN"] != "${{ secrets.CLOUDFLARE_API_TOKEN }}" { + t.Fatalf("relay deploy step does not read Cloudflare secrets through env: %v", deployEnv) + } + if deployEnv["CODEAF_TELEMETRY"] != "off" || deployEnv["DO_NOT_TRACK"] != "1" { + t.Fatalf("relay deploy step must disable telemetry: %v", deployEnv) + } + + // Neither the workflow nor the relay scripts may trace commands or echo secrets. + secretLeak := regexp.MustCompile(`(?m)(^\s*set\s+-[A-Za-z]*x|echo\s+.*\$\{?CLOUDFLARE_(?:API_TOKEN|ACCOUNT_ID))`) + for _, rel := range []string{ + ".github/workflows/release.yml", + "scripts/relay-deploy.sh", + "scripts/relay-wrangler-config.sh", + "scripts/relay-fingerprint.sh", + } { + raw, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + if secretLeak.Match(raw) { + t.Errorf("%s traces commands or echoes Cloudflare secrets", rel) + } + } + + // Both the workflow comment and the deploy script warn that a rollback + // cannot undo a one-way storage upgrade. + rawDeploy, err := os.ReadFile(filepath.Join(root, "scripts", "relay-deploy.sh")) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{ + "release.yml": workflowText, + "relay-deploy.sh": string(rawDeploy), + } { + flat := strings.Join(strings.Fields(strings.ReplaceAll(content, "#", " ")), " ") + if !strings.Contains(flat, "rollback cannot undo a one-way storage upgrade") { + t.Errorf("%s lacks the one-way storage upgrade rollback explanation", name) + } + } +} + +func TestRelayProductionSwitchDefaultsOffAndDevStagingHaveNoSwitch(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not on PATH") + } + doc := workflowDocument(t, "release.yml") + relay := obj(t, obj(t, doc["jobs"])["relay"]) + gateStep := obj(t, list(t, relay["steps"])[0]) + gateEnv := obj(t, gateStep["env"]) + if gateEnv["CHANNEL"] != "${{ needs.prepare.outputs.channel }}" || + gateEnv["RELAY_DEPLOY_PRODUCTION"] != "${{ vars.RELAY_DEPLOY_PRODUCTION }}" { + t.Fatalf("gate env = %v", gateEnv) + } + gateScript := stringField(t, gateStep["run"]) + + for _, channel := range []string{"stable", "rc", "staging", "dev"} { + for _, variable := range []string{"", "false", "TRUE", "0", "1", "true"} { + t.Run(channel+"/"+variable, func(t *testing.T) { + outFile := filepath.Join(t.TempDir(), "github_output") + cmd := exec.Command("bash", "-c", gateScript) + cmd.Env = append(os.Environ(), + "CHANNEL="+channel, + "RELAY_DEPLOY_PRODUCTION="+variable, + "GITHUB_OUTPUT="+outFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gate failed for %s/%q: %v\n%s", channel, variable, err, out) + } + rawOut, err := os.ReadFile(outFile) + if err != nil { + t.Fatal(err) + } + productionChannel := channel == "stable" || channel == "rc" + wantDeploy := !productionChannel || variable == "true" + if wantDeploy { + if strings.TrimSpace(string(rawOut)) != "deploy=true" { + t.Fatalf("output = %q, want deploy=true", rawOut) + } + if strings.TrimSpace(string(out)) != "" { + t.Fatalf("deploy-enabled gate printed unexpected output: %q", out) + } + return + } + if strings.TrimSpace(string(rawOut)) != "deploy=false" { + t.Fatalf("output = %q, want deploy=false", rawOut) + } + lines := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") + if len(lines) != 1 || !strings.Contains(lines[0], "RELAY_DEPLOY_PRODUCTION=true") { + t.Fatalf("production-off gate must print one line explaining how to enable it, got %q", out) + } + }) + } + } +} + +func TestRelayPipelineTouchesOnlyChannelFabricWorkers(t *testing.T) { + root := repositoryRoot(t) + forbidden := []string{"caf-relay-staging", "caf-relay-a", "caf-relay-c", "codeaf-pool"} + checked := []string{ + ".github/workflows/release.yml", + "scripts/relay-wrangler-config.sh", + "scripts/relay-deploy.sh", + "scripts/relay-fingerprint.sh", + } + for _, rel := range checked { + raw, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, bad := range forbidden { + if strings.Contains(text, bad) { + t.Errorf("%s names forbidden Worker %q", rel, bad) + } + } + } + + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash is not on PATH") + } + gen := filepath.Join(root, "scripts", "relay-wrangler-config.sh") + wantWorker := map[string]string{ + "stable": "caf-fabric", + "rc": "caf-fabric", + "staging": "caf-fabric-staging", + "dev": "caf-fabric-dev", + } + nameLine := regexp.MustCompile(`(?m)^name = "([^"]+)"$`) + for channel, want := range wantWorker { + cmd := exec.Command("bash", gen, channel) + cmd.Env = append(os.Environ(), "CLOUDFLARE_ACCOUNT_ID=acct_test_0123456789") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("relay-wrangler-config.sh %s: %v\n%s", channel, err, out) + } + match := nameLine.FindSubmatch(out) + if match == nil || string(match[1]) != want { + t.Fatalf("channel %s generated name %q, want %q", channel, match, want) + } + for _, bad := range forbidden { + if strings.Contains(string(out), bad) { + t.Errorf("generated config for %s names forbidden Worker %q", channel, bad) + } + } + } +} diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 59269d28fe..9dcd203e48 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -188,7 +188,7 @@ func classify(method string) callClass { MethodAutomationsDelete, MethodAutomationsRunNow, MethodAutomationsStopRun, MethodAutomationsClaim: return classAct - case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodOpenPlanTaskWork, MethodCompact, + case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodOpenPlanTaskWork, MethodCompact, MethodTaskRunHere, // A side ask is a model call of up to two minutes that writes nothing // into the conversation, so it owes a turn no order and a turn owes it // none (sideask.go). diff --git a/internal/remote/client.go b/internal/remote/client.go index 38c26d8ad7..f0ddf45c78 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/syncwords" "io" "os" "path/filepath" @@ -16,6 +17,7 @@ import ( "sync/atomic" "time" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/store" @@ -87,10 +89,11 @@ const callDeadline = 10 * time.Second // it has to be: the surface asks synchronous getters from its update loop while // a turn's events are arriving on the reader. type Client struct { - // host is the ssh destination as the person typed it, and it exists for one - // purpose: the sentence a broken connection says names the machine they were - // working on. A person with three windows open needs to know WHICH. + // host keeps the typed destination as the fallback until the window chooses + // its displayed name. Keeping the two apart preserves the connection route. host string + // displayName is atomic because diagnostics can run with mu already held. + displayName atomic.Pointer[string] // conn is the pipe pair, and closing it is what ends the ssh process. A // roaming client replaces it with the next one rather than dying with it, so // it is read under mu and never cached by anything that outlives a frame. @@ -183,7 +186,9 @@ type Client struct { // the ONLY thing on this side that answers the question — a client that // worked it out for itself would be a second authority on a fact that can // only have one (driver.go). - driver Driver + driver Driver + localSaved bool + localSavingID uint64 // driverWake is closed and replaced whenever [Client.driver] changes, which // is how a surface drawing on a frame clock learns that an answer arrived // on the wire with no keystroke behind it. It is a channel rather than a @@ -233,7 +238,9 @@ type Client struct { // per call rather than a hang per call. done is closed at the same moment, // which is what wakes the calls that were already waiting. dead error - done chan struct{} + // deadCause lets a name chosen after a broken welcome retain the engine's reason. + deadCause error + done chan struct{} // closing says Close was called here, so the EOF the reader is about to see // is expected and not worth a sentence about a lost connection. closing bool @@ -276,6 +283,8 @@ func Dial(conn io.ReadWriteCloser, host string, hello Hello) (*Client, error) { // anything has been said on a pipe. func newClient(host string, hello Hello) *Client { hello.Version = Version + hello.LocalSavingReceipts = true + hello.SyncNoticeWords = true // THIS MACHINE'S NAME IS FILLED IN HERE AND NOT AT THE DOOR, so that no // door can forget it: --host, --at and every test all reach this one // constructor, and a hello without a name is a screen on the far side @@ -485,6 +494,17 @@ func (c *Client) Welcome() Welcome { // Host is the ssh destination this client dialled. func (c *Client) Host() string { return c.host } +// SetDisplayName keeps connection messages on the name selected for the window. +func (c *Client) SetDisplayName(name string) { + name = devname.Shown(name) + c.mu.Lock() + defer c.mu.Unlock() + c.displayName.Store(&name) + if c.dead != nil && !c.closing { + c.dead = c.gone(c.deadCause) + } +} + // Attached is how many OTHER surfaces are on this session, as the engine // counted them at the door. // @@ -745,15 +765,36 @@ func (c *Client) Err() error { return c.dead } -// where names the far end the way a sentence about it should: the destination -// the person typed, or "the engine" when they typed nothing nameable. +// where uses the window's selected name, falling back to its destination +// until the serving computer has supplied a name. func (c *Client) where() string { + if name := c.displayName.Load(); name != nil && *name != "" { + return *name + } if c.host == "" { return "the engine" } return c.host } +// SendRefusal reports only an already known broken link, without a wire call. +// The composer asks before spending a draft, so a refused Enter keeps its words. +func (c *Client) SendRefusal() string { + c.mu.Lock() + defer c.mu.Unlock() + if c.dead != nil { + name := c.where() + if c.welcome.Machine != "" { + name = devname.Shown(c.welcome.Machine) + } + return fmt.Sprintf("not sent — the connection to %s is gone; your words are still in the box. Run the same command to pick the conversation back up", name) + } + if c.reconnecting { + return fmt.Sprintf("not sent — still reconnecting to %s; enter sends it once the link is back", c.where()) + } + return "" +} + // gone is THE HONEST SENTENCE, and it is one sentence in one place so that // every road to a dead connection says the same thing. // @@ -833,6 +874,12 @@ func (c *Client) read() { c.observerFrame(frame) case "sideAskDelta": c.sideAskDelta(frame) + case "localSaved": + c.mu.Lock() + if c.welcome.LocalSavingReceipts && frame.ID == c.localSavingID { + c.localSaved = true + } + c.mu.Unlock() case "result": c.deliver(frame) case "event": @@ -919,6 +966,14 @@ func (c *Client) read() { // deliver routes one result to the call waiting for it. func (c *Client) deliver(frame Frame) { + if frame.LocalSaved { + c.mu.Lock() + if c.welcome.LocalSavingReceipts && frame.ID == c.localSavingID { + c.localSaved = true + } + c.mu.Unlock() + } + c.mu.Lock() waiting, ok := c.calls[frame.ID] delete(c.calls, frame.ID) @@ -938,6 +993,9 @@ func (c *Client) deliver(frame Frame) { frame.WorkRefusal.Line = frame.Error err = frame.WorkRefusal } + if c.Welcome().LocalSavingReceipts && frame.SyncPending && frame.LocalSaved { + err = syncwords.UploadFailure(err) + } if frame.UnsavedTranscript != "" { err = session.SavingError(frame.UnsavedTranscript, err) } @@ -981,6 +1039,7 @@ func (c *Client) bury(cause error) { c.mu.Unlock() return } + c.deadCause = cause if c.closing { // We closed the pipe ourselves, so the EOF is the sound of the door // shutting. It is still a dead client — nothing may be called on it — @@ -1118,6 +1177,12 @@ func (c *Client) callAnswered(ctx context.Context, method string, args any, dead payload = encoded } id := c.seq.Add(1) + if method == MethodClose || method == MethodDetach { + c.mu.Lock() + c.localSavingID = id + c.localSaved = false + c.mu.Unlock() + } waiting := make(chan result, 1) c.mu.Lock() @@ -2107,9 +2172,15 @@ func (a *Agent) Close() error { // finishSaving gives departures their saving budget and distinguishes a missing reply from a refusal. func (a *Agent) finishSaving(method string, args any) error { + a.c.mu.Lock() + a.c.localSaved = false + a.c.mu.Unlock() _, answered, err := a.c.callAnswered(nil, method, args, executor.DrainWithin) if err != nil && !answered { err = errors.Join(session.ErrSavingUnanswered, err) + if a.LocalDurable() { + err = syncwords.UploadFailure(err) + } } return session.SavingError(a.c.Welcome().SessionFile, err) } @@ -2924,3 +2995,13 @@ func (a *Agent) refuseUnkeptClientID(message session.Submission) error { func (a *Agent) AttachmentSourcesKnown() bool { return a.welcomeSays(func(w Welcome) bool { return w.AttachmentSources }) } + +// WriterChanges lets an off-loop send wait for the writer's next pushed state. +// It uses existing facts and adds neither a request nor a wire field. +func (a *Agent) WriterChanges() <-chan struct{} { return a.c.facts.changes() } + +// LocalDurable is a receipt from the engine after its local close succeeded. +func (a *Agent) LocalDurable() bool { a.c.mu.Lock(); defer a.c.mu.Unlock(); return a.c.localSaved } + +// SavingReceipts reports the two-way capability agreed in this connection's hello. +func (a *Agent) SavingReceipts() bool { return a.c.Welcome().LocalSavingReceipts } diff --git a/internal/remote/l4_saving_test.go b/internal/remote/l4_saving_test.go new file mode 100644 index 0000000000..4bd022f270 --- /dev/null +++ b/internal/remote/l4_saving_test.go @@ -0,0 +1,106 @@ +package remote + +import ( + "context" + "errors" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// L4-H4b: the remote engine distinguishes a failed upload from failed local storage. +// L4-M1a: the welcome carries the device name used by the --at window bar. +func TestL4RemoteUploadReceiptAndDeviceName(t *testing.T) { + home := t.TempDir() + t.Setenv("CODEAF_HOME", home) + if _, err := devname.Set(home, "desk-b"); err != nil { + t.Fatal(err) + } + loop, err := Loopback(Hello{}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: &fakeAgent{}, FinishSaving: func(WrappedAgent) error { + return session.SavingError("/temporary/chat/transcript.jsonl", syncwords.UploadFailure(context.DeadlineExceeded)) + }, SessionFile: "/temporary/chat/transcript.jsonl"}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + if got := loop.Client.Welcome().Machine; got != "desk-b" { + t.Fatal(got) + } + err = loop.Client.Agent().Close() + var unsaved *session.UnsavedChatError + if !errors.As(err, &unsaved) || !syncwords.UploadPending(err) { + t.Fatalf("upload fact was lost: %v", err) + } +} + +type l4LocalDeadline struct{ *fakeAgent } + +func (l4LocalDeadline) Close() error { + return session.SavingError("/temporary/chat/transcript.jsonl", context.DeadlineExceeded) +} + +// L4-H4c: a local capture deadline cannot acquire an upload receipt across the wire. +func TestL4RemoteLocalDeadlineStaysFailure(t *testing.T) { + loop, err := Loopback(Hello{}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: l4LocalDeadline{&fakeAgent{}}, SessionFile: "/temporary/chat/transcript.jsonl"}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + err = loop.Client.Agent().Close() + if err == nil || syncwords.UploadPending(err) || loop.Client.Agent().LocalDurable() { + t.Fatalf("local failure became a receipt: %v", err) + } +} + +// L4-H4b, L4-H4e: the engine acknowledges local durability before its upload can finish. +func TestL4RemoteLocalReceiptPrecedesUpload(t *testing.T) { + upload := make(chan struct{}) + entered := make(chan struct{}) + loop, err := Loopback(Hello{}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: &fakeAgent{}, FinishSaving: func(WrappedAgent) error { close(entered); <-upload; return nil }}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + closed := make(chan error, 1) + go func() { closed <- loop.Client.Agent().Close() }() + <-entered + deadline := time.After(time.Second) + for !loop.Client.Agent().LocalDurable() { + select { + case <-deadline: + close(upload) + t.Fatal("no local receipt during the upload wait") + default: + runtime.Gosched() + } + } + close(upload) + if err := <-closed; err != nil { + t.Fatal(err) + } +} + +// L4-H4c: local failure remains a failure even if the later upload failed too. +func TestL4RemoteMixedSavingFailure(t *testing.T) { + loop, err := Loopback(Hello{}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: l4LocalDeadline{&fakeAgent{}}, SessionFile: "/temporary/chat/transcript.jsonl", FinishSaving: func(WrappedAgent) error { return syncwords.UploadFailure(context.DeadlineExceeded) }}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + err = loop.Client.Agent().Close() + if err == nil || syncwords.UploadPending(err) { + t.Fatalf("mixed failure acquired a receipt: %v", err) + } +} diff --git a/internal/remote/l4b2_send_test.go b/internal/remote/l4b2_send_test.go new file mode 100644 index 0000000000..042562940f --- /dev/null +++ b/internal/remote/l4b2_send_test.go @@ -0,0 +1,25 @@ +package remote + +import ( + "errors" + "testing" +) + +// L4-H10a, L4-H10b: Refused-send words identify the link and use the serving computer's chosen name. +func TestL4B2RefusedSendWords(t *testing.T) { + c := newClient("localhost", Hello{}) + if c.SendRefusal() != "" { + t.Fatal("healthy link refused") + } + c.reconnecting = true + if got := c.SendRefusal(); got != "not sent — still reconnecting to localhost; enter sends it once the link is back" { + t.Fatal(got) + } + c.reconnecting = false + c.host = "seagull-lichen-63" + c.welcome.Machine = "desk-b" + c.dead = errors.New("raw pipe error") + if got := c.SendRefusal(); got != "not sent — the connection to desk-b is gone; your words are still in the box. Run the same command to pick the conversation back up" { + t.Fatal(got) + } +} diff --git a/internal/remote/l4fix2_notices_test.go b/internal/remote/l4fix2_notices_test.go new file mode 100644 index 0000000000..8327859ddf --- /dev/null +++ b/internal/remote/l4fix2_notices_test.go @@ -0,0 +1,40 @@ +package remote + +import ( + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" + "testing" +) + +// L4-B16: Old surfaces keep their recognized notice spellings; a surface that +// requests the new vocabulary receives the current host-aware sentences. +func TestL4Fix2NoticeVocabularyOnTheWire(t *testing.T) { + for _, modern := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy surface", true: "current surface"}[modern], func(t *testing.T) { + for _, tc := range []struct{ text, old string }{ + {syncwords.Outage("https://sync.example"), "this chat is not syncing because other machines are unreachable; it keeps working here"}, + {syncwords.Recovered, chatlist.SyncRestored}, + {chatlist.Removed, "this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back"}, + } { + far := &fakeAgent{prefill: []session.Event{{Kind: session.EventNotice, Text: tc.text}}} + l := dialAgent(t, &Engine{Agent: far}) + l.write(Frame{Kind: "hello", Payload: raw(t, map[string]any{"version": Version, "syncNoticeWords": modern})}) + if welcome := l.recv(); welcome.Kind != "welcome" { + t.Fatal(welcome) + } + l.ok(1, MethodSubmit, SubmitArgs{Text: "hello"}) + frame := l.await(func(f Frame) bool { return f.Kind == "event" }) + event := decode[EventWire](t, frame.Payload) + want := tc.old + if modern { + want = tc.text + } + if event.Text != want { + t.Fatalf("notice=%q want %q", event.Text, want) + } + l.end() + } + }) + } +} diff --git a/internal/remote/l4fix_compat_test.go b/internal/remote/l4fix_compat_test.go new file mode 100644 index 0000000000..913dd8dd0d --- /dev/null +++ b/internal/remote/l4fix_compat_test.go @@ -0,0 +1,89 @@ +package remote + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" +) + +// L4-R4: A legacy surface receives only a successful legacy reply after a local close with an upload failure. +func TestL4FixLegacySurfaceSaving(t *testing.T) { + for _, interrupt := range []bool{false, true} { + t.Run(map[bool]string{false: "upload", true: "interrupt"}[interrupt], func(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + l := dialAgent(t, &Engine{Agent: &fakeAgent{}, SessionFile: "/chat/transcript.jsonl", FinishSaving: func(WrappedAgent) error { + close(entered) + <-release + return syncwords.UploadFailure(context.DeadlineExceeded) + }}) + hello := l.hello(Hello{Version: Version}) + var welcome map[string]any + if err := json.Unmarshal(hello.Payload, &welcome); err != nil { + t.Fatal(err) + } + if welcome["localSavingReceipts"] == true { + t.Fatal("unrequested capability was enabled") + } + l.write(Frame{Kind: "call", ID: 1, Method: MethodClose}) + <-entered + // Interrupting an older surface restores the terminal while the engine still owns this upload. + // Completing it here also verifies that no progress frame leaked to that surface. + if interrupt { + _ = l.toward.Close() + } + close(release) + reply := l.recv() + if reply.Kind != "result" || reply.ID != 1 || reply.Error != "" || reply.LocalSaved || reply.SyncPending { + t.Fatalf("legacy reply=%+v", reply) + } + }) + } +} + +// L4-R4: A legacy engine's empty close reply stays success; its missing receipt is negotiated, never guessed. +func TestL4FixLegacyEngineSaving(t *testing.T) { + c, _ := newEngine(t) + if err := c.Agent().Close(); err != nil { + t.Fatal(err) + } + capability, ok := any(c.Agent()).(interface{ SavingReceipts() bool }) + if !ok || capability.SavingReceipts() { + t.Fatal("legacy welcome did not select legacy saving") + } +} + +// L4-R4: Legacy errors retain the old result and cannot acquire a new upload receipt. +func TestL4FixLegacyEngineUploadReply(t *testing.T) { + c, e := newEngine(t) + e.fails[MethodClose] = "upload did not finish" + err := c.Agent().Close() + if err == nil || syncwords.UploadPending(err) || errors.Is(err, session.ErrSavingUnanswered) { + t.Fatalf("legacy error=%v", err) + } + capability, ok := any(c.Agent()).(interface{ SavingReceipts() bool }) + if !ok || capability.SavingReceipts() { + t.Fatal("legacy engine requires legacy words") + } +} + +// L4-G6: A link that breaks before window naming still reports that selected name afterwards. +func TestL4FixSelectedNameAfterBrokenWelcome(t *testing.T) { + client, engine := newEngine(t) + _ = engine.conn.Close() + if err := client.Agent().Compact(t.Context()); err == nil { + t.Fatal("broken link succeeded") + } + client.SetDisplayName("desk-b") + err := client.Agent().Compact(t.Context()) + if err == nil || !strings.Contains(err.Error(), "desk-b") || strings.Contains(err.Error(), "devbox") { + t.Fatalf("cached failure retained the route name: %v", err) + } + if client.Host() != "devbox" { + t.Fatal("window naming changed the connection route") + } +} diff --git a/internal/remote/modelsource_wire_law_test.go b/internal/remote/modelsource_wire_law_test.go index 7eee6cba08..ac54ff4216 100644 --- a/internal/remote/modelsource_wire_law_test.go +++ b/internal/remote/modelsource_wire_law_test.go @@ -23,6 +23,10 @@ var helloFields = map[string]bool{ // Window says the connection is a person's window, so the far machine holds // automations presence for it (wire_automations.go). A boolean, not a credential. "Window": true, + // LocalSavingReceipts is a boolean capability, with no key or address. + "LocalSavingReceipts": true, + // SyncNoticeWords is a boolean vocabulary capability, with no key or address. + "SyncNoticeWords": true, } // TestNoServiceKeyOrAddressCrossesTheWire refuses model-service credential diff --git a/internal/remote/replica.go b/internal/remote/replica.go index 9f84df4f62..d290ae67bf 100644 --- a/internal/remote/replica.go +++ b/internal/remote/replica.go @@ -38,8 +38,9 @@ type replica struct { // rather than applied, which is the whole of the ordering law: two facts // that move in the same instant race to the writer on the engine, and a late // push applied over a newer one would make a live row go backwards. - rev uint64 - facts session.Facts + rev uint64 + facts session.Facts + changed chan struct{} } // fill takes the set a welcome arrived with, REPLACING whatever was held. @@ -60,6 +61,10 @@ func (r *replica) fill(push *FactsPush) { r.mu.Lock() defer r.mu.Unlock() r.rev, r.facts = push.Rev, push.Facts + if r.changed != nil { + close(r.changed) + r.changed = nil + } } // take applies one "facts" frame, newest wins and nothing else lands. @@ -95,6 +100,10 @@ func (r *replica) takePush(push FactsPush) bool { return false } r.rev, r.facts = push.Rev, push.Facts + if r.changed != nil { + close(r.changed) + r.changed = nil + } return true } @@ -246,3 +255,14 @@ func (r *replica) setLevel(model, level string) { // because a caller that asked four getters would be reading four independent // photographs of a set that is published whole. func (c *Client) Facts() session.Facts { return c.facts.read() } + +// changes broadcasts the next pushed fact set without consuming another reader's +// notification. Registering before reading prevents missing writer installation. +func (r *replica) changes() <-chan struct{} { + r.mu.Lock() + defer r.mu.Unlock() + if r.changed == nil { + r.changed = make(chan struct{}) + } + return r.changed +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 2e786850da..5763508197 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -58,6 +58,8 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/syncwords" "io" "path/filepath" "sort" @@ -131,6 +133,8 @@ type WrappedAgent interface { // and the locked-file fallback; this package owns nothing about how an agent is // made and everything about how one is spoken to. type Engine struct { + // SyncService is the engine's address for chat outage notices. + SyncService string // Headless records the approval context the agent was built for. Reusing an // interactive agent must not silently give a headless caller its default gate. Headless bool @@ -300,7 +304,9 @@ type Engine struct { // handshake, because the workspace and the session file are things the surface // says. type Options struct { - Boot func(Hello) (*Engine, error) + // SyncService carries the engine's service choice to remote outage notices. + SyncService string + Boot func(Hello) (*Engine, error) } // AttachOptions is [ServeAttach]'s one function: which conversation this hello @@ -373,6 +379,9 @@ func Serve(in io.Reader, out io.Writer, opts Options) error { return nil, errors.New("this engine opens one conversation for one connection, so there is none already open here to join") } engine, err := opts.Boot(hello) + if engine != nil && engine.SyncService == "" { + engine.SyncService = opts.SyncService + } if err != nil { return nil, err } @@ -413,9 +422,11 @@ type Session struct { persistent bool // releaseOnIdle remembers a paired window's clean departure while its last // stream finishes. A new attached window cancels that departure (C31.1). - releaseOnIdle bool - instance string - closeDone chan struct{} + releaseOnIdle bool + instance string + closeDone chan struct{} + localSaved chan struct{} + localSavedOnce sync.Once // closeErr retains the completed saving result for concurrent and repeated departures. closeErr error // renewalStop ends idle health forwarding when this conversation changes or closes. @@ -546,6 +557,7 @@ func NewSession(engine *Engine, persistent bool) *Session { persistent: persistent, instance: rand.Text(), closeDone: make(chan struct{}), + localSaved: make(chan struct{}), rings: map[uint64]*ring{}, follows: map[uint64]followQueued{}, held: newHeldSet(), @@ -860,6 +872,9 @@ func (sess *Session) shutDown(agent WrappedAgent, already bool, door session.Sto agent.InterruptFor(door) } err := agent.Close() + if err == nil && sess.localSaved != nil { + sess.localSavedOnce.Do(func() { close(sess.localSaved) }) + } return errors.Join(err, sess.noteClosed(agent)) } @@ -1115,6 +1130,9 @@ func (sess *Session) attach(s *server, hello Hello) error { // Attached counts the OTHERS, so it is read before this one is added // (wire.go's Welcome.Attached states why the number is carried at all). welcome := sess.welcomeLocked(s) + // The initial capability reads the arrival directly, preserving the machine's + // existing point at which a watcher becomes registered. + welcome.TaskComputers = !hello.Watch && sess.engine.SessionFile != "" && taskComputersKnown(sess.agent) welcome.Encoding = s.encoding welcome.Attached = len(sess.surfaces) sess.surfaces[s] = struct{}{} @@ -1335,6 +1353,9 @@ func (sess *Session) welcomeLocked(s *server) Welcome { AttachmentSources: messageMarksKnown(sess.agent), SteerMarks: steerMarksKnown(sess.agent), FollowUpMarks: followUpMarksKnown(sess.agent), + LocalSavingReceipts: s.localSavingReceipts, + Machine: MachineName(), + SyncService: sess.engine.SyncService, Version: Version, Workspace: sess.engine.Workspace, SessionFile: sess.engine.SessionFile, @@ -1385,6 +1406,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { SteerOwner: true, TaskSetup: taskSetupKnown(sess.agent), TaskRetry: taskRetryKnown(sess.agent), + TaskComputers: !s.watching && sess.engine.SessionFile != "" && taskComputersKnown(sess.agent), TaskWorkspace: taskWorkspaceKnown(sess.agent), // Whether this engine has a dial on the conversation's own thinking, // asked of the agent it has open — for [Welcome.Effort]'s stated reason: @@ -1787,7 +1809,9 @@ type server struct { dead bool // encoding is selected from the hello before the welcome is sent. Empty is // the old wire, which keeps a new engine compatible with an older surface. - encoding string + encoding string + localSavingReceipts bool + syncNoticeWords bool open func(Hello) (*Session, error) session *Session @@ -2054,6 +2078,8 @@ func (s *server) handshake(line []byte) error { if supportsEncoding(hello.Encodings, frameEncodingGzip) { s.encoding = frameEncodingGzip } + s.localSavingReceipts = hello.LocalSavingReceipts + s.syncNoticeWords = hello.SyncNoticeWords sess, err := s.open(hello) if err != nil { return s.refuse("engine: " + err.Error()) @@ -2173,7 +2199,20 @@ func (s *server) dispatch(call Frame) { // here — the one funnel every keystroke takes — is what keeps that // gap from reading as an empty room. s.noteAct() + // A progress receipt precedes the upload wait and carries no transcript or key. + progressDone := make(chan struct{}) + if s.localSavingReceipts && (call.Method == MethodClose || call.Method == MethodDetach) && s.session != nil { + saved := s.session.localSaved + go func() { + select { + case <-saved: + _ = s.send(Frame{Kind: "localSaved", ID: call.ID}) + case <-progressDone: + } + }() + } payload, err := s.invoke(call) + close(progressDone) if err == nil && call.Method == MethodAppRename { // Acknowledgement is a read-after-write boundary: the local title // replica must see owner facts before RenameByPerson returns, even @@ -2181,12 +2220,27 @@ func (s *server) dispatch(call Frame) { s.session.announce() } result := Frame{Kind: "result", ID: call.ID, Payload: payload} + if (call.Method == MethodClose || call.Method == MethodDetach) && s.session != nil { + select { + case <-s.session.localSaved: + if s.localSavingReceipts { + result.LocalSaved = true + } else if syncwords.UploadPending(err) { + // A legacy surface cannot distinguish an upload from a local + // failure. A completed local close keeps its successful reply. + err = nil + } + default: + } + } + if err != nil { result.Payload, result.Error = nil, err.Error() var refusal *executor.WorkRefusal if errors.As(err, &refusal) { result.WorkRefusal = refusal } + result.SyncPending = result.LocalSaved && syncwords.UploadPending(err) var unsaved *session.UnsavedChatError if errors.As(err, &unsaved) { result.UnsavedTranscript = unsaved.Transcript @@ -2254,7 +2308,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { switch call.Method { case MethodRunHarness, MethodSubmitBash, MethodSubmitSetup, MethodSubmit, MethodFollowUp, MethodUnqueueFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles, MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodProcessRerunAt, MethodProcessStopAt, MethodProcessStopAll, MethodRenderCancel, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppResumeAfterClose, MethodAppRename, - MethodAppTaskRoutingAct, MethodTaskWorkspaceReview, MethodTaskWorkspaceApply: + MethodAppTaskRoutingAct, MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodTaskStartOn, MethodTaskRunHere: if err := s.mayDrive(); err != nil { return nil, err } @@ -2272,6 +2326,8 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppResumeAfterClose, MethodAppRename, MethodAppTaskRouting, MethodAppTaskRoutingAct, MethodAppTaskRoutingOptions, MethodAppProgramActions: return dispatchAppTask(agent, call) + case MethodTaskStartOn, MethodTaskRunHere: + return s.taskComputerInvoke(call) case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodTaskWorkspaceCancel: args, err := arg[taskWorkspaceArgs](call) if err != nil { @@ -2598,7 +2654,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, errors.New("engine: this session has no task proposals to answer") } door.ResolveTask(args.ID, session.TaskAnswer{ - Approved: args.Approved, Redirect: args.Redirect, Model: args.Model, + Approved: args.Approved, Redirect: args.Redirect, Model: args.Model, Computer: args.Computer, }) return nil, nil case MethodTaskSettle: @@ -3737,6 +3793,24 @@ func (s *server) send(frame Frame) error { // what an arrival uses ([Session.attach]) so that the welcome and the replay // behind it cannot be overtaken by a live event. func (s *server) sendLocked(frame Frame) error { + if !s.syncNoticeWords && frame.Kind == "event" { + var event EventWire + if err := json.Unmarshal(frame.Payload, &event); err != nil { + return err + } + if event.Kind == session.EventNotice { + text := chatlist.LegacySyncNotice(event.Text) + if text != event.Text { + event.Text = text + payload, err := json.Marshal(event) + if err != nil { + return err + } + frame.Payload = payload + } + } + } + if frame.Kind != "welcome" { if err := compressFrame(&frame, s.encoding); err != nil { return err diff --git a/internal/remote/taskcomputers.go b/internal/remote/taskcomputers.go new file mode 100644 index 0000000000..1717318159 --- /dev/null +++ b/internal/remote/taskcomputers.go @@ -0,0 +1,120 @@ +package remote + +import ( + "context" + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/session" +) + +const taskComputersUnavailable = "paired task computers are unavailable here" + +type taskComputerDoor interface { + TaskComputersSupported() bool + StartTaskOn(context.Context, string, bool, string, ...string) (uint64, string, string, error) + RunTaskHere(context.Context, uint64) (string, error) +} + +func taskComputersKnown(agent any) bool { + door, ok := agent.(taskComputerDoor) + return ok && door.TaskComputersSupported() +} + +// An omitted welcome field means the engine does not understand these new +// requests. A client method's presence alone must never enable its UI control. +func (a *Agent) TaskComputersSupported() bool { return a.c.Welcome().TaskComputers } + +func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, computer string, effort ...string) (uint64, string, string, error) { + word := "" + if len(effort) > 0 { + word = effort[0] + } + return a.StartTaskOnIn(ctx, brief, solo, computer, word, a.c.Welcome().SessionFile) +} + +// The captured conversation survives a window switch while its start command +// is queued, so the new request cannot commission work in the replacement chat. +func (a *Agent) StartTaskOnIn(ctx context.Context, brief string, solo bool, computer, effort, conversation string) (uint64, string, string, error) { + if !a.TaskComputersSupported() { + return 0, "", "", errors.New(taskComputersUnavailable) + } + raw, err := a.callTaskStart(ctx, MethodTaskStartOn, TaskStartOnArgs{Brief: brief, Solo: solo, Computer: computer, Effort: effort, Session: conversation}) + if err != nil { + return 0, "", "", err + } + var reply TaskStarted + if err := json.Unmarshal(raw, &reply); err != nil { + return 0, "", "", err + } + return reply.ID, reply.Title, reply.Note, nil +} + +func (a *Agent) RunTaskHere(ctx context.Context, id uint64) (string, error) { + return a.RunTaskHereIn(ctx, id, a.c.Welcome().SessionFile) +} + +// Recovery carries the same owner binding as a named start. The existing +// session operation owns abandonment and local admission, not the connection. +func (a *Agent) RunTaskHereIn(ctx context.Context, id uint64, conversation string) (string, error) { + if !a.TaskComputersSupported() { + return "", errors.New(taskComputersUnavailable) + } + raw, err := a.c.call(ctx, MethodTaskRunHere, TaskSetupArgs{ID: id, Session: conversation}) + if err != nil { + return "", err + } + var reply TaskStarted + if err := json.Unmarshal(raw, &reply); err != nil { + return "", err + } + return reply.Note, nil +} + +func (s *server) taskComputerOwner(conversation string) (taskComputerDoor, error) { + want, agreed := steerConversation(s.joined, conversation) + if !agreed || want == "" { + return nil, session.ErrNotThatConversation + } + owner, mine := s.session.agentOf(want) + if !mine { + return nil, session.ErrNotThatConversation + } + if !taskComputersKnown(owner) { + return nil, errors.New(taskComputersUnavailable) + } + return owner.(taskComputerDoor), nil +} + +func (s *server) taskComputerInvoke(call Frame) (json.RawMessage, error) { + if call.Method == MethodTaskStartOn { + args, err := arg[TaskStartOnArgs](call) + if err != nil { + return nil, err + } + door, err := s.taskComputerOwner(args.Session) + if err != nil { + return nil, err + } + id, title, note, err := door.StartTaskOn(context.Background(), args.Brief, args.Solo, args.Computer, args.Effort) + if err != nil { + return nil, err + } + return json.Marshal(TaskStarted{ID: id, Title: title, Note: note}) + } + args, err := arg[TaskSetupArgs](call) + if err != nil { + return nil, err + } + door, err := s.taskComputerOwner(args.Session) + if err != nil { + return nil, err + } + words, err := door.RunTaskHere(context.Background(), args.ID) + if err != nil { + return nil, err + } + return json.Marshal(TaskStarted{ID: args.ID, Note: words}) +} + +var _ taskComputerDoor = (*session.Agent)(nil) diff --git a/internal/remote/taskcomputers_test.go b/internal/remote/taskcomputers_test.go new file mode 100644 index 0000000000..13fa9436f7 --- /dev/null +++ b/internal/remote/taskcomputers_test.go @@ -0,0 +1,250 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// S9 keeps the watcher's welcome bytes unchanged when task controls are absent. +func TestAWatcherWelcomeIsUnchanged(t *testing.T) { + taskComputerWireHome(t) + file := t.TempDir() + "/chat.jsonl" + welcome := func(paired bool) []byte { + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: paired} + loop, err := Loopback(Hello{Version: Version, Watch: true}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, SessionFile: file}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + welcome := loop.Client.Welcome() + // Each live owner mints its own cursor scope, independently of capabilities. + welcome.SessionInstance = "" + raw, err := json.Marshal(welcome) + if err != nil { + t.Fatal(err) + } + return raw + } + original, paired := welcome(false), welcome(true) + if !bytes.Equal(original, paired) || bytes.Contains(paired, []byte(`"taskComputers"`)) { + t.Fatalf("a watcher's welcome changed:\n%s\n%s", original, paired) + } +} + +type pairedTaskWireAgent struct { + *fakeAgent + computerMu sync.Mutex + computer, effort, brief string + solo bool + here uint64 + answer session.TaskAnswer + available bool +} + +func taskComputerWireHome(t *testing.T) { + t.Helper() + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("CODEAF_HOME", root) +} + +func (a *pairedTaskWireAgent) TaskComputersSupported() bool { return a.available } +func (a *pairedTaskWireAgent) StartTaskOn(_ context.Context, brief string, solo bool, computer string, effort ...string) (uint64, string, string, error) { + a.computerMu.Lock() + defer a.computerMu.Unlock() + a.brief, a.solo, a.computer = brief, solo, computer + if len(effort) > 0 { + a.effort = effort[0] + } + return 7, "Repair export", "the task works in its own copy", nil +} +func (a *pairedTaskWireAgent) RunTaskHere(_ context.Context, id uint64) (string, error) { + a.computerMu.Lock() + defer a.computerMu.Unlock() + a.here = id + return "running task 7 Repair export here", nil +} +func (a *pairedTaskWireAgent) ResolveTask(_ uint64, answer session.TaskAnswer) { + a.computerMu.Lock() + defer a.computerMu.Unlock() + a.answer = answer +} + +// DESIGN contracts 1, 6 and 11 require a connected window's controls to reach +// its actual engine agent with the computer, effort and recovery task intact. +func TestConnectedTaskComputerControlsReachTheEngineAgent(t *testing.T) { + taskComputerWireHome(t) + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: true} + file := t.TempDir() + "/chat.jsonl" + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, SessionFile: file}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + agent := loop.Client.Agent() + controls, ok := any(agent).(interface { + TaskComputersSupported() bool + StartTaskOn(context.Context, string, bool, string, ...string) (uint64, string, string, error) + RunTaskHere(context.Context, uint64) (string, error) + }) + if !ok || !controls.TaskComputersSupported() { + t.Fatal("the connection omits paired task controls") + } + id, title, note, err := controls.StartTaskOn(context.Background(), "repair export", true, "Abir's Mac", "cheap") + if err != nil || id != 7 || title != "Repair export" || note != "the task works in its own copy" { + t.Fatalf("named start = %d %q %q %v", id, title, note, err) + } + far.computerMu.Lock() + if far.computer != "Abir's Mac" || far.effort != "cheap" || far.brief != "repair export" || !far.solo { + t.Errorf("the start lost its chosen controls") + } + far.computerMu.Unlock() + if line, err := controls.RunTaskHere(context.Background(), 7); err != nil || line != "running task 7 Repair export here" { + t.Fatalf("run here = %q, %v", line, err) + } + far.computerMu.Lock() + if far.here != 7 { + t.Errorf("recovery reached task %d", far.here) + } + far.computerMu.Unlock() + agent.ResolveTask(7, session.TaskAnswer{Approved: true, Computer: "this computer"}) + waitFor(t, "the proposal's computer to cross", func() bool { + far.computerMu.Lock() + defer far.computerMu.Unlock() + return far.answer.Approved && far.answer.Computer == "this computer" + }) +} + +// The compatibility law keeps old requests byte-for-byte unchanged when no +// new optional choice is supplied; the original method names keep their work. +func TestExistingTaskRequestsKeepTheirMethodAndBytes(t *testing.T) { + taskComputerWireHome(t) + for _, tc := range []struct { + value any + want string + }{ + {TaskStartArgs{Brief: "repair export", Solo: true, Effort: "best"}, `{"brief":"repair export","solo":true,"effort":"best"}`}, + {TaskResolveArgs{ID: 7, Approved: true, Redirect: "also test it", Model: "test/model"}, `{"id":7,"approved":true,"redirect":"also test it","model":"test/model"}`}, + {session.TaskAnswer{Approved: true, Redirect: "also test it", Model: "test/model"}, `{"Approved":true,"Redirect":"also test it","Model":"test/model"}`}, + } { + raw, err := json.Marshal(tc.value) + if err != nil || string(raw) != tc.want { + t.Fatalf("old request changed: %s, %v; want %s", raw, err, tc.want) + } + } + if MethodTaskStart != "Task.Start" || MethodTaskResolve != "Task.Resolve" { + t.Fatal("an existing method was renamed") + } + far := &fakeAgent{} + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, SessionFile: t.TempDir() + "/chat.jsonl"}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + if _, _, _, err := loop.Client.Agent().StartTask(context.Background(), "repair export", true); err != nil { + t.Fatal(err) + } + far.mu.Lock() + defer far.mu.Unlock() + if len(far.tasks) != 1 || far.tasks[0] != "solo:repair export" { + t.Fatalf("the original start no longer reaches its original operation: %v", far.tasks) + } +} + +// The absence and compatibility laws require an omitted welcome capability +// to keep the new methods unavailable without probing an older engine. +func TestAnEngineWithoutPairedTaskMethodsAdvertisesNoComputerControls(t *testing.T) { + taskComputerWireHome(t) + client, old := newEngine(t) + controls, ok := any(client.Agent()).(interface{ TaskComputersSupported() bool }) + if !ok { + t.Fatal("the client cannot read the task-computer capability") + } + if controls.TaskComputersSupported() { + t.Fatal("an older welcome enabled paired task controls") + } + if _, err := client.Ping(); err != nil { + t.Fatal(err) + } + old.mu.Lock() + defer old.mu.Unlock() + for _, call := range old.seen { + if call.Method == "Task.StartOn" || call.Method == "Task.RunHere" { + t.Fatalf("an older engine was probed with %s", call.Method) + } + } +} + +// DESIGN contract 11 addresses the task in its own chat. Neither a foreign +// conversation claim nor a read-only connection may restart another task. +func TestConnectedTaskComputerControlsKeepTheirConversationAndRefuseWatchers(t *testing.T) { + for _, watch := range []bool{false, true} { + t.Run(map[bool]string{false: "owner", true: "watcher"}[watch], func(t *testing.T) { + taskComputerWireHome(t) + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: true} + file := t.TempDir() + "/chat.jsonl" + loop, err := Loopback(Hello{Version: Version, Watch: watch}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far, SessionFile: file}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + claimed := t.TempDir() + "/another-chat.jsonl" + if watch { + claimed = file + } + if _, err := loop.Client.call(nil, MethodTaskStartOn, TaskStartOnArgs{Brief: "repair export", Computer: "work computer", Session: claimed}); err == nil { + t.Fatal("a foreign or read-only named start was accepted") + } + if _, err := loop.Client.call(nil, MethodTaskRunHere, TaskSetupArgs{ID: 7, Session: claimed}); err == nil { + t.Fatal("a foreign or read-only local restart was accepted") + } + far.computerMu.Lock() + defer far.computerMu.Unlock() + if far.computer != "" || far.here != 0 { + t.Fatal("a refused control changed work") + } + if watch && loop.Client.Agent().TaskComputersSupported() { + t.Fatal("a read-only window advertises paired task controls") + } + }) + } +} + +// The absence law applies to a new engine too: method shapes alone do not +// make an unpaired or ownerless conversation able to run tasks elsewhere. +func TestAnUnpairedOrOwnerlessEngineOffersNoTaskComputerControls(t *testing.T) { + for _, available := range []bool{false, true} { + t.Run(map[bool]string{false: "unpaired", true: "ownerless"}[available], func(t *testing.T) { + taskComputerWireHome(t) + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: available} + file := t.TempDir() + "/chat.jsonl" + if available { + file = "" + } + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far, SessionFile: file}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + if loop.Client.Agent().TaskComputersSupported() { + t.Fatal("unusable paired task controls were advertised") + } + raw, err := json.Marshal(loop.Client.Welcome()) + if err != nil || strings.Contains(string(raw), `"taskComputers"`) { + t.Fatalf("the absent optional capability changed welcome bytes: %s %v", raw, err) + } + }) + } +} diff --git a/internal/remote/tasklane.go b/internal/remote/tasklane.go index 569903d85f..a3b855e92c 100644 --- a/internal/remote/tasklane.go +++ b/internal/remote/tasklane.go @@ -395,6 +395,7 @@ func (a *Agent) TaskUpdates() <-chan session.Event { func (a *Agent) ResolveTask(id uint64, answer session.TaskAnswer) { args := TaskResolveArgs{ ID: id, Approved: answer.Approved, Redirect: answer.Redirect, Model: answer.Model, + Computer: answer.Computer, } c := a.c go func() { diff --git a/internal/remote/teams.go b/internal/remote/teams.go index ab3170ddec..8f490615c4 100644 --- a/internal/remote/teams.go +++ b/internal/remote/teams.go @@ -5,6 +5,8 @@ import ( "errors" "time" + "github.com/Agent-Field/codeaf/internal/config" + teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -76,6 +78,14 @@ func (s *server) teamsCall(call Frame) (json.RawMessage, bool, error) { if limit <= 0 || limit > teamsPage { limit = teamsPage } + if args.History { + entries, cursor, mirror, reset, err := teamsWatch.History(dir, args.Team, args.After, args.Mirror, limit) + if err != nil { + return nil, true, err + } + payload, err := json.Marshal(TeamsTraffic{Entries: entries, Cursor: cursor, Mirror: mirror, Reset: reset}) + return payload, true, err + } entries, stamp, err := teamsWatch.Traffic(dir, args.Team, args.After, limit) if err != nil { return nil, true, err @@ -99,6 +109,16 @@ func teamsRead(dir string, args TeamsReadArgs) (TeamsReading, error) { if args.Stamp != "" && args.Stamp == stamp { return TeamsReading{Stamp: stamp, Same: true}, nil } + if args.Stamp != "" && config.TeamsShareAt(dir) { + // Refresh is observational. Persisted repairs remain at the + // explicit opening and write boundaries, even over a host connection. + f, err := teamstore.Snapshot(dir) + if err != nil { + return TeamsReading{}, err + } + f.Colour(args.Reserved) + return TeamsReading{Stamp: stamp, Teams: f.Teams}, nil + } f, err := teamstore.LoadHued(dir, args.Reserved) if err != nil { // AN UNREADABLE FILE IS MOVED ASIDE, NOT OVERWRITTEN, exactly as the @@ -188,3 +208,15 @@ func (c *Client) TeamsTraffic(team, after string, limit int) (TeamsTraffic, erro err = json.Unmarshal(payload, &out) return out, err } + +// TeamsHistory uses the existing Traffic door with an optional display flag. +// An older engine ignores the flag and still returns its local history safely. +func (c *Client) TeamsHistory(team, after, version string, limit int) (TeamsTraffic, error) { + payload, err := c.call(nil, MethodTeamsTraffic, TeamsTrafficArgs{Team: team, After: after, Limit: limit, History: true, Mirror: version}) + if err != nil { + return TeamsTraffic{}, err + } + var out TeamsTraffic + err = json.Unmarshal(payload, &out) + return out, err +} diff --git a/internal/remote/teams_history_test.go b/internal/remote/teams_history_test.go new file mode 100644 index 0000000000..0a3f0be912 --- /dev/null +++ b/internal/remote/teams_history_test.go @@ -0,0 +1,87 @@ +package remote + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/teams/teamstest" +) + +// A hosted window uses its engine's mirror and retains the engine's local +// cursor. The ordinary Traffic method remains the local-only compatibility door. +func TestHostedTrafficHistoryKeepsMirrorSeparateAndCursorLocal(t *testing.T) { + loop, dir := teamsLoop(t) + if err := teams.AppendTraffic(dir, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "local"}); err != nil { + t.Fatal(err) + } + first, err := loop.Client.TeamsHistory("alpha", "", "", 100) + if err != nil { + t.Fatal(err) + } + if err := teams.WriteTrafficMirror(dir, "alpha", []teams.Entry{{ID: "desktop/1", At: time.Now(), Kind: teams.KindDirective, Text: "history"}}); err != nil { + t.Fatal(err) + } + got, err := loop.Client.TeamsHistory("alpha", first.Cursor, first.Mirror, 100) + if err != nil || !got.Reset || got.Cursor != first.Cursor || len(got.Entries) != 2 { + t.Fatal(got, err) + } + local, err := loop.Client.TeamsTraffic("alpha", "", 100) + if err != nil || len(local.Entries) != 1 { + t.Fatal("compatibility door read mirror", local, err) + } +} + +func TestHostedTeamRefreshDoesNotPersistRepairs(t *testing.T) { + loop, dir := teamsLoop(t) + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + first, err := loop.Client.TeamsRead("", nil) + if err != nil { + t.Fatal(err) + } + raw := []byte(`{"version":2,"teams":[{"id":"alpha","name":"alpha","parent":"","members":[],"manager":"","made":"2026-01-01T00:00:00Z"}]}`) + // app/v3 keeps teams in an SQLite store (#2066): the old file's team is + // written through the store, and the refresh must leave what it stores. + teamstest.Write(t, dir, raw) + before := teamstest.JSON(t, dir) + if _, err := loop.Client.TeamsRead(first.Stamp, nil); err != nil { + t.Fatal(err) + } + if after := teamstest.JSON(t, dir); string(after) != string(before) { + t.Fatal("background hosted refresh repaired file") + } +} + +func TestHostedWindowsKeepIndependentMirrorVersions(t *testing.T) { + first, dir := teamsLoop(t) + second, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: &fakeAgent{model: "m"}, ProfileDir: dir}, nil }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = second.Close() }) + if err := teams.AppendTraffic(dir, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "local"}); err != nil { + t.Fatal(err) + } + one, err := first.Client.TeamsHistory("alpha", "", "", 100) + if err != nil { + t.Fatal(err) + } + two, err := second.Client.TeamsHistory("alpha", "", "", 100) + if err != nil { + t.Fatal(err) + } + if err := teams.WriteTrafficMirror(dir, "alpha", []teams.Entry{{ID: "desktop/1", At: time.Now(), Kind: teams.KindNote, Text: "new mirror"}}); err != nil { + t.Fatal(err) + } + for i, client := range []*Client{first.Client, second.Client} { + prev := []TeamsTraffic{one, two}[i] + got, err := client.TeamsHistory("alpha", prev.Cursor, prev.Mirror, 100) + if err != nil || !got.Reset || len(got.Entries) != 2 { + t.Fatal("hosted consumer missed refresh", got, err) + } + } +} diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 9775879eb5..f43a8bfd86 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -453,6 +453,10 @@ type Frame struct { Error string `json:"error,omitempty"` // WorkRefusal preserves ownership facts beside the unchanged error sentence. WorkRefusal *executor.WorkRefusal `json:"workRefusal,omitempty"` + // LocalSaved confirms a completed local close on a saving reply. + LocalSaved bool `json:"localSaved,omitempty"` + // SyncPending distinguishes an upload failure after the local close succeeded. + SyncPending bool `json:"syncPending,omitempty"` // UnsavedTranscript preserves a recovery location on a failed saving reply. UnsavedTranscript string `json:"unsavedTranscript,omitempty"` @@ -913,6 +917,11 @@ type DetachArgs struct { // after the colon — empty means the engine's own home — and the engine answers // with the path it resolved. type Hello struct { + // SyncNoticeWords requests the current sync-notice vocabulary; absence keeps + // the sentences older surfaces recognize for outage and removal recovery. + SyncNoticeWords bool `json:"syncNoticeWords,omitempty"` + // LocalSavingReceipts permits an optional acknowledgment before remote upload waits. + LocalSavingReceipts bool `json:"localSavingReceipts,omitempty"` // SessionInstance scopes Resume cursors to the live owner that issued them. SessionInstance string `json:"sessionInstance,omitempty"` @@ -1122,6 +1131,12 @@ type Welcome struct { // an older engine, which would drop it unread: a call carrying one is refused // before it is sent. AttachmentSources bool `json:"attachmentSources,omitempty"` + // LocalSavingReceipts acknowledges the surface's optional saving capability. + LocalSavingReceipts bool `json:"localSavingReceipts,omitempty"` + // SyncService names the service that saves this chat across computers. + SyncService string `json:"syncService,omitempty"` + // Machine supplies the serving computer's chosen name for the remote window bar. + Machine string `json:"machine,omitempty"` // SessionInstance identifies this live conversation owner, not its durable // transcript. A replaced engine can reopen the same file with stream IDs // starting over, so a returning surface must forget the previous owner IDs. @@ -1299,6 +1314,9 @@ type Welcome struct { TaskRetry bool `json:"taskRetry,omitempty"` TaskWorkspace bool `json:"taskWorkspace,omitempty"` TaskSetup bool `json:"taskSetup,omitempty"` + // TaskComputers enables named starts, proposal choices and local recovery. + // Absence keeps these controls off a window attached to an older engine. + TaskComputers bool `json:"taskComputers,omitempty"` // TaskSettle says this engine can be ASKED TO DECIDE A LANDING — accept, not // right, one more merge round, the hand-over and the take-back // ([MethodTaskSettle]). diff --git a/internal/remote/wire_task.go b/internal/remote/wire_task.go index 160d6897b8..3fdaf51064 100644 --- a/internal/remote/wire_task.go +++ b/internal/remote/wire_task.go @@ -18,6 +18,10 @@ const ( MethodDelegateList = "Delegate.List" MethodDelegateStart = "Delegate.Start" MethodTaskStart = "Task.Start" + // Placement uses new methods so an older Task.Start never silently drops + // a computer choice. The optional welcome capability gates both controls. + MethodTaskStartOn = "Task.StartOn" + MethodTaskRunHere = "Task.RunHere" // MethodTaskRedoStronger runs the newest task again with every seat nobody // pinned one step stronger ([session.Agent.RedoStronger]). MethodTaskRedoStronger = "Task.RedoStronger" @@ -175,6 +179,16 @@ type TaskStartArgs struct { Effort string `json:"effort,omitempty"` } +// TaskStartOnArgs belongs only to the new named-computer method. The older +// TaskStartArgs and Task.Start request keep their bytes and meaning unchanged. +type TaskStartOnArgs struct { + Brief string `json:"brief"` + Solo bool `json:"solo,omitempty"` + Computer string `json:"computer"` + Effort string `json:"effort,omitempty"` + Session string `json:"session,omitempty"` +} + // TaskRedoArgs names the task to run again stronger; 0 is the newest one. type TaskRedoArgs struct { ID uint64 `json:"id,omitempty"` @@ -216,15 +230,14 @@ type TaskPending struct { IDs []uint64 `json:"ids"` } -// TaskResolveArgs is one person's answer to one proposal, in the engine's own -// three fields ([session.TaskAnswer]): whether it may run, the correction to -// append if they redirected it, and the model they picked where the card -// offered a choice. +// TaskResolveArgs carries one person's proposal answer. The optional computer +// choice leaves the bytes of earlier answers unchanged when it is absent. type TaskResolveArgs struct { ID uint64 `json:"id"` Approved bool `json:"approved,omitempty"` Redirect string `json:"redirect,omitempty"` Model string `json:"model,omitempty"` + Computer string `json:"computer,omitempty"` } // TaskHoldArgs names the proposal whose first typed rune stopped its clock. diff --git a/internal/remote/wire_teams.go b/internal/remote/wire_teams.go index a5731c6b92..1dda899868 100644 --- a/internal/remote/wire_teams.go +++ b/internal/remote/wire_teams.go @@ -76,14 +76,21 @@ type TeamsUpdateArgs struct { // TeamsTrafficArgs is one team's log after a cursor: After "" is the tail, // the last Limit entries; an id pages forward from it. type TeamsTrafficArgs struct { - Team string `json:"team"` - After string `json:"after,omitempty"` - Limit int `json:"limit,omitempty"` + // History requests display-only mirrors as well as the local log. + History bool `json:"history,omitempty"` + Mirror string `json:"mirror,omitempty"` + Team string `json:"team"` + After string `json:"after,omitempty"` + Limit int `json:"limit,omitempty"` } // TeamsTraffic is what came after the cursor, oldest first, and the log's // stamp when it was read. type TeamsTraffic struct { + // Cursor remains local; Reset replaces a pane tail when the mirror moves. + Mirror string `json:"mirror,omitempty"` + Cursor string `json:"cursor,omitempty"` + Reset bool `json:"reset,omitempty"` Entries []teamstore.Entry `json:"entries,omitempty"` Stamp string `json:"stamp,omitempty"` } diff --git a/internal/remote/writer_changes_test.go b/internal/remote/writer_changes_test.go new file mode 100644 index 0000000000..beab2df716 --- /dev/null +++ b/internal/remote/writer_changes_test.go @@ -0,0 +1,41 @@ +package remote + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestWriterInstallationWakesEveryWaitingSend(t *testing.T) { + agent := &Agent{c: &Client{}} + first, second := agent.WriterChanges(), agent.WriterChanges() + // Existing facts are the only carrier; subscribing emits no request. + if !agent.c.facts.takePush(FactsPush{Rev: 1, Facts: session.Facts{Renewal: &session.RenewalFact{Period: time.Second, Held: true}}}) { + t.Fatal("facts refused") + } + for _, changed := range []<-chan struct{}{first, second} { + select { + case <-changed: + default: + t.Fatal("writer change did not broadcast") + } + } + next := agent.WriterChanges() + select { + case <-next: + t.Fatal("new subscription was already spent") + default: + } + if _, period, held := agent.RenewalStatus(); period != time.Second || !held { + t.Fatal("pushed writer authority did not arrive") + } + if agent.c.facts.takePush(FactsPush{Rev: 1}) { + t.Fatal("old facts landed") + } + select { + case <-next: + t.Fatal("old facts woke a new send") + default: + } +} diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 6bf64ed94e..8869e4060d 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "github.com/Agent-Field/codeaf/internal/effort" procexec "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/provider" @@ -87,11 +88,20 @@ type BashWorker struct { seat procexec.Seat workspaceURI string workspaceSet *session.WorkspaceSet + // The task door carries the settled task's controls into this child run. + taskEffort effort.Rung + noProgress int } // WorkerOption changes how a worker is built; the zero set is the host worker. type WorkerOption func(*BashWorker) +// WithTaskControls carries an explicitly settled task's controls without +// changing this computer's saved defaults or spending permission. +func WithTaskControls(rung effort.Rung, noProgress int) WorkerOption { + return func(w *BashWorker) { w.taskEffort = rung; w.noProgress = noProgress } +} + // WithWorkspaceURI carries the admitted identity without deriving it from a path. func WithWorkspaceURI(uri string) WorkerOption { return func(w *BashWorker) { w.workspaceURI = uri } @@ -156,6 +166,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run } } agent, err := session.NewBeltWorker(session.Config{ + Effort: w.taskEffort, Workspace: w.workspace, WorkspaceURI: w.workspaceURI, WorkspaceSet: w.workspaceSet.Clone(), @@ -232,6 +243,10 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // the store and still unread until a turn takes them, so a refused splice // leaves it to the next boundary rather than to this carry. var owed []string + stalledAfter := sameStepNote + sameStepEnd + if w.noProgress > 0 { + stalledAfter = w.noProgress + } // notes holds the ids of this task's notes THIS WORKER has already been // handed. THE MARK IS THE WORKER'S ALONE and lives only for the life of the // loop: a person opening the task's page reads the same notes without @@ -375,7 +390,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run owed = append(owed, sameStepSpoken()) } } - if same >= sameStepNote+sameStepEnd { + if same >= stalledAfter { // A TASK THE STORE SAYS IS BLOCKED ON ANOTHER TASK goes to // the parking the belt already has rather than an ending: // `plandb wait` refuses a wait with nothing open, so a park @@ -385,7 +400,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // work. The worker was told before the ending came, so // the park is not the first the worker hears of the law. if _, waitErr := w.store.Wait(task.ID, task.ID); waitErr == nil { - ending = storeEnding{kind: endingWait, reason: fmt.Sprintf("waiting: the same command came back with the same answer %d times in a row", sameStepNote+sameStepEnd)} + ending = storeEnding{kind: endingWait, reason: fmt.Sprintf("waiting: the same command came back with the same answer %d times in a row", stalledAfter)} stop() } else { stalled = true @@ -509,7 +524,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // figure counts the whole run of identical steps, the three that // brought the note and the three that followed it, because that is // what a person opening the record counts. - reason := fmt.Sprintf("the same command came back with the same answer %d times in a row: the work was not moving", sameStepNote+sameStepEnd) + reason := fmt.Sprintf("the same command came back with the same answer %d times in a row: the work was not moving", stalledAfter) if err := appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryEndKind, ExitsRecorded: true, Steps: steps, Reason: reason}); err != nil { return Report{Steps: steps}, err } diff --git a/internal/run/task_controls_stability_test.go b/internal/run/task_controls_stability_test.go new file mode 100644 index 0000000000..1ff76fb576 --- /dev/null +++ b/internal/run/task_controls_stability_test.go @@ -0,0 +1,38 @@ +package run_test + +import ( + "context" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/run" +) + +// S8 preserves the worker's six-action bound unless its caller explicitly +// supplies task controls. Both cases use the real worker and its trajectory. +func TestBashWorkerWithoutTaskControlsKeepsItsDefaultLimit(t *testing.T) { + for _, chosen := range []bool{false, true} { + name, want := "ordinary", 6 + if chosen { + name, want = "task controls", 2 + } + t.Run(name, func(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", stubCLI(t)) + store := runOpenStore(t) + seat := &seat{ever: func(context.Context, []ai.Message) (*ai.Response, error) { + return toolReply(`{"command":"printf 'same answer'"}`), nil + }} + var options []run.WorkerOption + if chosen { + options = append(options, run.WithTaskControls(effort.None, want)) + } + worker := run.NewBashWorker(store, t.TempDir(), "test/model", seat, options...) + report, err := worker.Run(run.WithStepsPerTask(runContext(t), 60), *store.Task(store.RootID())) + if err == nil || report.Steps != want { + t.Fatalf("worker ended after %d actions, want %d: %v", report.Steps, want, err) + } + }) + } +} diff --git a/internal/run/task_stability_test.go b/internal/run/task_stability_test.go new file mode 100644 index 0000000000..6dc5db5872 --- /dev/null +++ b/internal/run/task_stability_test.go @@ -0,0 +1,50 @@ +package run + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// S4 and S7 preserve the original append bytes for an ordinary run. +func TestALocalStepLineHasNoOrderFields(t *testing.T) { + dir := t.TempDir() + // A sibling path that cannot be read as a file must never be scanned by + // an ordinary append. The original append only touches its own record. + if err := os.MkdirAll(filepath.Join(plandb.TaskDir(dir, "sibling"), trajectoryName), 0700); err != nil { + t.Fatal(err) + } + if err := appendTrajectory(dir, "1", Step{Kind: "step", Step: 1, Command: "pwd"}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(plandb.TaskDir(dir, "1"), trajectoryName)) + if err != nil { + t.Fatal(err) + } + const original = "{\"kind\":\"step\",\"step\":1,\"command\":\"pwd\"}\n" + if string(raw) != original { + t.Fatalf("local trajectory bytes changed: %s", raw) + } +} + +// S7 orders only the opted-in child, across tasks, and restores local bytes. +func TestOnlyTheTaskDoorChildRecordsStepOrder(t *testing.T) { + dir := t.TempDir() + restore := RecordTaskTrajectoryOrder() + for _, id := range []string{"2", "1"} { + if err := appendTrajectory(dir, id, Step{Kind: "step", Step: 1, Command: "pwd"}); err != nil { + restore() + t.Fatal(err) + } + } + restore() + for i, id := range []string{"2", "1"} { + steps, err := Trajectory(dir, id) + if err != nil || len(steps) != 1 || steps[0].RecordedOrder != uint64(i+1) || steps[0].RecordedAt.IsZero() { + t.Fatalf("task %s order=%+v, %v", id, steps, err) + } + } + TestALocalStepLineHasNoOrderFields(t) +} diff --git a/internal/run/trajectory.go b/internal/run/trajectory.go index ed5723ad89..c90b79ac22 100644 --- a/internal/run/trajectory.go +++ b/internal/run/trajectory.go @@ -16,6 +16,8 @@ import ( "os" "path/filepath" "strings" + "sync" + "sync/atomic" "time" "unicode/utf8" @@ -55,6 +57,10 @@ const observationHeadBytes = 2048 // line: Kind says which one a line is. type Step struct { Kind string `json:"kind"` + // RecordedAt retains append timing. RecordedOrder lets a follower wait + // for an earlier append observed later in another task's file. + RecordedAt time.Time `json:"recorded_at,omitzero"` + RecordedOrder uint64 `json:"recorded_order,omitempty"` // Step is the step's number, counted from one over the task across its wakes. Step int `json:"step"` // Tool identifies the native action; older records leave it empty. @@ -185,11 +191,60 @@ func Trajectory(storeDir, id string) ([]Step, error) { return steps, nil } +// Inside the task child, one writer orders all trajectories of its run. The follower can wait for a +// missing order when a directory scan observes a later task's append first. +var trajectoryWriter = struct { + sync.Mutex + orders map[string]uint64 +}{orders: map[string]uint64{}} + // appendTrajectory appends one line to the task's trajectory file, creating // the task's record folder when the first line arrives. Appends are one // write: the record grows in the order the worker took its steps, and a // reader walking the file sees the run the way it happened. func appendTrajectory(storeDir, id string, step Step) error { + if taskTrajectoryOrdering.Load() { + return appendTaskTrajectory(storeDir, id, step) + } + dir := plandb.TaskDir(storeDir, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + line, err := json.Marshal(step) + if err != nil { + return err + } + file := filepath.Join(dir, trajectoryName) + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(append(line, '\n')) + return err +} + +func appendTaskTrajectory(storeDir, id string, step Step) error { + trajectoryWriter.Lock() + defer trajectoryWriter.Unlock() + order, known := trajectoryWriter.orders[storeDir] + if !known { + paths, _ := filepath.Glob(filepath.Join(storeDir, "tasks", "*", trajectoryName)) + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + for _, encoded := range strings.Split(string(raw), "\n") { + var line Step + if json.Unmarshal([]byte(encoded), &line) == nil && line.RecordedOrder > order { + order = line.RecordedOrder + } + } + } + } + step.RecordedAt = time.Now() + step.RecordedOrder = order + 1 dir := plandb.TaskDir(storeDir, id) if err := os.MkdirAll(dir, 0o700); err != nil { return err @@ -205,6 +260,9 @@ func appendTrajectory(storeDir, id string, step Step) error { } defer f.Close() _, err = f.Write(append(line, '\n')) + if err == nil { + trajectoryWriter.orders[storeDir] = step.RecordedOrder + } return err } @@ -221,3 +279,12 @@ func observationHead(text string) string { } return text[:cut] } + +var taskTrajectoryOrdering atomic.Bool + +// RecordTaskTrajectoryOrder is enabled only in the hidden task-store child. +// Its cleanup also keeps in-process command fixtures from changing later runs. +func RecordTaskTrajectoryOrder() func() { + previous := taskTrajectoryOrdering.Swap(true) + return func() { taskTrajectoryOrdering.Store(previous) } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index 4094f13880..7e74108f1b 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -163,8 +163,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { watch.TrackTurn(config.Place.Dir) } agent := &Agent{ - config: config, - system: system, + taskComputerPrompt: config.mayNameTaskComputer(), + config: config, + system: system, // STAMPED ONLY WHERE WE RENDERED IT. A prompt handed in by a caller is // theirs, and re-rendering ours over the top of it later would be this // file deciding what another door's worker is told. @@ -2881,6 +2882,7 @@ func (a *Agent) Close() (saveErr error) { // cancellations were cut above; join their accepted lifetimes before any // store is closed. Setup may have created the graph since the first snapshot. a.waitOrchestrations() + a.waitEndingBeltRun(beltDone, closeGrace) a.mu.Lock() tasks = a.tasks a.mu.Unlock() @@ -2986,10 +2988,16 @@ func (a *Agent) Close() (saveErr error) { } return err } - if file == nil { - return nil + var localErr error + if file != nil { + localErr = file.Close() + } + if localErr == nil { + a.mu.Lock() + a.localDurable = true + a.mu.Unlock() } - return file.Close() + return localErr } // ── transcript ────────────────────────────────────────────────────────────── diff --git a/internal/session/artifact_append_law_test.go b/internal/session/artifact_append_law_test.go index ca203de8e0..0034dc9e25 100644 --- a/internal/session/artifact_append_law_test.go +++ b/internal/session/artifact_append_law_test.go @@ -37,7 +37,10 @@ func artifactAppendViolations(path string, file *ast.File) (funnels int, violati "renderlog.go": "renderLedger.append", "sessionfile.go": "openSessionFile", "task_index.go": "appendTaskIndex", - "usage_ledger.go": "openUsageLedger", + // A remote task's mirrored money and step events are their own ledgers. + "task_remote_money.go": "appendRemoteUsage", + "task_remote_record.go": "remoteStepMirror.append", + "usage_ledger.go": "openUsageLedger", } ast.Inspect(file, func(node ast.Node) bool { flag, ok := node.(*ast.Ident) diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index c251ac013e..5d4b2bbec1 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -879,6 +879,9 @@ func promptWithBeltFacts(config Config) string { for strings.Contains(page, "\n\n\n") { page = strings.ReplaceAll(page, "\n\n\n", "\n\n") } + if config.mayNameTaskComputer() && config.mayProposeTask() { + page += "\n\nA task that works in its own copy can run on a paired computer. The optional `computer` argument on `propose_task` names its shown name; omitted uses Run tasks on, whose default is this computer. A remote task runs alone; wait for it to finish before proposing more work." + } return page } diff --git a/internal/session/copy_contract_test.go b/internal/session/copy_contract_test.go index 10a6e03b1c..7a3a79aa1c 100644 --- a/internal/session/copy_contract_test.go +++ b/internal/session/copy_contract_test.go @@ -2,12 +2,14 @@ package session import ( "context" + "fmt" "os" "path/filepath" "testing" "time" "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/store" ) @@ -73,3 +75,226 @@ func TestC29CopyCarriesTruthAndReplacesTheListRow(t *testing.T) { t.Fatal("copy edited original") } } + +// TestBucketSessionDirsKeepsMovingAndLegacyChatsOnce verifies that ReadWorld, +// Adopt, RecentSessions, SessionWorkspaces and knowledgeHistory.Inventory list +// standalone `.moving` chats alongside copied `.moving` chats and uncopied +// legacy chats, each once, while hiding unfinished `.moving` copy scratch folders. +func TestBucketSessionDirsKeepsMovingAndLegacyChatsOnce(t *testing.T) { + homeDir := t.TempDir() + t.Setenv(home.EnvVar, homeDir) + root := PlacesRoot() + bucket := filepath.Join(root, "proj") + workspace := t.TempDir() + now := time.Unix(1700001000, 0) + + writeChat := func(dir, id, title string, at time.Time) { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + place := Place{Dir: dir} + if err := os.MkdirAll(filepath.Dir(place.Transcript()), 0o700); err != nil { + t.Fatal(err) + } + body := `{"type":"session","version":1,"id":"` + id + `"}` + "\n" + + `{"type":"message","role":"user","content":"` + title + `"}` + "\n" + if err := os.WriteFile(place.Transcript(), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := SaveMeta(dir, Meta{ID: id, Title: title, Workspace: workspace, Created: at, LastUserAt: at}); err != nil { + t.Fatal(err) + } + } + + // 1. Legacy chat with no copy. + legacyDir := filepath.Join(bucket, "legacy-one") + writeChat(legacyDir, "legacy-one", "Legacy One", now.Add(-3*time.Minute)) + + // 2. Legacy chat with a completed `.moving` copy. + copiedOrig := filepath.Join(bucket, "copied-orig") + writeChat(copiedOrig, "copied-orig", "Stale Original", now.Add(-10*time.Minute)) + copiedCell, err := cell.CopyLegacy(t.Context(), copiedOrig, func(ctx context.Context, c cell.Cell) error { + return PrepareCopy(ctx, copiedOrig, c, nil) + }) + if err != nil { + t.Fatal(err) + } + if err := SaveMeta(copiedCell.Root, Meta{ID: copiedCell.ID, Title: "Copied Moving", Workspace: workspace, Created: now.Add(-10 * time.Minute), LastUserAt: now.Add(-2 * time.Minute)}); err != nil { + t.Fatal(err) + } + + // 3. Standalone `.moving` chat with no top-level legacy folder. + standaloneCell, err := cell.CreateIn(filepath.Join(bucket, ".moving"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + writeChat(standaloneCell.Root, standaloneCell.ID, "Standalone Moving", now.Add(-1*time.Minute)) + + // 4. Unfinished `.moving` scratch copy without `copy-ready`. + unfinishedOrig := filepath.Join(bucket, "unfinished-orig") + writeChat(unfinishedOrig, "unfinished-orig", "Unfinished Original", now) + scratch := cell.CopyRoot(unfinishedOrig) + if err := os.WriteFile(scratch+".lock", nil, 0o600); err != nil { + t.Fatal(err) + } + writeChat(scratch, filepath.Base(scratch), "Half Made Scratch", now) + + world := ReadWorld(root) + rows := world.Sessions() + if len(rows) != 4 { + t.Fatalf("ReadWorld returned %d sessions, want 4: %+v", len(rows), rows) + } + recent := RecentSessions(bucket, 10) + if len(recent) != 4 { + t.Fatalf("RecentSessions returned %d summaries, want 4: %+v", len(recent), recent) + } + + // Adopt an unspoken open `.moving` shell into the bucket's project. + shellCell, err := cell.CreateIn(filepath.Join(bucket, ".moving"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + if err := SaveMeta(shellCell.Root, Meta{ID: shellCell.ID, Workspace: workspace, Created: now}); err != nil { + t.Fatal(err) + } + shellTranscript := Place{Dir: shellCell.Root}.Transcript() + if !world.Adopt(root, SessionRow{Transcript: shellTranscript, Workspace: workspace}, now) { + t.Fatal("World.Adopt rejected an open unspoken .moving session") + } + if len(world.Sessions()) != 5 { + t.Fatalf("World.Sessions after Adopt = %d, want 5", len(world.Sessions())) + } + + workspaces := SessionWorkspaces() + for _, wantID := range []string{"legacy-one", copiedCell.ID, standaloneCell.ID, "unfinished-orig", shellCell.ID} { + if workspaces[wantID] != workspace { + t.Fatalf("SessionWorkspaces[%q] = %q, want %q", wantID, workspaces[wantID], workspace) + } + } + if got, exists := workspaces[filepath.Base(scratch)]; exists { + t.Fatalf("SessionWorkspaces answered %q for unfinished .moving scratch copy %q", got, filepath.Base(scratch)) + } + if got, exists := workspaces["copied-orig"]; exists { + t.Fatalf("SessionWorkspaces answered %q for replaced legacy original \"copied-orig\"", got) + } + + journals, err := (knowledgeHistory{root: root}).Inventory(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(journals) != 4 { + t.Fatalf("knowledgeHistory.Inventory returned %d journals, want 4: %+v", len(journals), journals) + } +} + +// TestBucketWithoutMovingMakesNoCopyLookups counts copy-root and copy-resolution +// lookups through seams so a bucket with many chats and no `.moving` directory +// pays zero per-folder copy work, and a bucket with `.moving` resolves only the +// originals whose copy roots exist in `.moving` without resolving a second time +// in readSessionRow. +func TestBucketWithoutMovingMakesNoCopyLookups(t *testing.T) { + homeDir := t.TempDir() + t.Setenv(home.EnvVar, homeDir) + root := PlacesRoot() + bucket := filepath.Join(root, "many-chats") + workspace := t.TempDir() + now := time.Unix(1700010000, 0) + + const totalChats = 200 + var sampleTranscripts []string + for i := 0; i < totalChats; i++ { + id := fmt.Sprintf("chat-%04d", i) + dir := filepath.Join(bucket, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + place := Place{Dir: dir} + body := fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"chat %d\"}\n", id, i) + if err := os.WriteFile(place.Transcript(), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + at := now.Add(time.Duration(i) * time.Second) + if err := SaveMeta(dir, Meta{ID: id, Title: fmt.Sprintf("Chat %d", i), Workspace: workspace, Created: at, LastUserAt: at}); err != nil { + t.Fatal(err) + } + if i < 10 { + sampleTranscripts = append(sampleTranscripts, place.Transcript()) + } + } + + var copyRootCalls, copiedCalls, openAtCalls int + prevCopyRoot, prevCopied, prevOpenAt := bucketCopyRoot, bucketCopied, bucketOpenAt + bucketCopyRoot = func(original string) string { + copyRootCalls++ + return prevCopyRoot(original) + } + bucketCopied = func(original string) string { + copiedCalls++ + return prevCopied(original) + } + bucketOpenAt = func(root, id string) (cell.Cell, error) { + openAtCalls++ + return prevOpenAt(root, id) + } + t.Cleanup(func() { + bucketCopyRoot = prevCopyRoot + bucketCopied = prevCopied + bucketOpenAt = prevOpenAt + }) + + t.Run("no_moving_folder", func(t *testing.T) { + copyRootCalls, copiedCalls, openAtCalls = 0, 0, 0 + dirs := BucketSessionDirs(bucket) + if len(dirs) != totalChats { + t.Fatalf("BucketSessionDirs returned %d dirs, want %d", len(dirs), totalChats) + } + world := ReadWorld(root) + if got := len(world.Sessions()); got != totalChats { + t.Fatalf("ReadWorld returned %d sessions, want %d", got, totalChats) + } + workspaces := SessionWorkspaces() + if len(workspaces) != totalChats { + t.Fatalf("SessionWorkspaces returned %d entries, want %d", len(workspaces), totalChats) + } + rows := ReadRows(sampleTranscripts) + if len(rows) != len(sampleTranscripts) { + t.Fatalf("ReadRows returned %d rows, want %d", len(rows), len(sampleTranscripts)) + } + if copyRootCalls != 0 || copiedCalls != 0 || openAtCalls != 0 { + t.Fatalf("bucket without .moving made copy lookups: copyRoot=%d copied=%d openAt=%d, want 0/0/0", + copyRootCalls, copiedCalls, openAtCalls) + } + }) + + t.Run("one_copy_among_many_chats", func(t *testing.T) { + copiedOrig := filepath.Join(bucket, "chat-0000") + copiedCell, err := cell.CopyLegacy(t.Context(), copiedOrig, func(ctx context.Context, c cell.Cell) error { + return PrepareCopy(ctx, copiedOrig, c, nil) + }) + if err != nil { + t.Fatal(err) + } + if err := SaveMeta(copiedCell.Root, Meta{ + ID: copiedCell.ID, + Title: "Copied Chat 0", + Workspace: workspace, + Created: now, + LastUserAt: now.Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + + copyRootCalls, copiedCalls, openAtCalls = 0, 0, 0 + world := ReadWorld(root) + if got := len(world.Sessions()); got != totalChats { + t.Fatalf("ReadWorld returned %d sessions, want %d", got, totalChats) + } + if copiedCalls != 1 { + t.Fatalf("ReadWorld called cell.Copied %d times for 1 copy among %d chats, want 1 (no uncopied lookups and no double lookup in readSessionRow)", + copiedCalls, totalChats) + } + if openAtCalls != 0 { + t.Fatalf("ReadWorld called extra cell.OpenAt %d times, want 0", openAtCalls) + } + }) +} diff --git a/internal/session/knowledge_history.go b/internal/session/knowledge_history.go index a0b61418e6..4098d2cea1 100644 --- a/internal/session/knowledge_history.go +++ b/internal/session/knowledge_history.go @@ -16,7 +16,6 @@ import ( "strings" "sync" - "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/knowledge" ) @@ -198,17 +197,20 @@ func (h knowledgeHistory) Inventory(ctx context.Context) ([]knowledge.Journal, e if err = ctx.Err(); err != nil { return nil, err } - path := filepath.Join(dir, entry.Name()) - if !entry.IsDir() { - if strings.HasSuffix(entry.Name(), ".jsonl") { - owners[strings.TrimSuffix(entry.Name(), ".jsonl")] = path - if err = add(path, strings.TrimSuffix(entry.Name(), ".jsonl"), "", false); err != nil { - return nil, err - } - } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { continue } - place := Place{Dir: cell.Copied(path)} + path := filepath.Join(dir, entry.Name()) + owners[strings.TrimSuffix(entry.Name(), ".jsonl")] = path + if err = add(path, strings.TrimSuffix(entry.Name(), ".jsonl"), "", false); err != nil { + return nil, err + } + } + for _, sessionDir := range BucketSessionDirs(dir) { + if err = ctx.Err(); err != nil { + return nil, err + } + place := Place{Dir: sessionDir} owners[place.ID()] = place.Transcript() if _, err = os.Stat(filepath.Join(place.Dir, conversationDeletedFile)); err == nil { deletedOwners[place.ID()] = true diff --git a/internal/session/l4_saving_test.go b/internal/session/l4_saving_test.go new file mode 100644 index 0000000000..0c8e6fe7c7 --- /dev/null +++ b/internal/session/l4_saving_test.go @@ -0,0 +1,32 @@ +package session + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/knowledge" +) + +// L4-H4b, L4-H4c: only a completed local drain and transcript close produce local durability. +func TestL4LocalDurabilityRequiresSuccessfulClose(t *testing.T) { + for _, fail := range []bool{false, true} { + place := newPlace(t, false) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place, c.SessionFile = place, place.Transcript() + c.knowledgeReadVault = knowledge.Open(t.TempDir()) + var cause error + if fail { + cause = context.DeadlineExceeded + } + c.Seat = failedSavingSeat{Seat: executor.Host, err: cause} + }) + if agent.LocalDurable() { + t.Fatal("an open agent claimed its final local receipt") + } + err := agent.Close() + if (err != nil) != fail || agent.LocalDurable() == fail { + t.Fatalf("failure=%v local=%v err=%v", fail, agent.LocalDurable(), err) + } + } +} diff --git a/internal/session/loop.go b/internal/session/loop.go index 5871150c52..c1b1d98e61 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -4421,6 +4421,7 @@ func utf8RuneStart(b byte) bool { return b&0xC0 != 0x80 } type bankedCall struct { // run is captured from the call's immutable execution context, never the current turn. run string + remote *remoteUsage used Usage model string role string diff --git a/internal/session/mention.go b/internal/session/mention.go index cc661c4566..1c4ddda3af 100644 --- a/internal/session/mention.go +++ b/internal/session/mention.go @@ -85,7 +85,7 @@ func mentionTeamBlock(profile, self, slug string, now time.Time) string { if !found { return "" } - log, _ := teams.ReadTraffic(profile, team.ID, "", 40) + log, _ := teams.ReadHistory(profile, team.ID, 40) states := map[string]teams.MemberState{} for _, m := range team.Members { if m.Key == self || m.File == self { diff --git a/internal/session/prompt.go b/internal/session/prompt.go index ef01a169ed..582229d166 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -557,7 +557,10 @@ func nowLine(now time.Time) string { // // It is called with a.mu held, from [Agent.startTurnLocked]. func (a *Agent) refreshClockLocked(now time.Time) { - if now.Sub(a.systemAt) < clockRefresh { + // A placement choice changes the available argument immediately, so its + // promise cannot wait for the clock or survive a reset to this computer. + placementChanged := a.systemOwn && a.config.TaskComputers != nil && a.taskComputerPrompt != a.config.mayNameTaskComputer() + if now.Sub(a.systemAt) < clockRefresh && !placementChanged { return } a.rerenderSystemLocked(now) @@ -566,10 +569,10 @@ func (a *Agent) refreshClockLocked(now time.Time) { // rerenderSystemLocked rebuilds this agent's instructions from its config AS IT // NOW STANDS, and puts them back at the head of the transcript. // -// IT IS ONE FUNCTION BECAUSE THE CONFIG MOVES UNDER RUNNING AGENTS, in three -// ways and no more: the clock going stale above, a conversation anchoring to a +// IT IS ONE FUNCTION BECAUSE THE CONFIG MOVES UNDER RUNNING AGENTS: the +// clock or task-placement preference changes above, a conversation anchors to a // project (tools_anchor_workspace.go), and work being armed to divide beside its -// worker (task_divide.go's [Agent.armDivisionBeside]). Each of the three changes +// worker (task_divide.go's [Agent.armDivisionBeside]). Each change // something [renderSystemAt] reads, and a prompt assembled in two different ways // is a prompt that will one day disagree with itself — which is the same reason // the clock re-renders the whole footer rather than editing one line. @@ -584,6 +587,7 @@ func (a *Agent) rerenderSystemLocked(now time.Time) { return } a.system = renderSystemAt(a.liveModelConfigLocked(), now) + a.taskComputerPrompt = a.config.mayNameTaskComputer() a.systemAt = now a.refreshSystemLocked() } diff --git a/internal/session/question.go b/internal/session/question.go index 70084018c8..8979fc3336 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -2224,6 +2224,7 @@ func (a *Agent) applyToLane(answer Answer) error { // The answers are on the row with their keys; the box is words. a.ResolveTask(answer.ID, TaskAnswer{ Approved: true, Redirect: words, Model: answer.Blanks[TaskModelBlank], + Computer: answer.Blanks[TaskComputerBlank], }) return nil } @@ -2247,6 +2248,7 @@ func (a *Agent) applyToLane(answer Answer) error { // option, which is what the card was showing. task := action.Task task.Model = answer.Blanks[TaskModelBlank] + task.Computer = answer.Blanks[TaskComputerBlank] a.ResolveTask(answer.ID, task) } return nil @@ -2936,6 +2938,10 @@ func (a *Agent) proposalQuestion(id uint64, notice TaskNotice) Question { // a choice somebody made and nothing acted on. const TaskModelBlank = "model" +// TaskComputerBlank carries the card's placement choice through the ordinary +// answer. Sharing the label keeps the surface and admission on the same choice. +const TaskComputerBlank = "computer" + // TaskModelPrompt is the sentence the hole sits in, with `{model}` where the // hole goes — so what a person reads is `run it on [ anthropic/claude-opus-5 ▾ ]` // rather than a form with a field name over it. diff --git a/internal/session/recentplace.go b/internal/session/recentplace.go index 78472eb7ea..d0fc0975b0 100644 --- a/internal/session/recentplace.go +++ b/internal/session/recentplace.go @@ -25,7 +25,6 @@ package session import ( - "github.com/Agent-Field/codeaf/internal/cell" "os" "path/filepath" "sort" @@ -45,6 +44,7 @@ func RecentSessions(dir string, limit int) []Summary { return nil } type candidate struct { + id string file string meta Meta at time.Time @@ -52,22 +52,25 @@ func RecentSessions(dir string, limit int) []Summary { files := make([]candidate, 0, len(entries)) for _, entry := range entries { name := entry.Name() - if !entry.IsDir() { - if !strings.HasSuffix(name, ".jsonl") { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - files = append(files, candidate{file: filepath.Join(dir, name), at: info.ModTime()}) + if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { continue } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, candidate{ + id: strings.TrimSuffix(name, ".jsonl"), + file: filepath.Join(dir, name), + at: info.ModTime(), + }) + } + for _, sessionDir := range BucketSessionDirs(dir) { // A DIRECTORY IS A SESSION WHEN IT HOLDS A TRANSCRIPT, asked through // [Place] so that where a transcript lives inside a folder is decided in // one file. Everything else under the v3 home — locks, the index, a // directory somebody made by hand — simply has no transcript in it. - place := Place{Dir: cell.Copied(filepath.Join(dir, name))} + place := Place{Dir: sessionDir} info, err := os.Stat(place.Transcript()) if err != nil || info.IsDir() { continue @@ -77,21 +80,33 @@ func RecentSessions(dir string, limit int) []Summary { if at.IsZero() { at = info.ModTime() } - files = append(files, candidate{file: place.Transcript(), meta: meta, at: at}) + files = append(files, candidate{ + id: firstWord(meta.ID, place.ID()), + file: place.Transcript(), + meta: meta, + at: at, + }) } // The cheap approximation of "newest" decides which files are worth opening, // and what the files themselves said decides the answer's order. sort.Slice(files, func(i, j int) bool { return files[i].at.After(files[j].at) }) found := make([]Summary, 0, limit) + seenID := make(map[string]bool, limit) for read, file := range files { if len(found) >= limit || read >= peekBudget { break } + if file.id != "" && seenID[file.id] { + continue + } summary, ok := Peek(file.file) if !ok { continue } + if file.id != "" { + seenID[file.id] = true + } // Peek records the path it opened. The listing sets it again so the // path a mention resolves is the transcript this walk opened. summary.File = file.file diff --git a/internal/session/saving_error.go b/internal/session/saving_error.go index 5cea84157c..092237dcbc 100644 --- a/internal/session/saving_error.go +++ b/internal/session/saving_error.go @@ -31,3 +31,6 @@ func SavingError(transcript string, cause error) error { } return &UnsavedChatError{Transcript: transcript, Cause: cause} } + +// LocalDurable confirms that this agent completed its local transcript and capture close. +func (a *Agent) LocalDurable() bool { a.mu.Lock(); defer a.mu.Unlock(); return a.localDurable } diff --git a/internal/session/session.go b/internal/session/session.go index f2149faac0..3a004cab7c 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -24,6 +24,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/knowledge" + "github.com/Agent-Field/codeaf/internal/teams" "sync" "sync/atomic" "time" @@ -1788,6 +1789,9 @@ type Config struct { // the same choices whichever way this is set. TaskSettle string + // TaskComputers is nil when this home cannot send tasks to paired computers. + TaskComputers TaskComputers + // Automations is the door to the automations store (automation_tool.go). // Nil is off: no `automation` tool and no card — which is what a run of an // automation is given, because nothing an automation does may arm another. @@ -1817,6 +1821,11 @@ type Config struct { // from a test. ProfileDir string + // TeamComputers is used only with local team sharing on. It checks current + // holders once for the complete recipient batch before any team Traffic is + // written; an unpaired home stays quiet. + TeamComputers func(context.Context, []teams.Member) (map[string]string, error) + // failures is the tally the piece of work this agent belongs to keeps of its // own failures (internal/taxonomy). A worker built for a task node carries // its node's; a conversation carries none, and every method on the type @@ -2742,12 +2751,14 @@ type Agent struct { // liveOutputWanted counts the windows that joined this conversation asking // for a shell call's output as it arrives ([Agent.WantLiveToolOutput]), // beside the window that opened it with [Config.LiveToolOutput]. - liveOutputWanted atomic.Int32 - chatStarts map[string]bool - knowledgeMu sync.RWMutex - knowledgeRetired []*knowledgeJournal - knowledgeVault *knowledge.Vault - knowledgeJournal *knowledgeJournal + liveOutputWanted atomic.Int32 + chatStarts map[string]bool + knowledgeMu sync.RWMutex + knowledgeRetired []*knowledgeJournal + knowledgeVault *knowledge.Vault + knowledgeJournal *knowledgeJournal + taskComputerPrompt bool + remoteAckJobs sync.WaitGroup // jailNotice keeps a host limit from becoming noise on every tool call. jailNotice sync.Once @@ -3591,6 +3602,7 @@ type Agent struct { // rather than a nil one. closeDone chan struct{} closedSignal chan struct{} + localDurable bool // takenOver says another window has asked for this conversation and this // process has not let go of it yet (takeover.go). Set once, never cleared: // the only way out is the close the ask is for. diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 57bc809b3d..f8f17688e6 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -894,15 +894,17 @@ type journalDivision struct { // counts every request to the provider, Turns only the ones a turn of the // person's made (see [Agent.addAuxiliaryUsage]). type journalUsage struct { - Model string `json:"model,omitempty"` - Input int `json:"input,omitempty"` - Output int `json:"output,omitempty"` - CacheRead int `json:"cacheRead,omitempty"` - CacheWrite int `json:"cacheWrite,omitempty"` - CostUSD float64 `json:"costUsd,omitempty"` - Calls int `json:"calls,omitempty"` - DurationMS int64 `json:"durationMs,omitempty"` - Aux bool `json:"aux,omitempty"` + // RemoteEvent makes mirrored charges idempotent across cursor replay. + RemoteEvent string `json:"remote_event,omitempty"` + Model string `json:"model,omitempty"` + Input int `json:"input,omitempty"` + Output int `json:"output,omitempty"` + CacheRead int `json:"cacheRead,omitempty"` + CacheWrite int `json:"cacheWrite,omitempty"` + CostUSD float64 `json:"costUsd,omitempty"` + Calls int `json:"calls,omitempty"` + DurationMS int64 `json:"durationMs,omitempty"` + Aux bool `json:"aux,omitempty"` // Empty marks a paid auxiliary call that reached its output ceiling without // returning any answer. Role says which errand it was; today only a reflex // writes the mark. @@ -1144,6 +1146,7 @@ func (p journalPart) placeholderBecause(reason string) ai.ContentPart { // lock orders the writes, this one keeps a write from being interleaved by // anything that reaches the file another way. type sessionFile struct { + remoteSeen map[string]bool // onWrite hears each line that reached the file, once the lock is released. onWrite func() mu sync.Mutex @@ -1748,6 +1751,7 @@ func openSessionFile(path, cwd, model, id string) (*sessionFile, replayedSession journal.stamps = &stampIndex{} } journal.restored = replayed.usage + journal.remoteSeen = replayed.remoteSeen if !replayed.existed { // The header names the session once. A resumed file keeps its @@ -1911,6 +1915,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // compaction marker for the same reason the title does — where a picture came // from is a fact about the file, not about the tail of the transcript. images := make(map[string]string) + remoteSeen := make(map[string]bool) // And the note index with it, for the same reason and in the same pass: the // mark is on the LINE, and once the line has been rebuilt into a message // there is nothing left to read it off (see [sessionFile.notes]). @@ -2194,6 +2199,12 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, continue } used := entry.Usage + if used.RemoteEvent != "" { + if remoteSeen[used.RemoteEvent] { + continue + } + remoteSeen[used.RemoteEvent] = true + } spent.Input += used.Input spent.Output += used.Output spent.CacheRead += used.CacheRead @@ -2326,6 +2337,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, stopped = "" } return replayedSession{ + remoteSeen: remoteSeen, stopped: stopped, messages: messages, unread: unread, @@ -2490,8 +2502,9 @@ func legacyCompactionNote(summary string) string { type replayedSession struct { modelSwitches []ModelSwitch // userLines is read-only physical provenance for display reads, nil on resume. - userLines map[any]int - messages []ai.Message + userLines map[any]int + remoteSeen map[string]bool + messages []ai.Message // requests is what the file's request lines said, read beside the rebuild // for a page watching the work with no lane to it ([RequestBooks]). requests RequestBooks diff --git a/internal/session/stoplaw_test.go b/internal/session/stoplaw_test.go index b54ca27a7a..8c61bbb388 100644 --- a/internal/session/stoplaw_test.go +++ b/internal/session/stoplaw_test.go @@ -31,17 +31,18 @@ import ( // here because they are not built from a literal state: their notices copy the // node's state, and the graph is the owner `task:N` has always reached. var stoppableRowPublishers = map[string]struct{ kind, proof string }{ - "startOrJoinTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, - "startHeldBeltRun": {CancelTask, "TestHeldBeltRunStopsWithoutPreparingRepository"}, - "startAdmittedBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, - "preparePendingBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, - "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, - "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, - "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, - "pauseRun": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, - "rename": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, - "parkUnroutedHandoff": {CancelTask, "TestAStopReachesATaskWaitingOnAModel"}, - "retryRestoredPark": {CancelTask, "TestAStopReachesATaskWaitingOnAModel"}, + "startOrJoinTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "startDeferredBeltRun": {CancelTask, "TestANamedTaskCanBeStoppedWhileItsCopyIsPreparing"}, + "startAdmittedBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "finishPendingCopy": {CancelTask, "TestStoppingARemoteTaskReturnsItsPartialChangeAndKeepsItsBranch"}, + "rememberPendingRemoteCopy": {CancelTask, "TestStoppingARemoteTaskReturnsItsPartialChangeAndKeepsItsBranch"}, + "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, + "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "pauseRun": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "rename": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "parkUnroutedHandoff": {CancelTask, "TestAStopReachesATaskWaitingOnAModel"}, + "retryRestoredPark": {CancelTask, "TestAStopReachesATaskWaitingOnAModel"}, } // stoppableStates are the states a stop means something in, by the names this diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index bb9e65d4a2..37eb35cbe9 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -122,7 +122,9 @@ func (a *Agent) stopCurrentBeltRow(run *beltRun, id uint64, why string) (string, g.planNote("the run's stop could not be written: " + err.Error()) } } - if cut != nil { + if run.remoteEngine != nil { + a.stopRemoteRun(run) + } else if cut != nil { cut() } promise := "its branch is kept" diff --git a/internal/session/task.go b/internal/session/task.go index f6970b051f..0c7d262634 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -258,6 +258,7 @@ type taskArguments struct { Brief string `json:"brief"` Deliverable string `json:"deliverable"` Where string `json:"where"` + Computer string `json:"computer,omitempty"` Ground string `json:"ground"` WorkspaceSet *WorkspaceSetRequest `json:"workspace_set,omitempty"` Acceptance string `json:"acceptance"` @@ -297,6 +298,7 @@ type taskSpec struct { irreversible bool workspaceSet *WorkspaceSetRequest title string + computer string // named says A MODEL ALREADY WROTE THIS TITLE as a name, rather than the // door cutting one out of a sentence it had to hand. It is the whole of what // decides whether the work is named again (taskname.go): a name the shaper @@ -616,6 +618,9 @@ func (a *Agent) taskTools() []bare.Tool { if a.config.mayDelegate() { schema = taskSchemaJSON } + if a.config.mayNameTaskComputer() { + schema = strings.Replace(schema, `"properties":{`, `"properties":{`+taskComputerSchema, 1) + } if WorkspaceOwnershipEnabled(a.config.Place) { schema = strings.Replace(schema, `"properties":{`, `"properties":{`+workspaceSetTaskSchema+`,`, 1) } @@ -715,6 +720,12 @@ func proposalProblems(contract string, stand taskStand) string { // started early (the quality law of an early start: the same brief, sooner). func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged { spec, problem := parseTaskArguments(args) + // A model cannot opt into placement through an argument its belt did not + // offer. Preserve the original treatment of that unknown field; a person + // can still choose a computer in the later proposal answer. + if !a.config.mayNameTaskComputer() { + spec.computer = "" + } if problem != "" { // A CONTRACT PROBLEM DOES NOT HIDE A PLACEMENT PROBLEM. Once the call has // enough of a task to place, both readings are already available and each @@ -724,6 +735,9 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged } return bare.Settled(rejectToolArguments(ctx, problem), true) } + if err := a.checkProposedTaskComputer(ctx, spec); err != nil { + return bare.Settled(err.Error(), true) + } // THE DOOR REFUSALS, before a card or a slot. A proposal that left out the // program the person named, a trivial ask and a depends_on that can never // resolve are all "do not start this"; they live in one helper so this @@ -890,6 +904,12 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { } return withElsewhere("the person declined this task", elsewhere), false, nil } + // A card choice is explicit placement for this proposal only. Admission + // still resolves it through the same roster and eligibility checks as a + // computer originally named in the tool call or the typed task command. + if computer := strings.TrimSpace(answer.Computer); computer != "" { + spec.computer = computer + } if redirect := strings.TrimSpace(answer.Redirect); redirect != "" { // APPENDED, never merged into the brief's prose. The person's words // arrive last and in their own voice, so the node reads them as the @@ -991,24 +1011,31 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec a.settleUnstartedHandoff(p.id, spec.title, err) return err.Error(), true, true } - joined, stand, asked, err := a.startApprovedHandoff(ctx, p, spec, via, question) + start, err := a.startApprovedHandoff(ctx, p, spec, via, question) + // A COMPUTER THAT CANNOT TAKE THE TASK IS SAID AS ITSELF, before any run row + // is opened: the placement refusal names the computer and the way out. + if refusal := (placementRefusal{}); errors.As(err, &refusal) { + return refusal.Error(), true, true + } + elsewhere = withReport(elsewhere, start.placed) // A ROLE WITH NOTHING ALLOWED TO RUN ON IS A QUESTION, NOT A FAILURE: the // task waits, asks, and starts from here once a model is allowed // (taskrouting.go). Nothing ran and nothing was moved onto another model. if receipt, parked := a.parkUnroutedHandoff(ctx, p, spec, elsewhere, via, err); parked { return receipt, false, true } - return a.handoffAnswer(p, spec, elsewhere, via, asked, stand, joined, err) + return a.handoffAnswer(p, spec, elsewhere, via, start.asked, start.stand, start.joined, err) } // runRoadOwns is an approved hand-off this door starts as a run: the bash -// belt, or a named program. A session inside a task, or one with no run -// engine, leaves the hand-off to the older road. +// belt, a named program, or a task placed on a paired computer. A session +// inside a task, or one with no run engine, leaves the hand-off to the older +// road. func (a *Agent) runRoadOwns(spec taskSpec) bool { if chatRunEngine == nil || a.config.InTask { return false } - return bashBeltAsked() || spec.via != "" + return bashBeltAsked() || spec.via != "" || a.taskComputerPreference() || (a.config.TaskComputers != nil && spec.computer != "") } // handoffDelegate is the program a hand-off named, or nil when it named none. @@ -1030,11 +1057,16 @@ func (a *Agent) handoffDelegate(spec taskSpec) (*delegate.Delegate, error) { // the model finished its sentence. What ends the run is the conversation, the // person's stop or the room closing. Whether it joined a run already underway // is this door's answer, decided under the start lock. -func (a *Agent) startApprovedHandoff(ctx context.Context, p *stagedProposal, spec taskSpec, via *delegate.Delegate, question string) (bool, taskStand, []string, error) { +func (a *Agent) startApprovedHandoff(ctx context.Context, p *stagedProposal, spec taskSpec, via *delegate.Delegate, question string) (handoffStart, error) { stand, err := a.admitWorkspaceProposal(ctx, p.stand, spec) if err != nil { - return false, stand, nil, err + return handoffStart{stand: stand}, err } + placement, err := a.placeHandoff(ctx, spec, stand, via) + if err != nil { + return handoffStart{stand: stand}, placementRefusal{err} + } + ctx = withTaskPlacement(ctx, placement) asked := programAsked(spec) var prior *programOutcome if via != nil { @@ -1043,9 +1075,46 @@ func (a *Agent) startApprovedHandoff(ctx context.Context, p *stagedProposal, spe ctx = withProgramWish(ctx, programWish{thinking: spec.thinking, carry: a.programCarryOf(prior, via.Name)}) } a.expectRunSource(p.id, spec.source) - joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, proposalBrief(spec, via), spec.dependsOn, stand, question, via, asked...) + joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, proposalRunDescription(spec, via, placement), spec.dependsOn, stand, question, via, asked...) a.rollbackFailedProgramStart(via, p.id, prior, err) - return joined, stand, asked, err + return handoffStart{joined: joined, stand: stand, asked: asked, placed: placement.sentence}, err +} + +// handoffStart is what starting an approved hand-off settled: whether it +// joined a run already underway, the stand it runs on, the programs asked +// for, and the sentence that says which computer it was placed on. +type handoffStart struct { + joined bool + stand taskStand + asked []string + placed string +} + +// placementRefusal is a hand-off whose named computer cannot take it. It is +// said as its own sentence, never as a run that did not start. +type placementRefusal struct{ err error } + +func (r placementRefusal) Error() string { return r.err.Error() } +func (r placementRefusal) Unwrap() error { return r.err } + +// placeHandoff resolves the computer an approved hand-off runs on. The +// explicit in-place request controls computer placement even when the +// repository's existing isolation law still prepares a local branch. +func (a *Agent) placeHandoff(ctx context.Context, spec taskSpec, stand taskStand, via *delegate.Delegate) (taskPlacement, error) { + if strings.EqualFold(strings.TrimSpace(spec.where), "in place") { + stand.mode = TaskModeInPlace + } + placement, err := a.resolveTaskComputer(ctx, spec.computer, stand, spec.kind(), via != nil) + if err != nil { + return placement, err + } + placement.model = namedModel(spec) + placement.effort = string(spec.crewEffort) + if spec.thinking != effort.None { + placement.effort = spec.thinking.String() + } + placement.maxSteps, placement.noProgress = spec.maxSteps, spec.noProgress + return placement, nil } // handoffAnswer is what an approved hand-off that was this door's to start @@ -1222,6 +1291,7 @@ func parseTaskArguments(args json.RawMessage) (taskSpec, string) { } spec := taskSpec{ title: strings.TrimSpace(parsed.Title), + computer: strings.TrimSpace(parsed.Computer), irreversible: parsed.Irreversible, // THE MODEL GROOMED THIS PROPOSAL AND NAMED IT IN THE SAME BREATH, so the // namer leaves it alone (taskname.go). @@ -1653,6 +1723,7 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T hold: make(chan struct{}), notice: TaskNotice{ ID: id, + Computer: spec.computer, Title: spec.title, Source: cloneSource(spec.source), Summary: spec.summary, @@ -1934,3 +2005,21 @@ func (a *Agent) admitWorkspaceProposal(ctx context.Context, stand taskStand, spe func singletonRunFallback(err error, stand taskStand) bool { return stand.workspaceSet == nil && errors.Is(err, errRunRoadUnavailable) } + +// Placement validation precedes the proposal card without adding roster work to local proposals. +func (a *Agent) checkProposedTaskComputer(ctx context.Context, spec taskSpec) error { + if spec.computer == "" { + return nil + } + _, err := a.resolveTaskComputer(ctx, spec.computer, taskStand{}, "", false) + return err +} + +// Checks belong only in the instruction transported to another computer. +func proposalRunDescription(spec taskSpec, via *delegate.Delegate, placement taskPlacement) string { + description := proposalBrief(spec, via) + if placement.computer != nil && len(spec.checks) > 0 { + description += "\n\nChecks\n\n" + strings.Join(spec.checks, "\n") + } + return description +} diff --git a/internal/session/task_accounting_r8_test.go b/internal/session/task_accounting_r8_test.go new file mode 100644 index 0000000000..30cec4c961 --- /dev/null +++ b/internal/session/task_accounting_r8_test.go @@ -0,0 +1,42 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// R7-8 and contract 5 include a complete ledger object whose final newline was lost. +func TestARemoteChargeWithoutItsNewlineIsNotChargedTwice(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.jsonl") + line := UsageLine{RemoteEvent: "att_0123456789abcdef:3", USD: .25} + raw, err := json.Marshal(line) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + if err = appendRemoteUsage(path, line); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(raw) { + t.Fatalf("replaying a complete EOF charge appended bytes: %s", got) + } + if strings.Count(string(got), line.RemoteEvent) != 1 { + t.Fatalf("charge repeated: %s", got) + } + if err = appendRemoteUsage(path, line); err != nil { + t.Fatal(err) + } + again, _ := os.ReadFile(path) + if string(got) != string(again) { + t.Fatal("second replay changed the ledger") + } +} diff --git a/internal/session/task_computer_choice_test.go b/internal/session/task_computer_choice_test.go new file mode 100644 index 0000000000..af6317a5cc --- /dev/null +++ b/internal/session/task_computer_choice_test.go @@ -0,0 +1,178 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +func taskComputerChoiceHome(t *testing.T) { + t.Helper() + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("CODEAF_HOME", root) +} + +// DESIGN contracts 1 and 6 require the named computer to receive the task's +// chosen controls; asking for effort must not route a worker on the origin. +func TestANamedComputerReceivesTheEffortChosenForThatTask(t *testing.T) { + for _, effort := range []string{"best", "cheap"} { + t.Run(effort, func(t *testing.T) { + taskComputerChoiceHome(t) + reached := make(chan string, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + reached <- spec.NamedEffort + _ = spec.Store.CompleteRoot("export repaired") + return RunSummary{Outcome: "done", Result: "export repaired"} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", true, "work computer", effort); err != nil { + t.Fatal(err) + } + select { + case got := <-reached: + if got != effort { + t.Fatalf("the other computer received effort %q, want %q", got, effort) + } + case <-time.After(5 * time.Second): + t.Fatal("the chosen computer did not receive the task") + } + waitRemoteRun(t, a) + if local.didRun() { + t.Fatal("the task ran on the origin") + } + }) + } +} + +// DESIGN contracts 1 and 7 require a card's explicit computer to outrank +// both the original proposal and the standing default, through real admission. +func TestAProposalAnswerChoosesTheComputerThroughThePlacementResolver(t *testing.T) { + for _, via := range []string{"task answer", "question answer", "correction"} { + for _, computer := range []string{"work computer", "this computer"} { + t.Run(via+"/"+computer, func(t *testing.T) { + taskComputerChoiceHome(t) + reached := make(chan string, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + reached <- "work computer" + _ = spec.Store.CompleteRoot("export repaired") + return RunSummary{Outcome: "done", Result: "export repaired"} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { + c.TaskAutoApproveSeconds = 0 + c.AskConsent = true + if computer == "this computer" { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + } + }) + var release sync.Once + t.Cleanup(func() { release.Do(func() { close(local.release) }) }) + args := taskOnOtherComputer() + args.Computer = "" + if computer == "this computer" { + args.Computer = "work computer" + } + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + events := watched(a) + staged := a.stageTask(ctx, raw) + var id uint64 + for id == 0 { + select { + case event := <-events: + if event.Kind == EventTaskProposal && event.Task != nil { + id = event.Task.ID + } + case <-ctx.Done(): + t.Fatal("the proposal was not shown") + } + } + if via == "task answer" { + a.ResolveTask(id, TaskAnswer{Approved: true, Computer: computer}) + } else { + answer := Answer{Kind: QuestionTask, ID: id, Key: "1", Blanks: map[string]string{TaskComputerBlank: computer}} + if via == "correction" { + answer.Key = "" + answer.Change = "also cover an empty export" + } + if err := a.ResolveQuestion(answer); err != nil { + t.Fatal(err) + } + } + result, refused, err := staged.Commit(ctx) + if err != nil || refused { + t.Fatalf("proposal = %q, %v, %v", result, refused, err) + } + if computer == "work computer" { + select { + case <-reached: + case <-ctx.Done(): + t.Fatal("the card's choice did not reach the other computer") + } + if local.didRun() { + t.Fatal("the origin ran the explicitly remote task") + } + } else { + select { + case <-local.entered: + case <-ctx.Done(): + t.Fatal("the card's local choice did not override placement") + } + release.Do(func() { close(local.release) }) + select { + case <-reached: + t.Fatal("the local choice also ran remotely") + default: + } + } + waitRemoteRun(t, a) + }) + } + } +} + +// DESIGN contract 6 requires a corrected card choice to get the same +// correctable unknown-name answer as every other placement entry point. +func TestAnUnknownComputerChosenOnAProposalUsesThePlacementError(t *testing.T) { + taskComputerChoiceHome(t) + a, local := remoteTaskAgent(t, nil, func(c *Config) { + c.TaskAutoApproveSeconds = 0 + c.AskConsent = true + }) + var release sync.Once + t.Cleanup(func() { release.Do(func() { close(local.release) }) }) + events := watched(a) + args := taskOnOtherComputer() + args.Computer = "" + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + staged := a.stageTask(ctx, raw) + for { + select { + case ev := <-events: + if ev.Kind != EventTaskProposal || ev.Task == nil { + continue + } + a.ResolveTask(ev.Task.ID, TaskAnswer{Approved: true, Computer: "missing"}) + result, refused, err := staged.Commit(ctx) + if err != nil || !refused || !strings.Contains(result, `no paired computer is named "missing"`) || !strings.Contains(result, "work computer") { + t.Fatalf("proposal correction = %q, %v, %v", result, refused, err) + } + if local.didRun() { + t.Fatal("an unknown choice started local work") + } + return + case <-ctx.Done(): + t.Fatal("the proposal was not shown") + } + } +} diff --git a/internal/session/task_computer_manual_test.go b/internal/session/task_computer_manual_test.go new file mode 100644 index 0000000000..9405a95be6 --- /dev/null +++ b/internal/session/task_computer_manual_test.go @@ -0,0 +1,48 @@ +package session + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// DESIGN 16 requires the rendered guidance and schema to agree for every +// belt shape, while the shared static prompt makes no unconditional promise. +func TestTheManualComputerGuidanceMatchesTheAvailableArgument(t *testing.T) { + if strings.Contains(systemPrompt, "`computer`") { + t.Fatal("the static system prompt promises a conditional argument") + } + for _, shape := range []struct { + name string + paired, preferred, inTask, offered bool + }{ + {"paired default conversation", true, false, false, false}, + {"paired chosen computer", true, true, false, true}, + {"unpaired conversation", false, false, false, false}, + {"worker without proposals", true, true, true, false}, + } { + t.Run(shape.name, func(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.InTask = shape.inTask + c.ProfileDir = t.TempDir() + if shape.paired { + c.TaskComputers = testTaskComputers{} + } + }) + if shape.preferred { + if err := config.WriteTaskRunsOn(a.config.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + } + argument := false + for _, tool := range a.taskTools() { + _, argument = schemaProperties(t, tool.Schema)["computer"] + } + guidance := strings.Contains(promptWithBeltFacts(a.config), "optional `computer` argument") + if argument != shape.offered || guidance != shape.offered { + t.Fatalf("computer argument=%v, guidance=%v, want both %v", argument, guidance, shape.offered) + } + }) + } +} diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 1c0ce384ca..b205a25986 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -460,6 +460,11 @@ type TaskNotice struct { // Retryable says this producer supports a fresh attempt; admission is checked when requested. Retryable bool + // Placement is separate from the state word: empty Computer means here. + Computer string `json:",omitempty"` + Unreachable bool `json:",omitempty"` + RanElsewhere bool `json:",omitempty"` + Remote *TaskRemoteRecord `json:",omitempty"` // Thinking is the effective setup, including a saved continuation choice. Thinking string // ID is the proposal's token: a surface hands it back to @@ -882,6 +887,9 @@ type TaskAnswer struct { Approved bool Redirect string Model string + // Computer is an explicit card choice. Empty preserves the proposal and + // default; the local alias overrides both without naming another device. + Computer string `json:",omitempty"` } // ── the phases of a running node ──────────────────────────────────────────── diff --git a/internal/session/task_elsewhere.go b/internal/session/task_elsewhere.go new file mode 100644 index 0000000000..36b2adcbe2 --- /dev/null +++ b/internal/session/task_elsewhere.go @@ -0,0 +1,321 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/devname" +) + +// TaskComputerControlWithin bounds best-effort stop and acknowledgement calls. +const TaskComputerControlWithin = 10 * time.Second + +// TaskComputer is a roster entry whose name can change without changing a +// saved preference. An unsupported target carries the sentence explaining why. +type TaskComputer struct { + Device string + Name string + OriginalName string `json:"originalName,omitempty"` + CanTakeTasks bool + Reason string +} + +// TaskComputers is absent where paired task placement cannot work. Session +// owns this boundary because the transport is built on the local run engine. +type TaskComputers interface { + List(context.Context) ([]TaskComputer, error) + ThisComputer() string + Engine(context.Context, string) (TaskRunEngine, error) +} + +// TaskRunEngine extends the ordinary engine only for a remote attempt's +// lifetime. Landing always belongs to the registered local engine. +type TaskRunEngine interface { + RunEngine + Resume(context.Context, RunSpec) RunSummary + Stop(context.Context, TaskRemoteRecord) error + Abandon(context.Context, TaskRemoteRecord) error + Acknowledge(context.Context, TaskRemoteRecord) error + CheckCopy(context.Context, string, string) error +} + +// TaskComputerRecovery builds an engine from a selected or saved identity. +// Selection already read the roster; each eventual dial checks permission again. +type TaskComputerRecovery interface { + RecoverEngine(TaskExecutor) TaskRunEngine +} + +func (a *Agent) taskExecutorEngine(ctx context.Context, executor TaskExecutor) (TaskRunEngine, error) { + if computers, ok := a.config.TaskComputers.(TaskComputerRecovery); ok { + return computers.RecoverEngine(executor), nil + } + return a.config.TaskComputers.Engine(ctx, executor.Device) +} + +type TaskExecutor struct { + Device string `json:"device"` + Name string `json:"name"` +} + +// TaskRemoteRecord is additive to the run row. Missing records describe local +// work; abandoned records fence late results before a local replacement starts. +type TaskRemoteRecord struct { + NamedModel string `json:"named_model,omitempty"` + NamedEffort string `json:"named_effort,omitempty"` + MaxSteps int `json:"max_steps,omitempty"` + NoProgress int `json:"no_progress,omitempty"` + Executor TaskExecutor `json:"executor"` + Attempt string `json:"attempt,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Seq uint64 `json:"seq,omitempty"` + USD float64 `json:"usd,omitempty"` + StartFailed bool `json:"start_failed,omitempty"` + Unreachable bool `json:"unreachable,omitempty"` + Abandoned bool `json:"abandoned,omitempty"` + Finished bool `json:"finished,omitempty"` + Settled bool `json:"settled,omitempty"` + AckPending bool `json:"ack_pending,omitempty"` + Final json.RawMessage `json:"final,omitempty"` + Restored bool `json:"restored,omitempty"` +} + +type taskPlacement struct { + computer *TaskComputer + sentence string + model, effort string + maxSteps, noProgress int +} +type taskPlacementKey struct{} + +func withTaskPlacement(ctx context.Context, p taskPlacement) context.Context { + return context.WithValue(ctx, taskPlacementKey{}, p) +} +func taskPlacementOf(ctx context.Context) taskPlacement { + p, _ := ctx.Value(taskPlacementKey{}).(taskPlacement) + return p +} + +const taskComputerSchema = `"computer":{"type":"string","description":"The paired computer to run the task on, by its shown name. Omitted means the Run tasks on setting decides; that is ` + config.TaskRunsHere + ` unless the person changed it. Only a task that works in its own copy can go."},` + +// resolveTaskComputer runs once at admission, before a copy or a join. A stale +// preference is harmless, while an explicit misspelling is a correctable error. +func (a *Agent) resolveTaskComputer(ctx context.Context, named string, stand taskStand, kind TaskKind, program bool) (taskPlacement, error) { + p := taskPlacementOf(ctx) + named = strings.TrimSpace(named) + if a.config.TaskComputers == nil { + return p, nil + } + explicit := named != "" + if explicit && (strings.EqualFold(named, "here") || strings.EqualFold(named, config.TaskRunsHere)) { + return p, nil + } + preference := config.TaskRunsOnAt(a.config.ProfileDir) + if !explicit && preference == "" { + return p, nil + } + computers, err := a.config.TaskComputers.List(ctx) + if err != nil { + return p, err + } + localName := a.config.TaskComputers.ThisComputer() + computer, err := chooseTaskComputer(TaskComputerNames(computers, localName), named, preference, localName) + if err != nil || computer == nil { + return p, err + } + return eligibleTaskComputer(p, *computer, stand, kind, program, explicit), nil +} + +// TaskComputerNames keeps device labels consistent while retaining ambiguous bare names for refusal. +func TaskComputerNames(rows []TaskComputer, localName string) []TaskComputer { + rows = append([]TaskComputer(nil), rows...) + counts := map[string]int{strings.ToLower(strings.TrimSpace(localName)): 1} + for _, row := range rows { + counts[taskComputerBareName(row)]++ + } + for i := range rows { + row := &rows[i] + if counts[taskComputerBareName(*row)] > 1 { + if row.OriginalName == "" { + row.OriginalName = row.Name + } + row.Name = devname.DistinctName(row.OriginalName, row.Device) + } + } + return rows +} +func taskComputerBareName(row TaskComputer) string { + name := row.OriginalName + if name == "" { + name = row.Name + } + return strings.ToLower(strings.TrimSpace(name)) +} + +// chooseTaskComputer never gives roster order authority over an ambiguous name. +func chooseTaskComputer(computers []TaskComputer, named, preference, localName string) (*TaskComputer, error) { + explicit := named != "" + local := explicit && strings.EqualFold(named, localName) + var matches []TaskComputer + for _, computer := range computers { + if taskComputerMatches(computer, named, preference) { + matches = append(matches, computer) + } + } + if len(matches) > 1 || local && len(matches) > 0 { + choices := taskComputerChoices(matches) + if local { + choices = append(choices, config.TaskRunsHere) + } + return nil, fmt.Errorf("%q matches more than one paired computer — choose one of: %s", named, strings.Join(choices, ", ")) + } + if local { + return nil, nil + } + if len(matches) == 1 { + return &matches[0], nil + } + if explicit { + return nil, fmt.Errorf("no paired computer is named %q — your computers are: %s", named, strings.Join(taskComputerChoices(computers), ", ")) + } + return nil, nil +} +func taskComputerMatches(computer TaskComputer, named, preference string) bool { + if named == "" { + return preference != "" && computer.Device == preference + } + return strings.EqualFold(named, computer.Name) || strings.EqualFold(named, taskComputerBareName(computer)) +} +func taskComputerChoices(computers []TaskComputer) []string { + names := make([]string, 0, len(computers)) + for _, computer := range computers { + names = append(names, computer.Name) + } + sort.Strings(names) + if len(names) == 0 { + names = append(names, config.TaskRunsHere) + } + return names +} + +// Ineligible work retains the existing local road and its explicit-choice explanation. +func eligibleTaskComputer(p taskPlacement, computer TaskComputer, stand taskStand, kind TaskKind, program, explicit bool) taskPlacement { + if !computer.CanTakeTasks { + if explicit { + p.sentence = computer.Reason + } + return p + } + reason := "" + switch { + case kind == TaskKindQuick: + reason = "a quick task" + case program: + reason = "a program run" + case !taskCopyCanTravel(stand): + reason = "this task" + } + if reason != "" { + if explicit { + p.sentence = reason + " runs here — only a task that works in its own copy can run on " + computer.Name + } + return p + } + p.computer = &computer + return p +} + +// StartTaskOn is the shared session entry for a person's explicit placement. +// Model proposals reach the same resolver through their normal admission road. +// The optional effort belongs to this task and leaves the standing crew alone. +func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, computer string, namedEffort ...string) (uint64, string, string, error) { + wish := crewWishOf(ctx) + if len(namedEffort) > 0 { + wish.effort, _ = crewroute.ParseEffort(namedEffort[0]) + ctx = withCrewWish(ctx, wish) + } + stand := a.taskGroundOrStandingIn(taskSpec{title: taskPersonTitle(brief), request: brief, brief: brief, acceptance: taskPersonAcceptance}) + p, err := a.resolveTaskComputer(ctx, computer, stand, "", false) + if err != nil { + return 0, "", "", err + } + p.effort = string(wish.effort) + var id uint64 + var title, note string + if p.computer != nil { + id, title, note, err = a.startTaskRun(withTaskPlacement(context.WithoutCancel(ctx), p), brief, solo, "") + } else { + id, title, note, err = a.StartTask(withTaskPlacement(ctx, p), brief, solo) + } + return id, title, withReport(note, p.sentence), err +} + +// TaskComputersSupported lets a connected window omit controls that this +// conversation cannot honor, even though the agent has the method shapes. +func (a *Agent) TaskComputersSupported() bool { return a.config.TaskComputers != nil } + +const taskComputersUnavailable = "paired task computers are unavailable here" + +var errRemotePlacementBusy = errors.New("the work already underway uses another computer; let it finish before starting this task") + +// cloneTaskRemote keeps published rows immutable while the watcher advances. +func cloneTaskRemote(r *TaskRemoteRecord) *TaskRemoteRecord { + if r == nil { + return nil + } + copy := *r + copy.Final = append(json.RawMessage(nil), r.Final...) + return © +} + +func (a *Agent) taskComputerPreference() bool { + return a.config.TaskComputers != nil && config.TaskRunsOnAt(a.config.ProfileDir) != "" +} + +// A private singleton uses the same carrier as an ordinary repository copy. +// Its folder mirror also has the independent history prepared by that road. +func taskCopyCanTravel(stand taskStand) bool { + if stand.mode == TaskModeInPlace || stand.mode == TaskModeReference || stand.mode == TaskModeFolder { + return false + } + if stand.workspaceSet != nil { + return len(stand.workspaceSet.Roots) == 1 && (stand.mode == TaskModeWorktree || stand.mode == TaskModeMirror) + } + return stand.mode == TaskModeWorktree +} + +// Pairing supplies the person's placement door. Only a saved remote preference +// adds placement to the model's vocabulary. Both reads are local. +func (c Config) mayNameTaskComputer() bool { + return c.TaskComputers != nil && config.TaskRunsOnAt(c.ProfileDir) != "" +} + +// The selected engine is resolved before start admission without changing local placement. +func (a *Agent) placeTaskRunEngine(ctx context.Context, engine RunEngine, stand taskStand, program bool) (context.Context, RunEngine, error) { + placement := taskPlacementOf(ctx) + if _, resolved := ctx.Value(taskPlacementKey{}).(taskPlacement); !resolved { + var err error + placement, err = a.resolveTaskComputer(ctx, "", stand, "", program) + if err != nil { + return ctx, nil, err + } + ctx = withTaskPlacement(ctx, placement) + } + if placement.computer == nil { + return ctx, engine, nil + } + remote, err := a.taskExecutorEngine(ctx, TaskExecutor{Device: placement.computer.Device, Name: placement.computer.Name}) + if err != nil { + return ctx, nil, err + } + if remote == nil { + return ctx, nil, errors.New("this computer cannot run that task elsewhere right now") + } + return ctx, remote, nil +} diff --git a/internal/session/task_elsewhere_test.go b/internal/session/task_elsewhere_test.go new file mode 100644 index 0000000000..42403b45e1 --- /dev/null +++ b/internal/session/task_elsewhere_test.go @@ -0,0 +1,348 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/plandb" + "path/filepath" + "strings" + "testing" + "time" +) + +// Contract 6 makes a misspelt computer repairable at the tool boundary. +func TestAnUnknownComputerNameIsAToolErrorListingTheComputers(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + c.TaskComputers = testTaskComputers{} + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + }) + result, refused, err := a.stageTask(context.Background(), json.RawMessage(`{"title":"Repair export","summary":"Repair the export path","brief":"Fix the export failure in the project","deliverable":"working export","acceptance":"export succeeds","computer":"missing"}`)).Commit(context.Background()) + if err != nil || !refused || !strings.Contains(result, `no paired computer is named "missing"`) || !strings.Contains(result, "work computer") { + t.Fatalf("tool result = %q, %v, %v", result, refused, err) + } +} + +// The absence law keeps an unusable argument off every unpaired belt. +func TestTheComputerArgumentIsAbsentWhenNothingIsPaired(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + if _, present := schemaProperties(t, a.taskTools()[0].Schema)["computer"]; present { + t.Fatal("unpaired schema offers computer") + } + +} + +type testTaskComputers struct{ engine TaskRunEngine } + +func (testTaskComputers) List(context.Context) ([]TaskComputer, error) { + return []TaskComputer{{Device: "dev_0123456789abcdef0123456789abcdef", Name: "work computer", CanTakeTasks: true}}, nil +} +func (testTaskComputers) ThisComputer() string { return "chat computer" } +func (c testTaskComputers) Engine(context.Context, string) (TaskRunEngine, error) { + return c.engine, nil +} + +// The fake stands at the process/network boundary; local copies, stores, +// notices and landing remain the conversation's real roads. +type scriptedTaskEngine struct { + start func(context.Context, RunSpec) RunSummary + resume func(context.Context, RunSpec) RunSummary + check func(context.Context, string, string) error + stop func() + abandon func() +} + +func (e *scriptedTaskEngine) Start(ctx context.Context, spec RunSpec) RunSummary { + return e.start(ctx, spec) +} +func (e *scriptedTaskEngine) Resume(ctx context.Context, spec RunSpec) RunSummary { + if e.resume != nil { + return e.resume(ctx, spec) + } + return e.start(ctx, spec) +} +func (e *scriptedTaskEngine) Land(context.Context, *plandb.Store, string, string, string) (RunLanding, error) { + panic("a remote engine must never land here") +} +func (e *scriptedTaskEngine) Stop(context.Context, TaskRemoteRecord) error { + if e.stop != nil { + e.stop() + } + return nil +} +func (e *scriptedTaskEngine) Abandon(context.Context, TaskRemoteRecord) error { + if e.abandon != nil { + e.abandon() + } + return nil +} +func (e *scriptedTaskEngine) Acknowledge(context.Context, TaskRemoteRecord) error { return nil } +func (e *scriptedTaskEngine) CheckCopy(ctx context.Context, path, fingerprint string) error { + if e.check != nil { + return e.check(ctx, path, fingerprint) + } + return nil +} + +func remoteTaskAgent(t *testing.T, remote TaskRunEngine, options func(*Config)) (*Agent, *beltRunDouble) { + t.Helper() + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done here") + local.real = true + local.honoursStop = true + registerBeltRunEngine(t, local) + place, repo := t.TempDir(), beltRunCommittedRepo(t) + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = t.TempDir() + c.AskConsent = false + c.TaskComputers = testTaskComputers{engine: remote} + if options != nil { + options(c) + } + }) + return a, local +} +func remoteRecordFor(spec RunSpec) TaskRemoteRecord { + r := *spec.Remote + r.Attempt = "att_0123456789abcdef" + r.Fingerprint = "sent-copy" + return r +} +func waitRemoteRun(t *testing.T, a *Agent) { + t.Helper() + beltRunWaitFor(t, "remote task to settle", func() bool { a.beltMu.Lock(); defer a.beltMu.Unlock(); return a.beltRun == nil }) +} +func approveRemoteProposal(t *testing.T, a *Agent, args taskArguments) (string, bool) { + t.Helper() + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + events, stop := a.WatchTaskUpdates() + defer stop() + done := make(chan struct{}) + defer close(done) + go func() { + for { + select { + case <-done: + return + case event, ok := <-events: + if !ok { + return + } + if event.Task != nil && event.Kind == EventTaskProposal { + a.ResolveTask(event.Task.ID, TaskAnswer{Approved: true}) + } + } + } + }() + answer, refused, err := a.stageTask(ctx, raw).Commit(ctx) + if err != nil { + t.Fatal(err) + } + return answer, refused +} +func taskOnOtherComputer() taskArguments { + return taskArguments{Title: "Repair export", Summary: "Repair the export failure", Brief: "Repair the export path in this project", Deliverable: "a working export", Acceptance: "the export regression passes", Checks: []string{"go test ./export"}, Computer: " WORK COMPUTER "} +} + +// Contract 6 sends the same settled instruction a local root worker would read. +func TestAProposalNamingAPairedComputerRunsThere(t *testing.T) { + reached := make(chan RunSpec, 1) + release := make(chan struct{}) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + reached <- spec + select { + case <-release: + case <-ctx.Done(): + } + _ = spec.Store.CompleteRoot("done there") + return RunSummary{Outcome: "done", Result: "done there"} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + c.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { + t.Error("remote work consulted the origin's crew") + return crewroute.Decision{}, errors.New("origin crew must stay unused") + } + }) + args := taskOnOtherComputer() + args.Model = "test/model" + args.Effort = "cheap" + args.MaxSteps = 17 + args.NoProgress = 9 + answer, refused := approveRemoteProposal(t, a, args) + if refused { + t.Fatalf("proposal refused: %s", answer) + } + spec := <-reached + if spec.NamedModel != "test/model" || spec.NamedEffort != "cheap" || spec.StepsPerTask != 17 || spec.NoProgress != 9 { + t.Fatalf("named controls = %+v", spec) + } + if !strings.Contains(spec.Brief, "a working export") || !strings.Contains(spec.Brief, "the export regression passes") || !strings.Contains(spec.Brief, "go test ./export") { + t.Fatalf("instruction lost its contract: %s", spec.Brief) + } + if local.didRun() { + t.Fatal("local model ran the remote task") + } + close(release) + waitRemoteRun(t, a) + if local.lands() != 1 { + t.Fatalf("local landing count = %d", local.lands()) + } +} + +// Contract 7 uses the stable preference only when the proposal omits placement. +func TestTheRunTasksOnSettingSendsATaskProposedWithoutAComputer(t *testing.T) { + reached := make(chan struct{}, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + reached <- struct{}{} + _ = spec.Store.CompleteRoot("done there") + return RunSummary{Outcome: "done"} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + }) + args := taskOnOtherComputer() + args.Computer = "" + answer, refused := approveRemoteProposal(t, a, args) + if refused { + t.Fatalf("proposal: %s", answer) + } + select { + case <-reached: + case <-time.After(5 * time.Second): + t.Fatal("setting did not send the task") + } + waitRemoteRun(t, a) + if local.didRun() { + t.Fatal("setting ran locally") + } +} + +// Contract 7 keeps quick work beside its caller even with a remote preference. +func TestAQuickTaskRunsHereEvenWhenTheSettingNamesAnotherComputer(t *testing.T) { + a, _ := remoteTaskAgent(t, &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + t.Error("quick work reached the other computer") + return RunSummary{} + }}, func(c *Config) { _ = config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef") }) + // The quick tool has its ordinary legacy-node road even when placement + // preferences name another computer; it never reaches a remote RunEngine. + t.Setenv("CODEAF_TASK_BELT", "off") + answer, refused, quickErr := a.startQuickTask(context.Background(), json.RawMessage(`{"line":"Inspect the project","items":["Inspect the project"]}`)) + if quickErr != nil || refused { + t.Fatalf("quick tool: %s, %v", answer, quickErr) + } + node := a.graph().node(1) + waitDoneNode(t, node) + if node.spec.kind() != TaskKindQuick { + t.Fatal("quick tool did not create quick work") + } + p, err := a.resolveTaskComputer(context.Background(), "", taskStand{mode: TaskModeInPlace}, TaskKindQuick, false) + if err != nil || p.computer != nil || p.sentence != "" { + t.Fatalf("quick placement = %+v, %v", p, err) + } + p, err = a.resolveTaskComputer(context.Background(), "work computer", taskStand{mode: TaskModeInPlace}, TaskKindQuick, false) + if err != nil || p.computer != nil || !strings.Contains(p.sentence, "a quick task runs here") { + t.Fatalf("named quick placement = %+v, %v", p, err) + } +} + +// Contract 5 mirrors one paid call, skipping replay and the final-total fold. +func TestRemoteSpendLandsOnThisLedgerOnce(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + charge := RunCharge{Model: "remote/model", TokensIn: 40, TokensOut: 8, USD: .25} + var current uint64 + calls := 0 + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + calls++ + alive() + for _, seq := range []uint64{1, 1, 2} { + current = seq + if err := deliver(seq); err != nil { + return err + } + } + return ErrTaskWatchEnded + } + err := FollowTaskWatch(ctx, nil, 0, watch, func(seq uint64) error { + if current == 1 { + return spec.OnRemoteCharge(seq, charge, "worker") + } + return nil + }, func(bool) {}) + if err != nil { + t.Error(err) + } + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done", USD: .25} + }} + a, _ := remoteTaskAgent(t, remote, func(c *Config) { c.usageLedger = ledger }) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + FlushUsage() + lines, err := ReadUsage(ledger, time.Time{}) + if err != nil { + t.Fatal(err) + } + if len(lines) != 1 || lines[0].USD != .25 || lines[0].Computer != "work computer" || lines[0].Task != "1" || lines[0].Session != a.journalID() { + t.Fatalf("ledger = %+v", lines) + } + if got := a.Usage(); got.CostUSD != .25 || got.Calls != 1 || got.Input != 40 { + t.Fatalf("usage = %+v", got) + } +} + +// Contract 7 keeps an explicit in-place request here even when repository +// isolation preserves a local branch for that request. +func TestAnExplicitInPlaceTaskRunsHereEvenWhenAComputerWasNamed(t *testing.T) { + elsewhere := make(chan struct{}, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + elsewhere <- struct{}{} + _ = spec.Store.CompleteRoot("wrong computer") + return RunSummary{Outcome: "done"} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + }) + args := taskOnOtherComputer() + args.Where = "in place" + args.Deliverable = "a corrected " + filepath.Join(a.config.Workspace, "output.txt") + args.Brief = "Write the corrected output in " + filepath.Join(a.config.Workspace, "output.txt") + receipt, refused := approveRemoteProposal(t, a, args) + if refused { + t.Fatalf("proposal refused: %s", receipt) + } + select { + case <-elsewhere: + t.Fatal("explicit in-place task went to another computer") + case <-local.entered: + case <-time.After(5 * time.Second): + t.Fatal("local in-place task never started") + } + if !strings.Contains(receipt, "runs here") { + t.Fatalf("named computer had no local-placement explanation: %s", receipt) + } + endBeltRun(t, a, local) +} diff --git a/internal/session/task_index.go b/internal/session/task_index.go index 0e719b1310..3fc009c484 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -118,6 +118,9 @@ const ( // and the cost, because those are the three questions asked about work that is // already over. type TaskIndexEntry struct { + Computer string `json:"computer,omitempty"` + Unreachable bool `json:"unreachable,omitempty"` + RanElsewhere bool `json:"ran_elsewhere,omitempty"` // ID is the node's id inside the session that ran it, decimal. It is NOT // unique across the file — ids restart with every conversation — which is // why SessionID sits beside it and why the pair is what identifies a row. @@ -707,6 +710,13 @@ func (a *Agent) recordTaskIndex(node *TaskNode) { // regular task reaches it through [Agent.recordTaskIndex]; an adaptive run has // no TaskNode, so its completion seam supplies the same citation directly. func (a *Agent) recordTaskIndexEntry(entry TaskIndexEntry) { + select { + case <-a.closeDone: + // A goroutine that outlived Close must not create index or lock files + // after the session has handed its directories back to the caller. + return + default: + } path := a.config.taskIndexFile() if path == "" || strings.TrimSpace(entry.Title) == "" { return diff --git a/internal/session/task_names_r8_test.go b/internal/session/task_names_r8_test.go new file mode 100644 index 0000000000..4949452429 --- /dev/null +++ b/internal/session/task_names_r8_test.go @@ -0,0 +1,45 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +type sameNameTaskComputers struct{ testTaskComputers } + +func (sameNameTaskComputers) List(context.Context) ([]TaskComputer, error) { + return []TaskComputer{ + {Device: "dev_0123456789abcdef0123456789ab1111", Name: "mac mini", CanTakeTasks: true}, + {Device: "dev_0123456789abcdef0123456789ab2222", Name: "mac mini", CanTakeTasks: true}, + }, nil +} + +// R7-12 and contract 6 require an ambiguous tool name to list distinct choices. +func TestTwoComputersWithOneNameAreToldApart(t *testing.T) { + taskComputerChoiceHome(t) + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + c.TaskComputers = sameNameTaskComputers{} + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789ab1111"); err != nil { + t.Fatal(err) + } + }) + args := taskOnOtherComputer() + args.Computer = "mac mini" + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + text, bad, err := a.stageTask(ctx, raw).Commit(ctx) + if err != nil || !bad || !strings.Contains(text, "more than one") || !strings.Contains(text, "mac mini #1111") || !strings.Contains(text, "mac mini #2222") { + t.Fatalf("ambiguous proposal: %q, %v, %v", text, bad, err) + } + p, err := a.resolveTaskComputer(context.Background(), "mac mini #2222", taskStand{mode: TaskModeWorktree}, "", false) + if err != nil || p.computer == nil || p.computer.Device != "dev_0123456789abcdef0123456789ab2222" { + t.Fatalf("second computer = %+v, %v", p.computer, err) + } +} diff --git a/internal/session/task_person.go b/internal/session/task_person.go index d64042ad8e..432485e1ad 100644 --- a/internal/session/task_person.go +++ b/internal/session/task_person.go @@ -88,7 +88,7 @@ func (a *Agent) StartTask(ctx context.Context, brief string, solo bool) (uint64, // A TYPED TASK IS ITS OWN SOURCE: the command's words, captured here once so // both roads below record the same directive (task_source.go). ctx = withTaskSource(ctx, a.directiveSource(brief)) - if bashBeltAsked() { + if bashBeltAsked() || a.taskComputerPreference() { return a.startTaskRun(ctx, brief, solo, "") } return a.startTaskLegacy(ctx, brief, solo) diff --git a/internal/session/task_placement_row_test.go b/internal/session/task_placement_row_test.go new file mode 100644 index 0000000000..50142d4b2c --- /dev/null +++ b/internal/session/task_placement_row_test.go @@ -0,0 +1,20 @@ +package session + +import "testing" + +// R9.1 requires the finished A-side row to carry the placement words after +// the worker's phase lane has been replaced by its final landing notice. +func TestTheFinishedTaskRowKeepsTheExecutorWords(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + for _, tc := range []struct { + name string + failed bool + want string + }{{"beta", false, "ran on beta"}, {"beta", true, ""}, {"", false, ""}} { + run := &beltRun{row: 7, title: "Create hello.txt", remoteEngine: &scriptedTaskEngine{}, remote: &TaskRemoteRecord{Executor: TaskExecutor{Name: tc.name}, Finished: true, StartFailed: tc.failed}} + notice := a.beltRunNotice(run, RunSummary{Outcome: beltRunOutcomeDone}, RunLanding{}) + if notice.Doing != tc.want { + t.Errorf("executor %q, failed %v: finished row lost its placement words: got %q, want %q", tc.name, tc.failed, notice.Doing, tc.want) + } + } +} diff --git a/internal/session/task_records_r8_test.go b/internal/session/task_records_r8_test.go new file mode 100644 index 0000000000..6e010028d9 --- /dev/null +++ b/internal/session/task_records_r8_test.go @@ -0,0 +1,176 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// R7-11 and contract 2 preserve lifecycle records without inventing tool steps. +func TestMirroredBeginAndEndAreNotDrawnAsSteps(t *testing.T) { + taskComputerChoiceHome(t) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + if err := spec.SaveRemote(r, true); err != nil { + t.Error(err) + } + for i, raw := range []string{`{"kind":"begin","brief":"repair"}`, `{"kind":"step","step":1,"command":"inspect"}`, `{"kind":"notes","observation":"progress"}`, `{"kind":"end","outcome":"done"}`} { + if err := spec.OnRemoteStep(uint64(i+1), json.RawMessage(raw)); err != nil { + t.Error(err) + } + } + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done"} + }} + a, _ := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + page, ok := a.PlanTaskPage("t-1") + if !ok || len(page.Steps) != 1 || page.Steps[0].Command != "inspect" { + t.Fatalf("drawn steps = %+v", page.Steps) + } + raw, err := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(a.graph().planPath()), "1"), "trajectory.jsonl")) + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"begin", "notes", "end"} { + var found bool + for _, line := range bytes.Split(raw, []byte{'\n'}) { + var record struct { + Kind string `json:"kind"` + } + _ = json.Unmarshal(line, &record) + found = found || record.Kind == kind + } + if !found { + t.Errorf("lost %s kind in %s", kind, raw) + } + } +} + +type ackOwedEngine struct { + scriptedTaskEngine + ack chan TaskRemoteRecord +} + +func (e *ackOwedEngine) Acknowledge(_ context.Context, r TaskRemoteRecord) error { + e.ack <- r + return nil +} + +// R7-13 and contract 10 recover the sidecar obligation even when the checkpoint predates it. +func TestAnAckOwedWhenTheConversationCrashedIsSentOnReopen(t *testing.T) { + taskComputerChoiceHome(t) + ready := make(chan struct{}) + remote := &ackOwedEngine{ack: make(chan TaskRemoteRecord, 2)} + remote.start = func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + } + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + a := open() + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + _ = a.Close() + waitRemoteRun(t, a) + store, err := openRemoteTaskPlan(a.graph(), 1) + if err != nil { + t.Fatal(err) + } + row, _ := runRowOf(a.graph(), 1) + r := readRemoteRecord(store, row.Remote) + r.Settled = true + r.Finished = true + r.AckPending = true + if err := writeRemoteRecord(store, *r); err != nil { + t.Fatal(err) + } + _ = store.Close() + b := open() + defer b.Close() + select { + case got := <-remote.ack: + if got.Attempt != r.Attempt { + t.Fatalf("wrong ack: %+v", got) + } + case <-time.After(time.Second): + t.Fatal("sidecar acknowledgement was not sent on reopen") + } +} + +// R7-10 and contract 11 give the local worker a clean record of effects in its own tree. +func TestRunItHereStartsWithAFreshRecord(t *testing.T) { + taskComputerChoiceHome(t) + ready := make(chan struct{}) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.Unreachable = true + if err := spec.SaveRemote(r, true); err != nil { + t.Error(err) + } + if err := spec.OnRemoteStep(1, json.RawMessage(`{"kind":"step","step":1,"command":"remote-only effect"}`)); err != nil { + t.Error(err) + } + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := a.RunTaskHere(context.Background(), 1); err != nil { + t.Fatal(err) + } + <-local.entered + defer endBeltRun(t, a, local) + local.mu.Lock() + dir := plandb.TaskDir(filepath.Dir(local.spec.Store.Path()), local.spec.Store.RootID()) + local.mu.Unlock() + raw, err := os.ReadFile(filepath.Join(dir, "trajectory.jsonl")) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if len(raw) != 0 { + t.Fatalf("local worker inherited remote effects: %s", raw) + } + archives, err := filepath.Glob(filepath.Join(dir, "trajectory-*.jsonl")) + if err != nil { + t.Fatal(err) + } + if len(archives) != 1 { + t.Fatalf("abandoned record not archived: %v", archives) + } + saved, err := os.ReadFile(archives[0]) + if err != nil || !strings.Contains(string(saved), "remote-only effect") { + t.Fatalf("remote record lost: %s, %v", saved, err) + } +} diff --git a/internal/session/task_remote_control.go b/internal/session/task_remote_control.go new file mode 100644 index 0000000000..b73a8c231e --- /dev/null +++ b/internal/session/task_remote_control.go @@ -0,0 +1,301 @@ +package session + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// continueRemoteRun adopts the original plan and copy. A terminal plan may +// still need its received change landed after a crash, so no local supervisor +// is asked to resume it and no start can be issued by this road. +func (a *Agent) continueRemoteRun(ctx context.Context, kept TaskNotice) (string, error) { + if a.config.TaskComputers == nil { + return "", errors.New(taskComputersUnavailable) + } + a.lockBeltStart() + defer a.beltStartMu.Unlock() + a.beltMu.Lock() + live := a.beltRun + a.beltMu.Unlock() + if live != nil { + return "", fmt.Errorf("%s is already running", taskStopName(live.row, live.title)) + } + g := a.graph() + store, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + return "", err + } + record := readRemoteRecord(store, kept.Remote) + if record == nil || record.Abandoned || record.StartFailed || record.Settled { + store.Close() + return "", errors.New("this task has no remote work to follow") + } + if store.RootID() != strconv.FormatUint(kept.ID, 10) { + store.Close() + return "", errors.New("this task's plan is no longer the current run") + } + engine, err := a.taskExecutorEngine(ctx, record.Executor) + if err != nil { + store.Close() + return "", err + } + tree, err := reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) + if err != nil { + store.Close() + return "", err + } + runCtx, cut := context.WithCancel(a.rootContext()) + run := &beltRun{store: store, root: store.RootID(), row: kept.ID, title: kept.Title, workspace: tree.dir, ground: tree.ground, tree: tree, stand: taskStand{dir: tree.ground, mode: tree.mode}, cut: cut, born: kept.StartedAt, over: make(chan struct{}), remote: record, remoteEngine: engine, remoteResume: true} + // The plan state's handle is the one every page already reads. + run.plan = &planState{path: g.planPath(), chat: g.planChat()} + if root := store.Task(store.RootID()); root != nil && root.Status == plandb.StatusCancelled { + run.stopped = true + run.stopReason = root.Error + } + a.installBeltRun(g, run) + if run.stopped { + a.stopRemoteRun(run) + } + kept.State = TaskRunning + kept.Remote = cloneTaskRemote(record) + kept.Unreachable = record.Unreachable + a.publishRunRow(g, kept) + go a.driveBeltRun(runCtx, engine, run, a.beltRunSpec(run, "")) + return "following " + taskStopName(kept.ID, kept.Title) + " on " + record.Executor.Name, nil +} + +// RunTaskHere fences the old attempt before touching its network or starting +// local work. The copy comparison and result restore share remoteMu, so a late +// result cannot pass between the comparison and the abandonment write. +func (a *Agent) RunTaskHere(ctx context.Context, row uint64) (string, error) { + epoch, err := a.beltAdmissionEpoch() + if err != nil { + return "", err + } + if a.config.TaskComputers == nil || chatRunEngine == nil { + return "", errRunRoadUnavailable + } + a.lockBeltStart() + defer a.beltStartMu.Unlock() + if err := a.checkBeltAdmission(epoch); err != nil { + return "", err + } + g := a.graph() + replacement, err := a.prepareTaskHere(ctx, g, row) + if err != nil { + return "", err + } + // Cleanup is best effort; the durable abandonment already excludes every late result. + go func(record TaskRemoteRecord, engine TaskRunEngine) { + stopCtx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) + defer cancel() + _ = engine.Abandon(stopCtx, record) + }(*replacement.record, replacement.engine) + if err := replacement.join(ctx); err != nil { + return "", err + } + a.beltMu.Lock() + busy := a.beltRun != nil + a.beltMu.Unlock() + if busy { + return "", errors.New("another task started before this task could run here") + } + return a.startTaskHere(g, replacement) +} + +type taskHereReplacement struct { + kept TaskNotice + live *beltRun + store *plandb.Store + record *TaskRemoteRecord + engine TaskRunEngine + tree taskTree + brief string +} + +// Preparation checks the untouched copy and durably fences the old attempt under its copy lock. +func (a *Agent) prepareTaskHere(ctx context.Context, g *TaskGraph, row uint64) (*taskHereReplacement, error) { + kept, found := runRowOf(g, row) + if !found || kept.Remote == nil || kept.Remote.Abandoned { + return nil, errors.New("this task is not waiting on another computer") + } + a.beltMu.Lock() + live := a.beltRun + a.beltMu.Unlock() + if live != nil && live.row != row { + return nil, errors.New("another task is already running in this conversation") + } + r := &taskHereReplacement{kept: kept, live: live} + if err := r.openRecord(ctx, a, g); err != nil { + r.releaseRecord() + return nil, err + } + defer r.releaseRecord() + if r.store.RootID() != strconv.FormatUint(row, 10) { + return nil, errors.New("this task's saved plan does not match its number") + } + if !r.mayStartHere() { + return nil, errors.New("the other computer is still answering; stop this task before running it here") + } + var err error + r.tree, err = reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) + if err != nil { + return nil, err + } + if err := r.engine.CheckCopy(ctx, r.tree.dir, r.record.Fingerprint); err != nil { + return nil, err + } + r.record.Abandoned, r.record.AckPending = true, true + if err := writeRemoteRecord(r.store, *r.record); err != nil { + return nil, err + } + if live != nil { + live.remote = cloneTaskRemote(r.record) + } + r.kept.Remote = cloneTaskRemote(r.record) + r.kept.Computer = "" + r.kept.Unreachable = false + a.publishRunRow(g, r.kept) + if root := r.store.Task(r.store.RootID()); root != nil { + r.brief = root.Description + } + return r, nil +} + +// A live record shares the watcher's fence; an archived record owns a separate store handle. +func (r *taskHereReplacement) openRecord(ctx context.Context, a *Agent, g *TaskGraph) error { + if r.live != nil { + r.live.remoteMu.Lock() + r.record = cloneTaskRemote(r.live.remote) + r.engine = r.live.remoteEngine + r.store = r.live.store + return nil + } + var err error + r.store, err = openRemoteTaskPlan(g, r.kept.ID) + if err != nil { + return err + } + r.record = readRemoteRecord(r.store, r.kept.Remote) + if r.record == nil { + return errors.New("this task is not waiting on another computer") + } + r.engine, err = a.taskExecutorEngine(ctx, r.record.Executor) + return err +} +func (r *taskHereReplacement) releaseRecord() { + if r.live != nil { + r.live.remoteMu.Unlock() + } else if r.store != nil { + _ = r.store.Close() + } +} +func (r *taskHereReplacement) mayStartHere() bool { + return r.record != nil && r.engine != nil && (r.record.StartFailed || (r.record.Unreachable && !r.record.Finished) || r.kept.State == TaskInterrupted) +} + +// The start lock remains held while joining, so another task cannot retire this copy first. +func (r *taskHereReplacement) join(ctx context.Context) error { + if r.live == nil { + return nil + } + if r.live.cut != nil { + r.live.cut() + } + select { + case <-r.live.over: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// The replacement adopts the ready copy and begins with a fresh local trajectory. +func (a *Agent) startTaskHere(g *TaskGraph, r *taskHereReplacement) (string, error) { + row := r.kept.ID + if err := archiveRemoteTrajectory(g.planPath(), row); err != nil { + return "", err + } + plan, store, err := a.openBeltRunStore(g, g.planPath(), strconv.FormatUint(row, 10), r.kept.Title, r.brief, false) + if err != nil { + return "", err + } + runCtx, cut := context.WithCancel(a.rootContext()) + run := &beltRun{plan: plan, store: store, root: store.RootID(), row: row, title: r.kept.Title, worker: r.record.NamedModel, workspace: r.tree.dir, ground: r.tree.ground, tree: r.tree, stand: taskStand{dir: r.tree.ground, mode: r.tree.mode}, cut: cut, born: a.taskClockNow(), over: make(chan struct{})} + a.installBeltRun(g, run) + kept := r.kept + kept.State, kept.Report, kept.Ending, kept.Stopped = TaskRunning, "", "", false + kept.StartedAt, kept.EndedAt = run.born, time.Time{} + kept.Computer, kept.Unreachable, kept.RanElsewhere = "", false, false + a.publishRunRow(g, kept) + go a.driveBeltRun(runCtx, chatRunEngine, run, a.beltRunSpec(run, r.brief)) + return "running " + taskStopName(row, kept.Title) + " here", nil +} + +func (a *Agent) stopRemoteRun(run *beltRun) { + // A pending task still owns only local preparation; no remote receipt is owed yet. + a.beltMu.Lock() + pending, cut := run.pending, run.cut + a.beltMu.Unlock() + if pending && cut != nil { + cut() + return + } + run.remoteMu.Lock() + record := cloneTaskRemote(run.remote) + run.remoteMu.Unlock() + if record == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) + defer cancel() + _ = run.remoteEngine.Stop(ctx, *record) + }() +} + +// A previous refused run can have its plan archived by later work. Read the +// requested task's plan rather than borrowing the newest task's instruction. +func openRemoteTaskPlan(g *TaskGraph, row uint64) (*plandb.Store, error) { + paths := append([]string{g.planPath()}, planArchivePaths(g.planPath())...) + for _, path := range paths { + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + continue + } + if store.RootID() == strconv.FormatUint(row, 10) { + return store, nil + } + _ = store.Close() + } + return nil, errors.New("this task's saved plan is no longer available") +} + +// A local replacement must not resume effects that happened only in the abandoned copy. +func archiveRemoteTrajectory(plan string, row uint64) error { + path := filepath.Join(plandb.TaskDir(filepath.Dir(plan), strconv.FormatUint(row, 10)), "trajectory.jsonl") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + archive, err := os.CreateTemp(filepath.Dir(path), "trajectory-abandoned-*.jsonl") + if err != nil { + return err + } + if err := archive.Close(); err != nil { + return err + } + if err := os.Rename(path, archive.Name()); err != nil { + _ = os.Remove(archive.Name()) + return err + } + return nil +} diff --git a/internal/session/task_remote_money.go b/internal/session/task_remote_money.go new file mode 100644 index 0000000000..81b222f143 --- /dev/null +++ b/internal/session/task_remote_money.go @@ -0,0 +1,166 @@ +package session + +import ( + "bufio" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sync" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/trace" +) + +type remoteUsage struct { + Task, Computer, Event string + Seat Seat +} +type remoteLedgerCursor struct { + offset int64 + seen map[string]bool +} + +var remoteUsageMemo = struct { + sync.Mutex + ledgers map[string]*remoteLedgerCursor +}{ledgers: map[string]*remoteLedgerCursor{}} + +// Mirrored calls are paid elsewhere. Refreshing the appended tail under a +// process-shared lock prevents a second chat owner from repeating a paid call. +func appendRemoteUsage(path string, line UsageLine) error { + remoteUsageMemo.Lock() + defer remoteUsageMemo.Unlock() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + lock, err := os.OpenFile(path+".remote-lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer lock.Close() + if err := filelock.Lock(lock, true, false); err != nil { + return err + } + defer filelock.Unlock(lock) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600) + if err != nil { + return err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return err + } + memo := remoteUsageMemo.ledgers[path] + if memo == nil || info.Size() < memo.offset { + memo = &remoteLedgerCursor{seen: map[string]bool{}} + remoteUsageMemo.ledgers[path] = memo + } + newline, err := memo.readTail(f) + if err != nil { + return err + } + if memo.seen[line.RemoteEvent] { + return nil + } + raw, err := json.Marshal(line) + if err != nil { + return err + } + if newline { + raw = append([]byte{'\n'}, raw...) + } + if _, err = f.Write(append(raw, '\n')); err != nil { + return err + } + if err = f.Sync(); err != nil { + return err + } + memo.seen[line.RemoteEvent] = true + // Leave offset at the last read boundary: another process or the ordinary + // asynchronous ledger writer may have appended while this call was written. + return nil +} + +// readTail recognizes complete EOF objects without turning a replay into another charge. +// A partial object is rolled back to the last complete boundary before any new write. +func (memo *remoteLedgerCursor) readTail(f *os.File) (bool, error) { + if _, err := f.Seek(memo.offset, io.SeekStart); err != nil { + return false, err + } + reader := bufio.NewReader(f) + for { + raw, err := reader.ReadBytes('\n') + if err != nil && err != io.EOF { + return false, err + } + if len(raw) == 0 { + return false, nil + } + var old UsageLine + readable := json.Unmarshal(raw, &old) == nil + if readable && old.RemoteEvent != "" { + memo.seen[old.RemoteEvent] = true + } + if err == io.EOF { + if readable { + return true, nil + } + return false, f.Truncate(memo.offset) + } + memo.offset += int64(len(raw)) + } +} + +// The transcript records the same event identity before changing live totals. +// A crash on either side of that write replays exactly one charge on reopen. +func (a *Agent) foldRemoteCharge(event string, charge RunCharge) error { + if charge.TokensIn == 0 && charge.TokensOut == 0 && charge.USD == 0 { + return nil + } + used := Usage{Input: charge.TokensIn, Output: charge.TokensOut, CacheRead: charge.Cached, Calls: 1, CostUSD: charge.USD} + if a.file != nil { + added, err := a.file.appendRemoteCharge(event, charge.Model, used) + if err != nil { + return err + } + if !added { + return nil + } + } + a.bank(bankedCall{used: used, model: charge.Model, late: true}) + return nil +} +func (s *sessionFile) appendRemoteCharge(event, model string, used Usage) (bool, error) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return false, os.ErrClosed + } + if s.remoteSeen == nil { + s.remoteSeen = map[string]bool{} + } + if s.remoteSeen[event] { + s.mu.Unlock() + return false, nil + } + raw, err := json.Marshal(sessionEntry{Type: "usage", Timestamp: stamp(), Usage: &journalUsage{RemoteEvent: event, Model: model, Input: used.Input, Output: used.Output, CacheRead: used.CacheRead, CostUSD: used.CostUSD, Calls: used.Calls, Aux: true}}) + if err == nil { + _, err = s.file.Write(append(trace.ScrubRegistered(raw), '\n')) + } + if err == nil { + err = s.file.Sync() + } + if err != nil { + s.mu.Unlock() + return false, errors.New("the task's remote spending could not be saved: " + err.Error()) + } + s.remoteSeen[event] = true + s.mu.Unlock() + if s.onWrite != nil { + s.onWrite() + } + return true, nil +} diff --git a/internal/session/task_remote_record.go b/internal/session/task_remote_record.go new file mode 100644 index 0000000000..341e3baa44 --- /dev/null +++ b/internal/session/task_remote_record.go @@ -0,0 +1,412 @@ +package session + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +var ErrTaskAttemptAbandoned = errors.New("this task attempt was abandoned") + +// Each plan's sidecar is authoritative for its remote cursor and fencing. The +// checkpoint projects it onto run rows for readers that do not open the plan. +func taskRemotePath(store *plandb.Store) string { + return filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "remote.json") +} +func writeRemoteRecord(store *plandb.Store, record TaskRemoteRecord) error { + raw, err := json.Marshal(record) + if err != nil { + return err + } + return atomicRemoteFile(taskRemotePath(store), append(raw, '\n')) +} +func atomicRemoteFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".remote-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if err = file.Chmod(0600); err == nil { + _, err = file.Write(data) + } + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + if err := os.Rename(file.Name(), path); err != nil { + return err + } + directory, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} +func readRemoteRecord(store *plandb.Store, fallback *TaskRemoteRecord) *TaskRemoteRecord { + // A local row is authoritative about being local. A stray task-door file + // must not change recovery or cause any new I/O for an ordinary run. + if fallback == nil || fallback.Executor.Device == "" { + return nil + } + raw, err := os.ReadFile(taskRemotePath(store)) + if err != nil { + return cloneTaskRemote(fallback) + } + var r TaskRemoteRecord + if json.Unmarshal(raw, &r) != nil || r.Executor.Device == "" { + return cloneTaskRemote(fallback) + } + return &r +} + +func (a *Agent) placeBeltRun(ctx context.Context, engine RunEngine, run *beltRun) { + run.placement = taskPlacementOf(ctx) + wish := crewWishOf(ctx) + if run.placement.effort == "" { + run.placement.effort = string(wish.effort) + } + if run.placement.model == "" { + run.placement.model = wish.worker + } + if run.placement.computer == nil { + return + } + run.remoteEngine, _ = engine.(TaskRunEngine) + c := run.placement.computer + run.remote = &TaskRemoteRecord{Executor: TaskExecutor{Device: c.Device, Name: c.Name}, NamedModel: run.placement.model, NamedEffort: run.placement.effort, MaxSteps: run.placement.maxSteps, NoProgress: run.placement.noProgress} +} + +// remoteBeltSpec hands the adapter durable writes and local book ownership. +// It refuses every late callback after abandonment under the same copy lock. +func (a *Agent) remoteBeltSpec(run *beltRun, spec RunSpec) RunSpec { + if run.remoteEngine == nil { + return spec + } + run.remoteMu.Lock() + spec.Remote = cloneTaskRemote(run.remote) + run.remoteMu.Unlock() + spec.TaskNumber = run.row + spec.NamedModel, spec.NamedEffort = spec.Remote.NamedModel, spec.Remote.NamedEffort + if spec.Remote.MaxSteps > 0 { + spec.StepsPerTask = spec.Remote.MaxSteps + } + spec.NoProgress = spec.Remote.NoProgress + spec.SaveRemote = func(record TaskRemoteRecord, force bool) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote != nil && run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + previous := run.remote + run.remote = cloneTaskRemote(&record) + if !force && time.Since(run.remoteSaved) < time.Second { + return nil + } + if err := writeRemoteRecord(run.store, record); err != nil { + run.remote = previous + return err + } + run.remoteSaved = time.Now() + a.publishRemoteRow(run) + return nil + } + spec.WithRemoteCopy = func(apply func() error) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + return apply() + } + steps := &remoteStepMirror{} + spec.OnRemoteStep = func(seq uint64, step json.RawMessage) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + return steps.append(run.store, run.remote.Attempt, seq, step) + } + spec.OnRemoteCharge = func(seq uint64, charge RunCharge, seat string) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + if err := a.recordUsageLine(bankedCall{model: charge.Model, used: Usage{Input: charge.TokensIn, Output: charge.TokensOut, CacheRead: charge.Cached, Calls: 1, CostUSD: charge.USD}, remote: &remoteUsage{Task: strconv.FormatUint(run.row, 10), Computer: run.remote.Executor.Name, Event: fmt.Sprintf("%s:%d", run.remote.Attempt, seq), Seat: Seat(seat)}}); err != nil { + return err + } + return a.foldRemoteCharge(fmt.Sprintf("%s:%d", run.remote.Attempt, seq), charge) + + } + spec.RemoteStopped = func() bool { stopped, _ := a.beltRunStopped(run); return stopped } + spec.OnRemotePhase = func(words string) { + a.emitTaskPhase(TaskPhaseNotice{ID: run.row, Phase: TaskPhaseWorking, Text: words}) + } + return spec +} + +// publishRemoteRow is called under remoteMu; the published value is a copy so +// a watcher cannot mutate a checkpoint while another writer serializes it. +func (a *Agent) publishRemoteRow(run *beltRun) { + g := a.graph() + if g == nil { + return + } + kept, ok := runRowOf(g, run.row) + if !ok { + return + } + kept.Remote = cloneTaskRemote(run.remote) + kept.Computer = run.remote.Executor.Name + kept.Unreachable = run.remote.Unreachable + kept.CostUSD = max(kept.CostUSD, run.remote.USD) + kept.RanElsewhere = run.remote.Finished && !run.remote.StartFailed + if run.remote.Unreachable { + kept.Doing = TaskComputerStoppedAnswering(kept.Computer) + } else if run.remote.StartFailed { + kept.Doing = kept.Report + } else if run.remote.Finished { + kept.Doing = TaskRanOn(kept.Computer) + } else { + kept.Doing = TaskRunningOn(kept.Computer) + } + a.publishRunRow(g, kept) +} + +// mirrorRemoteStep keeps remote output paths off local file actions. The +// remote sequence on each line makes replay after a checkpoint crash harmless. +type remoteStepMirror struct { + seen map[uint64]bool + readBytes, writeBytes int64 +} + +func (mirror *remoteStepMirror) append(store *plandb.Store, attempt string, seq uint64, raw json.RawMessage) error { + path := filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "trajectory.jsonl") + if mirror.seen == nil { + if err := mirror.readPrefix(path, attempt); err != nil { + return err + } + } + if mirror.seen[seq] { + return nil + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + delete(fields, "full_output") + delete(fields, "children") + fields["remote_attempt"], _ = json.Marshal(attempt) + fields["remote_seq"], _ = json.Marshal(seq) + line, err := json.Marshal(fields) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return err + } + n, err := file.Write(append(line, '\n')) + mirror.writeBytes += int64(n) + if err == nil { + err = file.Sync() + } + closed := file.Close() + if err == nil { + err = closed + } + if err == nil { + mirror.seen[seq] = true + } + return err +} + +// readPrefix loads replay identities once; ordinary appends then do only new work. +func (mirror *remoteStepMirror) readPrefix(path, attempt string) error { + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + mirror.readBytes += int64(len(existing)) + // An interrupted append cannot swallow the next complete step. + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + end := bytes.LastIndexByte(existing, '\n') + 1 + if err := os.Truncate(path, int64(end)); err != nil { + return err + } + existing = existing[:end] + } + mirror.seen = map[uint64]bool{} + scan := bufio.NewScanner(bytes.NewReader(existing)) + scan.Buffer(make([]byte, 4096), 1<<20) + for scan.Scan() { + var line struct { + Attempt string `json:"remote_attempt"` + Seq uint64 `json:"remote_seq"` + } + if json.Unmarshal(scan.Bytes(), &line) == nil && line.Attempt == attempt { + mirror.seen[line.Seq] = true + } + } + if err := scan.Err(); err != nil { + return err + } + + return nil +} + +// ackRemoteRun follows local settlement, so B cannot discard an unreceived +// change. A failed acknowledgement remains durable for the next reopen. +func (a *Agent) ackRemoteRun(run *beltRun) { + run.remoteMu.Lock() + if run.remote == nil || run.remote.Abandoned || run.remote.StartFailed || !run.remote.Finished { + run.remoteMu.Unlock() + return + } + record := *cloneTaskRemote(run.remote) + record.Settled, record.AckPending = true, true + if err := writeRemoteRecord(run.store, record); err != nil { + run.remoteMu.Unlock() + return + } + run.remote = &record + a.publishRemoteRow(run) + run.remoteMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) + defer cancel() + if run.remoteEngine.Acknowledge(ctx, record) != nil { + return + } + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + run.remote.AckPending = false + _ = writeRemoteRecord(run.store, *run.remote) + a.publishRemoteRow(run) +} + +// retryRemoteAck updates only the attempt metadata of the current row. Network +// latency must not let an old receipt overwrite a newer local task ending. +func (a *Agent) retryRemoteAck(g *TaskGraph, store *plandb.Store, kept TaskNotice) { + record := *cloneTaskRemote(kept.Remote) + a.remoteAckJobs.Add(1) + go func() { + defer a.remoteAckJobs.Done() + ctx, cancel := context.WithTimeout(a.rootContext(), TaskComputerControlWithin) + defer cancel() + engine, err := a.taskExecutorEngine(ctx, record.Executor) + if err != nil { + return + } + if record.Abandoned { + err = engine.Abandon(ctx, record) + } else { + err = engine.Acknowledge(ctx, record) + } + if err != nil { + return + } + record.AckPending = false + _ = writeRemoteRecord(store, record) + // Only metadata changes here. Publishing an earlier full row after a + // concurrent local completion could put its state back to running. + g.mu.Lock() + rows := g.runs[kept.ID] + for i := range rows { + if rows[i].ID == kept.ID && rows[i].Remote != nil && rows[i].Remote.Attempt == record.Attempt { + rows[i].Remote = cloneTaskRemote(&record) + } + } + g.runs[kept.ID] = rows + g.mu.Unlock() + g.checkpoint() + }() +} + +// Settled attempts can outlive the current plan after a lost acknowledgement. +// Reopening retries those saved obligations as well as the newest run's watch. +func (a *Agent) retrySavedRemoteAcks(g *TaskGraph) { + if a.config.TaskComputers == nil { + return + } + g.mu.Lock() + rows := g.runRowsLocked() + g.mu.Unlock() + for _, row := range rows { + if row.Remote == nil || row.Remote.Executor.Device == "" { + continue + } + store, err := openRemoteTaskPlan(g, row.ID) + if err != nil { + continue + } + record := readRemoteRecord(store, row.Remote) + if record != nil && record.AckPending { + row.Remote = record + a.retryRemoteAck(g, store, row) + } + _ = store.Close() + } +} + +func (run *beltRun) taskComputerName() string { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote != nil && !run.remote.Abandoned { + return run.remote.Executor.Name + } + return "" +} + +// Abandonment fences the result before the ordinary ending road is entered. +func (run *beltRun) remoteAbandoned() bool { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + return run.remote != nil && run.remote.Abandoned +} + +// A closing chat retains a remote watch that has not received its final receipt. +func (run *beltRun) remoteWaitingOnClose(closing bool) bool { + if !closing || run.remoteEngine == nil { + return false + } + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + return run.remote == nil || !run.remote.Finished +} + +// Preparation receipts survive cancellation before dispatch, including retained partial roots. +func (a *Agent) rememberPendingRemoteCopy(run *beltRun, tree taskTree) { + if run.remoteEngine == nil || tree.dir == "" && tree.workspaceSet == nil { + return + } + a.beltMu.Lock() + run.tree = tree + a.beltMu.Unlock() + if g := a.graph(); g != nil { + a.publishRunRow(g, TaskNotice{ID: run.row, Title: run.title, State: TaskQueued, + Copy: runCopyOf(tree), WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet)}) + } +} diff --git a/internal/session/task_remote_recovery_test.go b/internal/session/task_remote_recovery_test.go new file mode 100644 index 0000000000..57b3cf5168 --- /dev/null +++ b/internal/session/task_remote_recovery_test.go @@ -0,0 +1,704 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type taskTestClock struct { + mu sync.Mutex + now time.Time + waits []taskTestWait +} +type taskTestWait struct { + at time.Time + ch chan time.Time +} + +func newTaskTestClock() *taskTestClock { return &taskTestClock{now: time.Unix(100, 0)} } +func (c *taskTestClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *taskTestClock) After(d time.Duration) <-chan time.Time { + c.mu.Lock() + defer c.mu.Unlock() + ch := make(chan time.Time, 1) + c.waits = append(c.waits, taskTestWait{c.now.Add(d), ch}) + return ch +} +func (c *taskTestClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) + var kept []taskTestWait + for _, w := range c.waits { + if !w.at.After(c.now) { + w.ch <- c.now + } else { + kept = append(kept, w) + } + } + c.waits = kept +} + +// Contract 9 names an unanswered computer within sixty injected seconds, +// even while a watch dial itself is still waiting to return. +func TestAComputerThatStopsAnsweringIsSaidWithinAMinute(t *testing.T) { + clock := newTaskTestClock() + watching := make(chan struct{}) + missing := make(chan bool, 2) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + err := FollowTaskWatch(ctx, clock, 0, func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + close(watching) + <-ctx.Done() + return ctx.Err() + }, func(uint64) error { return nil }, func(no bool) { + r.Unreachable = no + _ = spec.SaveRemote(r, true) + spec.OnRemotePhase(TaskComputerStoppedAnswering("work computer")) + missing <- no + }) + return RunSummary{Outcome: "interrupted", Result: err.Error(), KeepCopy: true} + }} + a, _ := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-watching + // Each turn yields to the watch driver after waking its injected timer. + for i := 0; i < 60; i++ { + clock.advance(time.Second) + time.Sleep(time.Millisecond) + } + select { + case no := <-missing: + if !no { + t.Fatal("computer still shown reachable") + } + case <-time.After(time.Second): + t.Fatal("missing notice after sixty seconds") + } + notice, ok := runRowOf(a.graph(), 1) + if !ok || !notice.Unreachable || notice.Doing != "work computer stopped answering — it may still be working" { + t.Fatalf("task notice = %+v", notice) + } + _ = a.Close() + waitRemoteRun(t, a) +} + +// Contract 9 replays a cursor after a drop and never repeats an observed step. +func TestReconnectingReplaysMissedStepsWithoutDoubles(t *testing.T) { + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + var seq uint64 + tries := 0 + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + tries++ + alive() + frames := []uint64{1} + if tries == 2 { + if from != 1 { + t.Errorf("reconnected cursor = %d", from) + } + frames = []uint64{1, 2, 3} + } + for _, next := range frames { + seq = next + if err := deliver(next); err != nil { + return err + } + } + if tries == 1 { + return errors.New("network dropped") + } + return ErrTaskWatchEnded + } + err := FollowTaskWatch(ctx, nil, 0, watch, func(n uint64) error { + return spec.OnRemoteStep(n, []byte(fmt.Sprintf(`{"kind":"step","step":%d,"command":"step %d","observation":"bounded head","full_output":"/other/home/output"}`, seq, seq))) + }, func(bool) {}) + if err != nil { + t.Error(err) + } + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done"} + }} + a, _ := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + page, ok := a.PlanTaskPage("t-1") + if !ok { + t.Fatal("task page missing") + } + if len(page.Steps) != 3 { + t.Fatalf("task steps = %+v", page.Steps) + } + for i, step := range page.Steps { + if step.Command != fmt.Sprintf("step %d", i+1) || step.FullOutput != "" { + t.Fatalf("step %d = %+v", i, step) + } + } +} + +// Contract 10 closes and reopens the actual conversation record; recovery +// watches the saved attempt/cursor and cannot issue another remote start. +func TestAReopenedConversationReattachesWithoutStartingAgain(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + var starts, resumes atomic.Int32 + ready, resumed := make(chan struct{}), make(chan RunSpec, 1) + finish := make(chan struct{}) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + starts.Add(1) + r := remoteRecordFor(spec) + r.Seq = 7 + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumes.Add(1) + resumed <- spec + <-finish + r := *spec.Remote + r.Finished = true + _ = spec.SaveRemote(r, true) + _ = spec.Store.CompleteRoot("done elsewhere") + return RunSummary{Outcome: "done", Result: "done elsewhere"} + }} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + _ = first.Close() + waitRemoteRun(t, first) + second := open() + select { + case spec := <-resumed: + if spec.Remote.Seq != 7 || spec.Remote.Attempt != "att_0123456789abcdef" { + t.Fatalf("resume record = %+v", spec.Remote) + } + case <-time.After(5 * time.Second): + t.Fatal("reopen did not reattach") + } + if starts.Load() != 1 || resumes.Load() != 1 || local.didRun() { + t.Fatal("reopen started another worker") + } + close(finish) + waitRemoteRun(t, second) + if local.lands() != 1 { + t.Fatalf("landings = %d", local.lands()) + } +} + +// Contracts 8 and 11 fence a dropped attempt before its local replacement. +// Even a result callback arriving after that fence cannot touch the copy. +func TestRunItHereRunsLocallyAndAnAbandonedResultNeverLands(t *testing.T) { + ready := make(chan struct{}) + late := make(chan error, 1) + abandoned := make(chan struct{}, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.Unreachable = true + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + late <- spec.WithRemoteCopy(func() error { + return os.WriteFile(filepath.Join(spec.Workspace, "late.txt"), []byte("late remote result"), 0600) + }) + return RunSummary{Outcome: "done", Result: "late result"} + }, abandon: func() { abandoned <- struct{}{} }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + receipt, err := a.RunTaskHere(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(receipt, "here") { + t.Fatalf("receipt = %s", receipt) + } + select { + case <-local.entered: + case <-time.After(5 * time.Second): + t.Fatal("local replacement never started") + } + if err := <-late; !errors.Is(err, ErrTaskAttemptAbandoned) { + t.Fatalf("late result = %v", err) + } + <-abandoned + endBeltRun(t, a, local) + if _, err := os.Stat(filepath.Join(a.config.Workspace, "late.txt")); !os.IsNotExist(err) { + t.Fatal("abandoned result landed") + } + kept, _ := runRowOf(a.graph(), 1) + if kept.Computer != "" || kept.Unreachable { + t.Fatalf("local placement = %+v", kept) + } + if local.lands() != 1 { + t.Fatalf("landing calls = %d", local.lands()) + } +} + +// Contract 8 preserves a refused remote start's ready copy until the person +// explicitly chooses here; that choice starts locally in the same copy. +func TestARefusedRemoteStartWaitsForRunItHere(t *testing.T) { + var sent string + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + sent = spec.Workspace + r := remoteRecordFor(spec) + r.StartFailed = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "incomplete", Result: "work computer is not taking tasks right now — run codeaf serve --take-tasks on it", KeepCopy: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + if local.didRun() { + t.Fatal("refusal fell back without a person's choice") + } + if _, err := os.Stat(sent); err != nil { + t.Fatal("refusal removed the ready copy") + } + if _, err := a.RunTaskHere(context.Background(), 1); err != nil { + t.Fatal(err) + } + <-local.entered + local.mu.Lock() + got := local.spec.Workspace + local.mu.Unlock() + if got != sent { + t.Fatalf("replacement copy = %s, sent %s", got, sent) + } + endBeltRun(t, a, local) +} + +// Contract 11 refuses local replacement before abandoning anything if the +// copy here changed while the other computer was working. +func TestRunItHereKeepsAChangedCopyAndTheRemoteAttempt(t *testing.T) { + ready := make(chan struct{}) + var abandons atomic.Int32 + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.Unreachable = true + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, check: func(context.Context, string, string) error { + return errors.New("the task copy changed here while the other computer worked; both copies were kept") + }, abandon: func() { abandons.Add(1) }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := a.RunTaskHere(context.Background(), 1); err == nil { + t.Fatal("changed copy allowed replacement") + } + kept, _ := runRowOf(a.graph(), 1) + if kept.Remote.Abandoned || abandons.Load() != 0 || local.didRun() { + t.Fatal("refused replacement changed the attempt") + } + _ = a.Close() + waitRemoteRun(t, a) +} + +// Contract 5 includes a crash after both accounting writes but before the +// throttled cursor checkpoint. The replayed call is still one conversation call. +func TestRemoteChargeReplayAfterReopenDoesNotChargeTheConversationTwice(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + charged := make(chan struct{}) + var resumed atomic.Int32 + charge := RunCharge{Model: "remote/model", TokensIn: 40, TokensOut: 8, USD: .25} + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + if err := spec.OnRemoteCharge(1, charge, "worker"); err != nil { + t.Error(err) + } + close(charged) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumed.Add(1) + if spec.Remote.Seq != 0 { + t.Errorf("fixture cursor = %d", spec.Remote.Seq) + } + if err := spec.OnRemoteCharge(1, charge, "worker"); err != nil { + t.Error(err) + } + r := *spec.Remote + r.Seq = 1 + r.USD = .25 + r.Finished = true + _ = spec.SaveRemote(r, true) + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done", USD: .25} + }} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + ledger := filepath.Join(profile, UsageLedgerName) + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + c.usageLedger = ledger + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-charged + _ = first.Close() + waitRemoteRun(t, first) + second := open() + waitRemoteRun(t, second) + if resumed.Load() != 1 { + t.Fatal("task did not replay the saved attempt") + } + if usage := second.Usage(); usage.CostUSD != .25 || usage.Calls != 1 { + t.Fatalf("replayed usage = %+v", usage) + } + lines, err := ReadUsage(ledger, time.Time{}) + if err != nil || len(lines) != 1 { + t.Fatalf("ledger = %+v, %v", lines, err) + } +} + +// Contract 2's durable step mirror does linear work in the record size. Replay +// reads the existing prefix once, and never writes an already held event again. +func TestRemoteStepMirroringReadsOnceAndWritesEachNewStepOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "plandb.db") + store, err := plandb.Open(path, "task", "1", "task", "brief") + if err != nil { + t.Fatal(err) + } + defer store.Close() + mirror := &remoteStepMirror{} + for i := 1; i <= 200; i++ { + raw := []byte(fmt.Sprintf(`{"kind":"step","step":%d,"command":"inspect","observation":"head"}`, i)) + if err := mirror.append(store, "att_0123456789abcdef", uint64(i), raw); err != nil { + t.Fatal(err) + } + } + if mirror.readBytes != 0 { + t.Fatalf("new record read %d bytes", mirror.readBytes) + } + before := mirror.writeBytes + replay := &remoteStepMirror{} + for i := 1; i <= 200; i++ { + if err := replay.append(store, "att_0123456789abcdef", uint64(i), []byte(`{"kind":"step"}`)); err != nil { + t.Fatal(err) + } + } + if replay.readBytes != before || replay.writeBytes != 0 { + t.Fatalf("replay read/write = %d/%d, original %d", replay.readBytes, replay.writeBytes, before) + } + t.Logf("200 steps: %d bytes written, replay read %d bytes once and wrote %d", before, replay.readBytes, replay.writeBytes) +} + +// Contract 6 keeps local placement usable while a paired directory is offline. +func TestChoosingHereDoesNotNeedTheOtherComputerOrTheDirectory(t *testing.T) { + a, local := remoteTaskAgent(t, nil, nil) + a.config.TaskComputers = offlineTaskComputers{} + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "here"); err != nil { + t.Fatal(err) + } + <-local.entered + endBeltRun(t, a, local) +} + +type offlineTaskComputers struct{ testTaskComputers } + +func (offlineTaskComputers) List(context.Context) ([]TaskComputer, error) { + return nil, errors.New("directory is offline") +} + +// Contract 5 includes another owner appending after a long-lived process has +// already cached the ledger prefix. Its replay refreshes that tail first. +func TestRemoteSpendReplaySeesAnotherOwnersAppendedCharge(t *testing.T) { + path := filepath.Join(t.TempDir(), UsageLedgerName) + now := time.Now() + first := UsageLine{At: now, Day: now.Local().Format(usageDayLayout), RemoteEvent: "att_0123456789abcdef:1", Model: "remote/model", USD: .10, Calls: 1} + if err := appendRemoteUsage(path, first); err != nil { + t.Fatal(err) + } + later := UsageLine{At: now, Day: now.Local().Format(usageDayLayout), RemoteEvent: "att_0123456789abcdef:2", Model: "remote/model", USD: .20, Calls: 1} + raw, _ := json.Marshal(later) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + t.Fatal(err) + } + _, err = file.Write(append(raw, '\n')) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + if err := appendRemoteUsage(path, later); err != nil { + t.Fatal(err) + } + lines, err := ReadUsage(path, time.Time{}) + if err != nil || len(lines) != 2 { + t.Fatalf("replayed ledger = %+v, %v", lines, err) + } +} + +// Contract 3 keeps a stopped task's partial change on its local task branch, +// and the person's Stop travels to the remote engine without cutting its watch. +func TestStoppingARemoteTaskReturnsItsPartialChangeAndKeepsItsBranch(t *testing.T) { + ready, stop := make(chan struct{}), make(chan struct{}) + remote := &scriptedTaskEngine{stop: func() { close(stop) }, start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + close(ready) + select { + case <-stop: + case <-ctx.Done(): + t.Error("Stop cut the watch before its partial result came back") + return RunSummary{KeepCopy: true} + } + if err := spec.WithRemoteCopy(func() error { + return os.WriteFile(filepath.Join(spec.Workspace, "partial.txt"), []byte("partial work"), 0600) + }); err != nil { + t.Error(err) + } + r.Finished = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "stopped", Result: "stopped with partial work", StorageSafe: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := a.Cancel("task:1"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + kept, _ := runRowOf(a.graph(), 1) + if !kept.Stopped || kept.Branch == "" || ProjectTask(kept.StatusFacts()).Changes != TaskChangesKept { + t.Fatalf("stopped task = %+v", kept) + } + if _, err := os.Stat(filepath.Join(a.config.Workspace, "partial.txt")); !os.IsNotExist(err) { + t.Fatal("stopped work merged into the person's project") + } + if out, err := git(a.config.Workspace, "show", kept.Branch+":partial.txt"); err != nil || strings.TrimSpace(out) != "partial work" { + t.Fatalf("kept partial change = %q, %v", out, err) + } + if local.didRun() { + t.Fatal("Stop started a local worker") + } +} + +// Contract 8 applies to any saved refused task number, even after a later +// task has archived its plan. The replacement must receive that task's brief. +func TestRunItHereUsesTheRequestedTasksArchivedInstruction(t *testing.T) { + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.StartFailed = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair the export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + if _, _, _, err := a.StartTask(context.Background(), "Repair the import", false); err != nil { + t.Fatal(err) + } + <-local.entered + endBeltRun(t, a, local) + replacement := newBeltRunDouble("done") + replacement.real = true + registerBeltRunEngine(t, replacement) + if _, err := a.RunTaskHere(context.Background(), 1); err != nil { + t.Fatal(err) + } + <-replacement.entered + replacement.mu.Lock() + brief := replacement.spec.Brief + replacement.mu.Unlock() + if brief != "Repair the export" { + t.Fatalf("replacement instruction = %q", brief) + } + endBeltRun(t, a, replacement) +} + +// Contract 9 replays a step whose append was interrupted before its newline. +func TestReplayingAStepWithAnUnfinishedAppendKeepsTheStep(t *testing.T) { + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "task", "1", "task", "brief") + if err != nil { + t.Fatal(err) + } + defer store.Close() + path := filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), "1"), "trajectory.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + raw := []byte(`{"kind":"step","step":1,"command":"inspect","remote_attempt":"att_0123456789abcdef","remote_seq":1}`) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + mirror := &remoteStepMirror{} + if err := mirror.append(store, "att_0123456789abcdef", 1, raw); err != nil { + t.Fatal(err) + } + if got := planTrajectory(filepath.Dir(store.Path()), "1"); len(got) != 1 || got[0].Command != "inspect" { + t.Fatalf("replayed trajectory = %+v", got) + } +} + +// Contracts 3 and 10 retain an outstanding remote stop across a quit. The +// reopened watch still collects the partial result before settling the stop. +func TestQuittingWhileARemoteTaskStopsReattachesForItsPartialResult(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + ready, stopping := make(chan struct{}), make(chan struct{}, 2) + var resumes atomic.Int32 + remote := &scriptedTaskEngine{stop: func() { stopping <- struct{}{} }, start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumes.Add(1) + if err := spec.WithRemoteCopy(func() error { + return os.WriteFile(filepath.Join(spec.Workspace, "partial.txt"), []byte("stopped partial work"), 0600) + }); err != nil { + t.Error(err) + } + r := *spec.Remote + r.Finished = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "stopped", StorageSafe: true} + }} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := first.Cancel("task:1"); err != nil { + t.Fatal(err) + } + <-stopping + _ = first.Close() + waitRemoteRun(t, first) + second := open() + waitRemoteRun(t, second) + if resumes.Load() != 1 { + t.Fatal("pending stop did not reattach") + } + kept, _ := runRowOf(second.graph(), 1) + if !kept.Stopped || kept.Branch == "" { + t.Fatalf("stopped partial work was not kept: %+v", kept) + } +} + +// Contract 4 keeps a single private repository copy eligible for the remote +// preference, then uses the ordinary local Review and Apply entry points. +func TestAPrivateTaskRunsElsewhereAndWaitsForLocalReviewAndApply(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + reached := make(chan struct{}, 1) + var written string + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + written = filepath.Join(spec.Workspace, "private-output.txt") + reached <- struct{}{} + r := remoteRecordFor(spec) + r.Finished, r.Restored = true, true + _ = spec.SaveRemote(r, true) + if err := os.WriteFile(written, []byte("the returned private change\n"), 0600); err != nil { + t.Error(err) + } + _ = spec.Store.CompleteRoot("private change made") + return RunSummary{Outcome: "done", StorageSafe: true} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { _ = config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef") }) + id, _, _, err := a.StartTask(context.Background(), "write private-output.txt in this project", false) + if err != nil { + t.Fatal(err) + } + select { + case <-reached: + case <-local.entered: + t.Fatal("private task preference ran locally") + case <-time.After(5 * time.Second): + t.Fatal("private task never started") + } + waitRemoteRun(t, a) + if _, err := os.Stat(filepath.Join(a.config.Workspace, "private-output.txt")); !os.IsNotExist(err) { + t.Fatal("private result applied before review") + } + // macOS gives a new file its folder's group, and the private copy sits under + // this test's temporary home, so with TMPDIR in /private/tmp the returned + // file is wheel's and Apply cannot carry that group. The fixture takes the + // process's own, as the other Apply fixtures do. + normalizeWorkspaceApplyFixtureMetadata(t, written) + review, err := a.ReviewTaskWorkspaceRoot(context.Background(), id, "work") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(review.Diff, "the returned private change") { + t.Fatalf("review = %+v", review) + } + if _, err := a.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(a.config.Workspace, "private-output.txt")); err != nil || string(got) != "the returned private change\n" { + t.Fatalf("applied result = %q, %v", got, err) + } +} diff --git a/internal/session/task_remote_watch.go b/internal/session/task_remote_watch.go new file mode 100644 index 0000000000..5289445970 --- /dev/null +++ b/internal/session/task_remote_watch.go @@ -0,0 +1,186 @@ +package session + +import ( + "context" + "errors" + "time" +) + +const ( + TaskComputerSilentAfter = time.Minute + TaskComputerRetryEvery = 30 * time.Second +) + +// TaskComputerStoppedAnswering is shared with every surface drawing a drop. +func TaskComputerStoppedAnswering(name string) string { + return name + " stopped answering — it may still be working" +} +func TaskRunningOn(name string) string { return "running on " + name } +func TaskRanOn(name string) string { return "ran on " + name } + +// TaskWatchClock supplies wakeups as well as readings so a network outage can +// be tested without spending a minute on the wall clock. +type TaskWatchClock interface { + Now() time.Time + After(time.Duration) <-chan time.Time +} +type taskWatchWall struct{} + +func (taskWatchWall) Now() time.Time { return time.Now() } +func (taskWatchWall) After(d time.Duration) <-chan time.Time { return time.After(d) } + +// TaskWatch retries only a watch. Activity includes pongs, so quiet useful work +// is never called missing. deliver waits until local writes have completed. +type TaskWatch func(context.Context, uint64, func(), func(uint64) error) error + +type taskWatchDelivery struct { + seq uint64 + done chan error +} + +// FollowTaskWatch serializes frames and reachability on its caller's goroutine. +// The network goroutine is joined on every exit and never sends a second start. +func FollowTaskWatch(ctx context.Context, clock TaskWatchClock, from uint64, watch TaskWatch, consume func(uint64) error, reachability func(bool)) error { + if clock == nil { + clock = taskWatchWall{} + } + state := taskWatchState{clock: clock, last: clock.Now(), delay: time.Second, reachability: reachability} + for { + var err error + var retry bool + from, err, retry = state.follow(ctx, from, watch, consume) + if !retry { + return err + } + if err == nil || errors.Is(err, ErrTaskWatchEnded) { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if err := state.wait(ctx); err != nil { + return err + } + } +} + +type taskWatchState struct { + clock TaskWatchClock + last time.Time + delay time.Duration + unreachable bool + reachability func(bool) +} + +// notice and alive keep reachability on the caller's goroutine, including retry waits. +func (s *taskWatchState) notice() { + if !s.unreachable && s.clock.Now().Sub(s.last) >= TaskComputerSilentAfter { + s.unreachable = true + s.reachability(true) + } +} +func (s *taskWatchState) alive() { + s.last, s.delay = s.clock.Now(), time.Second + if s.unreachable { + s.unreachable = false + s.reachability(false) + } +} + +// follow joins this connection before retrying, so two watches cannot interleave callbacks. +func (s *taskWatchState) follow(ctx context.Context, from uint64, watch TaskWatch, consume func(uint64) error) (uint64, error, bool) { + child, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + activity := make(chan struct{}, 1) + deliveries := make(chan taskWatchDelivery) + joined := false + defer func() { + cancel() + if !joined { + <-done + } + }() + alive := func() { + select { + case activity <- struct{}{}: + default: + } + } + go func(cursor uint64) { + done <- watch(child, cursor, alive, func(seq uint64) error { return deliverTaskWatch(child, deliveries, seq) }) + }(from) + tick := s.clock.After(time.Second) + for { + select { + case <-activity: + s.alive() + case delivery := <-deliveries: + var err error + if delivery.seq > from { + err = consume(delivery.seq) + if err == nil { + from = delivery.seq + } + } + delivery.done <- err + if err != nil { + return from, err, false + } + case err := <-done: + joined = true + return from, err, true + case <-ctx.Done(): + return from, ctx.Err(), false + case <-tick: + s.notice() + tick = s.clock.After(time.Second) + } + } +} + +// deliverTaskWatch waits for the durable consumer before allowing another wire event. +func deliverTaskWatch(ctx context.Context, deliveries chan<- taskWatchDelivery, seq uint64) error { + delivery := taskWatchDelivery{seq: seq, done: make(chan error, 1)} + select { + case deliveries <- delivery: + case <-ctx.Done(): + return ctx.Err() + } + select { + case err := <-delivery.done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// wait keeps the same silence notice active while backing off between connections. +func (s *taskWatchState) wait(ctx context.Context) error { + delay := s.delay + if s.unreachable { + delay = TaskComputerRetryEvery + } + retry, tick := s.clock.After(delay), s.clock.After(time.Second) +waiting: + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-retry: + break waiting + case <-tick: + s.notice() + tick = s.clock.After(time.Second) + } + } + if s.delay < TaskComputerRetryEvery { + s.delay *= 2 + if s.delay > TaskComputerRetryEvery { + s.delay = TaskComputerRetryEvery + } + } + return nil +} + +// ErrTaskWatchEnded distinguishes a final frame from a transport drop. +var ErrTaskWatchEnded = errors.New("the task watch ended") diff --git a/internal/session/task_reopen_r8_test.go b/internal/session/task_reopen_r8_test.go new file mode 100644 index 0000000000..9d55368205 --- /dev/null +++ b/internal/session/task_reopen_r8_test.go @@ -0,0 +1,90 @@ +package session + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +// R8 recovery splits must retain authoritative abandonment on the local fallthrough path. +func TestReopeningKeepsTheAbandonmentSavedBeforeItsCheckpoint(t *testing.T) { + taskComputerChoiceHome(t) + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done here") + local.real = true + local.honoursStop = true + registerBeltRunEngine(t, local) + ready := make(chan struct{}) + var starts, resumes atomic.Int32 + remote := &scriptedTaskEngine{ + start: func(ctx context.Context, spec RunSpec) RunSummary { + starts.Add(1) + r := remoteRecordFor(spec) + if err := spec.SaveRemote(r, true); err != nil { + t.Error(err) + } + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, + resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumes.Add(1) + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, + } + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(t.Context(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + _ = first.Close() + waitRemoteRun(t, first) + store, err := openRemoteTaskPlan(first.graph(), 1) + if err != nil { + t.Fatal(err) + } + row, _ := runRowOf(first.graph(), 1) + record := readRemoteRecord(store, row.Remote) + record.Abandoned = true + record.AckPending = false + if err := writeRemoteRecord(store, *record); err != nil { + t.Fatal(err) + } + if err := store.CompleteRoot("the local replacement finished"); err != nil { + t.Fatal(err) + } + _ = store.Close() + second := open() + events, stop := second.WatchTaskUpdates() + defer stop() + deadline := time.After(5 * time.Second) + for { + select { + case event := <-events: + if event.Kind != EventTaskUpdate || event.Task == nil || event.Task.ID != 1 { + continue + } + if event.Task.Remote == nil || !event.Task.Remote.Abandoned { + t.Fatalf("reopen forgot the saved abandonment: %+v", event.Task.Remote) + } + if starts.Load() != 1 || resumes.Load() != 0 || local.didRun() { + t.Fatal("settled recovery started another worker") + } + return + case <-deadline: + t.Fatal("reopened task was not replayed") + } + } +} diff --git a/internal/session/task_run_admission.go b/internal/session/task_run_admission.go index c30b8c73f1..becdb43d2b 100644 --- a/internal/session/task_run_admission.go +++ b/internal/session/task_run_admission.go @@ -45,6 +45,11 @@ func (a *Agent) beltRunQuiet(run *beltRun) bool { return run.quiet } func (a *Agent) settleBeltStopIfNeeded(run *beltRun, summary RunSummary) bool { + // A remote stop still needs its partial result. A closed watch or failed + // transfer cannot retire the untouched local copy as though it came back. + if run.remoteEngine != nil && summary.KeepCopy { + return false + } if stopped, why := a.beltRunStopped(run); stopped { a.settleStoppedBeltRun(run, why, summary.Cut) a.settleTaskCrew(run.row, router.CrewStopped, summary.USD) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 185b2d6258..f35607087f 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -32,6 +32,7 @@ package session import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -119,6 +120,18 @@ func (a *Agent) holdProgramCeilings(run *beltRun, spec RunSpec) { // the working copy its workers share, the run's own words, the conversation's // two limits, and the provider its workers are seated on. type RunSpec struct { + // Remote is the durable attempt being followed. SaveRemote fences abandoned + // attempts; WithRemoteCopy serializes result restore against running here. + Remote *TaskRemoteRecord + SaveRemote func(TaskRemoteRecord, bool) error + WithRemoteCopy func(func() error) error + OnRemoteStep func(uint64, json.RawMessage) error + OnRemoteCharge func(uint64, RunCharge, string) error + OnRemotePhase func(string) + RemoteStopped func() bool + TaskNumber uint64 + NamedModel, NamedEffort string + NoProgress int // Store is the plan the run drives. The door opened it and keeps it open // for the life of the run; the engine reads and writes it like any other // writer of the store. @@ -280,6 +293,11 @@ const ( // the engine's outcome word, the root's result, the run's size, and the limit // that ended it when one did. type RunSummary struct { + Model string + // KeepCopy preserves the untouched origin copy after a start refusal or a + // result conflict. NeedsDecision says both copies need the person's choice. + KeepCopy bool + NeedsDecision bool // StorageSafe means every worker closed its jobs and no owned process group // remained alive when the run returned. Missing proof keeps copies resident. StorageSafe bool @@ -358,16 +376,22 @@ var beltRunSummaryDeadline = 6 * time.Second // on the Agent and nowhere else, so ownership of a running run is this // process's. type beltRun struct { - plan *planState - store *plandb.Store - root string - row uint64 - title string - brief string - stand taskStand - // pending is true until the run's machine admission has passed. A pending - // run has a plan store but no folder or working copy, so stopping it cannot - // leave a repository lock or branch behind. + remoteMu sync.Mutex + remote *TaskRemoteRecord + remoteEngine TaskRunEngine + remoteResume bool + remoteSaved time.Time + placement taskPlacement + plan *planState + store *plandb.Store + root string + row uint64 + title string + brief string + stand taskStand + // pending lasts through admission and deferred copy preparation. The row + // already names its plan; remote copies are recorded as they become available + // so stopping or closing cannot lose their ownership handles. pending bool admission RunAdmission // workspace is the run's own copy, the directory every worker types in, and @@ -588,6 +612,10 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri return false, err } } + ctx, engine, err = a.placeTaskRunEngine(ctx, engine, stand, via != nil) + if err != nil { + return false, err + } path := g.planPath() storeID := strconv.FormatUint(id, 10) dependencies := make([]plandb.Dependency, 0, len(dependsOn)) @@ -657,11 +685,11 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri // ADMISSION IS CHECKED BEFORE A FOLDER OR COPY IS TOUCHED. A held run seeds // only its plan store so stopping it cannot leave a repository claim behind; - // the admitted road below keeps the original synchronous refusal order. + // admitted local tasks below keep the original synchronous refusal order. admission := newRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.config.ProfileDir, a.graph().lanes) held := admission != nil && !admission.MayStart() - if held { - return a.startHeldBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission) + if held || taskPlacementOf(ctx).computer != nil { + return a.startDeferredBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission, held) } return a.startAdmittedBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission) @@ -718,6 +746,7 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T delegate: via, folder: folder, asked: asked, crew: crew, } run.wishedFrom(ctx) + a.placeBeltRun(ctx, engine, run) a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because // the branch it names exists only in this variable until it is: the road that @@ -729,6 +758,7 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T // `via` — so every later publish carries it forward from here // ([TaskNotice.Program], [Agent.publishRunRow]). a.publishRunRow(g, TaskNotice{ + Remote: cloneTaskRemote(run.remote), Computer: run.taskComputerName(), ID: id, Title: title, State: TaskRunning, StartedAt: born, Copy: runCopyOf(tree), WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet), Program: programName(via), // THE ROOT'S ROW NAMES THE STORE'S ROOT, which is this same number: the @@ -746,13 +776,13 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T return false, nil } -// startHeldBeltRun seeds the only state a machine-held run may own: its plan -// store and queued row. Crew routing remains synchronous, while folder and -// copy preparation stay behind the driver's later admission. -func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskGraph, path, storeID string, id uint64, title, brief string, stand taskStand, question string, via *delegate.Delegate, asked []string, admission RunAdmission) (bool, error) { +// startDeferredBeltRun publishes a durable queued row before preparing its copy. +// Remote starts must answer without repository-sized work on the ordered road; +// machine-held local runs keep their existing deferred admission and crew routing. +func (a *Agent) startDeferredBeltRun(ctx context.Context, engine RunEngine, g *TaskGraph, path, storeID string, id uint64, title, brief string, stand taskStand, question string, via *delegate.Delegate, asked []string, admission RunAdmission, held bool) (bool, error) { var crew *taskCrew var err error - if via == nil { + if via == nil && taskPlacementOf(ctx).computer == nil { crew, err = a.routeTaskCrew(ctx, id, title, brief) if err != nil { return false, err @@ -774,22 +804,29 @@ func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskG // saying stop, and until this cancel was kept nothing could say it (stoprun.go). runCtx, cut := context.WithCancel(ctx) born := a.taskClockNow() + waiting := "" + var machineHeld map[string]bool + if held { + waiting = waitingMachineBusy + machineHeld = map[string]bool{planStoreID(storeID): true} + } run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, brief: brief, stand: stand, ground: canonicalPath(stand.dir), pending: true, admission: admission, - machineHeld: map[string]bool{planStoreID(storeID): true}, + machineHeld: machineHeld, cut: cut, born: born, over: make(chan struct{}), delegate: via, asked: asked, crew: crew, } run.wishedFrom(ctx) + a.placeBeltRun(ctx, engine, run) a.installBeltRun(g, run) - // A HELD RUN IS QUEUED, not working: there is no worker and deliberately no - // copy yet. Its plan id is present from the first publish, which gives stop - // and recovery one persisted identity to reconcile. + // The queued row names its plan before any worker or copy exists, so stop + // and recovery already have one persisted identity to reconcile. A remote + // copy being prepared is not a machine-pressure refusal. a.publishRunRow(g, TaskNotice{ - ID: id, Title: title, State: TaskQueued, Program: programName(via), + ID: id, Title: title, State: TaskQueued, Program: programName(via), Remote: cloneTaskRemote(run.remote), Computer: run.taskComputerName(), PlanTask: planStoreID(storeID), Crew: run.crewDecision(), CrewState: a.initialCrewRecord(run), - Model: run.crewWorker(), Waiting: waitingMachineBusy, + Model: run.crewWorker(), Waiting: waiting, PendingRun: &PendingRunRecord{WorkspaceSet: stand.workspaceSet, Brief: brief, Ground: stand.dir, Mode: stand.mode, Asked: asked}, }) go a.driveBeltRun(runCtx, engine, run, RunSpec{}) @@ -803,7 +840,7 @@ func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskG func (a *Agent) prepareBeltRunStart(ctx context.Context, id uint64, title, brief, sessionDir string, stand taskStand, via *delegate.Delegate) (*taskCrew, *ProgramFolder, error) { var crew *taskCrew var err error - if via == nil { + if via == nil && taskPlacementOf(ctx).computer == nil { crew, err = a.routeTaskCrew(ctx, id, title, brief) if err != nil { return nil, nil, err @@ -919,14 +956,10 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } over := live.over if !live.ending && !live.closing && !live.stopped { - if refusal := programJoinRefusal(via, live); refusal != nil { + if refusal := taskRunJoinRefusal(ctx, stand, via, live); refusal != nil { a.beltMu.Unlock() return nil, refusal } - if !stand.sharesWorkspace(live) { - a.beltMu.Unlock() - return nil, standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} - } _, err := live.store.AddMany([]plandb.TaskSpec{{ ID: storeID, ParentID: live.root, Title: title, Description: brief, Dependencies: dependencies, Model: crewWishOf(ctx).worker, }}) @@ -1706,6 +1739,14 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { notice.Paused = true } } + if notice.Remote == nil { + if kept, ok := runRowOf(g, notice.ID); ok { + notice.Remote = cloneTaskRemote(kept.Remote) + notice.Computer = kept.Computer + notice.Unreachable = kept.Unreachable + notice.RanElsewhere = kept.RanElsewhere + } + } if crew := a.crews.get(notice.ID); crew != nil { crew.persistMu.Lock() defer crew.persistMu.Unlock() @@ -1723,9 +1764,13 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { if notice.State.settled() && notice.CostUSD == 0 { notice.CostUSD = a.beltRunSpent(notice.ID) } - a.emitTaskUpdate(notice) + // Persist the row in the graph checkpoint and project index before + // announcing it on the live lane, so any reader or close triggered by the + // update finds the settled record already on disk and no index write + // trails behind the announcement. g.keepRunRows(notice.ID, []TaskNotice{notice}) a.indexRunRow(notice) + a.emitTaskUpdate(notice) } // carryRunRow preserves the durable identity fields across partial updates. @@ -1791,12 +1836,17 @@ func keptRunProgram(g *TaskGraph, notice TaskNotice) string { // all. A crash writes nothing either way; that store is ended by the next // process to find it ([endOrphanedProgramRun]). func (a *Agent) cutBeltRun() <-chan struct{} { + a.remoteAckJobs.Wait() a.beltMu.Lock() run := a.beltRun var cut context.CancelFunc stopped := false if run != nil { run.closing = true + // Local preparation must join before Close finishes its checkpoint. + if run.pending && run.remoteEngine != nil { + run.ending = true + } cut, stopped = run.cut, run.stopped } a.beltMu.Unlock() @@ -1818,6 +1868,29 @@ func (a *Agent) cutBeltRun() <-chan struct{} { return nil } +// waitEndingBeltRun joins a run whose engine has already answered so its final +// checkpoint, project index write, storage park, and plan store close finish +// before Close shuts the session's stores and returns the folder to its caller. +// A run still inside its engine is left unawaited so an interactive quit never +// blocks on a live worker or external program. +func (a *Agent) waitEndingBeltRun(done <-chan struct{}, grace time.Duration) { + if done == nil { + return + } + a.beltMu.Lock() + ending := a.beltRun == nil || a.beltRun.over != done || a.beltRun.ending + a.beltMu.Unlock() + if !ending { + return + } + timer := time.NewTimer(grace) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + } +} + // waitForBeltAdmission keeps a newly seeded run queued until the machine gate // admits its first worker. It is deliberately the run's existing goroutine: // stopping the row cuts this wait directly, without a polling goroutine that @@ -1840,10 +1913,12 @@ func (a *Agent) waitForBeltAdmission(ctx context.Context, run *beltRun) bool { } } -// preparePendingBeltRun is the only road from a held run to a repository. The -// admission check has passed before this function calls either folder or copy -// preparation, which is the lock-ownership seam for held tasks. +// preparePendingBeltRun readies a deferred run only after admission. A recovered +// remote copy is adopted before considering any new folder or copy preparation. func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { + if run.remoteEngine != nil && run.tree.dir != "" { + return a.finishPendingCopy(ctx, run, run.folder, run.tree, run.ground) + } folder, err := a.readyRunFolder(run.row, run.title, filepath.Dir(run.store.Path()), run.stand, run.delegate, run.carry) if err != nil { return err @@ -1854,10 +1929,16 @@ func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { tree = folder.tree() } else { tree, err = beltRunPrepare(ctx, a.config.Place, a.config.Workspace, a.journalID(), run.row, run.title, run.stand) + a.rememberPendingRemoteCopy(run, tree) if err != nil { return err } } + return a.finishPendingCopy(ctx, run, folder, tree, ground) +} + +// finishPendingCopy publishes the ready layout before the engine can dispatch work. +func (a *Agent) finishPendingCopy(ctx context.Context, run *beltRun, folder *ProgramFolder, tree taskTree, ground string) error { tree.bashBelt = true a.beltMu.Lock() stopped := run.stopped @@ -1878,12 +1959,11 @@ func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { return nil } -// settlePendingBeltRun closes the two ways a held run can leave before its -// engine starts. A person stop uses the same stopped landing as an admitted -// run; a folder refusal keeps that refusal's words on both the store and row. +// settlePendingBeltRun records a stop or preparation refusal before dispatch. +// Closing a remote preparation keeps its intent for the next owner instead. func (a *Agent) settlePendingBeltRun(run *beltRun, err error) { a.beltMu.Lock() - stopped, why := run.stopped, run.stopReason + stopped, closing, why := run.stopped, run.closing, run.stopReason run.ending = true a.beltMu.Unlock() defer a.releaseBeltRun(run) @@ -1892,6 +1972,10 @@ func (a *Agent) settlePendingBeltRun(run *beltRun, err error) { a.settleTaskCrew(run.row, router.CrewStopped, 0) return } + // Closing before dispatch leaves the durable remote intent for reopen. + if closing && run.remoteEngine != nil { + return + } if err == nil { return } @@ -1906,7 +1990,7 @@ func (a *Agent) settlePendingBeltRun(run *beltRun, err error) { a.settleTaskCrew(run.row, router.CrewNotKept, 0) } -// startPendingBeltRun admits a held run and prepares the repository only after +// startPendingBeltRun admits a deferred run and prepares its copy only after // admission. It returns false after settling a stop, close, or preparation // refusal, so the driver has one short road for every pending exit. func (a *Agent) startPendingBeltRun(ctx context.Context, run *beltRun) (RunSpec, bool) { @@ -1945,9 +2029,21 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // and whatever the run's running total holds beyond those — a bash // worker's spend, which arrives only as a total. fold := &beltFold{agent: a} - spec.OnSpend = fold.total - spec.OnCharge = fold.charge - summary := startRunWithTaskStorageProof(ctx, engine, spec) + // Remote charges enter both origin books by event identity; a total reading + // must never fold them again through the local worker's accounting road. + if run.remoteEngine == nil { + spec.OnSpend = fold.total + spec.OnCharge = fold.charge + } + spec = a.remoteBeltSpec(run, spec) + summary := startPlacedBeltRun(ctx, engine, run, spec) + if run.remoteEngine != nil { + engine = chatRunEngine + } + if run.remoteAbandoned() { + a.releaseBeltRun(run) + return + } // THE RUN'S WORK IS OVER THE MOMENT THE ENGINE ANSWERS, and that instant is // taken now, before the landing, the summary refresh and the note — which // can take a quarter of a minute between them and are not the work. @@ -1957,16 +2053,14 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // to be over instead of joining it ([Agent.joinOrWait]). The run is cleared // off the Agent and its waiters released on every road out of here, which is // what the deferred release says once. - a.beltMu.Lock() - run.ended, run.spent = a.taskClockNow(), summary.USD - run.ending = true - closing := run.closing - a.beltMu.Unlock() + closing := a.markBeltRunEnding(run, summary) defer a.releaseBeltRun(run) - defer a.parkBeltRunStorage(run, summary) + defer a.finishBeltRunStorage(run, summary) // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. - fold.total(summary.USD) + if run.remoteEngine == nil { + fold.total(summary.USD) + } // AND meta.json IS TOLD NOW, not at the next turn's seal. Home reads this // conversation's bill as the larger of its stamped books and its index rows // (tui3's homeFacts), which is exact only while the books on disk already @@ -1976,25 +2070,40 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun if run.cut != nil { defer run.cut() } + if run.remoteWaitingOnClose(closing) { + return + } if a.settleBeltStopIfNeeded(run, summary) { return } - - if closing && run.delegate == nil && summary.Outcome != beltRunOutcomeDone { - // THE CONVERSATION CLOSED UNDER THE RUN, AND THAT IS NOBODY'S ENDING. The - // run is not landed, its row is not settled and nothing is written on its - // record: it is work nothing is driving any more, every step of it is in - // its store, and the row read back tomorrow says so in the one word for - // it ([TaskInterrupted]). Landing it here put the work into the folder of - // a person who had closed the window on it, and settling the row said - // `failed` about work that had not failed. - // - // A PROGRAM'S RUN IS THE EXCEPTION: [Agent.cutBeltRun] already wrote its - // ending, nothing can carry it on, and its folder is finished on its own - // branch below — never merged into the person's. + // THE CONVERSATION CLOSED UNDER THE RUN, AND THAT IS NOBODY'S ENDING. The + // run is not landed, its row is not settled and nothing is written on its + // record: it is work nothing is driving any more, every step of it is in + // its store, and the row read back tomorrow says so in the one word for + // it ([TaskInterrupted]). Landing it here put the work into the folder of + // a person who had closed the window on it, and settling the row said + // `failed` about work that had not failed. + // + // A PROGRAM'S RUN IS THE EXCEPTION: [Agent.cutBeltRun] already wrote its + // ending, nothing can carry it on, and its folder is finished on its own + // branch below — never merged into the person's. + if beltRunLeavesUnlanded(closing, run, summary) { return } - landing := a.finishBeltRunLanding(ctx, engine, run, summary) + a.completeBeltRun(ctx, engine, run, summary) +} + +// Completion keeps landing, the final summary and the outcome note in their original order. +func (a *Agent) completeBeltRun(ctx context.Context, engine RunEngine, run *beltRun, summary RunSummary) { + var landing RunLanding + if summary.KeepCopy { + // Refusing a transfer leaves the copy ready, not the plan still running. + // Closing only the root records the ending without landing any files. + _ = run.store.EndRoot(summary.Outcome) + landing = RunLanding{Home: mergeKept, Branch: run.tree.branch, Refused: summary.Result} + } else { + landing = a.finishBeltRunLanding(ctx, engine, run, summary) + } if a.settleBeltStopIfNeeded(run, summary) { return } @@ -2004,7 +2113,9 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // beyond that bound. RefreshRunSummary itself declines without a store. if !a.beltRunQuiet(run) { refreshCtx, cancelRefresh := context.WithTimeout(withCrewTask(ctx, run.crew), beltRunSummaryDeadline) - a.RefreshRunSummary(refreshCtx, run.root, time.Time{}) + if run.remoteEngine == nil { + a.RefreshRunSummary(refreshCtx, run.root, time.Time{}) + } cancelRefresh() } note := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) @@ -2035,6 +2146,44 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun a.settleTaskCrew(run.row, outcome, run.crew.taskSpent(summary.USD)) } +// Resuming a placed run only watches; ordinary starts retain their storage proof. +func startPlacedBeltRun(ctx context.Context, engine RunEngine, run *beltRun, spec RunSpec) RunSummary { + if run.remoteResume { + return run.remoteEngine.Resume(ctx, spec) + } + return startRunWithTaskStorageProof(ctx, engine, spec) +} + +// The engine's return is the end of work, before landing and its summary calls. +func (a *Agent) markBeltRunEnding(run *beltRun, summary RunSummary) bool { + a.beltMu.Lock() + defer a.beltMu.Unlock() + run.ended, run.spent = a.taskClockNow(), summary.USD + if run.remoteEngine != nil && summary.Outcome == "stopped" { + run.stopped = true + } + run.ending = true + return run.closing +} + +// Storage settlement remains between cancellation and releasing the run's waiting callers. +func (a *Agent) finishBeltRunStorage(run *beltRun, summary RunSummary) { + if summary.KeepCopy { + return + } + if run.remoteEngine == nil { + a.parkBeltRunStorage(run, summary) + } else { + a.ackRemoteRun(run) + } +} + +// Closing a conversation leaves unfinished ordinary work available for continuation. +// Programs keep their existing terminal receipt because their own invocation cannot resume. +func beltRunLeavesUnlanded(closing bool, run *beltRun, summary RunSummary) bool { + return closing && run.delegate == nil && summary.Outcome != beltRunOutcomeDone +} + // finishBeltRunLanding settles the isolated work before its receipt is published. func (a *Agent) finishBeltRunLanding(ctx context.Context, engine RunEngine, run *beltRun, summary RunSummary) RunLanding { var landing RunLanding @@ -2473,26 +2622,12 @@ func cancelledByRunEnding(store *plandb.Store, id uint64) bool { // the row and the conversation's note are built from, so the two cannot name // two different endings. func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLanding) TaskNotice { - state := TaskDone - if summary.Outcome != beltRunOutcomeDone { - state = TaskFailed - } - outcome, result := runEndingWords(summary) - report := result - if summary.Outcome != beltRunOutcomeDone { - if summary.Program != nil { - // THE PROGRAM'S OWN SENTENCE LEADS, and its account follows: the - // reason line a surface draws is the report's first line. - report = strings.TrimSpace(outcome + "\n" + result) - } else if report == "" { - report = outcome - } + state, report := beltRunResultWords(summary, landing) + if run.remoteEngine != nil && summary.NeedsDecision { + state = TaskUnverified } - if line := beltLandingLine(landing); line != "" { - if report != "" { - report += "\n" - } - report += line + if run.remoteEngine != nil && summary.Outcome == "interrupted" { + state = TaskInterrupted } notice := TaskNotice{ ID: run.row, Title: run.title, State: state, @@ -2508,11 +2643,14 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi // picked under, for the card's crew line. Crew: run.crewDecision(), Model: run.crewWorker(), CostUSD: run.crew.taskSpent(summary.USD), } + if run.remoteEngine != nil { + run.fillRemoteLandingNotice(¬ice, summary) + } if state == TaskFailed && run.crew != nil { decision := run.crew.stoppedIfCutOff() notice.Crew = &decision } - if run.crew != nil && !run.crew.anyStarted() { + if run.remoteEngine == nil && run.crew != nil && !run.crew.anyStarted() { // A CREW NO SEAT OF WHICH EVER ANSWERED WROTE NOTHING: whatever the // landing counted is the run's own scaffolding, and "1 file" under a // run that never started claims work nobody did. @@ -2531,6 +2669,24 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi return notice } +// A remote landing keeps the executor with its final reading, since this +// notice replaces the worker's phase row. +func (run *beltRun) fillRemoteLandingNotice(notice *TaskNotice, summary RunSummary) { + if summary.Outcome == "stopped" { + notice.Stopped = true + notice.Ending = TaskEndingStopped + } + run.remoteMu.Lock() + notice.Remote = cloneTaskRemote(run.remote) + notice.Computer = run.remote.Executor.Name + notice.RanElsewhere = !run.remote.StartFailed + if notice.RanElsewhere && notice.Computer != "" && notice.State != TaskInterrupted { + notice.Doing = TaskRanOn(notice.Computer) + } + run.remoteMu.Unlock() + notice.Model = summary.Model +} + // beltRunEnding is the run row's ending: a limit its person set, or how the // program a delegated run was handed to ended it — a crash is the fault it is, // and every other ending of the program's own is [TaskEndingProgram], whose @@ -2689,6 +2845,11 @@ func (run *beltRun) crewWorker() string { // initialCrewRecord records the accepted seats even when routing is disabled. func (a *Agent) initialCrewRecord(run *beltRun) *TaskCrewRecord { + // B seats its own crew. Recording A's ladder would claim models that never + // worked and could spend on an origin-side route the target cannot use. + if run.remoteEngine != nil { + return &TaskCrewRecord{Version: 1, Work: run.placement.model} + } if run.crew != nil { return run.crew.record() } @@ -2747,3 +2908,43 @@ func (tree taskTree) workerWorkspaceURI() string { func (stand taskStand) sharesWorkspace(live *beltRun) bool { return canonicalPath(stand.dir) == live.ground && sameWorkspaceSetRequest(stand.workspaceSet, live.stand.workspaceSet) } + +// The result's words are composed before executor and crew metadata are attached. +func beltRunResultWords(summary RunSummary, landing RunLanding) (TaskState, string) { + state := TaskDone + if summary.Outcome != beltRunOutcomeDone { + state = TaskFailed + } + outcome, result := runEndingWords(summary) + report := result + if summary.Outcome != beltRunOutcomeDone { + if summary.Program != nil { + // THE PROGRAM'S OWN SENTENCE LEADS, and its account follows: the + // reason line a surface draws is the report's first line. + report = strings.TrimSpace(outcome + "\n" + result) + } else if report == "" { + report = outcome + } + } + if line := beltLandingLine(landing); line != "" { + if report != "" { + report += "\n" + } + report += line + } + return state, report +} + +// A remote instruction is frozen, and a program or a different ground cannot join it. +func taskRunJoinRefusal(ctx context.Context, stand taskStand, via *delegate.Delegate, live *beltRun) error { + if live.remoteEngine != nil || taskPlacementOf(ctx).computer != nil { + return errRemotePlacementBusy + } + if refusal := programJoinRefusal(via, live); refusal != nil { + return refusal + } + if !stand.sharesWorkspace(live) { + return standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} + } + return nil +} diff --git a/internal/session/task_run_continue.go b/internal/session/task_run_continue.go index 3733824d3e..11d72a8934 100644 --- a/internal/session/task_run_continue.go +++ b/internal/session/task_run_continue.go @@ -53,6 +53,9 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { return "", errRunRoadUnavailable } + if kept, remote := remoteRowToContinue(g, row); remote { + return a.continueRemoteRun(ctx, kept) + } // A RUN ALREADY GOING IS NOT CARRIED ON. Read under the start lock, held // until the carried-on run is registered, because a run installed between // this look and the work below would be a second run on one conversation's @@ -65,11 +68,8 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { a.beltMu.Lock() live := a.beltRun a.beltMu.Unlock() - if live != nil { - if live.row == row { - return "", fmt.Errorf("%s is already running", taskStopName(row, live.title)) - } - return "", fmt.Errorf("%s is running, and a conversation drives one run at a time", taskStopName(row, live.title)) + if err := liveContinueRefusal(live, row); err != nil { + return "", err } kept, found := runRowOf(g, row) @@ -153,3 +153,20 @@ func runRowOf(g *TaskGraph, row uint64) (TaskNotice, bool) { } return TaskNotice{}, false } + +// Remote rows return to their saved executor before local continuation takes the start lock. +func remoteRowToContinue(g *TaskGraph, row uint64) (TaskNotice, bool) { + kept, found := runRowOf(g, row) + return kept, found && kept.Remote != nil && !kept.Remote.Abandoned +} + +// A live run is refused in the same words whether it is this row or another one. +func liveContinueRefusal(live *beltRun, row uint64) error { + if live == nil { + return nil + } + if live.row == row { + return fmt.Errorf("%s is already running", taskStopName(row, live.title)) + } + return fmt.Errorf("%s is running, and a conversation drives one run at a time", taskStopName(row, live.title)) +} diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go index 376c30bd2c..f8e323a595 100644 --- a/internal/session/task_run_index.go +++ b/internal/session/task_run_index.go @@ -68,6 +68,7 @@ func (a *Agent) indexRunRow(notice TaskNotice) { // machine is a lie anywhere else. func crewRunEntry(notice TaskNotice, session string) TaskIndexEntry { entry := TaskIndexEntry{ + Computer: notice.Computer, Unreachable: notice.Unreachable, RanElsewhere: notice.RanElsewhere, ID: strconv.FormatUint(notice.ID, 10), Parent: taskIndexParent(notice.Parent), Name: TaskSlug(notice.Title), diff --git a/internal/session/task_run_money_test.go b/internal/session/task_run_money_test.go index 6578cec8fa..96d5c3ebcf 100644 --- a/internal/session/task_run_money_test.go +++ b/internal/session/task_run_money_test.go @@ -124,3 +124,99 @@ func TestARunsDollarsAreStampedOnTheConversationWhenItSettles(t *testing.T) { t.Fatalf("meta.json says the conversation spent %v with no turn sealed since the run, want the run's $2.30", meta.SpentUSD) } } + +// A SETTLED RUN ROW IS ON DISK BEFORE ITS UPDATE GOES OUT, AND CLOSE JOINS THE +// ENDING DRIVER. Announcing the settled row ahead of its checkpoint and index +// write let a caller or test close the conversation on the announcement and +// sweep the bucket directory while the index lockfile was still being created. +func TestSettledRunRowIsRecordedBeforeTaskUpdateAndJoinedOnClose(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run ended") + double.summary.USD = 0.42 + registerBeltRunEngine(t, double) + + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run ended"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = Place{Dir: dir}.Transcript() + config.AskConsent = false + }) + updates, stopUpdates := agent.WatchTaskUpdates() + defer stopUpdates() + + id, _, _, err := agent.StartTask(context.Background(), "account for this run", false) + if err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + agent.beltMu.Lock() + over := agent.beltRun.over + agent.beltMu.Unlock() + + writing := make(chan struct{}, 1) + releaseWrite := make(chan struct{}) + agent.graph().store.duringWrite = func() { + select { + case writing <- struct{}{}: + default: + } + <-releaseWrite + } + + settled := make(chan TaskNotice, 1) + doneWatching := make(chan struct{}) + defer close(doneWatching) + go func() { + for { + select { + case <-doneWatching: + return + case event, ok := <-updates: + if !ok { + return + } + if event.Task != nil && (event.Task.State == TaskDone || event.Task.State == TaskFailed) { + settled <- *event.Task + return + } + } + } + }() + + close(double.release) + select { + case <-writing: + case <-time.After(10 * time.Second): + t.Fatal("settled checkpoint write did not begin") + } + select { + case got := <-settled: + close(releaseWrite) + t.Fatalf("settled update %+v arrived before its checkpoint finished writing", got) + default: + } + close(releaseWrite) + + var notice TaskNotice + select { + case notice = <-settled: + case <-time.After(10 * time.Second): + t.Fatal("no final update reached the standing lane") + } + if notice.ID != id || notice.State != TaskDone { + t.Fatalf("settled notice = %+v, want done row %d", notice, id) + } + rows := ReadTaskIndex(agent.config.taskIndexFile()) + if len(rows) != 1 || rows[0].Status != string(TaskDone) || rows[0].Cost != 0.42 { + t.Fatalf("task index at settled update = %+v, want one done row at $0.42", rows) + } + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + select { + case <-over: + default: + t.Fatal("Close returned before the ending run driver released its store") + } +} diff --git a/internal/session/task_run_recover.go b/internal/session/task_run_recover.go index c507a6d86a..cf721ac646 100644 --- a/internal/session/task_run_recover.go +++ b/internal/session/task_run_recover.go @@ -2,6 +2,7 @@ package session import ( "context" + "errors" "log" "os" "path/filepath" @@ -23,6 +24,7 @@ func (a *Agent) recoverBeltRun() { if g == nil || g.planPath() == "" { return } + defer a.retrySavedRemoteAcks(g) if _, err := os.Stat(g.planPath()); err != nil { return } @@ -38,6 +40,9 @@ func (a *Agent) recoverBeltRun() { return } kept, found := runRowOf(g, id) + if a.recoverRemoteBeltRun(g, store, &kept, found) { + return + } if !found || kept.State != TaskInterrupted { return } @@ -171,10 +176,17 @@ func (a *Agent) resumePendingRun(g *TaskGraph, kept TaskNotice) { admission: newRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.config.ProfileDir, g.lanes), machineHeld: map[string]bool{planStoreID(reopened.RootID()): true}, } + engine, err := a.pendingRunEngine(ctx, run, kept) + if err != nil { + cut() + reopened.Close() + a.interruptUnrecoverableRun(g, kept, err.Error()) + return + } a.installBeltRun(g, run) kept.State, kept.Waiting = TaskQueued, waitingMachineBusy a.publishRunRow(g, kept) - go a.driveBeltRun(ctx, chatRunEngine, run, RunSpec{}) + go a.driveBeltRun(ctx, engine, run, RunSpec{}) } // invalidPendingRun refuses incomplete or obsolete records instead of guessing @@ -239,3 +251,76 @@ func recoveredJoinedRows(g *TaskGraph, parent uint64) []uint64 { } return ids } + +// Remote recovery follows the authoritative sidecar and never starts another worker. +func (a *Agent) recoverRemoteBeltRun(g *TaskGraph, store *plandb.Store, kept *TaskNotice, found bool) bool { + if !found { + return false + } + remote := readRemoteRecord(store, kept.Remote) + if remote == nil { + return false + } + kept.Remote = remote + if remote.Settled && !remote.Abandoned { + return true + } + if remote.Abandoned { + return false + } + if kept.PendingRun != nil && remote.Attempt == "" { + a.resumeRemotePreparation(g, *kept) + return true + } + if remote.StartFailed { + kept.State = TaskFailed + a.publishRunRow(g, *kept) + return true + } + if kept.State.settled() && kept.State != TaskInterrupted && !kept.EndedAt.IsZero() { + // A persisted final row already completed landing; only its acknowledgement can remain. + if remote.Finished && remote.Restored { + remote.Settled = true + remote.AckPending = true + _ = writeRemoteRecord(store, *remote) + kept.Remote = remote + } + return true + } + _, _ = a.continueRemoteRun(a.rootContext(), *kept) + return true +} + +// Missing capability leaves the remote intent readable and recoverable without local execution. +func (a *Agent) resumeRemotePreparation(g *TaskGraph, kept TaskNotice) { + if a.config.TaskComputers == nil { + kept.State, kept.Report = TaskInterrupted, taskComputersUnavailable + a.publishRunRow(g, kept) + return + } + a.resumePendingRun(g, kept) +} + +// Recovery adopts any recorded copy; preparing it again would overwrite retained work. +func (a *Agent) pendingRunEngine(ctx context.Context, run *beltRun, kept TaskNotice) (RunEngine, error) { + if kept.Remote == nil || kept.Remote.Abandoned { + return chatRunEngine, nil + } + if a.config.TaskComputers == nil { + return nil, errors.New(taskComputersUnavailable) + } + remote, err := a.taskExecutorEngine(ctx, kept.Remote.Executor) + if err != nil { + return nil, err + } + run.remoteEngine, run.remote = remote, cloneTaskRemote(kept.Remote) + run.placement.computer = &TaskComputer{Device: kept.Remote.Executor.Device, Name: kept.Remote.Executor.Name, CanTakeTasks: true} + if kept.Copy != nil || kept.WorkspaceSet != nil { + tree, err := reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) + if err != nil { + return nil, err + } + run.tree, run.workspace, run.ground = tree, tree.dir, tree.ground + } + return remote, nil +} diff --git a/internal/session/task_settings_r8_test.go b/internal/session/task_settings_r8_test.go new file mode 100644 index 0000000000..433fb28602 --- /dev/null +++ b/internal/session/task_settings_r8_test.go @@ -0,0 +1,46 @@ +package session + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// R7-1 and S10 keep unavailable placement out of both settings hands. +func TestTheSettingsToolSaysNothingAboutRunTasksOnWithoutTaskComputers(t *testing.T) { + a, profile := settingsAgent(t) + read, write := settingsHands(t, a) + text, bad := callSetting(t, read, map[string]string{}) + if bad || strings.Contains(text, "Run tasks on") || strings.Contains(text, config.KeyTaskRunsOn) { + t.Fatalf("unsupported listing: %s", text) + } + for _, tool := range []string{"read", "write"} { + hand := read + if tool == "write" { + hand = write + } + args := map[string]string{"key": config.KeyTaskRunsOn} + if tool == "write" { + args["value"] = "dev_0123456789abcdef0123456789abcdef" + } + text, bad = callSetting(t, hand, args) + if !bad { + t.Fatalf("unsupported %s accepted: %s", tool, text) + } + } + if config.TaskRunsOnAt(profile) != "" { + t.Fatal("unsupported write changed the preference") + } +} + +// R7-1 retains the paired conversation's opt-in setting. +func TestTheSettingsToolListsRunTasksOnWhenTheConversationHasTaskComputers(t *testing.T) { + a, _ := settingsAgent(t) + a.config.TaskComputers = &testTaskComputers{} + read, _ := settingsHands(t, a) + text, bad := callSetting(t, read, map[string]string{}) + if bad || !strings.Contains(text, "Run tasks on") { + t.Fatalf("paired listing: %s", text) + } +} diff --git a/internal/session/task_stability_baseline_test.go b/internal/session/task_stability_baseline_test.go new file mode 100644 index 0000000000..e320af1eb2 --- /dev/null +++ b/internal/session/task_stability_baseline_test.go @@ -0,0 +1,186 @@ +package session + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// These frozen record schemas come from origin/dev before task placement. +// Comparing real saved bytes against them catches added fields and reordered +// fields without replacing the runtime values (paths, identities or clocks). +type originalTaskDocument struct { + WorkspaceTransfer *taskcopy.WorkspacePlan `json:"workspace_transfer,omitempty"` + Type string `json:"type"` + Version int `json:"version"` + Seq uint64 `json:"seq"` + Nodes []taskRecord `json:"nodes"` + Runs []originalRunRecord `json:"runs,omitempty"` +} + +type originalRunRecord struct { + RecoveredCompletion bool `json:"recoveredCompletion,omitempty"` + CrewState *TaskCrewRecord `json:"crewState,omitempty"` + ID uint64 `json:"id"` + Run string `json:"run,omitempty"` + Node string `json:"node,omitempty"` + Parent uint64 `json:"parent,omitempty"` + Title string `json:"title,omitempty"` + Kind TaskKind `json:"kind,omitempty"` + Program string `json:"program,omitempty"` + State TaskState `json:"state"` + Stopped bool `json:"stopped,omitempty"` + Report string `json:"report,omitempty"` + Model string `json:"model,omitempty"` + CostUSD float64 `json:"costUsd,omitempty"` + StartedAt time.Time `json:"startedAt,omitzero"` + EndedAt time.Time `json:"endedAt,omitzero"` + WorkspaceSet *WorkspaceSet `json:"workspaceSet,omitempty"` + Copy *TaskCopyRecord `json:"copy,omitempty"` + PendingRun *PendingRunRecord `json:"pendingRun,omitempty"` + PlanTask string `json:"planTask,omitempty"` + ElapsedMS int64 `json:"elapsed_ms,omitempty"` + Ending TaskEnding `json:"ending,omitempty"` + Branch string `json:"branch,omitempty"` + Merge string `json:"merge,omitempty"` + Result string `json:"result,omitempty"` + Changed []string `json:"changed,omitempty"` +} + +type originalTaskNotice struct { + // app/v3 fields that predate remote placement. + Retryable bool + Thinking string + ID uint64 + Run string + Node string + Title string + Kind TaskKind + Program string + Source *TaskSource + Dismissed bool + Ceiling string + Summary string + Brief string + Acceptance string + Where string + WorkspaceURI, ChatURI, ProjectURI, GroundURI, RootURI string + Ground string + Mode TaskMode + Rung GroundRung + DependsOn []uint64 + Parent uint64 + Deadline time.Time + Irreversible bool + Withdrawn string + Decided *TaskAnswer + ModelOptions []string + Elsewhere string + State TaskState + Elapsed time.Duration + StartedAt time.Time + EndedAt time.Time + Report string + Result string + ResultWhole string + ResultCut bool + ResultHeld bool + Changed []string + Branch string + Copy *TaskCopyRecord + WorkspaceSet *WorkspaceSet + RecoveredCompletion bool + PendingRun *PendingRunRecord + PlanTask string + Merge string + Doing string + Context string + Mending string + Waiting string + Paused bool + Settling string + Stopped bool + Ending TaskEnding + Conflicts []string + Shifted bool + GroundHeld bool + Decider TaskAskOwner + Checked provider.Reading + Model string + NextModel string + Crew *crewroute.Decision + CrewState *TaskCrewRecord `json:"-"` + CostUSD float64 + Tokens int +} + +type originalUsageLine struct { + ID string `json:"id,omitempty"` // app/v3: each row names itself once + At time.Time `json:"at"` + Day string `json:"day"` + Model string `json:"model,omitempty"` + Role string `json:"role,omitempty"` + Seat Seat `json:"seat,omitempty"` + Calls int `json:"calls,omitempty"` + Input int `json:"in,omitempty"` + Output int `json:"out,omitempty"` + USD float64 `json:"usd,omitempty"` + Reconciled bool `json:"reconciled,omitempty"` + Unbilled bool `json:"unbilled,omitempty"` + Empty bool `json:"empty,omitempty"` + Session string `json:"session,omitempty"` + Task string `json:"task,omitempty"` + Root string `json:"root,omitempty"` + Standing string `json:"standing,omitempty"` + Automation string `json:"automation,omitempty"` + Workspace string `json:"workspace,omitempty"` + Lane string `json:"lane,omitempty"` + TTFTms int64 `json:"ttft_ms,omitempty"` + TPS float64 `json:"tps,omitempty"` + Hedged bool `json:"hedged,omitempty"` + HedgeWasteUSD float64 `json:"hedge_waste_usd,omitempty"` +} + +type originalJournalUsage struct { + Model string `json:"model,omitempty"` + Input int `json:"input,omitempty"` + Output int `json:"output,omitempty"` + CacheRead int `json:"cacheRead,omitempty"` + CacheWrite int `json:"cacheWrite,omitempty"` + CostUSD float64 `json:"costUsd,omitempty"` + Calls int `json:"calls,omitempty"` + DurationMS int64 `json:"durationMs,omitempty"` + Aux bool `json:"aux,omitempty"` + Empty bool `json:"empty,omitempty"` + Role string `json:"role,omitempty"` +} + +// The schema is the historical fixture, while raw is the actual persisted +// record. Unknown fields and different ordering therefore fail byte equality. +func assertOriginalLocalJSON(t *testing.T, raw []byte, original any, indented bool) { + t.Helper() + if err := json.Unmarshal(raw, original); err != nil { + t.Fatal(err) + } + var expected []byte + var err error + if indented { + expected, err = json.MarshalIndent(original, "", " ") + } else { + expected, err = json.Marshal(original) + } + if err != nil { + t.Fatal(err) + } + if indented { + expected = append(expected, '\n') + } + if !bytes.Equal(raw, expected) { + t.Fatalf("local record changed from the original schema:\n%s\nwant:\n%s", raw, expected) + } +} diff --git a/internal/session/task_stability_test.go b/internal/session/task_stability_test.go new file mode 100644 index 0000000000..b8c22a95ae --- /dev/null +++ b/internal/session/task_stability_test.go @@ -0,0 +1,316 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +type countedTaskComputers struct { + testTaskComputers + lists, engines int +} + +func (c *countedTaskComputers) List(ctx context.Context) ([]TaskComputer, error) { + c.lists++ + return c.testTaskComputers.List(ctx) +} + +func (c *countedTaskComputers) Engine(ctx context.Context, device string) (TaskRunEngine, error) { + c.engines++ + return c.testTaskComputers.Engine(ctx, device) +} + +// S1 and S4 exercise proposal admission, a local join and normal landing with +// a paired home. There must be no placement I/O or remote spec/record fields. +func TestALocalTaskKeepsItsRunLandingAndRecords(t *testing.T) { + computers := &countedTaskComputers{} + a, local := remoteTaskAgent(t, nil, func(c *Config) { c.TaskComputers = computers }) + args := taskOnOtherComputer() + args.Computer = "" + if result, refused := approveRemoteProposal(t, a, args); refused { + t.Fatal(result) + } + <-local.entered + local.mu.Lock() + spec := local.spec + local.mu.Unlock() + if spec.Remote != nil || spec.SaveRemote != nil || spec.WithRemoteCopy != nil || spec.OnRemoteStep != nil || spec.OnRemoteCharge != nil || spec.OnRemotePhase != nil || spec.RemoteStopped != nil || spec.TaskNumber != 0 || spec.NamedModel != "" || spec.NamedEffort != "" || spec.NoProgress != 0 { + t.Fatal("an ordinary run received remote controls") + } + if strings.Contains(spec.Store.Task(spec.Store.RootID()).Description, "\n\nChecks\n") { + t.Fatal("a local plan row gained the remote Checks section") + } + if result, refused := approveRemoteProposal(t, a, args); refused || !strings.Contains(result, "joined") { + t.Fatalf("local join changed: %q, refused=%v", result, refused) + } + close(local.release) + waitRemoteRun(t, a) + if local.lands() == 0 || computers.lists != 0 || computers.engines != 0 { + t.Fatalf("landing=%d, directory=%d, engines=%d", local.lands(), computers.lists, computers.engines) + } + if records, _ := filepath.Glob(filepath.Join(filepath.Dir(spec.Store.Path()), "tasks", "*", "remote.json")); len(records) != 0 { + t.Fatalf("local tasks wrote remote sidecars: %v", records) + } + a.SettleWrites() + checkpoint, err := os.ReadFile(a.config.checkpointFile()) + if err != nil { + t.Fatal(err) + } + assertOriginalLocalJSON(t, checkpoint, &originalTaskDocument{}, true) + for _, row := range a.graph().runRows(1) { + raw, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + assertOriginalLocalJSON(t, raw, &originalTaskNotice{}, false) + } + // The real local billing door must retain the original per-call schema. + a.config.usageLedger = filepath.Join(t.TempDir(), "usage.jsonl") + if err := a.recordUsageLine(bankedCall{model: "test/model", used: Usage{Input: 7, Output: 3, CostUSD: .25, Calls: 1}}); err != nil { + t.Fatal(err) + } + if !FlushUsage() { + t.Fatal("local usage did not reach disk") + } + ledger, err := os.ReadFile(a.config.usageLedger) + if err != nil { + t.Fatal(err) + } + for _, line := range bytes.Split(bytes.TrimSpace(ledger), []byte("\n")) { + assertOriginalLocalJSON(t, line, &originalUsageLine{}, false) + } + a.file.appendUsage(Usage{Input: 7, Output: 3, CostUSD: .25, Calls: 1}, "test/model", true, "") + journal, err := os.ReadFile(a.config.SessionFile) + if err != nil { + t.Fatal(err) + } + usageRows := 0 + for _, line := range bytes.Split(bytes.TrimSpace(journal), []byte("\n")) { + var entry struct { + Type string `json:"type"` + Usage json.RawMessage `json:"usage"` + } + if err := json.Unmarshal(line, &entry); err != nil { + t.Fatal(err) + } + if entry.Type == "usage" { + assertOriginalLocalJSON(t, entry.Usage, &originalJournalUsage{}, false) + usageRows++ + } + } + if usageRows == 0 { + t.Fatal("the local journal recorded no usage") + } +} + +// S4 keeps a paired local run's copy on recovery, stops it through the normal +// cancellation road, and never starts stopped work again on a later reopen. +func TestALocalTaskStopRecoveryAndReopenUseTheOriginalRoad(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done here") + local.honoursStop = true + registerBeltRunEngine(t, local) + computers := &countedTaskComputers{} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = repo, Place{Dir: place}, filepath.Join(place, placeTranscript) + c.ProfileDir, c.TaskComputers, c.AskConsent = profile, computers, false + }) + return a + } + first := open() + g := first.graph() + id := g.reserve() + tree, err := prepareTaskTree(first.config.Place, repo, "1111bbbb1111bbbb", id, "repair export") + if err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(tree.dir, "partial.txt") + if err := os.WriteFile(sentinel, []byte("keep this"), 0600); err != nil { + t.Fatal(err) + } + store, err := plandb.Open(g.planPath(), "repair export", strconv.FormatUint(id, 10), "repair export", "original local brief") + if err != nil { + t.Fatal(err) + } + first.publishRunRow(g, TaskNotice{ID: id, Title: "repair export", State: TaskRunning, Copy: runCopyOf(tree), CrewState: first.unroutedCrewRecord(), PlanTask: planStoreID(strconv.FormatUint(id, 10))}) + // A stray sidecar may not turn a checkpoint without an executor into remote work. + if err := writeRemoteRecord(store, TaskRemoteRecord{Executor: TaskExecutor{Device: "dev_0123456789abcdef0123456789abcdef", Name: "work computer"}, Attempt: "att_0123456789abcdef"}); err != nil { + t.Fatal(err) + } + _ = store.Close() + if err := first.Close(); err != nil { + t.Fatal(err) + } + second := open() + select { + case <-local.entered: + case <-time.After(5 * time.Second): + t.Fatal("the original local recovery did not start") + } + local.mu.Lock() + workspace, brief := local.spec.Workspace, local.spec.Brief + rootBrief := local.spec.Store.Task(local.spec.Store.RootID()).Description + local.mu.Unlock() + if workspace != tree.dir || brief != "" || rootBrief != "original local brief" { + t.Fatalf("local recovery changed copy or instruction: %q, %q, %q", workspace, brief, rootBrief) + } + if raw, err := os.ReadFile(sentinel); err != nil || string(raw) != "keep this" { + t.Fatalf("local recovery changed the retained copy: %q, %v", raw, err) + } + if words, err := second.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)); err != nil || !strings.HasPrefix(words, "stopping ") || !strings.Contains(words, "its branch is kept") { + t.Fatalf("local stop changed: %q, %v", words, err) + } + waitRemoteRun(t, second) + row, _ := runRowOf(second.graph(), id) + if row.State != TaskFailed || !row.Stopped || row.Ending != "" || row.EndedAt.IsZero() || !strings.HasPrefix(row.Report, "stopped") { + t.Fatalf("local stop changed its original ending: %+v", row) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + stopped, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + root := stopped.Task(stopped.RootID()) + _ = stopped.Close() + if root == nil || root.Status != plandb.StatusCancelled { + t.Fatalf("the stopped local plan can be replayed: %+v", root) + } + third := open() + third.beltMu.Lock() + live := third.beltRun + third.beltMu.Unlock() + if live != nil || computers.lists != 0 || computers.engines != 0 { + t.Fatalf("a local reopen used a task computer or replayed stopped work: live=%v lists=%d engines=%d", live != nil, computers.lists, computers.engines) + } +} + +// S1 and S2 require the person's choice. An argument absent from the default +// schema remains an ignored unknown field, as it was before paired tasks. +func TestTheModelCannotOptIntoAnotherComputerWithTheDefaultTool(t *testing.T) { + computers := &countedTaskComputers{} + a, local := remoteTaskAgent(t, nil, func(c *Config) { c.TaskComputers = computers }) + if result, refused := approveRemoteProposal(t, a, taskOnOtherComputer()); refused { + t.Fatalf("an argument outside the default schema changed local admission: %s", result) + } + <-local.entered + close(local.release) + waitRemoteRun(t, a) + if computers.lists != 0 || computers.engines != 0 { + t.Fatalf("the model opted in without the person: directory=%d, engine=%d", computers.lists, computers.engines) + } +} + +// S2 keeps the model's tool and belt facts unchanged until the person chooses +// a standing executor; pairing alone is not that choice. +func TestProposeTaskIsUnchangedUntilRunTasksOnNamesAComputer(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + }) + original := a.taskTools()[0].Schema + facts := promptWithBeltFacts(a.config) + a.config.TaskComputers = testTaskComputers{} + if got := a.taskTools()[0].Schema; !bytes.Equal(got, original) { + t.Fatalf("pairing changed the default proposal schema:\n%s", got) + } + if got := promptWithBeltFacts(a.config); got != facts { + t.Fatal("pairing changed the default belt facts") + } + if err := config.WriteTaskRunsOn(a.config.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + if _, offered := schemaProperties(t, a.taskTools()[0].Schema)["computer"]; !offered { + t.Fatal("the chosen standing executor has no computer argument") + } + if promptWithBeltFacts(a.config) == facts { + t.Fatal("the chosen standing executor has no computer guidance") + } + if err := config.WriteTaskRunsOn(a.config.ProfileDir, ""); err != nil { + t.Fatal(err) + } + if !bytes.Equal(a.taskTools()[0].Schema, original) || promptWithBeltFacts(a.config) != facts { + t.Fatal("resetting to this computer did not restore the original tool and facts") + } +} + +// S4 preserves the instruction a local proposal gives its root, even with checks. +func TestALocalTaskBriefHasNoChecksSection(t *testing.T) { + spec := taskSpec{brief: "Repair export", deliverable: "working export", acceptance: "export succeeds", checks: []string{"go test ./export"}} + const original = "THE WORK\n\nRepair export\n\nWHAT TO PRODUCE\n\nworking export\n\nDONE WHEN\n\nexport succeeds" + if got := proposalBrief(spec, nil); got != original { + t.Fatalf("local brief changed:\n%q", got) + } +} + +// S4 leaves every new placement field out of local checkpoints and wire notices. +func TestALocalTaskRecordOmitsEveryRemoteField(t *testing.T) { + for _, value := range []any{TaskNotice{ID: 7, Title: "Repair export"}, runRowRecord(TaskNotice{ID: 7, Title: "Repair export"}), TaskIndexEntry{ID: "7"}, UsageLine{}, journalUsage{}} { + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"Computer", "Unreachable", "RanElsewhere", "Remote", "computer", "unreachable", "ran_elsewhere", "remote", "remote_event"} { + if strings.Contains(string(raw), `"`+field+`":`) { + t.Fatalf("%T wrote an empty placement field: %s", value, raw) + } + } + } +} + +// S4 keeps local ending words on their existing mapping, even when an engine +// reports a word the remote door also uses. +func TestALocalTaskKeepsItsOriginalEndingMapping(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + notice := a.beltRunNotice(&beltRun{row: 7, title: "Repair export"}, RunSummary{Outcome: "interrupted"}, RunLanding{}) + if notice.State != TaskFailed { + t.Fatalf("local ending took a remote branch: %s", notice.State) + } +} + +// S2 applies to the next turn after changing the setting, including resetting +// it before the ordinary prompt clock is due to refresh. +func TestProposeTaskFactsFollowTheSettingOnTheNextTurn(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.System = "" + c.ProfileDir = t.TempDir() + c.TaskComputers = testTaskComputers{} + }) + now := time.Now().Truncate(time.Minute) + a.mu.Lock() + a.rerenderSystemLocked(now) + original := a.system + a.mu.Unlock() + if err := config.WriteTaskRunsOn(a.config.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + a.mu.Lock() + a.refreshClockLocked(now.Add(time.Second)) + chosen := a.system + a.mu.Unlock() + if !strings.Contains(chosen, "optional `computer` argument") { + t.Fatal("the next turn lacks the chosen computer's belt fact") + } + if err := config.WriteTaskRunsOn(a.config.ProfileDir, ""); err != nil { + t.Fatal(err) + } + a.mu.Lock() + a.refreshClockLocked(now.Add(2 * time.Second)) + restored := a.system + a.mu.Unlock() + if restored != original { + t.Fatal("resetting the preference left a remote promise in the next turn") + } +} diff --git a/internal/session/task_start_r8b_test.go b/internal/session/task_start_r8b_test.go new file mode 100644 index 0000000000..9daf8acfc2 --- /dev/null +++ b/internal/session/task_start_r8b_test.go @@ -0,0 +1,355 @@ +package session + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// R8B and contracts 1–2 require the named task's receipt before repository-sized work. +func TestANamedTaskAnswersWhileItsCopyIsStillPreparing(t *testing.T) { + a, _, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, a, entered, release) + row, found := runRowOf(a.graph(), id) + if !found || row.State != TaskQueued || row.PendingRun == nil || row.Remote == nil || row.Remote.Executor.Name != "work computer" || row.Waiting != "" { + t.Fatalf("preparing task lost its queued placement: %+v", row) + } + release() + waitRemoteRun(t, a) + row, _ = runRowOf(a.graph(), id) + if row.State != TaskFailed || !strings.Contains(row.Report, "the fixture copy could not be prepared") { + t.Fatalf("preparation failure did not reach the task row: %+v", row) + } +} + +// R8B and contract 11 keep stop independent of preparation after the receipt. +func TestANamedTaskCanBeStoppedWhileItsCopyIsPreparing(t *testing.T) { + a, called, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, a, entered, release) + if _, err := a.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + row, _ := runRowOf(a.graph(), id) + if !row.Stopped || row.State != TaskFailed || called.Load() != 0 { + t.Fatalf("stopped preparation reached a worker or lost its ending: row=%+v starts=%d", row, called.Load()) + } +} + +func gatedRemoteStart(t *testing.T) (*Agent, *atomic.Int32, <-chan struct{}, func()) { + t.Helper() + remoteStartHome(t) + entered, freed := make(chan struct{}), make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(freed) }) } + previous := beltRunPrepare + beltRunPrepare = func(ctx context.Context, _ Place, _, _ string, _ uint64, _ string, _ taskStand) (taskTree, error) { + close(entered) + select { + case <-freed: + return taskTree{}, errors.New("the fixture copy could not be prepared") + case <-ctx.Done(): + return taskTree{}, ctx.Err() + } + } + t.Cleanup(func() { beltRunPrepare = previous }) + called := &atomic.Int32{} + remote := &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + called.Add(1) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }} + a, _ := remoteTaskAgent(t, remote, func(c *Config) { + c.ProfileDir = filepath.Join(os.Getenv("CODEAF_HOME"), "profile") + c.TaskMaxLoad, c.TaskMinFreeMB = 0, 0 + }) + t.Cleanup(func() { release(); joinRemoteStartFixture(t, a) }) + return a, called, entered, release +} + +func answerGatedRemoteStart(t *testing.T, a *Agent, entered <-chan struct{}, release func()) uint64 { + t.Helper() + type answer struct { + id uint64 + err error + } + reply := make(chan answer, 1) + returned := make(chan struct{}) + go func() { + defer close(returned) + id, _, _, err := a.StartTaskOn(t.Context(), "repair the export", true, "work computer") + reply <- answer{id, err} + }() + t.Cleanup(func() { release(); <-returned }) + select { + case <-entered: + case result := <-reply: + if result.err != nil || result.id == 0 { + t.Fatalf("named task was not accepted: %+v", result) + } + select { + case <-entered: + case <-time.After(30 * time.Second): + t.Fatal("accepted task never reached preparation") + } + return result.id + case <-time.After(30 * time.Second): + t.Fatal("named task never reached preparation") + } + select { + case result := <-reply: + if result.err != nil || result.id == 0 { + t.Fatalf("named task was not accepted: %+v", result) + } + return result.id + case <-time.After(5 * time.Second): + t.Fatal("the start receipt waited for repository preparation") + return 0 + } +} + +func remoteStartHome(t *testing.T) { + t.Helper() + home, err := os.MkdirTemp("/tmp", "rn-l1-r8b-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(home) }) + // On macOS /tmp is a link into /private/tmp, and owned conversation + // storage refuses a recorded binding whose spelling crosses a link. A real + // home is already canonical, so the fixture names its home the same way. + if home, err = filepath.EvalSymlinks(home); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) +} + +type alreadySelectedTaskComputers struct { + testTaskComputers + factories, recoveries atomic.Int32 +} + +func (c *alreadySelectedTaskComputers) Engine(context.Context, string) (TaskRunEngine, error) { + c.factories.Add(1) + return nil, errors.New("the directory was queried again for a selected computer") +} +func (c *alreadySelectedTaskComputers) RecoverEngine(TaskExecutor) TaskRunEngine { + c.recoveries.Add(1) + return c.engine +} + +// R8B and contract 6 resolve once; each eventual dial still checks permission. +func TestASelectedTaskComputerDoesNotNeedAnotherRosterReadForItsEngine(t *testing.T) { + remoteStartHome(t) + remote := &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }} + computers := &alreadySelectedTaskComputers{testTaskComputers: testTaskComputers{engine: remote}} + a, _ := remoteTaskAgent(t, remote, func(c *Config) { + c.TaskComputers = computers + c.ProfileDir = filepath.Join(os.Getenv("CODEAF_HOME"), "profile") + }) + if _, _, _, err := a.StartTaskOn(t.Context(), "repair the export", true, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + if computers.factories.Load() != 0 || computers.recoveries.Load() != 1 { + t.Fatalf("selected engine read the roster again: factories=%d selected=%d", computers.factories.Load(), computers.recoveries.Load()) + } +} + +// R8B and contract 10 retain a not-yet-dispatched task across closing its chat. +func TestReopeningANamedTaskResumesItsPendingCopyBeforeDispatch(t *testing.T) { + first, started, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, first, entered, release) + if err := first.Close(); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, first) + store, err := plandb.Open(first.graph().planPath(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + root := store.Task(store.RootID()) + _ = store.Close() + if root == nil || terminalStoreStatus(root.Status) { + t.Fatalf("closing preparation ended the pending task: %+v", root) + } + // The old preparer has joined, so recovery can exercise the real copy road. + beltRunPrepare = prepareTaskTreeOn + second, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = first.config.Workspace, first.config.Place, first.config.SessionFile + c.ProfileDir, c.TaskComputers = first.config.ProfileDir, first.config.TaskComputers + c.AskConsent = false + }) + waitRemoteRun(t, second) + row, found := runRowOf(second.graph(), id) + if !found || row.PendingRun != nil || row.Remote == nil || row.Remote.Executor.Name != "work computer" || started.Load() != 1 { + t.Fatalf("reopen did not dispatch the original placement once: row=%+v starts=%d", row, started.Load()) + } + second.recoverBeltRun() + if started.Load() != 1 { + t.Fatal("repeated recovery dispatched another task") + } +} + +// Contracts 10–11 require an unavailable executor to remain remote until an explicit replacement. +func TestReopeningAPendingRemoteTaskDoesNotChooseTheLocalEngine(t *testing.T) { + first, _, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, first, entered, release) + if err := first.Close(); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, first) + first.SettleWrites() + // A mistaken local dispatch must fail the assertion rather than reuse the first gate. + beltRunPrepare = func(context.Context, Place, string, string, uint64, string, taskStand) (taskTree, error) { + return taskTree{}, errors.New("fixture caught an unexpected local dispatch") + } + second := reopenRemoteStart(t, first, nil) + second.beltMu.Lock() + dispatched := second.beltRun != nil + second.beltMu.Unlock() + row, found := runRowOf(second.graph(), id) + if dispatched || !found || row.Remote == nil || row.PendingRun == nil || !strings.Contains(row.Report, "paired task computers are unavailable here") { + t.Fatalf("unavailable remote intent was lost or dispatched locally: live=%v row=%+v", dispatched, row) + } +} + +// Contract 10 preserves the actual owned copy when Close wins after preparation finishes. +func TestReopeningARemoteTaskAdoptsTheCopyPreparedAsTheChatClosed(t *testing.T) { + remoteStartHome(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + prepared := make(chan taskTree, 1) + previous := beltRunPrepare + var preparations atomic.Int32 + beltRunPrepare = func(ctx context.Context, p Place, workspace, chat string, id uint64, title string, stand taskStand) (taskTree, error) { + preparations.Add(1) + tree, err := previous(ctx, p, workspace, chat, id, title, stand) + if err != nil { + return tree, err + } + if err := os.WriteFile(filepath.Join(tree.dir, "kept-before-close"), []byte("prepared copy"), 0600); err != nil { + return tree, err + } + prepared <- tree + <-ctx.Done() + return tree, nil + } + t.Cleanup(func() { beltRunPrepare = previous }) + var starts, resumes atomic.Int32 + remote := &scriptedTaskEngine{ + start: func(context.Context, RunSpec) RunSummary { + starts.Add(1) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }, + resume: func(context.Context, RunSpec) RunSummary { + resumes.Add(1) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }, + } + first, _ := remoteTaskAgent(t, remote, nil) + t.Cleanup(func() { joinRemoteStartFixture(t, first) }) + id, _, _, err := first.StartTaskOn(t.Context(), "repair export", true, "work computer") + if err != nil { + t.Fatal(err) + } + var copy taskTree + select { + case copy = <-prepared: + case <-time.After(30 * time.Second): + t.Fatal("owned copy never prepared") + } + if copy.workspaceSet == nil || copy.uri == "" { + t.Fatal("fixture did not prepare an owned workspace") + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, first) + first.SettleWrites() + beltRunPrepare = func(ctx context.Context, p Place, workspace, chat string, id uint64, title string, stand taskStand) (taskTree, error) { + preparations.Add(1) + return previous(ctx, p, workspace, chat, id, title, stand) + } + second := reopenRemoteStart(t, first, first.config.TaskComputers) + waitRemoteRun(t, second) + row, _ := runRowOf(second.graph(), id) + if row.Copy == nil || row.Copy.URI != copy.uri || preparations.Load() != 1 || starts.Load() != 1 || resumes.Load() != 0 { + t.Fatalf("reopen recarved or lost its owned copy: row=%+v preparations=%d starts=%d resumes=%d", row, preparations.Load(), starts.Load(), resumes.Load()) + } + if raw, err := os.ReadFile(filepath.Join(copy.dir, "kept-before-close")); err != nil || string(raw) != "prepared copy" { + t.Fatalf("prepared bytes lost: %q %v", raw, err) + } +} + +// Contract 3 keeps the ownership handles of a real copy returned with a preparation error. +func TestARemotePreparationFailureKeepsItsRetainedCopyOnTheTaskRow(t *testing.T) { + remoteStartHome(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + previous := beltRunPrepare + beltRunPrepare = func(ctx context.Context, p Place, workspace, chat string, id uint64, title string, stand taskStand) (taskTree, error) { + tree, err := previous(ctx, p, workspace, chat, id, title, stand) + if err != nil { + return tree, err + } + return tree, errors.New("fixture failure after retaining the private copy") + } + t.Cleanup(func() { beltRunPrepare = previous }) + remote := &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + t.Error("failed preparation dispatched a worker") + return RunSummary{} + }} + a, _ := remoteTaskAgent(t, remote, nil) + t.Cleanup(func() { joinRemoteStartFixture(t, a) }) + id, _, _, err := a.StartTaskOn(t.Context(), "repair export", true, "work computer") + if err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + row, _ := runRowOf(a.graph(), id) + if row.State != TaskFailed || row.Copy == nil || row.Copy.URI == "" || row.WorkspaceSet == nil || !strings.Contains(row.Report, "failure after retaining") { + t.Fatalf("failed row dropped its retained private copy: %+v", row) + } + if _, err := os.Stat(row.Copy.Dir); err != nil { + t.Fatal(err) + } +} + +func reopenRemoteStart(t *testing.T, first *Agent, computers TaskComputers) *Agent { + t.Helper() + second, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = first.config.Workspace, first.config.Place, first.config.SessionFile + c.ProfileDir, c.TaskComputers = first.config.ProfileDir, computers + c.AskConsent = false + }) + t.Cleanup(func() { joinRemoteStartFixture(t, second) }) + return second +} + +func joinRemoteStartFixture(t *testing.T, a *Agent) { + t.Helper() + a.beltMu.Lock() + run := a.beltRun + a.beltMu.Unlock() + if run != nil && run.cut != nil { + run.cut() + } + _ = a.Close() + if run != nil { + select { + case <-run.over: + case <-time.After(30 * time.Second): + t.Error("fixture driver did not join before cleanup") + } + } +} diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 66e867e15f..222af9530e 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -772,8 +772,12 @@ type runRecord struct { JobCallID string `json:"job_call_id,omitempty"` JobExitCode *int `json:"job_exit_code,omitempty"` - RecoveredCompletion bool `json:"recoveredCompletion,omitempty"` - CrewState *TaskCrewRecord `json:"crewState,omitempty"` + Remote *TaskRemoteRecord `json:"remote,omitempty"` + Computer string `json:"computer,omitempty"` + Unreachable bool `json:"unreachable,omitempty"` + RanElsewhere bool `json:"ran_elsewhere,omitempty"` + RecoveredCompletion bool `json:"recoveredCompletion,omitempty"` + CrewState *TaskCrewRecord `json:"crewState,omitempty"` ID uint64 `json:"id"` Run string `json:"run,omitempty"` @@ -1208,6 +1212,7 @@ func runRowRecord(notice TaskNotice) runRecord { } return runRecord{ JobCommand: notice.JobCommand, JobCallID: notice.JobCallID, JobExitCode: notice.JobExitCode, + Remote: cloneTaskRemote(notice.Remote), Computer: notice.Computer, Unreachable: notice.Unreachable, RanElsewhere: notice.RanElsewhere, CrewState: notice.CrewState, ID: notice.ID, Run: notice.Run, @@ -1259,6 +1264,7 @@ func runRowRecord(notice TaskNotice) runRecord { func runRowNotice(record runRecord) TaskNotice { notice := TaskNotice{ JobCommand: record.JobCommand, JobCallID: record.JobCallID, JobExitCode: record.JobExitCode, + Remote: cloneTaskRemote(record.Remote), Computer: record.Computer, Unreachable: record.Unreachable, RanElsewhere: record.RanElsewhere, CrewState: record.CrewState, ID: record.ID, Run: record.Run, diff --git a/internal/session/task_watch_r8_test.go b/internal/session/task_watch_r8_test.go new file mode 100644 index 0000000000..831a73fd01 --- /dev/null +++ b/internal/session/task_watch_r8_test.go @@ -0,0 +1,24 @@ +package session + +import ( + "context" + "errors" + "testing" +) + +// The CI watcher split must retain the fatal boundary for a failed durable consumer. +func TestAFailedRemoteRecordWriteStopsWatchingInsteadOfRetrying(t *testing.T) { + want := errors.New("the task record could not be written") + attempts := 0 + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + attempts++ + if attempts > 1 { + return ErrTaskWatchEnded + } + return deliver(1) + } + err := FollowTaskWatch(t.Context(), nil, 0, watch, func(uint64) error { return want }, func(bool) {}) + if !errors.Is(err, want) || attempts != 1 { + t.Fatalf("failed record write returned %v after %d connections", err, attempts) + } +} diff --git a/internal/session/task_workspace_apply_test.go b/internal/session/task_workspace_apply_test.go index ae040353e5..3adcb6b9da 100644 --- a/internal/session/task_workspace_apply_test.go +++ b/internal/session/task_workspace_apply_test.go @@ -13,20 +13,49 @@ import ( "github.com/Agent-Field/codeaf/internal/home" ) +// macOS temp roots can contain symlink ancestors that explicit workspace admission refuses. +func workspaceApplyFixtureDir(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + prepareWorkspaceApplyFixture(t, root) + return root +} + +// macOS success fixtures need supported ownership and no inherited extended attributes. +func prepareWorkspaceApplyFixture(t *testing.T, roots ...string) { + t.Helper() + for _, root := range roots { + if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + normalizeWorkspaceApplyFixtureMetadata(t, path) + return nil + }); err != nil { + t.Fatalf("prepare Apply fixture %s: %v", root, err) + } + } +} + func TestTaskWorkspaceExplicitReviewAndApply(t *testing.T) { + // macOS needs canonical, metadata-free fixtures before the successful Review and Apply. for _, repository := range []bool{false, true} { t.Run(map[bool]string{false: "folder", true: "repository"}[repository], func(t *testing.T) { - t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", workspaceApplyFixtureDir(t)) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv("CODEAF_TASK_BELT", "bash") t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") - source := t.TempDir() + source := workspaceApplyFixtureDir(t) if repository { - source = newTestRepo(t) + source = canonicalPath(newTestRepo(t)) } else { writeFile(t, filepath.Join(source, "shared.txt"), "the original line\n") } - place := Place{Dir: t.TempDir(), Workspace: source} + prepareWorkspaceApplyFixture(t, source) + place := Place{Dir: workspaceApplyFixtureDir(t), Workspace: source} engine := newBeltRunDouble("done") engine.real = true registerBeltRunEngine(t, engine) @@ -68,6 +97,7 @@ func TestTaskWorkspaceExplicitReviewAndApply(t *testing.T) { t.Fatal(err) } t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + prepareWorkspaceApplyFixture(t, copyDir) review, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work") if err != nil { t.Fatal(err) @@ -111,12 +141,14 @@ func TestTaskWorkspaceExplicitReviewAndApply(t *testing.T) { } func TestTaskWorkspaceApplyPreservesConflictingOriginalAndPrivateCopy(t *testing.T) { - t.Setenv("CODEAF_HOME", t.TempDir()) + // macOS needs a supported successful Review before this test introduces its content conflict. + t.Setenv("CODEAF_HOME", workspaceApplyFixtureDir(t)) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv("CODEAF_TASK_BELT", "bash") - source := t.TempDir() + source := workspaceApplyFixtureDir(t) writeFile(t, filepath.Join(source, "shared.txt"), "baseline\n") - place := Place{Dir: t.TempDir(), Workspace: source} + prepareWorkspaceApplyFixture(t, source) + place := Place{Dir: workspaceApplyFixtureDir(t), Workspace: source} engine := newBeltRunDouble("done") engine.real = true registerBeltRunEngine(t, engine) @@ -131,6 +163,7 @@ func TestTaskWorkspaceApplyPreservesConflictingOriginalAndPrivateCopy(t *testing engine.mu.Unlock() writeFile(t, filepath.Join(copyDir, "shared.txt"), "private version\n") endBeltRun(t, agent, engine) + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } @@ -155,21 +188,31 @@ func completedWorkspaceApplyFixture(t *testing.T, repository bool) (*Agent, uint } func completedWorkspaceApplyFixtureWithInput(t *testing.T, repository bool, setup func(string)) (*Agent, uint64, Place, string, string) { + // macOS success fixtures need primary ownership while setup may deliberately retain a different root group. t.Helper() - t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", workspaceApplyFixtureDir(t)) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv("CODEAF_TASK_BELT", "bash") t.Setenv("CODEAF_FURROW", filepath.Join(t.TempDir(), "unavailable-native-engine")) - source := t.TempDir() + source := workspaceApplyFixtureDir(t) if repository { - source = newTestRepo(t) + source = canonicalPath(newTestRepo(t)) } else { writeFile(t, filepath.Join(source, "shared.txt"), "the original line\n") } + prepareWorkspaceApplyFixture(t, source) if setup != nil { setup(source) + // Keep the root group chosen by setup so the inheritance test still exercises it. + entries, err := os.ReadDir(source) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + prepareWorkspaceApplyFixture(t, filepath.Join(source, entry.Name())) + } } - place := Place{Dir: t.TempDir(), Workspace: source} + place := Place{Dir: workspaceApplyFixtureDir(t), Workspace: source} if err := SaveMeta(place.Dir, Meta{ID: place.ID(), Workspace: source, WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { t.Fatal(err) } @@ -187,6 +230,7 @@ func completedWorkspaceApplyFixtureWithInput(t *testing.T, repository bool, setu engine.mu.Unlock() writeFile(t, filepath.Join(copyDir, "shared.txt"), "private result\n") endBeltRun(t, agent, engine) + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } @@ -194,6 +238,7 @@ func completedWorkspaceApplyFixtureWithInput(t *testing.T, repository bool, setu } func TestTaskWorkspaceApplyRejectsAlteredStageReceipt(t *testing.T) { + // macOS needs an unadorned foreign fixture so the refusal still exercises the altered receipt. for _, kind := range []string{"cleanup-path", "stage-destination", "comparison-baseline"} { t.Run(kind, func(t *testing.T) { agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, kind != "comparison-baseline") @@ -215,9 +260,10 @@ func TestTaskWorkspaceApplyRejectsAlteredStageReceipt(t *testing.T) { case "cleanup-path": ticket.Stage.Dir = copyDir case "stage-destination": - foreign := newTestRepo(t) + foreign := canonicalPath(newTestRepo(t)) + prepareWorkspaceApplyFixture(t, foreign) // A second authentic review can be transplanted without inventing any URI. - otherPlace := Place{Dir: t.TempDir(), Workspace: foreign} + otherPlace := Place{Dir: workspaceApplyFixtureDir(t), Workspace: foreign} if err := SaveMeta(otherPlace.Dir, Meta{ID: otherPlace.ID(), Workspace: foreign, WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { t.Fatal(err) } @@ -235,6 +281,7 @@ func TestTaskWorkspaceApplyRejectsAlteredStageReceipt(t *testing.T) { engine.mu.Unlock() writeFile(t, filepath.Join(foreignCopy, "foreign.txt"), "unselected root\n") endBeltRun(t, other, engine) + prepareWorkspaceApplyFixture(t, foreignCopy) if _, err := other.ReviewTaskWorkspaceRoot(context.Background(), otherID, "work"); err != nil { t.Fatal(err) } @@ -300,8 +347,10 @@ func TestTaskWorkspaceReviewReplacesItsOwnedStagingCopy(t *testing.T) { } func TestTaskWorkspaceLargeReviewNamesItsImmutableDiff(t *testing.T) { + // macOS can inherit unsupported metadata on files and directories added after the initial Review. agent, id, _, _, copyDir := completedWorkspaceApplyFixture(t, true) writeFile(t, filepath.Join(copyDir, "large.txt"), strings.Repeat("large immutable line\n", 20000)) + prepareWorkspaceApplyFixture(t, copyDir) review, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work") if err != nil { t.Fatal(err) @@ -324,6 +373,7 @@ func TestTaskWorkspaceLargeReviewNamesItsImmutableDiff(t *testing.T) { } func TestTaskWorkspaceApplyPreservesReviewedPortableMetadata(t *testing.T) { + // macOS can inherit unsupported metadata on files and directories added after the initial Review. for _, kind := range []string{"mode-and-crlf", "empty-directory", "directory-mode"} { t.Run(kind, func(t *testing.T) { agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, false) @@ -345,6 +395,7 @@ func TestTaskWorkspaceApplyPreservesReviewedPortableMetadata(t *testing.T) { } writeFile(t, filepath.Join(copyDir, "nested", "result.txt"), "nested result\n") } + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } @@ -381,14 +432,15 @@ func TestTaskWorkspaceApplyPreservesReviewedPortableMetadata(t *testing.T) { } func TestTaskWorkspaceStartsWithPortableSourceRepresentation(t *testing.T) { + // macOS temp aliases must resolve before these explicit source roots are admitted. for _, repository := range []bool{false, true} { t.Run(map[bool]string{false: "folder", true: "repository"}[repository], func(t *testing.T) { - t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", workspaceApplyFixtureDir(t)) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv("CODEAF_TASK_BELT", "bash") - source := t.TempDir() + source := workspaceApplyFixtureDir(t) if repository { - source = newTestRepo(t) + source = canonicalPath(newTestRepo(t)) } writeFile(t, filepath.Join(source, ".gitattributes"), "shared.txt text eol=lf\n") writeFile(t, filepath.Join(source, "shared.txt"), "input\r\n") @@ -398,7 +450,8 @@ func TestTaskWorkspaceStartsWithPortableSourceRepresentation(t *testing.T) { if err := os.Mkdir(filepath.Join(source, "empty"), 0750); err != nil { t.Fatal(err) } - place := Place{Dir: t.TempDir(), Workspace: source} + prepareWorkspaceApplyFixture(t, source) + place := Place{Dir: workspaceApplyFixtureDir(t), Workspace: source} engine := newBeltRunDouble("done") engine.real = true registerBeltRunEngine(t, engine) @@ -430,6 +483,7 @@ func TestTaskWorkspaceStartsWithPortableSourceRepresentation(t *testing.T) { } func TestTaskWorkspaceApplyUsesExplicitDirectoryDeletionCoverage(t *testing.T) { + // macOS can inherit unsupported metadata on files and directories added after the initial Review. for _, remove := range []bool{false, true} { t.Run(map[bool]string{false: "newly-ignored-retained", true: "explicit-empty-directory-deletion"}[remove], func(t *testing.T) { agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, false, func(source string) { @@ -444,6 +498,7 @@ func TestTaskWorkspaceApplyUsesExplicitDirectoryDeletionCoverage(t *testing.T) { } else { writeFile(t, filepath.Join(copyDir, ".gitignore"), "keep/\n") } + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } diff --git a/internal/session/task_workspace_fixture_other_test.go b/internal/session/task_workspace_fixture_other_test.go new file mode 100644 index 0000000000..f7b7836036 --- /dev/null +++ b/internal/session/task_workspace_fixture_other_test.go @@ -0,0 +1,10 @@ +//go:build !linux && !darwin + +package session + +import "testing" + +// Private Apply is supported only on Linux and macOS, where ownership can be prepared. +func normalizeWorkspaceApplyFixtureMetadata(t *testing.T, path string) { + t.Helper() +} diff --git a/internal/session/task_workspace_fixture_unix_test.go b/internal/session/task_workspace_fixture_unix_test.go new file mode 100644 index 0000000000..12e23f20ac --- /dev/null +++ b/internal/session/task_workspace_fixture_unix_test.go @@ -0,0 +1,138 @@ +//go:build linux || darwin + +package session + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + + "golang.org/x/sys/unix" + + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// macOS inherits directory groups and stamps provenance on otherwise plain fixture files. +func normalizeWorkspaceApplyFixtureMetadata(t *testing.T, path string) { + t.Helper() + if err := os.Lchown(path, os.Getuid(), os.Getgid()); err != nil { + t.Fatalf("set Apply fixture ownership for %s: %v", path, err) + } + // macOS 15 stamps com.apple.provenance on files a process tree started + // from some apps creates, and its owner cannot remove it; the product + // tolerates exactly that attribute (taskcopy.OSManagedAttribute), so the + // fixture leaves it and clears only attributes a person could have set. + for _, name := range workspaceApplyFixtureAttributes(t, path) { + if taskcopy.OSManagedAttribute(name) { + continue + } + if err := unix.Lremovexattr(path, name); err != nil { + t.Fatalf("remove extended attribute %q from Apply fixture %s: %v", name, path, err) + } + } + for _, name := range workspaceApplyFixtureAttributes(t, path) { + if !taskcopy.OSManagedAttribute(name) { + t.Fatalf("extended attribute %q remains on Apply fixture %s after removal", name, path) + } + } +} + +func workspaceApplyFixtureAttributes(t *testing.T, path string) []string { + t.Helper() + size, err := unix.Llistxattr(path, nil) + if errors.Is(err, unix.ENOTSUP) { + return nil + } + if err != nil { + t.Fatalf("list extended attributes on Apply fixture %s: %v", path, err) + } + if size == 0 { + return nil + } + buffer := make([]byte, size) + n, err := unix.Llistxattr(path, buffer) + if err != nil { + t.Fatalf("read extended attributes on Apply fixture %s: %v", path, err) + } + return strings.Split(strings.TrimSuffix(string(buffer[:n]), "\x00"), "\x00") +} + +func TestTaskWorkspaceReviewRefusesInheritedGroupUntilFixtureIsNormalized(t *testing.T) { + // SPEC-4 pins macOS directory-group inheritance on Linux with a setgid fixture. + groups, err := os.Getgroups() + if err != nil { + t.Fatal(err) + } + secondary := -1 + for _, group := range groups { + if group != os.Getgid() { + secondary = group + break + } + } + if secondary < 0 { + t.Skip("no supplementary group differs from the process primary group for the setgid inheritance fixture") + } + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, false) + root := workspaceApplyFixtureDir(t) + if err := os.Chown(root, os.Getuid(), secondary); err != nil { + t.Fatalf("set inherited-group fixture to supplementary group %d: %v", secondary, err) + } + if err := os.Chmod(root, 0750|os.ModeSetgid); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "inherited.txt") + writeFile(t, path, "inherited group\n") + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if stat := info.Sys().(*syscall.Stat_t); stat.Gid != uint32(secondary) { + t.Fatalf("setgid fixture file group = %d, want inherited group %d", stat.Gid, secondary) + } + private := filepath.Join(copyDir, "inherited.txt") + if err := os.Rename(path, private); err != nil { + t.Fatal(err) + } + if _, err := agent.ReviewTaskWorkspaceRoot(t.Context(), id, "work"); err == nil || !strings.Contains(err.Error(), "portable apply cannot preserve a different owner or group") { + t.Fatalf("Review did not refuse inherited file ownership: %v", err) + } + if _, err := os.Stat(filepath.Join(source, "inherited.txt")); !os.IsNotExist(err) { + t.Fatalf("refused Review changed the original: %v", err) + } + prepareWorkspaceApplyFixture(t, private) + if _, err := agent.ReviewTaskWorkspaceRoot(t.Context(), id, "work"); err != nil { + t.Fatalf("normalized fixture still refused Review: %v", err) + } +} + +func TestTaskWorkspaceReviewAcceptsFixtureAfterRemovingExtendedAttributes(t *testing.T) { + // An attribute a person set is still refused by Review; only macOS provenance is tolerated. + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, false) + attribute := "user.codeaf.fixture" + if runtime.GOOS == "darwin" { + attribute = "com.codeaf.fixture" + } + for _, root := range []string{source, copyDir} { + if err := unix.Lsetxattr(filepath.Join(root, "shared.txt"), attribute, []byte("fixture metadata"), 0); err != nil { + t.Fatalf("set extended attribute %q on fixture %s: %v", attribute, root, err) + } + } + if _, err := agent.ReviewTaskWorkspaceRoot(t.Context(), id, "work"); err == nil || !strings.Contains(err.Error(), "portable apply cannot preserve extended attributes or access-control metadata") { + t.Fatalf("Review did not refuse fixture attributes: %v", err) + } + prepareWorkspaceApplyFixture(t, source, copyDir) + if _, err := agent.ReviewTaskWorkspaceRoot(t.Context(), id, "work"); err != nil { + t.Fatalf("metadata-free fixture still refused Review: %v", err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(t.Context(), id, "work"); err != nil { + t.Fatalf("metadata-free fixture still refused Apply: %v", err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "private result\n" { + t.Fatalf("metadata-free Apply changed reviewed bytes: %q", got) + } +} diff --git a/internal/session/task_workspace_transaction_test.go b/internal/session/task_workspace_transaction_test.go index ca8f7f3425..33978cb637 100644 --- a/internal/session/task_workspace_transaction_test.go +++ b/internal/session/task_workspace_transaction_test.go @@ -98,6 +98,7 @@ func TestTaskWorkspaceTransactionRejectsChangedPermissions(t *testing.T) { } func TestTaskWorkspaceTransactionGitRefFailureRollsBackPortableState(t *testing.T) { + // macOS additions need supported metadata without changing the original project root group. agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, true, func(root string) { if err := os.Symlink("shared.txt", filepath.Join(root, "pointer")); err != nil { t.Fatal(err) @@ -120,6 +121,7 @@ func TestTaskWorkspaceTransactionGitRefFailureRollsBackPortableState(t *testing. if err := os.Mkdir(filepath.Join(copyDir, "empty"), 0750); err != nil { t.Fatal(err) } + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } @@ -193,10 +195,12 @@ func TestTaskWorkspaceTransactionPreservesUnrelatedStagedAndDirtyFiles(t *testin } func TestTaskWorkspaceTransactionPreservesRawBytesWithCleanGitRepresentation(t *testing.T) { + // macOS additions need supported metadata without changing the original project root group. agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) mustGit(t, source, "config", "core.safecrlf", "warn") writeFile(t, filepath.Join(copyDir, ".gitattributes"), "*.txt text eol=lf\n") writeFile(t, filepath.Join(copyDir, "shared.txt"), "reviewed CRLF\r\n") + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } @@ -272,11 +276,13 @@ func TestTaskWorkspaceTransactionCheckpointFailureReplaysWithoutApplyingAgain(t } func TestTaskWorkspaceTransactionPreservesGitFilenames(t *testing.T) { + // macOS additions need supported metadata without changing the original project root group. agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) paths := []string{"has space.txt", "line\nbreak.txt", "-leading.txt", "café-文件.txt"} for _, path := range paths { writeFile(t, filepath.Join(copyDir, path), "reviewed "+path+"\r\n") } + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } @@ -294,12 +300,14 @@ func TestTaskWorkspaceTransactionPreservesGitFilenames(t *testing.T) { } func TestTaskWorkspaceTransactionApplyScale(t *testing.T) { + // macOS additions need supported metadata without changing the original project root group. for _, count := range []int{100, 1000} { t.Run(fmt.Sprint(count), func(t *testing.T) { agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) for index := 0; index < count; index++ { writeFile(t, filepath.Join(copyDir, fmt.Sprintf("result-%04d.txt", index)), "reviewed result\r\n") } + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } @@ -413,6 +421,7 @@ func TestTaskWorkspaceTransactionRefusesConfiguredSplitPreference(t *testing.T) } func TestTaskWorkspaceTransactionKeepsRootGroupAndNormalizesNewDirectory(t *testing.T) { + // macOS additions need supported metadata without changing the original project root group. groups, err := os.Getgroups() if err != nil { t.Fatal(err) @@ -435,6 +444,7 @@ func TestTaskWorkspaceTransactionKeepsRootGroupAndNormalizesNewDirectory(t *test if err := os.Mkdir(filepath.Join(copyDir, "new-directory"), 0750); err != nil { t.Fatal(err) } + prepareWorkspaceApplyFixture(t, copyDir) if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { t.Fatal(err) } diff --git a/internal/session/team.go b/internal/session/team.go index 073f9ed98a..35ce4313b0 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -1040,15 +1040,43 @@ func (a *Agent) readTeamCursorsLocked() { return } var stored struct { - Read map[string]string `json:"read"` + Read map[string]string `json:"read"` + Device string `json:"device,omitempty"` } if json.Unmarshal(raw, &stored) != nil { return } + // A COPIED FILE'S POSITIONS COUNT ANOTHER COMPUTER'S LOG. A chat continued + // here from another computer brings its cursor file along, and those ids + // number lines this computer never wrote, so every team starts again from + // [Agent.firstTeamCursor], exactly as for a team met for the first time; + // that keeps the directive a rouse opened this conversation for. + sharing := config.TeamsShareAt(a.config.teamProfile()) + if sharing && stored.Device != "" && stored.Device != teamCursorDevice() { + return + } for id, cursor := range stored.Read { - if strings.TrimSpace(cursor) != "" { - a.team.cursors[id] = cursor + if strings.TrimSpace(cursor) == "" { + continue + } + // An untagged file can still have travelled: it was written before its + // computer paired. A position past the end of this computer's log was + // not written here, and keeping it would silently skip every local line + // up to it, so that team alone starts again. A position at or before the + // end can at worst hand a line over twice, the direction this must fail + // in, and a log that cannot be read keeps the cursor as it always did. + if sharing { + if tail, err := teams.ReadTraffic(a.config.teamProfile(), id, "", 1); err == nil { + end := teamLogStart + if len(tail) > 0 { + end = tail[len(tail)-1].ID + } + if cursor > end { + continue + } + } } + a.team.cursors[id] = cursor } } @@ -1064,9 +1092,14 @@ func (a *Agent) saveTeamCursorsLocked() { for id, cursor := range a.team.cursors { read[id] = cursor } + device := "" + if config.TeamsShareAt(a.config.teamProfile()) { + device = teamCursorDevice() + } raw, err := json.Marshal(struct { - Read map[string]string `json:"read"` - }{read}) + Read map[string]string `json:"read"` + Device string `json:"device,omitempty"` + }{read, device}) if err != nil { return } @@ -1162,7 +1195,7 @@ func (a *Agent) teamDigest(profile string) string { continue } team = managedView(file, team) - log, _ := teams.ReadTraffic(profile, role.id, "", teamStateLook) + log, _ := teams.ReadHistory(profile, role.id, teamStateLook) states := memberStates(team, keys, now, log, &a.team.journals) markShared(file, team, states) parts = append(parts, teams.Digest(team, states, recentOf(log), teamDigestBudget)) diff --git a/internal/session/team_cursor_device.go b/internal/session/team_cursor_device.go new file mode 100644 index 0000000000..b48a93aae2 --- /dev/null +++ b/internal/session/team_cursor_device.go @@ -0,0 +1,43 @@ +package session + +import ( + "path/filepath" + "sync" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" +) + +var cursorDeviceCache struct { + sync.Mutex + root string + stamps [3]fileStamp + device string + known bool + reads int +} + +// Identity reads never create files. Save checks only stamps on quiet paths; +// pairing, replacement and device renewal invalidate the process's cached id. +func teamCursorDevice() string { + root := home.Dir() + stamps := [3]fileStamp{stampOf(filepath.Join(root, "identity-solo")), stampOf(filepath.Join(root, identity.File)), stampOf(filepath.Join(root, identity.DeviceFile))} + cursorDeviceCache.Lock() + defer cursorDeviceCache.Unlock() + if cursorDeviceCache.known && cursorDeviceCache.root == root && cursorDeviceCache.stamps == stamps { + return cursorDeviceCache.device + } + cursorDeviceCache.root, cursorDeviceCache.stamps, cursorDeviceCache.known = root, stamps, true + cursorDeviceCache.device = "" + if identity.Solo(root) { + return "" + } + cursorDeviceCache.reads++ + if _, err := identity.Load(root); err != nil { + return "" + } + if dev, err := identity.LoadDevice(root); err == nil { + cursorDeviceCache.device = dev.ID() + } + return cursorDeviceCache.device +} diff --git a/internal/session/team_locality.go b/internal/session/team_locality.go new file mode 100644 index 0000000000..7f8a60412f --- /dev/null +++ b/internal/session/team_locality.go @@ -0,0 +1,81 @@ +package session + +import ( + "context" + "strings" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/teams" +) + +type teamUnreached struct { + member teams.Member + computer string +} + +// Only remote identities or a successful ownership reading can refuse local +// work. Failed discovery leaves the existing local delivery path available. +func (a *Agent) teamDeliveryTargets(ctx context.Context, targets []teams.Member) ([]teams.Member, []teamUnreached) { + if !config.TeamsShareAt(a.config.teamProfile()) { + return targets, nil + } + names := map[string]string{} + if a.config.TeamComputers != nil { + got, err := a.config.TeamComputers(ctx, targets) + if err != nil { + teams.SharingError(a.config.teamProfile(), err) + } else { + names = got + } + + } + var local []teams.Member + var remote []teamUnreached + for _, member := range targets { + name := names[member.Key] + if name == "" { + _, sentinel := teams.RemoteCell(member.Key) + if (sentinel || teams.OnOtherComputer(member.Key)) && teamCursorDevice() != "" { + name = "the other computer" + } + } + if name == "" { + local = append(local, member) + } else { + remote = append(remote, teamUnreached{member, name}) + } + } + return local, remote +} + +func (a *Agent) teamDeliveryRefusal(ctx context.Context, targets []teams.Member) string { + _, remote := a.teamDeliveryTargets(ctx, targets) + if len(remote) > 0 { + return teams.DeliveryRefusal(remote[0].member, remote[0].computer) + } + return "" +} + +// A mixed audience still receives one local line. The receipt distinguishes +// remote recipients without changing the delivery rules or rewriting addresses. +func (a *Agent) teamSendPreflight(ctx context.Context, targets []teams.Member) (string, string, []teams.Member) { + local, remote := a.teamDeliveryTargets(ctx, targets) + if len(local) == 0 && len(remote) > 0 { + return teams.DeliveryRefusal(remote[0].member, remote[0].computer), "", nil + } + var missed []string + for _, r := range remote { + who := r.member.Word + if r.member.Handle != "" { + who = "@" + r.member.Handle + } + if who == "" { + who = "This chat" + } + missed = append(missed, who+" (on "+r.computer+")") + } + if len(missed) > 0 { + return "", " Not reached: " + strings.Join(missed, ", ") + ".", local + } + return "", "", local +} diff --git a/internal/session/team_locality_test.go b/internal/session/team_locality_test.go new file mode 100644 index 0000000000..a54e221aeb --- /dev/null +++ b/internal/session/team_locality_test.go @@ -0,0 +1,195 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/teams" +) + +func TestLocalTeamDeliverySurvivesMissingFilesAndLookupFailure(t *testing.T) { + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + if _, err := identity.EnsureSolo(root); err != nil { + t.Fatal(err) + } + f := newTeamFixture(t, true) + enableSessionTeamSharing(t, f.profile) + a := teamAgent(t, f, f.manager, nil, manualRelayTeamClock) + a.config.TeamComputers = func(context.Context, []teams.Member) (map[string]string, error) { return nil, errors.New("offline") } + if err := teams.Update(f.profile, func(file *teams.File) error { + for i := range file.Teams[0].Members { + if file.Teams[0].Members[i].Handle == "web" { + file.Teams[0].Members[i].File = "" + } + } + return nil + }); err != nil { + t.Fatal(err) + } + said, failed, err := a.teamSendTool(t.Context(), json.RawMessage(`{"to":"web","text":"local delivery"}`)) + if err != nil || failed { + t.Fatal(said, failed, err) + } + if refusal := a.teamDeliveryRefusal(t.Context(), []teams.Member{{Key: "claim-with-no-file"}, {Key: filepath.Join(root, "missing"), File: filepath.Join(root, "missing")}, {Key: teams.RemoteKey("old")}}); refusal != "" { + t.Fatal("unpaired refusal", refusal) + } + lines, _ := teams.ReadTraffic(f.profile, f.teamID, "", 0) + if len(lines) != 1 || lines[0].Text != "local delivery" { + t.Fatal(lines) + } +} + +func TestMixedTeamRecipientsWriteOnceAndNameThoseNotReached(t *testing.T) { + for _, mode := range []string{"everyone", "several", "room", "all-remote-room", "single"} { + t.Run(mode, func(t *testing.T) { + f := newTeamFixture(t, true) + enableSessionTeamSharing(t, f.profile) + transcript, args := f.manager, `{"to":"everyone","text":"hello"}` + if mode == "several" { + args = `{"to":"web, parser","text":"hello"}` + } + if mode == "single" { + args = `{"to":"web","text":"hello"}` + } + if strings.Contains(mode, "room") { + transcript, args = f.web, `{"text":"hello"}` + } + a := teamAgent(t, f, transcript, nil, manualRelayTeamClock) + calls := 0 + a.config.TeamComputers = func(_ context.Context, members []teams.Member) (map[string]string, error) { + calls++ + names := map[string]string{} + for _, m := range members { + if m.Handle == "web" || m.Handle == "boss" || mode == "all-remote-room" { + names[m.Key] = "desktop" + } + } + return names, nil + } + run := a.teamSendTool + if strings.Contains(mode, "room") { + run = a.teamPostTool + } + said, failed, err := run(t.Context(), json.RawMessage(args)) + wantRefusal := mode == "single" || mode == "all-remote-room" + if err != nil || failed != wantRefusal || calls != 1 { + t.Fatal(said, failed, err, calls) + } + lines, _ := teams.ReadTraffic(f.profile, f.teamID, "", 0) + if wantRefusal { + if len(lines) != 0 || !strings.Contains(said, "message would not reach") { + t.Fatal(said, lines) + } + return + } + if len(lines) != 1 || !strings.Contains(said, "Not reached:") || !strings.Contains(said, "desktop") { + t.Fatal(said, lines) + } + if strings.Contains(mode, "room") { + if !strings.Contains(said, "@boss") || !strings.Contains(teamLine(teamRole{handle: "parser"}, lines[0]), "hello") { + t.Fatal(said, lines) + } + } else if !strings.Contains(teamLine(teamRole{handle: "parser"}, lines[0]), "hello") { + t.Fatal("local recipient lost", lines) + } + }) + } +} + +func TestPacketDecideAndEscalateRefuseRemoteDestinationsBeforeMutation(t *testing.T) { + for _, escalate := range []bool{false, true} { + t.Run(map[bool]string{false: "decide", true: "escalate"}[escalate], func(t *testing.T) { + f := newTeamFixture(t, true) + enableSessionTeamSharing(t, f.profile) + parentKey := "remote-parent" + if err := teams.Update(f.profile, func(file *teams.File) error { + file.Teams = append(file.Teams, teams.Team{ID: "parent", Name: "parent", Members: []teams.Member{{Key: parentKey, Handle: "upstream", Word: "upstream"}, {Key: convKeyOf(t, f.manager), File: f.manager, Handle: "boss", Word: "boss"}}, Manager: parentKey}) + file.Teams[0].Parent = "parent" + for i := range file.Teams[1].Members { + if file.Teams[1].Members[i].Key == convKeyOf(t, f.manager) { + file.Teams[1].Members[i].Home = true + } + } + for i := range file.Teams[0].Members { + if file.Teams[0].Members[i].Key == convKeyOf(t, f.manager) { + file.Teams[0].Members[i].Home = false + } + } + return nil + }); err != nil { + t.Fatal(err) + } + p, err := teams.Raise(f.profile, teams.Packet{Kind: teams.PacketQuestion, Team: f.teamID, Origin: f.teamID, RaisedBy: "web", Question: "which?", Parties: []teams.Party{{Key: convKeyOf(t, f.web), Handle: "web", Team: f.teamID}}}) + if err != nil { + t.Fatal(err) + } + before, _ := teams.ReadTraffic(f.profile, f.teamID, "", 0) + a := teamAgent(t, f, f.manager, nil, manualRelayTeamClock) + a.config.TeamComputers = func(_ context.Context, members []teams.Member) (map[string]string, error) { + names := map[string]string{} + for _, m := range members { + if m.Handle == "web" || m.Key == parentKey { + names[m.Key] = "desktop" + } + } + return names, nil + } + run, args := a.teamDecideTool, `{"packet":"`+p.ID+`","answer":"one"}` + if escalate { + run, args = a.teamEscalateTool, `{"packet":"`+p.ID+`","reason":"need advice"}` + } + said, failed, err := run(t.Context(), json.RawMessage(args)) + after, _ := teams.ReadTraffic(f.profile, f.teamID, "", 0) + held, readErr := teams.PacketByID(f.profile, p.ID) + if err != nil || readErr != nil || !failed || !strings.Contains(said, "desktop") || held.State != p.State || held.Team != p.Team || len(after) != len(before) { + t.Fatal(said, failed, err, held, after) + } + }) + } +} + +func TestCursorIdentityCacheInvalidatesOnPairingAndReplacement(t *testing.T) { + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + if _, err := identity.EnsureSolo(root); err != nil { + t.Fatal(err) + } + if got := teamCursorDevice(); got != "" { + t.Fatal(got) + } + if err := identity.EndSolo(root); err != nil { + t.Fatal(err) + } + first := teamCursorDevice() + cursorDeviceCache.Lock() + before := cursorDeviceCache.reads + cursorDeviceCache.Unlock() + for range 20 { + if got := teamCursorDevice(); got != first { + t.Fatal(got) + } + } + cursorDeviceCache.Lock() + after := cursorDeviceCache.reads + cursorDeviceCache.Unlock() + if first == "" || after != before { + t.Fatal("identity files read on quiet saves", before, after) + } + if err := os.Remove(filepath.Join(root, identity.DeviceFile)); err != nil { + t.Fatal(err) + } + dev, err := identity.Device(root) + if err != nil { + t.Fatal(err) + } + if got := teamCursorDevice(); got != dev.ID() || got == first { + t.Fatal("replacement did not invalidate", got, first) + } +} diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index a3ba58ee5d..3cfd378dde 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -42,6 +42,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/config" "strconv" "strings" "time" @@ -594,6 +595,13 @@ func (a *Agent) teamRaiseTool(ctx context.Context, args json.RawMessage) (string if origin == "" { origin = role.id } + // A remote decider cannot read this computer's packet or its wake. Refuse + // before Raise records either, rather than claiming it reached a manager. + if manager, ok := lca.Member(lca.Manager); ok { + if refusal := a.teamDeliveryRefusal(ctx, []teams.Member{manager}); refusal != "" { + return refusal, true, nil + } + } prov := turnOriginFrom(ctx).provenance(role.key) raised, err := teams.RaiseFrom(profile, teams.Packet{ Team: decider, Origin: origin, Kind: teams.PacketConflict, RaisedBy: role.handle, @@ -696,6 +704,17 @@ func readableBelow(profile string, team teams.Team, words string) (teams.Member, // to, marked with the sub-team it came from, and that manager roused. func (a *Agent) teamPostUp(team teams.Team, role teamRole, entry teams.Entry) (string, bool, error) { profile := a.config.teamProfile() + if config.TeamsShareAt(profile) { + if file, _, _, _ := a.teamSnapshot(profile); file != nil { + if boss, ok := file.Team(role.boss); ok { + if m, ok := boss.Member(boss.Manager); ok { + if refusal := a.teamDeliveryRefusal(context.Background(), []teams.Member{m}); refusal != "" { + return refusal, true, nil + } + } + } + } + } entry.Member = role.key entry.Text = fmt.Sprintf("(from %q, a team under yours with no manager of its own) %s", team.Name, entry.Text) // THE LINE'S OWN NUMBER AND ITS TEAM'S ID ARE IN THE ANSWER, as for a post diff --git a/internal/session/team_questions.go b/internal/session/team_questions.go index 8a832f5e8c..cf0812ec0a 100644 --- a/internal/session/team_questions.go +++ b/internal/session/team_questions.go @@ -43,6 +43,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/config" "strconv" "strings" @@ -141,6 +142,13 @@ func (a *Agent) askUp(ctx context.Context, q Question) (string, bool) { if member.handle == "" { return "", false } + if target, ok := file.Team(home.Team); ok { + if manager, ok := target.Member(target.Manager); ok { + if refusal := a.teamDeliveryRefusal(context.Background(), []teams.Member{manager}); refusal != "" { + return refusal, true + } + } + } packet.Team, packet.Origin, packet.RaisedBy = home.Team, member.id, member.handle packet.Parties = []teams.Party{{Key: member.key, Handle: member.handle, Team: member.id, Context: strings.TrimSpace(q.Reason)}} prov := turnOriginFrom(ctx).provenance(member.key) @@ -298,6 +306,12 @@ func (a *Agent) teamDecideTool(ctx context.Context, args json.RawMessage) (strin case teams.PacketCap, teams.PacketClosing: return fmt.Sprintf("A %s is the person's to decide, never a manager's. Nothing was done.", p.Kind), true, nil } + if config.TeamsShareAt(a.config.teamProfile()) { + file, _, _, _ := a.teamSnapshot(a.config.teamProfile()) + if refusal := a.teamDeliveryRefusal(ctx, teamPacketRecipients(file, p)); refusal != "" { + return refusal, true, nil + } + } // AN OPTION MAY BE NAMED BY ITS LABEL: a model that writes "JSON" for // option 1 means option 1. for _, option := range p.Options { @@ -338,6 +352,11 @@ func (a *Agent) teamEscalateTool(ctx context.Context, args json.RawMessage) (str if home, ok := homeOf(file, []string{team.Manager}); ok { to = home.Team if at, ok := file.Team(home.Team); ok { + if manager, ok := at.Member(at.Manager); ok { + if refusal := a.teamDeliveryRefusal(ctx, []teams.Member{manager}); refusal != "" { + return refusal, true, nil + } + } where = fmt.Sprintf("the manager of %q", at.Name) } } @@ -557,3 +576,38 @@ func teamPacketWakes(profile string, role teamRole, entry teams.Entry) bool { } return packetLineFor(role, entry, p) != "" && (role.manager || entry.State == teams.PacketDecided) } + +// Packet decisions travel only in local Traffic, including rulings to conflict +// parties. Check those exact destinations before changing a packet's state. +func teamPacketRecipients(file *teams.File, p teams.Packet) []teams.Member { + var out []teams.Member + seen := map[string]bool{} + for _, party := range p.Parties { + if party.Key == "" || seen[party.Key] { + continue + } + seen[party.Key] = true + member := teams.Member{Key: party.Key, Handle: party.Handle} + if file != nil { + for _, t := range file.Teams { + if found, ok := t.Member(party.Key); ok { + member = found + break + } + } + } + out = append(out, member) + } + if len(out) == 0 && file != nil { + if origin, ok := file.Team(p.Origin); ok { + if p.RaisedBy == teams.FromManager { + if m, ok := origin.Member(origin.Manager); ok { + out = append(out, m) + } + } else if m, ok := origin.ByHandle(p.RaisedBy); ok { + out = append(out, m) + } + } + } + return out +} diff --git a/internal/session/team_relay_test.go b/internal/session/team_relay_test.go new file mode 100644 index 0000000000..033e60b12c --- /dev/null +++ b/internal/session/team_relay_test.go @@ -0,0 +1,247 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/teams" +) + +// These tests advance the production watcher explicitly so scheduler timing +// cannot consume a cursor before its carried-file fixture is installed. +func manualRelayTeamClock(c *Config) { c.teamWatchManual = true } + +// Exercise the boundary, idle watcher and running-turn stop watcher, all with +// the same mirror. None of these readers may ever consume display history. +func TestTrafficMirrorNeverDeliversStartsQuestionsWakesOrStops(t *testing.T) { + f := newWakingTeamFixture(t) + enableSessionTeamSharing(t, f.profile) + member := teamAgent(t, f, f.web, nil, manualRelayTeamClock) + manager := teamAgent(t, f, f.manager, nil, manualRelayTeamClock) + member.teamBoundary() + manager.teamBoundary() + at := time.Now() + var lines []teams.Entry + for _, kind := range []string{teams.KindDirective, teams.KindStart, teams.KindQuestion, teams.KindStop} { + entry := teams.Entry{ID: "other/" + kind, At: at, Kind: kind, From: teams.FromManager, To: "web", Member: convKeyOf(t, f.web), Text: "history only"} + if kind == teams.KindQuestion { + entry.From, entry.To = "web", teams.ToManager + } + lines = append(lines, entry) + } + if err := teams.WriteTrafficMirror(f.profile, f.teamID, lines); err != nil { + t.Fatal(err) + } + visible, err := teams.ReadHistory(f.profile, f.teamID, 0) + if err != nil || len(visible) != 4 { + t.Fatal(visible, err) + } + if member.teamBoundary() != "" || manager.teamBoundary() != "" { + t.Fatal("delivered mirror") + } + memberWakes, stopMember := member.WatchWakes() + defer stopMember() + managerWakes, stopManager := manager.WatchWakes() + defer stopManager() + member.teamWatchTick(f.profile, at) + manager.teamWatchTick(f.profile, at) + manager.teamWatchTick(f.profile, at.Add(teamWakeDelay())) + for _, wakes := range []<-chan (<-chan Event){memberWakes, managerWakes} { + select { + case <-wakes: + t.Fatal("mirror woke a chat") + default: + } + } + if len(member.teamStarts(f.profile)) != 0 { + t.Fatal("mirror started a member") + } + if member.running || manager.running { + t.Fatal("mirror started a turn") + } + stopped := false + member.running = true + member.teamTurnSerial = 9 + member.cancel = func(error) { stopped = true } + member.teamStopTick(f.profile, at.Add(-time.Second), 9) + if stopped || !member.running { + t.Fatal("mirror stopped the running member") + } + member.running = false + member.cancel = nil + if local, _ := teams.ReadTraffic(f.profile, f.teamID, "", 0); len(local) != 0 { + t.Fatal("watch wrote mirror into log") + } +} + +func TestForeignDeliveryCursorStartsAtThisComputersOpeningAndDeliversOnce(t *testing.T) { + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + if _, err := identity.Ensure(root); err != nil { + t.Fatal(err) + } + dev, err := identity.Device(root) + if err != nil { + t.Fatal(err) + } + f := newWakingTeamFixture(t) + enableSessionTeamSharing(t, f.profile) + // A line from long before the chat arrived is history; the directive sent + // a second before the engine opened is the one a rouse opened it for, which + // a conversation meeting a team for the first time is always handed. + appendTraffic(t, f, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "web", Text: "long before arrival", At: time.Now().Add(-time.Hour)}) + appendTraffic(t, f, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "web", Text: "the rousing directive", At: time.Now().Add(-time.Second)}) + if err := os.MkdirAll(filepath.Join(filepath.Dir(f.web), ".cell"), 0700); err != nil { + t.Fatal(err) + } + a := teamAgent(t, f, f.web, nil, manualRelayTeamClock) + raw, _ := json.Marshal(map[string]any{"device": "device-a", "read": map[string]string{f.teamID: "999999999999"}}) + if err := writeTeamFile(a.teamCursorFile(), raw); err != nil { + t.Fatal(err) + } + if got := a.teamBoundary(); !strings.Contains(got, "the rousing directive") || strings.Contains(got, "long before arrival") { + t.Fatalf("a continued chat must start as a chat meeting the team: %q", got) + } + if got := a.teamBoundary(); got != "" { + t.Fatalf("rousing directive delivered twice: %q", got) + } + appendTraffic(t, f, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "web", Text: "next local directive"}) + if got := a.teamBoundary(); !strings.Contains(got, "next local directive") { + t.Fatalf("lost next line: %q", got) + } + if got := a.teamBoundary(); got != "" { + t.Fatalf("duplicate: %q", got) + } + saved, _ := os.ReadFile(a.teamCursorFile()) + if !strings.Contains(string(saved), dev.ID()) { + t.Fatal("no cursor device") + } + solo := t.TempDir() + t.Setenv("CODEAF_HOME", solo) + if _, err := identity.EnsureSolo(solo); err != nil { + t.Fatal(err) + } + a.saveTeamCursorsLocked() + saved, _ = os.ReadFile(a.teamCursorFile()) + expected := `{"read":{"` + f.teamID + `":"000000000003"}}` + if string(saved) != expected { + t.Fatalf("unpaired cursor encoding changed: %s", saved) + } + + if err := a.Close(); err != nil { + t.Fatal(err) + } + legacy := teamAgent(t, f, f.web, nil, manualRelayTeamClock) + if got := legacy.teamBoundary(); got != "" { + t.Fatalf("a legacy cursor without a device was discarded: %q", got) + } + +} + +func TestRemoteTeamSendsStartsPostsAndRousesRefuseBeforeTraffic(t *testing.T) { + f := newWakingTeamFixture(t) + enableSessionTeamSharing(t, f.profile) + a := teamAgent(t, f, f.manager, nil, manualRelayTeamClock) + a.config.TeamComputers = func(_ context.Context, members []teams.Member) (map[string]string, error) { + names := map[string]string{} + for _, m := range members { + if m.Handle == "web" { + names[m.Key] = "desktop" + } + } + return names, nil + } + checks := []struct { + run func(context.Context, json.RawMessage) (string, bool, error) + args string + }{ + {a.teamSendTool, `{"to":"web","text":"work","kind":"directive"}`}, + {a.teamStartTool, `{"handle":"web","brief":"work"}`}, + {a.teamStopTool, `{"handle":"web"}`}, + } + for _, c := range checks { + said, failed, err := c.run(t.Context(), json.RawMessage(c.args)) + if err != nil || !failed || !strings.Contains(said, "desktop") || !strings.Contains(said, "message would not reach") { + t.Fatalf("%q %v %v", said, failed, err) + } + } + file, _ := teams.Snapshot(f.profile) + m, _ := file.Teams[0].ByHandle("web") + if said := a.teamRouse(f.profile, file.Teams[0], true, []teams.Member{m}, "", teams.Provenance{}); !strings.Contains(said, "desktop") { + t.Fatal(said) + } + member := teamAgent(t, f, f.web, nil, manualRelayTeamClock) + member.config.TeamComputers = func(_ context.Context, members []teams.Member) (map[string]string, error) { + names := map[string]string{} + for _, m := range members { + names[m.Key] = "laptop" + } + return names, nil + } + said, failed, err := member.teamPostTool(t.Context(), json.RawMessage(`{"to":"manager","text":"reply"}`)) + if err != nil || !failed || !strings.Contains(said, "laptop") { + t.Fatal(said, failed, err) + } + said, failed, err = member.teamRaiseTool(t.Context(), json.RawMessage(`{"question":"which format?","parties":["@parser"],"options":[{"label":"one","consequence":"use one"},{"label":"two","consequence":"use two"}]}`)) + if err != nil || !failed || !strings.Contains(said, "laptop") { + t.Fatal("remote decider received a conflict receipt", said, failed, err) + } + local, _ := teams.ReadTraffic(f.profile, f.teamID, "", 0) + if len(local) != 0 { + t.Fatal("refusal wrote Traffic", local) + } +} + +func enableSessionTeamSharing(t *testing.T, profile string) { + t.Helper() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: profile}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } +} + +func TestSharingOffKeepsLegacyCursorBytesAndSkipsEveryOwnershipLookup(t *testing.T) { + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + if _, err := identity.Ensure(root); err != nil { + t.Fatal(err) + } + f := newWakingTeamFixture(t) + a := teamAgent(t, f, f.web, nil, manualRelayTeamClock) + a.config.TeamComputers = func(context.Context, []teams.Member) (map[string]string, error) { + t.Fatal("sharing off called ownership lookup") + return nil, nil + } + targets := []teams.Member{{Key: teams.RemoteKey("remote")}, {Key: teams.OtherComputerKey("device:a:chat")}, {Key: "local"}} + local, unreached := a.teamDeliveryTargets(t.Context(), targets) + if len(local) != len(targets) || len(unreached) != 0 { + t.Fatal("sharing off refused legacy delivery") + } + for _, device := range []string{"", "foreign-device"} { + raw, _ := json.Marshal(map[string]any{"read": map[string]string{f.teamID: "999999999999"}, "device": device}) + if err := writeTeamFile(a.teamCursorFile(), raw); err != nil { + t.Fatal(err) + } + a.team.mu.Lock() + a.team.cursorsRead = false + a.readTeamCursorsLocked() + got := a.team.cursors[f.teamID] + a.saveTeamCursorsLocked() + a.team.mu.Unlock() + if got != "999999999999" { + t.Fatal("sharing off discarded legacy cursor", got) + } + saved, err := os.ReadFile(a.teamCursorFile()) + want := `{"read":{"` + f.teamID + `":"999999999999"}}` + if err != nil || string(saved) != want { + t.Fatalf("sharing off changed cursor shape: %s %v", saved, err) + } + } +} diff --git a/internal/session/team_wakewatch.go b/internal/session/team_wakewatch.go index 5e1e247d30..5d2065c3fb 100644 --- a/internal/session/team_wakewatch.go +++ b/internal/session/team_wakewatch.go @@ -1,6 +1,7 @@ package session import ( + "context" "fmt" "os" "strings" @@ -832,9 +833,15 @@ func SetTeamResume(open func(file, workspace string) error) { // closed over by the goroutine below: the note it may write when the wake fails // is stamped with THAT turn, not with whichever turn this conversation is in // when the wake gives up. A wake nothing caused (a watch tick) passes none. -func (a *Agent) teamRouse(profile string, team teams.Team, wakes bool, targets []teams.Member, answers string, prov teams.Provenance) { +// +// A target on another computer cannot be woken from here: that refusal is +// returned, and nothing is opened. +func (a *Agent) teamRouse(profile string, team teams.Team, wakes bool, targets []teams.Member, answers string, prov teams.Provenance) string { + if refusal := a.teamDeliveryRefusal(context.Background(), targets); refusal != "" { + return refusal + } if profile == "" || !wakes || len(targets) == 0 { - return + return "" } a.team.mu.Lock() self := append([]string(nil), a.teamKeysLocked()...) @@ -847,7 +854,7 @@ func (a *Agent) teamRouse(profile string, team teams.Team, wakes bool, targets [ absent = append(absent, member) } if len(absent) == 0 { - return + return "" } guard.Go("team rouse", func() { for _, member := range absent { @@ -867,6 +874,7 @@ func (a *Agent) teamRouse(profile string, team teams.Team, wakes bool, targets [ } } }) + return "" } // rouseMember opens one conversation when nothing holds it, and says why not diff --git a/internal/session/tools_settings.go b/internal/session/tools_settings.go index 4005795c26..36067d474c 100644 --- a/internal/session/tools_settings.go +++ b/internal/session/tools_settings.go @@ -115,6 +115,9 @@ func (a *Agent) settingsTools() []bare.Tool { // the LIST of rows, which changes only when the binary does. registry := config.NewSettings(config.SettingsOptions{ ProfileDir: dir, + Include: func(key string) bool { + return key != config.KeyTaskRunsOn || a.config.TaskComputers != nil + }, // THE ONE LIVE SEAM, AND IT IS A READER. Without it the conversation's // model row reads BLANK — the registry has no way to ask a running // session what it is on — and a sheet whose first row is empty is a diff --git a/internal/session/tools_settings_test.go b/internal/session/tools_settings_test.go index 67a5a3ce42..f07423d3ab 100644 --- a/internal/session/tools_settings_test.go +++ b/internal/session/tools_settings_test.go @@ -96,6 +96,7 @@ func TestTheSettingsPairIsAbsentInsideATaskAndPresentWithTheDefaultProfile(t *te // write tool takes, grouped the way the sheet groups them. func TestSettingsListsEveryRowByItsRegistryKey(t *testing.T) { agent, profile := settingsAgent(t) + agent.config.TaskComputers = testTaskComputers{} agent.model = "anthropic/claude-opus-5" read, _ := settingsHands(t, agent) text, isError := callSetting(t, read, map[string]string{}) diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 0faabeba3d..3152afde70 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -267,7 +267,7 @@ func (a *Agent) teamStatusTool(ctx context.Context, args json.RawMessage) (strin a.team.mu.Lock() keys := append([]string(nil), a.teamKeysLocked()...) a.team.mu.Unlock() - log, _ := teams.ReadTraffic(profile, team.ID, "", teamStateLook) + log, _ := teams.ReadHistory(profile, team.ID, teamStateLook) file, _, _, _ := a.teamSnapshot(profile) states := memberStates(team, keys, time.Now(), log, &a.team.journals) markShared(file, team, states) @@ -428,7 +428,13 @@ func journalRow(entry sessionEntry) string { // ── team_send ─────────────────────────────────────────────────────────────── -func (a *Agent) teamSendTool(ctx context.Context, args json.RawMessage) (string, bool, error) { +func (a *Agent) teamSendTool(ctx context.Context, args json.RawMessage) (said string, failed bool, callErr error) { + missedReceipt := "" + defer func() { + if !failed && callErr == nil { + said += missedReceipt + } + }() var parsed struct { To string `json:"to"` Text string `json:"text"` @@ -475,6 +481,11 @@ func (a *Agent) teamSendTool(ctx context.Context, args json.RawMessage) (string, } } entry.Kind, entry.From, entry.Text = kind, teams.FromManager, text + refusal, missed, local := a.teamSendPreflight(ctx, teamSendTargets(team, entry)) + missedReceipt = missed + if refusal != "" { + return refusal, true, nil + } prov := turnOriginFrom(ctx).provenance(role.key) entry.Provenance = prov id, err := teams.AppendTrafficID(a.config.teamProfile(), team.ID, entry) @@ -492,7 +503,7 @@ func (a *Agent) teamSendTool(ctx context.Context, args json.RawMessage) (string, // A DIRECTIVE WAKES, and a member nobody has open is opened so it can // (team_wakewatch.go). The answer says what will happen and no more: // whether the wake ran is the Traffic's to say, where the person reads it. - a.teamRouse(a.config.teamProfile(), team, role.wakes, teamSendTargets(team, entry), id, prov) + a.teamRouse(a.config.teamProfile(), team, role.wakes, local, id, prov) if len(shared) > 0 { return fmt.Sprintf("Sent a directive to %d member(s) of %q who report to you: %s. Not sent to %s: they report to another team's manager; send them a note.", len(entry.Recipients()), team.Name, who, strings.Join(shared, ", ")), false, nil @@ -602,12 +613,21 @@ func linkRefusal(member teams.Member, where, what string) string { "Nothing was sent. Send it a note (kind note), or raise it with its manager.", member.Handle, where, what) } -// teamSendTargets is who a manager's message is addressed to: the members it -// names, or every member but the manager. +// teamSendTargets follows local delivery: a member's post reaches its manager, +// and room posts reach teammates as well as explicitly addressed messages. func teamSendTargets(team teams.Team, entry teams.Entry) []teams.Member { var out []teams.Member for _, member := range team.Members { - if member.Key != team.Manager && entry.Addressed(member.Handle) { + if member.Key == team.Manager { + if entry.From != teams.FromManager && entry.From != teams.FromYou && entry.From != teams.FromSystem { + out = append(out, member) + } + continue + } + if member.Handle == entry.From && entry.From != "" { + continue + } + if entry.To == teams.ToRoom || entry.Addressed(member.Handle) { out = append(out, member) } } @@ -644,6 +664,9 @@ func (a *Agent) teamStopTool(ctx context.Context, args json.RawMessage) (string, if reason == "" { reason = "stopped by the manager" } + if refusal := a.teamDeliveryRefusal(ctx, []teams.Member{member}); refusal != "" { + return refusal, true, nil + } entry := teams.Entry{Kind: teams.KindStop, From: teams.FromManager, To: member.Handle, Member: member.Key, Text: reason, Provenance: turnOriginFrom(ctx).provenance(role.key)} if err := teams.AppendTraffic(a.config.teamProfile(), team.ID, entry); err != nil { @@ -684,6 +707,11 @@ func (a *Agent) teamStartTool(ctx context.Context, args json.RawMessage) (string if refusal != "" { return refusal, true, nil } + if member, taken := team.ByHandle(handle); taken { + if refusal := a.teamDeliveryRefusal(ctx, []teams.Member{member}); refusal != "" { + return refusal, true, nil + } + } model := "" if word := strings.TrimSpace(parsed.Model); word != "" { choice := a.resolveTaskModel(word) @@ -768,7 +796,13 @@ func teamStartGloss(arguments string) string { // ── team_post ─────────────────────────────────────────────────────────────── -func (a *Agent) teamPostTool(ctx context.Context, args json.RawMessage) (string, bool, error) { +func (a *Agent) teamPostTool(ctx context.Context, args json.RawMessage) (said string, failed bool, callErr error) { + missedReceipt := "" + defer func() { + if !failed && callErr == nil { + said += missedReceipt + } + }() var parsed struct { To string `json:"to"` Text string `json:"text"` @@ -834,6 +868,20 @@ func (a *Agent) teamPostTool(ctx context.Context, args json.RawMessage) (string, return a.teamPostUp(team, role, entry) } entry.Answers = thread + targets := teamSendTargets(team, entry) + // A specifically named destination must be reachable even when the + // manager would also observe that post in its ordinary local news. + if entry.To != teams.ToRoom && entry.To != teams.ToManager { + targets = nil + if member, ok := team.ByHandle(entry.To); ok { + targets = []teams.Member{member} + } + } + refusal, missed, _ := a.teamSendPreflight(ctx, targets) + if refusal != "" { + return refusal, true, nil + } + missedReceipt = missed // THE POST'S OWN NUMBER IS IN ITS ANSWER, ` as #N`, so the surface can find // this call in the member's transcript when somebody asks to be taken to // the message (tui3's teamjump.go). diff --git a/internal/session/tools_write.go b/internal/session/tools_write.go index b257956396..50d622abe4 100644 --- a/internal/session/tools_write.go +++ b/internal/session/tools_write.go @@ -59,9 +59,9 @@ func (a *Agent) appendableWrite(inner bare.Tool) bare.Tool { Schema: schemaWithAppend(inner.Schema), Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { var parsed struct { - Path string `json:"path"` - Content string `json:"content"` - Append bool `json:"append"` + Path string `json:"path"` + Content *string `json:"content"` + Append bool `json:"append"` } // A call whose arguments do not parse belongs to bare: it owns // the wording of every other write error, and a second parser @@ -69,6 +69,9 @@ func (a *Agent) appendableWrite(inner bare.Tool) bare.Tool { if err := decodeToolArguments(args, &parsed); err != nil || !parsed.Append { return inner.Execute(ctx, args) } + if parsed.Content == nil { + return rejectToolArguments(ctx, "Invalid arguments: content is required and must be a string"), true, nil + } if strings.TrimSpace(parsed.Path) == "" { return rejectToolArguments(ctx, "Invalid arguments: path is required"), true, nil } @@ -85,7 +88,7 @@ func (a *Agent) appendableWrite(inner bare.Tool) bare.Tool { combined, err := json.Marshal(struct { Path string `json:"path"` Content string `json:"content"` - }{parsed.Path, string(existing) + parsed.Content}) + }{parsed.Path, string(existing) + *parsed.Content}) if err != nil { return "Error preparing the append: " + err.Error(), true, nil } @@ -96,8 +99,8 @@ func (a *Agent) appendableWrite(inner bare.Tool) bare.Tool { // The success line speaks in lines rather than bytes because // lines are the unit a continuation reasons in — the salvage // note counts them the same way (salvage.go). - added := lineTally(parsed.Content) - total := lineTally(string(existing) + parsed.Content) + added := lineTally(*parsed.Content) + total := lineTally(string(existing) + *parsed.Content) return fmt.Sprintf("Appended %d line%s to %s; the file now has %d line%s.", added, plural(added), parsed.Path, total, plural(total)), false, nil }, diff --git a/internal/session/usage_ledger.go b/internal/session/usage_ledger.go index 4ee70b8f76..1cf80f038a 100644 --- a/internal/session/usage_ledger.go +++ b/internal/session/usage_ledger.go @@ -160,6 +160,10 @@ type UsageLine struct { // supplied one. Older, auxiliary, or late receipt rows may have no identity; // absence must never be read as a single aggregate run. Run string `json:"run,omitempty"` + // Computer and RemoteEvent identify a mirrored call independently of cursor + // checkpoints, so a replay cannot charge the same call twice. + Computer string `json:"computer,omitempty"` + RemoteEvent string `json:"remote_event,omitempty"` // At is the instant the call was journaled, RFC3339 with nanoseconds. At time.Time `json:"at"` // Day is At's LOCAL calendar day, "2006-01-02". It is written down rather @@ -974,10 +978,10 @@ func scanUsage(reader io.Reader, since time.Time) ([]UsageLine, int64, error) { // now happens on a writer goroutine ([RecordUsage]), the lock is still released // before the hand-off so no reader of the session's totals ever queues behind // the ledger at all. -func (a *Agent) recordUsageLine(call bankedCall) { +func (a *Agent) recordUsageLine(call bankedCall) error { used := call.used if used.Input == 0 && used.Output == 0 && used.CostUSD == 0 { - return + return nil } path := strings.TrimSpace(a.config.usageLedger) if path == "" { @@ -1029,6 +1033,11 @@ func (a *Agent) recordUsageLine(call bankedCall) { } line = TagUsage(line, roles.Role(call.role), seat) line = withUsageBilling(line, call.billing) + if remote := call.remote; remote != nil { + line.Task, line.Computer, line.RemoteEvent = remote.Task, remote.Computer, remote.Event + line.Seat = remote.Seat + return appendRemoteUsage(path, line) + } // THE LANE HALF OF THE LINE GOES ON THROUGH ONE DOOR, and it comes in as an // argument rather than being read from anywhere here — because the only @@ -1053,6 +1062,7 @@ func (a *Agent) recordUsageLine(call bankedCall) { if a.config.mayBashBelt() { a.recordPlanSpend(used, call.model) } + return nil } // usageTaskID spells a node's id the way the task index spells it, and answers diff --git a/internal/session/workspace_chat_test.go b/internal/session/workspace_chat_test.go index 31073263b4..340a96c830 100644 --- a/internal/session/workspace_chat_test.go +++ b/internal/session/workspace_chat_test.go @@ -13,6 +13,7 @@ import ( ) func TestOwnershipChatRoutesScratchWithoutMovingTheProject(t *testing.T) { + // macOS mktemp needs an explicit template to create its file inside the assigned TMPDIR. t.Setenv("CODEAF_HOME", t.TempDir()) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") project := t.TempDir() @@ -20,7 +21,7 @@ func TestOwnershipChatRoutesScratchWithoutMovingTheProject(t *testing.T) { c.Workspace = project c.Place = Place{Dir: t.TempDir(), Workspace: project} }) - if a.config.Workspace != project { + if canonicalPath(a.config.Workspace) != canonicalPath(project) { t.Fatal("a borrowed project was moved") } registry, err := workspaceidentity.Open(home.Join("workspace-identities")) @@ -31,7 +32,7 @@ func TestOwnershipChatRoutesScratchWithoutMovingTheProject(t *testing.T) { if err != nil || chat.Role != workspaceidentity.Chat { t.Fatalf("chat identity missing: %v", err) } - result, err := a.config.seat().In(project).Exec(context.Background(), executor.ExecRequest{Argv: []string{"sh", "-c", "mktemp"}}, nil) + result, err := a.config.seat().In(project).Exec(context.Background(), executor.ExecRequest{Argv: []string{"sh", "-c", `mktemp "$TMPDIR/codeaf-runtime.XXXXXX"`}}, nil) if err != nil { t.Fatal(err) } diff --git a/internal/session/workspace_identity_test.go b/internal/session/workspace_identity_test.go index 706cdf1ba6..c31355ba5f 100644 --- a/internal/session/workspace_identity_test.go +++ b/internal/session/workspace_identity_test.go @@ -128,6 +128,7 @@ func TestOneConversationCanOwnRunsForDifferentProjects(t *testing.T) { } func TestOwnedBeltWorkerUsesRealScratchWithoutChangingHome(t *testing.T) { + // macOS mktemp needs an explicit template to create its file inside the assigned TMPDIR. t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv(home.EnvVar, t.TempDir()) t.Setenv("CODEAF_TASK_BELT", "bash") @@ -148,7 +149,7 @@ func TestOwnedBeltWorkerUsesRealScratchWithoutChangingHome(t *testing.T) { defer store.Close() script := &scriptedCompleter{steps: []step{ func(context.Context, []ai.Message) (*ai.Response, error) { - return toolResponse("scratch", "bash", `{"command":"printf '%s\n' \"$HOME\" \"$TMPDIR\" \"$XDG_CACHE_HOME\" \"$CODEAF_WORKSPACE_URI\" > runtime.txt; mktemp >> runtime.txt"}`), nil + return toolResponse("scratch", "bash", `{"command":"printf '%s\n' \"$HOME\" \"$TMPDIR\" \"$XDG_CACHE_HOME\" \"$CODEAF_WORKSPACE_URI\" > runtime.txt; mktemp \"$TMPDIR/codeaf-runtime.XXXXXX\" >> runtime.txt"}`), nil }, func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, }} @@ -426,11 +427,16 @@ func TestOwnedCopyCannotLandInAnotherConversationsKnownRun(t *testing.T) { } func TestExplicitInPlaceWorkerRoutesScratchWithoutOwningTheProject(t *testing.T) { + // macOS mktemp needs an explicit template to create its file inside the assigned TMPDIR. t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv(home.EnvVar, t.TempDir()) t.Setenv("CODEAF_TASK_BELT", "bash") t.Setenv(planCLIBinEnv, filepath.Join(t.TempDir(), "stub-codeaf")) - project := t.TempDir() + project := filepath.Join(t.TempDir(), "project") + // A symlink alias exercises the canonical path comparison used with macOS temp directories. + if err := os.Symlink(t.TempDir(), project); err != nil { + t.Fatal(err) + } place := Place{Dir: t.TempDir()} tree, err := registerOwnedWorkspace(taskTree{dir: project, ground: project, mode: TaskModeInPlace}, place) if err != nil { @@ -443,7 +449,7 @@ func TestExplicitInPlaceWorkerRoutesScratchWithoutOwningTheProject(t *testing.T) defer store.Close() script := &scriptedCompleter{steps: []step{ func(context.Context, []ai.Message) (*ai.Response, error) { - arguments, _ := json.Marshal(map[string]string{"command": `pwd > placement.txt; printf '%s\n' "$TMPDIR" "$XDG_CACHE_HOME" "$CODEAF_WORKSPACE_URI" >> placement.txt; mktemp >> placement.txt`}) + arguments, _ := json.Marshal(map[string]string{"command": `pwd > placement.txt; printf '%s\n' "$TMPDIR" "$XDG_CACHE_HOME" "$CODEAF_WORKSPACE_URI" >> placement.txt; mktemp "$TMPDIR/codeaf-runtime.XXXXXX" >> placement.txt`}) return toolResponse("runtime", "bash", string(arguments)), nil }, func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, @@ -459,7 +465,7 @@ func TestExplicitInPlaceWorkerRoutesScratchWithoutOwningTheProject(t *testing.T) t.Fatal(err) } lines := strings.Split(strings.TrimSpace(string(raw)), "\n") - if len(lines) != 5 || canonicalPath(lines[0]) != project || lines[3] != tree.projectURI || filepath.Dir(lines[4]) != lines[1] { + if len(lines) != 5 || canonicalPath(lines[0]) != canonicalPath(project) || lines[3] != tree.projectURI || filepath.Dir(lines[4]) != lines[1] { t.Fatalf("in-place worker changed placement: %q", raw) } registry, err := workspaceidentity.Open(home.Join("workspace-identities")) diff --git a/internal/session/workspace_node_ownership_test.go b/internal/session/workspace_node_ownership_test.go index 9c858099ad..0a674e97bc 100644 --- a/internal/session/workspace_node_ownership_test.go +++ b/internal/session/workspace_node_ownership_test.go @@ -142,11 +142,12 @@ func TestLegacyOwnedTaskUnknownIdentityRefusesFreshWorld(t *testing.T) { } func TestLegacyOwnedWorkerUsesItsAdmittedRuntime(t *testing.T) { + // macOS mktemp needs an explicit template to create its file inside the assigned TMPDIR. t.Setenv(home.EnvVar, t.TempDir()) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") script := &scriptedCompleter{steps: []step{ func(context.Context, []ai.Message) (*ai.Response, error) { - return toolResponse("runtime", "bash", `{"command":"printf '%s\\n' \"$CODEAF_WORKSPACE_URI\" \"$TMPDIR\" \"$XDG_CACHE_HOME\" > runtime.txt; mktemp >> runtime.txt"}`), nil + return toolResponse("runtime", "bash", `{"command":"printf '%s\\n' \"$CODEAF_WORKSPACE_URI\" \"$TMPDIR\" \"$XDG_CACHE_HOME\" > runtime.txt; mktemp \"$TMPDIR/codeaf-runtime.XXXXXX\" >> runtime.txt"}`), nil }, func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, }} diff --git a/internal/session/workspace_ownership_public_test.go b/internal/session/workspace_ownership_public_test.go index 3078b022a1..bfb74e3abe 100644 --- a/internal/session/workspace_ownership_public_test.go +++ b/internal/session/workspace_ownership_public_test.go @@ -151,6 +151,7 @@ func TestOwnershipStartTaskReopensPendingWorkspaceSet(t *testing.T) { } func TestOwnershipStartTaskPreservesExplicitInPlaceAndHomeScratch(t *testing.T) { + // macOS temp paths may use aliases for the canonical placement recorded by the worker. for _, choice := range []string{"in place", "home"} { t.Run(choice, func(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) @@ -186,7 +187,7 @@ func TestOwnershipStartTaskPreservesExplicitInPlaceAndHomeScratch(t *testing.T) if spec.WorkspaceSet != nil { t.Fatal("explicit in-place or home reference was converted into a snapshot") } - if choice == "in place" && spec.Workspace != project { + if choice == "in place" && canonicalPath(spec.Workspace) != canonicalPath(project) { t.Fatalf("explicit placement moved to %s", spec.Workspace) } if choice == "home" { diff --git a/internal/session/workspace_set_prepare.go b/internal/session/workspace_set_prepare.go index 21dac3cead..0b6d0fe336 100644 --- a/internal/session/workspace_set_prepare.go +++ b/internal/session/workspace_set_prepare.go @@ -537,6 +537,7 @@ func recoverWorkspaceSetPreparations(place Place, document taskDocument) (taskDo for i := range document.Runs { if document.Runs[i].ID == id { record.CrewState = document.Runs[i].CrewState + record = keepPendingRemotePreparation(receipt, record, document.Runs[i]) document.Runs[i] = record replaced = true break @@ -559,3 +560,12 @@ func recoverWorkspaceSetPreparations(place Place, document taskDocument) (taskDo } return document, changed } + +// A completed preparation receipt belongs to its saved remote intent, not a new local run. +func keepPendingRemotePreparation(receipt workspaceSetPreparation, recovered, saved runRecord) runRecord { + if receipt.Complete && saved.Remote != nil && !saved.Remote.Abandoned && saved.PendingRun != nil { + saved.Copy, saved.WorkspaceSet = recovered.Copy, recovered.WorkspaceSet + return saved + } + return recovered +} diff --git a/internal/session/world.go b/internal/session/world.go index 6ac0b5dab9..92a5513e4d 100644 --- a/internal/session/world.go +++ b/internal/session/world.go @@ -480,15 +480,7 @@ func SessionWorkspaces() map[string]string { if !bucket.IsDir() { continue } - sessions, err := os.ReadDir(filepath.Join(root, bucket.Name())) - if err != nil { - continue - } - for _, entry := range sessions { - if !entry.IsDir() { - continue - } - dir := filepath.Join(root, bucket.Name(), entry.Name()) + for _, dir := range BucketSessionDirs(filepath.Join(root, bucket.Name())) { meta, err := LoadMeta(dir) if err != nil || meta.ID == "" || strings.TrimSpace(meta.Workspace) == "" { continue @@ -499,6 +491,120 @@ func SessionWorkspaces() map[string]string { return answered } +var ( + bucketCopyRoot = cell.CopyRoot + bucketCopied = cell.Copied + bucketOpenAt = cell.OpenAt +) + +// BucketSessionDirs returns every conversation folder in one project bucket, +// whether it lives at the bucket's top level or in the bucket's `.moving` copy +// root, each once. A completed copy in `.moving` replaces its untouched legacy +// original, while an unfinished copy scratch folder stays hidden so a half-made +// copy can never appear beside or instead of the original. +// +// It reads `.moving` at most once up front and checks copy roots only for +// originals whose destination name is present in that `.moving` directory; a +// bucket with no `.moving` directory pays zero per-folder copy work. +func BucketSessionDirs(bucket string) []string { + entries, err := os.ReadDir(bucket) + if err != nil { + return nil + } + hasMoving := false + for _, entry := range entries { + if entry.Name() == ".moving" { + hasMoving = true + break + } + } + var ( + movingDir string + movingEntries []os.DirEntry + movingDirs map[string]bool + movingLocks map[string]bool + ) + if hasMoving { + movingDir = filepath.Join(bucket, ".moving") + if list, readErr := os.ReadDir(movingDir); readErr == nil { + movingEntries = list + for _, entry := range list { + name := entry.Name() + if strings.HasSuffix(name, ".lock") { + if movingLocks == nil { + movingLocks = make(map[string]bool) + } + movingLocks[strings.TrimSuffix(name, ".lock")] = true + continue + } + if !entry.IsDir() || skipBucketDirName(name) { + continue + } + if movingDirs == nil { + movingDirs = make(map[string]bool, len(list)) + } + movingDirs[name] = true + } + } + } + seen := make(map[string]bool, len(entries)+len(movingDirs)) + dirs := make([]string, 0, len(entries)+len(movingDirs)) + var claimedCopy map[string]bool + if len(movingDirs) > 0 { + claimedCopy = make(map[string]bool, len(movingDirs)) + } + for _, entry := range entries { + if !entry.IsDir() || skipBucketDirName(entry.Name()) { + continue + } + orig := filepath.Clean(filepath.Join(bucket, entry.Name())) + resolved := orig + if len(movingDirs) > 0 { + copyName := filepath.Base(bucketCopyRoot(orig)) + if movingDirs[copyName] { + claimedCopy[copyName] = true + resolved = filepath.Clean(bucketCopied(orig)) + } + } + if !seen[resolved] { + seen[resolved] = true + dirs = append(dirs, resolved) + } + } + for _, entry := range movingEntries { + name := entry.Name() + if !entry.IsDir() || skipBucketDirName(name) || claimedCopy[name] { + continue + } + mDir := filepath.Clean(filepath.Join(movingDir, name)) + if seen[mDir] { + continue + } + if movingLocks[name] { + // CopyLegacy creates `.lock` before populating `.moving/` + // and stamps `copy-ready` only after sealing; if the legacy original + // is gone, an unfinished copy still stays hidden. + if _, readyErr := os.Stat(filepath.Join(mDir, filepath.FromSlash(cell.CopyReadyPath))); readyErr != nil { + continue + } + if _, openErr := bucketOpenAt(mDir, name); openErr != nil { + continue + } + } + seen[mDir] = true + dirs = append(dirs, mDir) + } + return dirs +} + +func skipBucketDirName(name string) bool { + return strings.HasPrefix(name, ".") || + strings.HasSuffix(name, ".lock") || + strings.HasSuffix(name, ".taking") || + strings.HasSuffix(name, ".arriving") || + strings.HasSuffix(name, ".replaced") +} + // ReadRows is the rows of the named conversations and of nothing else, keyed by // each transcript's cleaned path: one [readSessionRow] per name, read exactly as // the walk reads that folder. @@ -556,7 +662,7 @@ func (w *World) Adopt(root string, seed SessionRow, now time.Time) bool { if transcript == "." || !isFolder { return false } - bucketDir := filepath.Dir(dir) + bucketDir := BucketOf(transcript) if filepath.Dir(bucketDir) != filepath.Clean(strings.TrimSpace(root)) { return false } @@ -666,8 +772,8 @@ func readWorld(root string, now time.Time) World { // launch that opened and closed is not a project, and a section header over no // rows is a heading that says nothing. func readProject(dir, bucket string, now time.Time) (Project, bool) { - entries, err := os.ReadDir(dir) - if err != nil { + sessionDirs := BucketSessionDirs(dir) + if len(sessionDirs) == 0 { return Project{}, false } // The index is the BUCKET's, read once for every session in it @@ -683,11 +789,9 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { } project := Project{Bucket: bucket, Dir: dir} - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - row, ok := readSessionRow(filepath.Join(dir, entry.Name()), entry.Name(), now) + byID := make(map[string]int, len(sessionDirs)) + for _, sessionDir := range sessionDirs { + row, ok := readResolvedSessionRow(sessionDir, filepath.Base(sessionDir), now) if !ok { continue } @@ -695,6 +799,13 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { project.Path = projectPath(row) } row.Tasks = rollUp(byTask[row.ID], row) + if idx, exists := byID[row.ID]; exists { + if preferSessionRow(row, project.Sessions[idx]) { + project.Sessions[idx] = row + } + continue + } + byID[row.ID] = len(project.Sessions) project.Sessions = append(project.Sessions, row) } if len(project.Sessions) == 0 { @@ -729,6 +840,20 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { return project, true } +func preferSessionRow(candidate, existing SessionRow) bool { + if candidate.Open != existing.Open { + return candidate.Open + } + if candidate.Archived != existing.Archived { + return !candidate.Archived + } + if !candidate.At.Equal(existing.At) { + return candidate.At.After(existing.At) + } + return filepath.Base(filepath.Dir(candidate.Dir)) == ".moving" && + filepath.Base(filepath.Dir(existing.Dir)) != ".moving" +} + // readSessionRow reads one session folder, and answers false for a folder that // is not a conversation somebody has had. // @@ -741,9 +866,21 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { // cannot say what it is, stays, because hiding somebody's conversation on the // strength of a lookup file is the more expensive mistake. func readSessionRow(dir, id string, now time.Time) (SessionRow, bool) { - if copy := cell.Copied(dir); copy != dir { - dir, id = copy, filepath.Base(copy) + // Callers such as ReadRows pass an explicit folder path rather than a + // pre-resolved BucketSessionDirs entry; resolve a `.moving` copy only when + // the folder sits at the bucket's top level and the bucket actually has a + // `.moving` directory. + if filepath.Base(filepath.Dir(dir)) != ".moving" { + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), ".moving")); err == nil { + if copy := bucketCopied(dir); copy != dir { + dir, id = copy, filepath.Base(copy) + } + } } + return readResolvedSessionRow(dir, id, now) +} + +func readResolvedSessionRow(dir, id string, now time.Time) (SessionRow, bool) { pending := false if _, err := os.Stat(filepath.Join(dir, conversationDeletedFile)); err == nil { if _, err := os.Stat(filepath.Join(dir, conversationTaskCleanupFile)); err != nil { diff --git a/internal/session/write_validation_test.go b/internal/session/write_validation_test.go new file mode 100644 index 0000000000..71ea229f0d --- /dev/null +++ b/internal/session/write_validation_test.go @@ -0,0 +1,34 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func TestAppendRejectsMissingContentWithoutCreatingFiles(t *testing.T) { + for _, fields := range []string{`"text":"wrong field"`, `"content":null`} { + t.Run(fields, func(t *testing.T) { + scripted := &scriptedCompleter{steps: []step{ + func(_ context.Context, _ []ai.Message) (*ai.Response, error) { + return toolResponse("badwrite", "write", `{"path":"new/file.txt","append":true,`+fields+`}`), nil + }, + func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + if !strings.Contains(lastToolText(messages), "content") { + t.Error("missing actionable content error") + } + return textResponse("done"), nil + }, + }} + agent, _ := newTestAgent(t, scripted, nil) + collect(t, mustSubmit(t, agent, "write the file")) + if _, err := os.Stat(filepath.Join(agent.config.Workspace, "new")); !os.IsNotExist(err) { + t.Errorf("invalid append created directory: %v", err) + } + }) + } +} diff --git a/internal/syncsetup/cluster.go b/internal/syncsetup/cluster.go index d4afc3ee8f..83246fb1b4 100644 --- a/internal/syncsetup/cluster.go +++ b/internal/syncsetup/cluster.go @@ -6,6 +6,7 @@ import ( "errors" "github.com/Agent-Field/codeaf/internal/clusterctl" cs "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/fsreplace" @@ -13,6 +14,7 @@ import ( "net/http" "os" "path/filepath" + "strings" ) func (s *Sync) clusterClient() clusterctl.Client { @@ -202,3 +204,33 @@ func saveLeadership(path string, v any) error { defer d.Close() return d.Sync() } + +// ClusterNames opens device names with this computer's keys. The service's +// bootstrap members are one sync service in the person's vocabulary. +func (s *Sync) ClusterNames(ctx context.Context, status clusterctl.Status) map[uint64]string { + names := map[uint64]string{} + listing, err := s.Dir.List(ctx) + key := directory.MetadataKey(s.Identity.CellKey()) + for _, member := range status.Members { + if member.Anchor { + names[member.Node] = "the sync service" + continue + } + if err != nil { + continue + } + dev, ok := listing.Devices[member.Device] + if !ok || dev.Revoked { + // An empty mapped name records positive absence; an unmapped node is unknown. + names[member.Node] = "" + continue + } + if name, e := directory.OpenName(key, dev.Name); e == nil && strings.TrimSpace(name) != "" { + names[member.Node] = devname.Shown(name) + } + } + if status.Self != 0 { + names[status.Self] = devname.Name(s.Home) + } + return names +} diff --git a/internal/syncsetup/confirm_work.go b/internal/syncsetup/confirm_work.go index 9e534293e5..a1d661a3f8 100644 --- a/internal/syncsetup/confirm_work.go +++ b/internal/syncsetup/confirm_work.go @@ -115,8 +115,8 @@ func (d *Drive) resumeViewer() { d.mu.Unlock() return } - changed := d.viewer.Line != "" - d.viewer = Viewer{} + changed := d.viewer.Line != "" || d.lost + d.viewer, d.lost = Viewer{}, false d.mu.Unlock() if changed && d.opt.OnDriving != nil { d.opt.OnDriving() diff --git a/internal/syncsetup/continue_reopened_writer_test.go b/internal/syncsetup/continue_reopened_writer_test.go new file mode 100644 index 0000000000..b504bb0a68 --- /dev/null +++ b/internal/syncsetup/continue_reopened_writer_test.go @@ -0,0 +1,48 @@ +package syncsetup + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestAReturnAfterTheWriterReopensIsNotALostRace(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + h := newTwoHomes(t) + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + a := h.openA() + a.mustSay("before moving") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(ctx)) + moved, err := h.continuerB().Take(ctx, h.cell.ID) + mustNil(t, err) + stale := &directory.LeaseExpectation{Device: h.b.Device.ID(), Fence: moved.Taken.Driving.Fence} + h.openOn(h.b, h.engB, moved.Taken.Cell, workspaceOf(moved.Taken.Cell.Root), nameB) + current, err := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, err) + if current.Cell.Lease.Device != stale.Device || current.Cell.Lease.Fence <= stale.Fence { + t.Fatalf("writer did not reopen: %+v", current.Cell.Lease) + } + _, err = h.continuerA().TakeSeen(ctx, h.cell.ID, stale) + t.Logf("card holder=%s ownership=%d; reopened holder=%s ownership=%d; stale confirmation=%v", stale.Device, stale.Fence, current.Cell.Lease.Device, current.Cell.Lease.Fence, err) + if !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("stale consent unexpectedly succeeded: %v", err) + } + // The same holder remains; the surface must ask again for this new ownership. + fresh, err := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, err) + if fresh.Cell.Lease != current.Cell.Lease { + t.Fatal("stale consent displaced the writer") + } + consent := &directory.LeaseExpectation{Device: fresh.Cell.Lease.Device, Fence: fresh.Cell.Lease.Fence} + got, err := h.continuerA().TakeSeen(ctx, h.cell.ID, consent) + mustNil(t, err) + if got.Taken.Driving.Fence <= consent.Fence { + t.Fatal("fresh consent did not continue") + } +} diff --git a/internal/syncsetup/continue_timing_test.go b/internal/syncsetup/continue_timing_test.go new file mode 100644 index 0000000000..a882a855fc --- /dev/null +++ b/internal/syncsetup/continue_timing_test.go @@ -0,0 +1,63 @@ +package syncsetup + +import ( + "log" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/handoff" +) + +func TestAContinueLogsWhereItsTimeWent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + h := newTwoHomes(t) + a := h.openA() + a.mustSay("before the measured move") + h.durable(h.cell.ID, h.cell) + path := filepath.Join(t.TempDir(), "chat.log") + file, err := os.Create(path) + mustNil(t, err) + before := log.Writer() + log.SetOutput(file) + defer func() { log.SetOutput(before); _ = file.Close() }() + c := h.continuerB() + phases := map[string]bool{} + c.opt.OnPhase = func(p handoff.Phase) { + if !p.Ended.IsZero() { + phases[p.Name] = true + } + } + _, err = c.Take(t.Context(), h.cell.ID) + mustNil(t, err) + _, err = h.continuerA().TakeSeen(t.Context(), h.cell.ID, &directory.LeaseExpectation{Device: "old-device", Fence: 1}) + if err == nil { + t.Fatal("expected a failed continue") + } + raw, err := os.ReadFile(path) + mustNil(t, err) + var lines []string + for _, line := range strings.Split(string(raw), "\n") { + if strings.Contains(line, "continue "+h.cell.ID+" phases ") { + lines = append(lines, line) + } + } + if len(lines) != 2 { + t.Fatalf("wanted one line per attempt, got %v", lines) + } + for _, line := range lines { + for _, name := range []string{"capture=", "directory=", "retirement=", "fetch=", "acquire=", "restore=", "hooks=", "filing=", "total="} { + if !strings.Contains(line, name) { + t.Errorf("missing %s in %s", name, line) + } + } + } + for _, name := range []string{"local capture", "directory", "retirement", "fetch", "acquire", "restore", "hooks", "filing"} { + if !phases[name] { + t.Errorf("observer missed %s", name) + } + } +} diff --git a/internal/syncsetup/counts_test.go b/internal/syncsetup/counts_test.go index daa6b1810b..4a8856fd84 100644 --- a/internal/syncsetup/counts_test.go +++ b/internal/syncsetup/counts_test.go @@ -81,9 +81,11 @@ func TestScopesCountsEveryRelayRequest(t *testing.T) { } a.mustSay("orphaned") // A does not know it lost the chat; its publish is refused and becomes a branch waitFor(t, "A to learn it was superseded", func() bool { _, viewer := a.drive.Viewer(); return viewer }) - if _, ok := branchRowOf(t, h.a, h.cell.ID); !ok { - t.Fatal("A's refused turns did not become a branch") - } + // The drive refuses new work before its asynchronous branch upload finishes. + waitFor(t, "A's refused turns to become a branch", func() bool { + _, ok := branchRowOf(t, h.a, h.cell.ID) + return ok + }) if err := a.drive.Close(ctx); err != nil { t.Fatal(err) } diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index fd5bab4641..749ebd9717 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -72,6 +72,10 @@ type Drive struct { stopped bool mu sync.Mutex viewer Viewer // the superseded sentence and device, empty while this device drives + // lost is true as soon as this writer learns its hold is gone, before its + // pending turns are branched and the new holder is named; a confirmed + // hold clears it ([Drive.resumeViewer]). + lost bool } // Viewer names the computer that continued a chat and the engine's sentence. @@ -307,20 +311,18 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri if !known { return drv, nonemptyHolder(view.Cell.Lease.Device), nil } - var localHead string + // Turns sealed here on top of the record's head are this computer's own + // work, not an older tree: taking the free lease publishes them (catchUp). wasFree := view.Cell.Lease.Expires <= view.Now // An outage can leave our own saved head ahead of the relay. The // unchanged, unreleased hold can renew without discarding that work. resumeOwn := view.Cell.Lease.Device == s.Device.ID() && view.Cell.Lease.Expires != 0 if wasFree && !resumeOwn { - head, err := cellstore.Head(c) + extends, err := cellstore.Extends(c, view.Cell.Head) if err != nil { return drv, "", err } - if head != nil { - localHead = head.Turn.ID - } - if localHead != view.Cell.Head { + if !extends { return drv, nonemptyHolder(view.Cell.Lease.Device), nil } } @@ -344,7 +346,7 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri return drv, "", err } known, err = cellstore.KnowsHead(c, v.Cell.Head) - if err != nil || !known || (wasFree && !resumeOwn && localHead != v.Cell.Head) { + if err != nil || !known || (wasFree && !resumeOwn && view.Cell.Head != v.Cell.Head) { _ = s.Dir.Release(ctx, drv.ID(), v.Cell.Lease.Fence) return drv, nonemptyHolder(view.Cell.Lease.Device), err } @@ -385,7 +387,7 @@ func (s *Sync) batcher(eng cellstore.Engine, c cell.Cell, drv cellsync.Driving, Info: d.info, OnFlush: d.flushed(), OnSuperseded: d.becomeViewer, - OnLost: func() { d.refusal(directory.ErrFenceStale) }, + OnLost: d.loseHold, OnError: d.refusal, Sleep: d.opt.Sleep, Liveness: s.Liveness(), @@ -402,6 +404,28 @@ func (d *Drive) info() cellsync.PublishInfo { return d.sync.publishInfo(d.cell, title) } +// loseHold retires this writer's permission to run tools at once, while +// leaving the viewer line, the notice and the send receipt until pending work +// has been preserved as a branch and the holder read ([Drive.becomeViewer]): +// a hold this computer still has keeps its receipt. +func (d *Drive) loseHold() { + d.mu.Lock() + changed := !d.lost && d.viewer.Line == "" + d.lost = true + d.mu.Unlock() + if changed && d.opt.OnRenewalChange != nil { + d.opt.OnRenewalChange() + } +} + +// displaced is whether this writer's hold is gone or another computer holds +// the chat: renewal health is not this window's to report then. +func (d *Drive) displaced() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.lost || d.viewer.Line != "" +} + // becomeViewer is the Batcher's word that another device took the chat over: // from now on this window only shows it, and says who has it. func (d *Drive) becomeViewer(s cellsync.Superseded) { @@ -478,7 +502,11 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { if s.Branch != "" { d.notice(chatlist.KeptEdits(s.Turns, d.opt.DeviceName)) } - d.notice(line) + // A repeated refusal or ownership check that names the same holder says + // nothing new: displacement is announced once. + if previous.Line != line { + d.notice(line) + } } // refusal shows the person-facing line of a refused request, which the @@ -525,7 +553,7 @@ func (d *Drive) Viewer() (string, bool) { // RenewalStatus reads this writer's cached renewal health without network work. func (d *Drive) RenewalStatus() (time.Time, time.Duration, bool) { - if _, viewer := d.Viewer(); viewer || d.batcher == nil || d.sync.removed.Load() { + if d.displaced() || d.batcher == nil || d.sync.removed.Load() { return time.Time{}, 0, false } return d.batcher.RenewalStatus() @@ -584,6 +612,19 @@ func (d *Drive) CheckLease(ctx context.Context) error { return nil } if err == nil && (v.Cell.Lease.Expires <= v.Now || v.Cell.Lease.Device != d.sync.Device.ID() || v.Cell.Lease.Fence != d.batcher.Fence()) { + if v.Cell.Lease.Device != d.sync.Device.ID() { + // Another computer holds the chat. Retire the hold at once so no + // new seal starts, wait for any seal already past Gate, and let the + // batcher keep unsent turns as a branch before the window names the + // new holder (#2091). The hold is retired quietly: becomeViewer + // announces the change once, with the viewer line already set. + d.mu.Lock() + d.lost = true + d.mu.Unlock() + d.sealMu.Lock() + d.sealMu.Unlock() + d.batcher.Displaced(ctx) + } d.becomeViewer(cellsync.Superseded{By: v.Cell.Lease.Device}) } else if err == nil { d.resumeViewer() @@ -641,7 +682,7 @@ func nonemptyHolder(id string) string { // nil while this device drives it. It is what a viewer's tool calls meet. func (d *Drive) Gate() error { d.mu.Lock() - viewer, stopped := d.viewer, d.stopped + viewer, stopped, lost := d.viewer, d.stopped, d.lost d.mu.Unlock() if stopped { return cellstore.ErrReadOnly @@ -649,6 +690,9 @@ func (d *Drive) Gate() error { if viewer.Line != "" { return &executor.WorkRefusal{Line: viewer.Line, HolderID: viewer.ID, HolderName: viewer.Name} } + if lost { + return &executor.WorkRefusal{Line: chatlist.Superseded(unknownDevice)} + } return nil } diff --git a/internal/syncsetup/drive_refusal_safety_test.go b/internal/syncsetup/drive_refusal_safety_test.go index bd825ce251..059731fd50 100644 --- a/internal/syncsetup/drive_refusal_safety_test.go +++ b/internal/syncsetup/drive_refusal_safety_test.go @@ -1,19 +1,59 @@ package syncsetup import ( + "slices" "testing" "time" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" ) func TestAStaleDriveRefusalImmediatelyMakesTheWindowAViewer(t *testing.T) { - d := &Drive{sync: &Sync{Home: t.TempDir()}} + // Refusal retires a saved send hold, so even this minimal drive needs an isolated home. + var notices []string + d := &Drive{ + sync: &Sync{Home: t.TempDir()}, + opt: DriveOptions{OnNotice: func(line string) { notices = append(notices, line) }}, + } + d.refusal(directory.ErrFenceStale) d.refusal(directory.ErrFenceStale) if d.Gate() == nil { t.Fatal("a refused drive still allows new work") } + want := chatlist.Superseded(unknownDevice) + if line, viewer := d.Viewer(); !viewer || line != want { + t.Fatalf("viewer = %q, %v; want %q, true", line, viewer, want) + } + if !slices.Equal(notices, []string{want}) { + t.Fatalf("notices = %v, want [%q] once", notices, want) + } +} + +func TestStalePublishRefusalMakesViewerAndNotifiesOnce(t *testing.T) { + h := newTwoHomes(t) + ctx := t.Context() + a := h.openA() + a.mustSay("durable") + h.durable(h.cell.ID, h.cell) + h.block.set(true) + a.mustSay("orphaned") + h.lapse() + mustNil(t, h.b.putDevice(ctx, nameB)) + if _, err := h.continuerB().Take(ctx, h.cell.ID); err != nil { + t.Fatal(err) + } + h.block.set(false) + want := chatlist.Superseded(nameB) + waitFor(t, "A to become a viewer after stale publish", func() bool { _, viewer := a.drive.Viewer(); return viewer }) + if line, viewer := a.drive.Viewer(); !viewer || line != want { + t.Fatalf("viewer = %q, %v; want %q, true", line, viewer, want) + } + kept := chatlist.KeptEdits(1, nameA) + if got := h.wall.all(); !slices.Equal(got, []string{kept, want}) { + t.Fatalf("notices = %v, want [%q %q]", got, kept, want) + } } func TestTheSendProofNamesTheDrivesActualFence(t *testing.T) { @@ -49,7 +89,11 @@ func TestFenceF1ReleasedHoldCannotAuthorizeTools(t *testing.T) { } } -func TestReleasedOlderHeadIsNotAcquiredOverLocalWork(t *testing.T) { +// TestReleasedHeadIsContinuedWithThisComputersNewerTurns pins #2072: turns +// this computer sealed on top of a released head are its own work, so the +// drive takes the free lease and publishes them instead of opening the chat +// read-only. It replaces a law that locked the only computer out of its chat. +func TestReleasedHeadIsContinuedWithThisComputersNewerTurns(t *testing.T) { r := newDriveRig(t) first, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) mustNil(t, err) @@ -61,7 +105,34 @@ func TestReleasedOlderHeadIsNotAcquiredOverLocalWork(t *testing.T) { drive, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) mustNil(t, err) defer drive.Close(t.Context()) - if drive.Gate() == nil { - t.Error("a released older head was silently acquired over local work") + if err := drive.Gate(); err != nil { + t.Errorf("this computer's newer turns opened read-only: %v", err) + } +} + +// An ownership check that finds another computer holding the chat keeps the +// unsent turn as a branch before the window names the new holder (#2091). +func TestOwnershipCheckThatFindsAnotherHolderKeepsUnsentTurns(t *testing.T) { + h := newTwoHomes(t) + ctx := t.Context() + a := h.openA() + a.mustSay("durable") + h.durable(h.cell.ID, h.cell) + h.block.set(true) + a.mustSay("orphaned") + h.lapse() + mustNil(t, h.b.putDevice(ctx, nameB)) + if _, err := h.continuerB().Take(ctx, h.cell.ID); err != nil { + t.Fatal(err) + } + // The check alone, not a later flush, keeps the turn: it is kept by the + // time the check returns. + mustNil(t, a.drive.CheckLease(ctx)) + if got := h.wall.all(); !slices.Contains(got, chatlist.KeptEdits(1, nameA)) { + t.Fatalf("notices = %v, want the unsent turn kept", got) + } + if a.drive.Gate() == nil { + t.Fatal("a displaced window still admits work") } + h.block.set(false) } diff --git a/internal/syncsetup/drive_test.go b/internal/syncsetup/drive_test.go index da3f0dab19..f6b8dba318 100644 --- a/internal/syncsetup/drive_test.go +++ b/internal/syncsetup/drive_test.go @@ -225,10 +225,12 @@ func (r *driveRig) startChat(notices *noticeLog) *chat { return &chat{r: r, drive: drive, seat: executor.Gated(seat, drive.Gate)} } -// say is one scripted model turn: it writes a file and the seat seals the call. +// say is one scripted model turn: it records a reply, writes a file and seals the call. func (c *chat) say() error { c.calls++ name := filepath.Join(c.r.work, "note"+string(rune('a'+c.calls))+".txt") + // Publication ignores unused chats, so each scripted reply must reach the transcript. + appendTo(c.r.t, transcriptOf(c.r.cell), "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"fixture turn\"}\n") call := executor.Call{Tool: "bash", Args: []byte(`{"command":"echo"}`)} return c.seat.Around(context.Background(), call, func() ([]byte, bool) { return nil, os.WriteFile(name, []byte("turn "+name), 0o600) != nil @@ -335,6 +337,8 @@ func countFiles(t *testing.T, dir string) (n int) { // C5 also requires the kept work to be announced rather than silently forked. func TestDriveSideSupersededStopsTools(t *testing.T) { r := newDriveRig(t) + // Suppress watches and heartbeats so A can seal its next call before discovering the takeover. + r.a.Dir = offlineDir{Client: r.a.Dir, down: func() bool { return true }} ctx := context.Background() var notices noticeLog c := r.startChat(¬ices) @@ -357,13 +361,14 @@ func TestDriveSideSupersededStopsTools(t *testing.T) { c.mustSay() // A does not know yet; this turn is unpublished want := chatlist.Superseded("laptop") waitFor(t, "A to learn it was superseded", func() bool { _, viewer := c.drive.Viewer(); return viewer }) + // The early viewer notice precedes branching, so wait for the kept-work notice too. + kept := chatlist.KeptEdits(1, "tower") + waitFor(t, "the superseded and kept-work lines to be shown", func() bool { + return notices.last() == want && notices.has(kept) + }) if line, _ := c.drive.Viewer(); line != want { t.Fatalf("line = %q, want %q", line, want) } - waitFor(t, "the superseded line to be shown", func() bool { return notices.last() == want }) - if kept := chatlist.KeptEdits(1, "tower"); !notices.has(kept) { - t.Fatalf("the displaced window silently kept a branch; notices = %v, want %q", notices.all(), kept) - } v, err := r.a.Dir.Cell(ctx, r.cell.ID) if err != nil || v.Cell.Head != durable { diff --git a/internal/syncsetup/firstrun.go b/internal/syncsetup/firstrun.go index ab3c563cec..66dc31ed3e 100644 --- a/internal/syncsetup/firstrun.go +++ b/internal/syncsetup/firstrun.go @@ -2,14 +2,26 @@ package syncsetup import ( "errors" + "net/url" "os" "path/filepath" + "strings" ) -// FirstRunLine is the one sentence a person is told, once, when their chats -// start to sync through the hosted relay without them having chosen it. It asks -// for nothing: there is no sign-in and no consent wall, only the two ways out. -const FirstRunLine = "Your chats sync end-to-end encrypted through codeaf's fabric (codeaf.agentfield.ai/fabric), a sync service that only ever sees ciphertext; turn that off with CODEAF_SYNC_URL=off, or use your own with CODEAF_SYNC_URL=." +// FirstRunLineFor formats the one sentence a person is told, once, when their +// chats start to sync through a hosted relay without them having chosen it. It +// asks for nothing: there is no sign-in and no consent wall, only the two ways +// out. +func FirstRunLineFor(relayURL string) string { + shown := strings.TrimSpace(relayURL) + if u, err := url.Parse(shown); err == nil && u.Host != "" { + shown = u.Host + strings.TrimRight(u.Path, "/") + } + return "Your chats sync end-to-end encrypted through codeaf's fabric (" + shown + "), a sync service that only ever sees ciphertext; turn that off with CODEAF_SYNC_URL=off, or use your own with CODEAF_SYNC_URL=." +} + +// FirstRunLine is the first-run sentence for this binary's hosted sync service. +var FirstRunLine = FirstRunLineFor(HostedRelayURL) // toldFile marks that the line has been said on this computer. const toldFile = "sync-told" diff --git a/internal/syncsetup/home_removed_test.go b/internal/syncsetup/home_removed_test.go new file mode 100644 index 0000000000..29b4f16224 --- /dev/null +++ b/internal/syncsetup/home_removed_test.go @@ -0,0 +1,107 @@ +package syncsetup + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/vaultsync" +) + +// C4.1: a late .env import must leave a removed home absent. +func TestGuardLeavesRemovedHomeAbsent(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + if _, err := identity.Ensure(h); err != nil { + t.Fatal(err) + } + c, err := cell.Create(h, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, ".env"), []byte("L6_FIXTURE=local\n"), 0o600); err != nil { + t.Fatal(err) + } + deleted := false + g := cellstore.Guard{Home: h, Kept: func(string) { + if !deleted { + deleted = true + if err := os.RemoveAll(h); err != nil { + t.Fatal(err) + } + } + }} + // Screening can refuse the import or keep going without it; neither may create keys. + _ = g.Screen(c, c.Root, filepath.Join(c.Root, cell.StateDir), []string{".env"}) + if !deleted { + t.Fatal("fixture never found .env") + } + if _, err := os.Stat(h); !os.IsNotExist(err) { + t.Fatalf("removed home was recreated: %v", err) + } +} + +// C4.1: capture refuses a replacement root before creating even its lock file. +func TestVaultCaptureLeavesReplacedHomeUntouched(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + if _, err := identity.Ensure(h); err != nil { + t.Fatal(err) + } + if err := os.Rename(h, h+".old"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(h, 0o700); err != nil { + t.Fatal(err) + } + s := &Sync{Home: h} + err := s.withVault(func(vaultsync.Syncer) error { t.Fatal("capture reached replacement home"); return nil }) + if !errors.Is(err, identity.ErrHomeRemoved) { + t.Fatalf("capture = %v", err) + } + entries, err := os.ReadDir(h) + if err != nil || len(entries) != 0 { + t.Fatalf("capture changed replacement: %v, %v", entries, err) + } +} + +// SPEC-3 item 2: writing or retiring a send hold cannot recreate a lost root. +func TestFixRound2SendHoldRefusesRemovedHome(t *testing.T) { + for _, replaced := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "replaced"}[replaced], func(t *testing.T) { + h := filepath.Join(t.TempDir(), "home") + id, err := identity.Ensure(h) + if err != nil { + t.Fatal(err) + } + dev, err := identity.Device(h) + if err != nil { + t.Fatal(err) + } + s := &Sync{Home: h, Identity: id, Device: dev, Ledger: "local"} + if err := os.Rename(h, h+".old"); err != nil { + t.Fatal(err) + } + if replaced { + if err := os.Mkdir(h, 0700); err != nil { + t.Fatal(err) + } + } + held := sendLease{Cell: "chat", Fence: 1} + if err := s.recordSendLease("chat", held); err == nil { + t.Fatal("wrote hold into removed home") + } + _ = s.forgetSendLease("chat", held) + if replaced { + entries, err := os.ReadDir(h) + if err != nil || len(entries) != 0 { + t.Fatalf("replacement changed: %v %v", entries, err) + } + } else if _, err := os.Stat(h); !os.IsNotExist(err) { + t.Fatalf("home recreated: %v", err) + } + }) + } +} diff --git a/internal/syncsetup/l4b2_names_test.go b/internal/syncsetup/l4b2_names_test.go new file mode 100644 index 0000000000..3258366a06 --- /dev/null +++ b/internal/syncsetup/l4b2_names_test.go @@ -0,0 +1,53 @@ +package syncsetup + +import ( + "context" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/clusterctl" + cs "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" +) + +type l4B2NameDirectory struct { + directory.Client + listing directory.Listing + err error +} + +func (d *l4B2NameDirectory) List(context.Context) (directory.Listing, error) { return d.listing, d.err } + +// L4-M14e: Only a successful roster read can establish that a labelled member's device is absent. +func TestL4B2ClusterNameEvidence(t *testing.T) { + dir := t.TempDir() + id, err := identity.Ensure(dir) + if err != nil { + t.Fatal(err) + } + sealed, err := directory.SealName(directory.MetadataKey(id.CellKey()), "desk-b") + if err != nil { + t.Fatal(err) + } + roster := &l4B2NameDirectory{listing: directory.Listing{Devices: map[string]directory.Device{"kept": {Name: sealed}, "unreadable": {Name: "not encrypted"}}}} + s := &Sync{Home: dir, Identity: id, Dir: roster} + status := clusterctl.Status{Self: 1, Members: []cs.MemberLabel{{Node: 2, Device: "kept"}, {Node: 3, Device: "gone"}, {Node: 4, Device: "unreadable"}, {Node: 5, Anchor: true}}} + names := s.ClusterNames(context.Background(), status) + if names[2] != "desk-b" { + t.Fatal(names) + } + if value, known := names[3]; !known || value != "" { + t.Fatal("missing device not recorded") + } + if _, known := names[4]; known { + t.Fatal("decryption failure claimed removal") + } + roster.err = errors.New("fixture read failure") + names = s.ClusterNames(context.Background(), status) + for _, node := range []uint64{2, 3, 4} { + if _, known := names[node]; known { + t.Fatal("failed read claimed a missing device") + } + } +} diff --git a/internal/syncsetup/l4fix_close_test.go b/internal/syncsetup/l4fix_close_test.go new file mode 100644 index 0000000000..16db44538f --- /dev/null +++ b/internal/syncsetup/l4fix_close_test.go @@ -0,0 +1,187 @@ +package syncsetup + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/vaultsync" +) + +type l4CloseEngine struct { + *cellsync.FakeEngine + fail string +} + +func (e l4CloseEngine) Export(ctx context.Context, c cell.Cell, head string) (cellsync.Export, error) { + if e.fail == "export" { + return cellsync.Export{}, os.ErrPermission + } + ex, err := e.FakeEngine.Export(ctx, c, head) + if e.fail == "frame read" && err == nil { + for _, f := range ex.Frames { + _ = os.Remove(f.Path) + } + } + return ex, err +} +func (e l4CloseEngine) Published(ctx context.Context, c cell.Cell, paths []string) error { + if e.fail == "ledger" { + return os.ErrPermission + } + return e.FakeEngine.Published(ctx, c, paths) +} + +// L4-R3: Drive.Close preserves local export, frame, ledger and branch-map errors; only service failures mean upload pending. +func TestL4FixDriveCloseOrigins(t *testing.T) { + for _, fail := range []string{"export", "frame read", "ledger", "branch map", "service"} { + t.Run(fail, func(t *testing.T) { + ctx := t.Context() + memory := directory.NewMemory(time.Now) + dir := memory.For("dev_a") + store := blobstore.NewMemory() + s := &Sync{Home: t.TempDir(), Store: store, Dir: dir} + c := cell.Cell{ID: "01J0000000000000000000000A", Root: t.TempDir()} + engine := cellsync.NewFakeEngine(t.TempDir()) + head := engine.Seal(c, map[string]string{"a": "one"}) + pub := &cellsync.Publisher{Engine: l4CloseEngine{engine, fail}, Store: store, Dir: dir} + driving := &cellsync.Driving{Cell: c} + branches, err := cellsync.OpenBranchMap(s.Home) + if err != nil { + t.Fatal(err) + } + b := &cellsync.Batcher{Publisher: pub, Driving: driving, Brancher: cellsync.Brancher{Dir: dir, Publisher: pub, Map: branches, NewID: func() (string, error) { return "01J0000000000000000000000B", nil }}} + if fail == "branch map" { + created, err := dir.Create(ctx, c.ID, directory.CellInit{Head: head}) + if err != nil { + t.Fatal(err) + } + driving.Fence = created.Cell.Lease.Fence + 1 + path := filepath.Join(s.Home, "v3", "sync") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("blocks map write"), 0600); err != nil { + t.Fatal(err) + } + } + if fail == "service" { + store.FailAfter(0, blobstore.ErrUnreachable) + } + b.Note(cellstore.Turn{ID: head}) + done := make(chan struct{}) + close(done) + d := &Drive{sync: s, cell: c, batcher: b, scope: s.scope(c.ID), files: vaultsync.Files{Withheld: func() ([]string, error) { return nil, nil }}, done: done, cancel: func() {}} + err = d.Close(ctx) + if err == nil || syncwords.UploadPending(err) != (fail == "service") { + t.Fatalf("Close=%v upload pending=%v", err, syncwords.UploadPending(err)) + } + if again := d.Close(ctx); again != err { + t.Fatalf("Close lost its result: %v != %v", again, err) + } + if fail == "export" || fail == "ledger" { + if !errors.Is(err, os.ErrPermission) { + t.Fatal(err) + } + } + }) + } +} + +type l4ClosingUpload struct { + blobstore.Store + entered chan struct{} +} + +func (s *l4ClosingUpload) PutFrame(ctx context.Context, raw []byte) (string, error) { + close(s.entered) + <-ctx.Done() + return "", ctx.Err() +} + +type l4CloseHeartbeat struct { + directory.Client + calls atomic.Int32 +} + +func (d *l4CloseHeartbeat) Heartbeat(ctx context.Context, id string, beat directory.Beat) (directory.CellView, error) { + d.calls.Add(1) + return d.Client.Heartbeat(ctx, id, beat) +} + +// L4-R3: Drive.Close retains service provenance when its budget ends behind an in-flight upload. +func TestL4FixDriveCloseJoinsUpload(t *testing.T) { + ctx := t.Context() + memory := directory.NewMemory(time.Now) + dir := &l4CloseHeartbeat{Client: memory.For("dev_a")} + store := &l4ClosingUpload{Store: blobstore.NewMemory(), entered: make(chan struct{})} + s := &Sync{Home: t.TempDir(), Store: store, Dir: dir} + c := cell.Cell{ID: "01J0000000000000000000000A", Root: t.TempDir()} + created, err := dir.Create(ctx, c.ID, directory.CellInit{Head: strings.Repeat("b", 64)}) + if err != nil { + t.Fatal(err) + } + engine := cellsync.NewFakeEngine(t.TempDir()) + newest := engine.Seal(c, map[string]string{"a": "one"}) + pub := &cellsync.Publisher{Engine: engine, Store: store, Dir: dir} + ticks := make(chan chan struct{}) + b := &cellsync.Batcher{Publisher: pub, Driving: &cellsync.Driving{Cell: c, Fence: created.Cell.Lease.Fence, Head: created.Cell.Head}, Sleep: func(ctx context.Context, d time.Duration) error { + if d != directory.HeartbeatEvery { + <-ctx.Done() + return ctx.Err() + } + step := make(chan struct{}) + select { + case ticks <- step: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-step: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }} + b.Note(cellstore.Turn{ID: newest}) + run, cancelRun := context.WithCancel(ctx) + defer cancelRun() + done := make(chan struct{}) + go func() { defer close(done); _ = b.Run(run) }() + <-store.entered + next := <-ticks + closing, cancelClose := context.WithCancel(ctx) + defer cancelClose() + d := &Drive{sync: s, cell: c, batcher: b, scope: s.scope(c.ID), files: vaultsync.Files{Withheld: func() ([]string, error) { return nil, nil }}, done: done, cancel: cancelRun} + result := make(chan error, 1) + go func() { result <- d.Close(closing) }() + // The next requested interval acknowledges the previous tick. With a live + // fence and the first upload still blocked, only closing suppresses its beat. + for { + before := dir.calls.Load() + close(next) + next = <-ticks + if dir.calls.Load() == before { + break + } + } + cancelClose() + err = <-result + if !syncwords.UploadPending(err) || !errors.Is(err, context.Canceled) { + t.Fatalf("Close lost upload origin: %v", err) + } + if again := d.Close(ctx); again != err { + t.Fatalf("Close lost its retained result: %v", again) + } +} diff --git a/internal/syncsetup/leadership_native_test.go b/internal/syncsetup/leadership_native_test.go index 6e4a58954b..1b0e765f25 100644 --- a/internal/syncsetup/leadership_native_test.go +++ b/internal/syncsetup/leadership_native_test.go @@ -39,6 +39,10 @@ func personalLeader(t *testing.T, r *memberRig, s *Sync) cs.LeadershipReceipt { if status.Leader != receipt.Node || !slices.Equal(status.Configuration.Voters, []uint64{1, 2, receipt.Node}) || status.Configuration.Joint() || !slices.Contains(status.Configuration.Learners, 3) || status.Membership.Leadership == nil || !status.Membership.Leadership.Completed { t.Fatalf("R6 transfer lacks durable settlement/elected leader: %+v %+v", receipt, status) } + // Status returns as soon as one anchor voter answers ReadIndex quorum, while + // the remaining peer responses and their cursor acknowledgments finish on + // the member socket; settle them before callers snapshot post-decision traffic. + waitMember(t, s, func(m *personalMember) bool { return m.idle }) return receipt } diff --git a/internal/syncsetup/manual_default_test.go b/internal/syncsetup/manual_default_test.go index 757fe70393..4e9541054c 100644 --- a/internal/syncsetup/manual_default_test.go +++ b/internal/syncsetup/manual_default_test.go @@ -1,6 +1,7 @@ package syncsetup_test import ( + "slices" "strings" "testing" @@ -8,23 +9,28 @@ import ( "github.com/Agent-Field/codeaf/internal/syncsetup" ) -// hostedAddress is the address the chat's own manual gives for the hosted relay. -const hostedAddress = "https://codeaf.agentfield.ai/fabric" - -// THE MANUAL NAMES THE DEFAULT RELAY THE CODE HAS. Where sync goes by default has -// one source of truth, [syncsetup.HostedRelayURL]. The manual cannot import this -// package (it would be a cycle), so the check lives here: when the constant is -// set it must be the address the pages name, and when the default flips this -// test fails naming the page that has to change with it. +// THE MANUAL NAMES EVERY CHANNEL'S DEFAULT RELAY. Where sync goes by default +// has one source of truth in internal/syncsetup/relayurl.go. The manual cannot +// import this package (it would be a cycle), so the check lives here: every +// channel constant must appear in the pages that explain the default sync +// service, and HostedRelayURL must be one of them. func TestTheManualNamesTheHostedRelayTheCodeDefaultsTo(t *testing.T) { var corpus strings.Builder - for _, section := range manual.Chat().Search("where does sync go by default hosted relay", 50) { + for _, section := range manual.Chat().Search("where does sync go by default hosted relay channel", 50) { corpus.WriteString(section.Body) } - if !strings.Contains(corpus.String(), hostedAddress) { - t.Fatalf("no page about where sync goes by default names %s", hostedAddress) + text := corpus.String() + channels := []string{ + syncsetup.ProductionRelayURL, + syncsetup.StagingRelayURL, + syncsetup.DevRelayURL, + } + for _, addr := range channels { + if !strings.Contains(text, addr) { + t.Errorf("no page about where sync goes by default names %s: update relay-hosted-or-your-own and use-this-on-another-computer", addr) + } } - if hosted := syncsetup.HostedRelayURL; hosted != "" && hosted != hostedAddress { - t.Errorf("the code's default relay is %s but the manual says %s: update relay-hosted-or-your-own and use-this-on-another-computer", hosted, hostedAddress) + if hosted := syncsetup.HostedRelayURL; hosted != "" && !slices.Contains(channels, hosted) { + t.Errorf("the code's default relay %s is not one of the documented channel relays %v", hosted, channels) } } diff --git a/internal/syncsetup/member_control_test.go b/internal/syncsetup/member_control_test.go index bdb27253da..403f44641d 100644 --- a/internal/syncsetup/member_control_test.go +++ b/internal/syncsetup/member_control_test.go @@ -38,7 +38,7 @@ func TestR6R23UnavailableLeadershipCannotLeaveNewIntent(t *testing.T) { want string }{ {"unjoined", 0, "this computer must join the cluster with persistent storage before it can lead"}, - {"joined", 4, "this relay does not offer cluster leadership yet; your saved chats are still here"}, + {"joined", 4, clusterctl.ErrUnsupported.Error()}, } { t.Run(tc.name, func(t *testing.T) { server := httptest.NewServer(clusterctl.Handler(func(r *http.Request, b []byte) (string, string, error) { return reqsign.Verify(r, b, time.Now()) }, func(string) (clusterctl.Backend, error) { return unavailableControl{tc.self, t}, nil })) @@ -71,7 +71,7 @@ func TestR6R30AbsentHostedControlKeepsLeadershipSentence(t *testing.T) { if err != nil || !ok { t.Fatal(err) } - if _, err := client.ClusterStatus(context.Background()); !errors.Is(err, clusterctl.ErrUnsupported) || err.Error() != "this relay does not offer cluster leadership yet; your saved chats are still here" { + if _, err := client.ClusterStatus(context.Background()); !errors.Is(err, clusterctl.ErrUnsupported) || err.Error() != clusterctl.ErrUnsupported.Error() { t.Fatal(err) } if _, err := client.LeadCluster(context.Background(), 0); !errors.Is(err, clusterctl.ErrUnsupported) { diff --git a/internal/syncsetup/old_service_write_test.go b/internal/syncsetup/old_service_write_test.go new file mode 100644 index 0000000000..941db6ceff --- /dev/null +++ b/internal/syncsetup/old_service_write_test.go @@ -0,0 +1,77 @@ +package syncsetup + +import ( + "context" + "net/http" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" +) + +// oldService is a sync service deployed before conditional takes: every route +// answers, but no response carries the promise. +func oldService(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(withoutTakePromise{w}, r) + }) +} + +// TestOldServiceStillLetsTheOnlyComputerWriteItsChat pins #2072's lockout: a +// chat whose lease has lapsed, and that no other device holds, opens as this +// computer's to write even when the service makes no conditional-take promise. +// The turn is published, so the other computers still see it. +func TestOldServiceStillLetsTheOnlyComputerWriteItsChat(t *testing.T) { + h := newTwoHomes(t, oldService) + ctx := context.Background() + a := h.openA() + a.mustSay("first words") + h.durable(h.cell.ID, h.cell) + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + h.lapse() + + again := h.openA() + if err := again.drive.Gate(); err != nil { + t.Fatalf("the only computer reopened its chat as a viewer: %v", err) + } + again.mustSay("second words") + h.durable(h.cell.ID, h.cell) +} + +// TestOldServiceLetsLocalTurnsCatchUp pins the lockout's second step: turns +// this computer saved while it could not publish, on top of the head the +// service has, are this computer's own work. Reopening the chat publishes them +// rather than opening it read-only behind "Continue this chat here?". +func TestOldServiceLetsLocalTurnsCatchUp(t *testing.T) { + h := newTwoHomes(t, oldService) + ctx := context.Background() + a := h.openA() + a.mustSay("published words") + h.durable(h.cell.ID, h.cell) + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + h.lapse() + + // A window that ran unsynced seals on the engine alone. + seat, err := cellstore.SeatOver(executor.HostBound, h.cell, h.work, nil, nil, + func(cellstore.Engine) cellstore.Store { return h.engine }) + if err != nil { + t.Fatal(err) + } + local := &openChat{t: t, seat: seat, cell: h.cell, work: h.work, calls: 10} + local.mustSay("words saved only here") + if h.directoryHead(h.cell.ID) == headOf(t, h.cell) { + t.Fatal("the unsynced turn reached the service") + } + + again := h.openA() + if err := again.drive.Gate(); err != nil { + t.Fatalf("this computer's own newer turns opened read-only: %v", err) + } + h.durable(h.cell.ID, h.cell) + again.mustSay("third words") + h.durable(h.cell.ID, h.cell) +} diff --git a/internal/syncsetup/quiet.go b/internal/syncsetup/quiet.go index 4aea8271b8..c50282f08e 100644 --- a/internal/syncsetup/quiet.go +++ b/internal/syncsetup/quiet.go @@ -74,9 +74,16 @@ func (s *Sync) list(ctx context.Context) (directory.Listing, error) { return directory.Listing{}, nil } if l, local, err := s.displayListing(ctx); local || err != nil { + if s.teamsSharing() { + s.rememberTeamDirectory(l, err) + } return l, err } - return s.Dir.List(ctx) + l, err := s.Dir.List(ctx) + if s.teamsSharing() { + s.rememberTeamDirectory(l, err) + } + return l, err } // ClusterHTTP supplies the same use-time quiet gate for enrollment, discovery, diff --git a/internal/syncsetup/relayurl.go b/internal/syncsetup/relayurl.go index 5dc8f11c50..a56e7c71e4 100644 --- a/internal/syncsetup/relayurl.go +++ b/internal/syncsetup/relayurl.go @@ -1,30 +1,102 @@ package syncsetup import ( + "net/url" "os" "path/filepath" "strings" + "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncwords" ) -// HostedRelayURL is the central sync service (the fabric) every computer syncs -// through when nobody has said otherwise. It is the one source of truth for that -// address: nothing else in the program spells it. Empty means there is no default, -// so sync stays off until an address is named. The address has a path, which every -// client keeps (see wireauth.Endpoint). -const HostedRelayURL = "https://codeaf.agentfield.ai/fabric" +const ( + // ProductionRelayURL is the central sync service (the fabric) that stable + // releases and release candidates sync through when nobody has said + // otherwise. The address has a path, which every client keeps (see + // wireauth.Endpoint). + ProductionRelayURL = "https://codeaf.agentfield.ai/fabric" + + // StagingRelayURL is the central sync service that staging builds sync + // through when nobody has said otherwise. + StagingRelayURL = "https://caf-fabric-staging.instrument-santosh.workers.dev" + + // DevRelayURL is the central sync service that dev builds, local builds from + // a checkout, and test binaries sync through when nobody has said otherwise, + // so a build nobody tagged can never write to production. + DevRelayURL = "https://caf-fabric-dev.instrument-santosh.workers.dev" +) + +// HostedRelayFor names the default sync service for a binary stamped with +// revision. A stable or release-candidate tag gets production; a staging tag +// gets staging; every other revision gets dev. The three addresses are spelled +// in this file and nowhere else in the program. +func HostedRelayFor(revision string) string { + switch buildinfo.Channel(revision) { + case "stable", "rc": + return ProductionRelayURL + case "staging": + return StagingRelayURL + default: + return DevRelayURL + } +} + +// LinkOriginOf is the scheme and host where a hosted sync service serves its +// short pairing pages (`/p/`), because every hosted Worker serves `/p/` +// at the root of its origin before stripping any base path. +func LinkOriginOf(relayURL string) string { + u, err := url.Parse(strings.TrimSpace(relayURL)) + if err != nil || u.Scheme == "" || u.Host == "" { + return "" + } + return u.Scheme + "://" + u.Host +} + +// HostedLinkOriginFor names the pairing-link origin for a binary stamped with +// revision. +func HostedLinkOriginFor(revision string) string { + return LinkOriginOf(HostedRelayFor(revision)) +} + +// HostedLinkHosts lists the hostnames of every channel's hosted sync service, +// so a pairing surface can recognise a short link from any codeaf channel +// without spelling the addresses outside this file. +func HostedLinkHosts() []string { + hosts := make([]string, 0, 3) + for _, raw := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + if u, err := url.Parse(raw); err == nil && u.Host != "" { + hosts = append(hosts, u.Host) + } + } + return hosts +} + +// HostedRelayURL is the central sync service this binary syncs through when +// nobody has said otherwise, decided once from the binary's release stamp. +// Empty means there is no default, so sync stays off until an address is named. +var HostedRelayURL = HostedRelayFor(buildinfo.Revision()) // hostedRelay is what the resolver reads, so a test can stand in a hosted relay -// without the constant changing. Nothing but tests assigns it. +// without HostedRelayURL changing. Nothing but tests assigns it. var hostedRelay = HostedRelayURL -// savedURLFile is where a computer keeps the relay another device told it to +func init() { + pair.LinkOrigin = LinkOriginOf(HostedRelayURL) + syncwords.UseHosted(HostedRelayURL, HostedLinkHosts()) +} + +// SavedURLFile is where a computer keeps the relay another device told it to // sync through when they were paired. It is a file beside the identity and not a // settings row, because CODEAF_SYNC_URL is deliberately never a row: a persisted // value would point a machine at a relay it was never told about, and this one -// was told, once, by the device the person had just approved. -const savedURLFile = "sync-url" +// was told, once, by the device the person had just approved. It is exported +// because a dev box's boot script writes it before its first join (the laptop +// that created the box told it, through Terraform), and the devbox package +// holds that script to this one spelling. +const SavedURLFile = "sync-url" // offWord is the value of CODEAF_SYNC_URL that turns sync off. const offWord = "off" @@ -59,6 +131,22 @@ func Resolve(home string) Relay { return Relay{} } +// ResolveUnnamed is the relay this computer syncs through when the process +// asking names none of its own: the address a pairing saved, else the hosted +// default. CODEAF_SYNC_URL is a word said in ONE shell, and a process started +// somewhere that never said it — `codeaf engine` under ssh for --host, most of +// all — has not chosen a different relay by staying quiet. The session host +// compares such a process against this answer and never against the variable +// the terminal that started it happened to carry (internal/enginehost). +func ResolveUnnamed(home string) Relay { + for _, from := range sources[1:] { + if relay, ok := from(home); ok { + return relay + } + } + return Relay{} +} + // Named is url when it is an address someone chose, and empty when it is the // hosted default, which needs no naming to another computer. func Named(url string) string { @@ -77,7 +165,7 @@ func fromVariable(string) (Relay, bool) { } func fromSavedFile(home string) (Relay, bool) { - raw, err := os.ReadFile(filepath.Join(home, savedURLFile)) + raw, err := os.ReadFile(filepath.Join(home, SavedURLFile)) saved := strings.TrimSpace(string(raw)) return Relay{URL: saved}, err == nil && saved != "" } @@ -92,5 +180,5 @@ func SaveRelayURL(home, url string) error { if err := os.MkdirAll(home, 0o700); err != nil { return err } - return os.WriteFile(filepath.Join(home, savedURLFile), []byte(url+"\n"), 0o600) + return os.WriteFile(filepath.Join(home, SavedURLFile), []byte(url+"\n"), 0o600) } diff --git a/internal/syncsetup/relayurl_law_test.go b/internal/syncsetup/relayurl_law_test.go new file mode 100644 index 0000000000..b2c2c61fe2 --- /dev/null +++ b/internal/syncsetup/relayurl_law_test.go @@ -0,0 +1,78 @@ +package syncsetup_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// ONE PLACE SPELLS THE THREE HOSTED RELAY ADDRESSES. Every command, pairing +// link and fallback reads them from internal/syncsetup/relayurl.go so two +// packages can never drift onto different relays. +func TestOnlyRelayURLSpellsTheHostedRelayAddresses(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + owner := filepath.Join(root, "internal", "syncsetup", "relayurl.go") + needles := []string{ + syncsetup.ProductionRelayURL, + strings.TrimPrefix(syncsetup.ProductionRelayURL, "https://"), + syncsetup.StagingRelayURL, + strings.TrimPrefix(syncsetup.StagingRelayURL, "https://"), + syncsetup.DevRelayURL, + strings.TrimPrefix(syncsetup.DevRelayURL, "https://"), + } + + fset := token.NewFileSet() + for _, top := range []string{"cmd", "internal"} { + err := filepath.WalkDir(filepath.Join(root, top), func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == "testdata" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || path == owner { + return nil + } + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + val, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + for _, needle := range needles { + if strings.Contains(val, needle) { + rel, _ := filepath.Rel(root, path) + pos := fset.Position(lit.Pos()) + t.Errorf("%s:%d spells %q; only internal/syncsetup/relayurl.go may spell the hosted relay addresses", rel, pos.Line, needle) + break + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/syncsetup/relayurl_test.go b/internal/syncsetup/relayurl_test.go index bbcfc35e85..ba04a24ed5 100644 --- a/internal/syncsetup/relayurl_test.go +++ b/internal/syncsetup/relayurl_test.go @@ -117,3 +117,110 @@ func TestNamedLeavesOutOnlyTheHostedDefault(t *testing.T) { t.Fatal("Named must drop the hosted default and keep any other address") } } + +// Each release channel gets its own hosted sync service, and any untagged or +// test revision lands on dev so it can never write to production. +func TestHostedRelayForChannel(t *testing.T) { + cases := []struct { + name string + revision string + want string + origin string + }{ + {"stable release", "v0.2.0", ProductionRelayURL, "https://codeaf.agentfield.ai"}, + {"release candidate", "v0.2.0-rc.1", ProductionRelayURL, "https://codeaf.agentfield.ai"}, + {"staging build", "staging-20261030-abcdef012345", StagingRelayURL, StagingRelayURL}, + {"dev build", "dev-20261030-abcdef012345", DevRelayURL, DevRelayURL}, + {"checkout commit", "abcdef01", DevRelayURL, DevRelayURL}, + {"pseudo-version", "v0.0.0-20261030000000-abcdef012345", DevRelayURL, DevRelayURL}, + {"unstamped test binary", "", DevRelayURL, DevRelayURL}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := HostedRelayFor(tc.revision); got != tc.want { + t.Fatalf("HostedRelayFor(%q) = %q, want %q", tc.revision, got, tc.want) + } + if got := HostedLinkOriginFor(tc.revision); got != tc.origin { + t.Fatalf("HostedLinkOriginFor(%q) = %q, want %q", tc.revision, got, tc.origin) + } + }) + } + if HostedRelayURL != DevRelayURL { + t.Fatalf("test binary HostedRelayURL = %q, want dev default %q", HostedRelayURL, DevRelayURL) + } +} + +// CODEAF_SYNC_URL and a saved pairing relay still beat every channel's hosted +// default, and Named drops only the active channel's hosted default. +func TestOverridesWinOnEveryChannel(t *testing.T) { + channels := []struct { + name string + revision string + foreign string + }{ + {"stable", "v1.2.3", DevRelayURL}, + {"rc", "v1.2.3-rc.2", StagingRelayURL}, + {"staging", "staging-20261030-0123456789ab", ProductionRelayURL}, + {"dev", "dev-20261030-0123456789ab", ProductionRelayURL}, + {"untagged", "deadbeef", ProductionRelayURL}, + } + for _, ch := range channels { + t.Run(ch.name, func(t *testing.T) { + channelDefault := HostedRelayFor(ch.revision) + hosted(t, channelDefault) + + home := machine(t, "") + if got := Resolve(home); got != (Relay{URL: channelDefault, Hosted: true}) { + t.Fatalf("default Resolve = %+v, want hosted %s", got, channelDefault) + } + if Named(channelDefault) != "" { + t.Fatalf("Named(%q) must be empty on channel %s", channelDefault, ch.name) + } + if Named(ch.foreign) != ch.foreign { + t.Fatalf("Named(%q) dropped another channel's address on %s", ch.foreign, ch.name) + } + + if err := SaveRelayURL(home, "https://saved.example"); err != nil { + t.Fatal(err) + } + if got := Resolve(home); got != (Relay{URL: "https://saved.example"}) { + t.Fatalf("saved Resolve = %+v, want saved relay", got) + } + if got := ResolveUnnamed(home); got != (Relay{URL: "https://saved.example"}) { + t.Fatalf("saved ResolveUnnamed = %+v, want saved relay", got) + } + + t.Setenv(URLVar, "https://custom.example") + if got := Resolve(home); got != (Relay{URL: "https://custom.example"}) { + t.Fatalf("variable Resolve = %+v, want variable relay", got) + } + + t.Setenv(URLVar, "off") + if got := Resolve(home); got != (Relay{URL: "off", Off: true}) { + t.Fatalf("off Resolve = %+v, want off", got) + } + }) + } +} + +func TestFirstRunLineNamesEachChannelService(t *testing.T) { + cases := map[string]string{ + ProductionRelayURL: "(codeaf.agentfield.ai/fabric)", + StagingRelayURL: "(caf-fabric-staging.instrument-santosh.workers.dev)", + DevRelayURL: "(caf-fabric-dev.instrument-santosh.workers.dev)", + } + for relayURL, want := range cases { + if got := FirstRunLineFor(relayURL); got == "" || !containsSubstring(got, want) { + t.Fatalf("FirstRunLineFor(%q) = %q, missing %q", relayURL, got, want) + } + } +} + +func containsSubstring(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/syncsetup/sendlease.go b/internal/syncsetup/sendlease.go index be6a4d8a5a..025318df0d 100644 --- a/internal/syncsetup/sendlease.go +++ b/internal/syncsetup/sendlease.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/fsreplace" + "github.com/Agent-Field/codeaf/internal/home" ) type sendLease struct { @@ -55,7 +56,7 @@ func (d *Drive) recordSendLease() error { fence := d.batcher.Fence() d.mu.Lock() defer d.mu.Unlock() - if d.viewer.Line != "" || fence == 0 || fence == d.recordedFence { + if d.lost || d.viewer.Line != "" || fence == 0 || fence == d.recordedFence { return nil } if err := d.sync.recordSendLease(d.cell.ID, sendLease{Cell: d.directoryID, Fence: fence}); err != nil { @@ -96,7 +97,7 @@ func (s *Sync) recordSendLease(id string, held sendLease) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + if err := home.MkdirAllUnder(s.Home, filepath.Dir(path), 0700); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(path), ".hold-*") @@ -155,7 +156,7 @@ func (s *Sync) lockChat(id, suffix string) (*os.File, error) { if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + if err := home.MkdirAllUnder(s.Home, filepath.Dir(path), 0700); err != nil { return nil, err } lock, err := os.OpenFile(path+suffix+".lock", os.O_CREATE|os.O_RDWR, 0600) diff --git a/internal/syncsetup/serve_state_test.go b/internal/syncsetup/serve_state_test.go new file mode 100644 index 0000000000..7d13f977e9 --- /dev/null +++ b/internal/syncsetup/serve_state_test.go @@ -0,0 +1,293 @@ +package syncsetup + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// R4.1 exercises the serving lifetime over the existing authenticated fake +// relay, including a key saved by another computer after serving began. +func TestR41ServeHomeStateReceivesProviderKeysAtStartAndOnInterval(t *testing.T) { + for _, atStart := range []bool{true, false} { + t.Run(map[bool]string{true: "startup", false: "interval"}[atStart], func(t *testing.T) { + server := relay(t) + var cluster atomic.Int64 + var directoryRequests atomic.Int64 + next := server.Config.Handler + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/cluster/") { + cluster.Add(1) + } + if strings.HasPrefix(r.URL.Path, "/v1/dir/") { + directoryRequests.Add(1) + } + next.ServeHTTP(w, r) + }) + aHome := machine(t, server.URL) + t.Setenv(config.ProfileDirEnv, "") + a, ok, err := Open(aHome) + if err != nil || !ok { + t.Fatalf("open source: %v", err) + } + bHome := t.TempDir() + mustNil(t, identity.Save(bHome, a.Identity)) + if atStart { + t.Setenv(IntervalVar, "3600000") + mustNil(t, config.WriteAPIKey(aHome, fakeKeyOne)) + mustNil(t, a.SyncState(t.Context())) + } else { + t.Setenv(IntervalVar, "25") + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + errors := make(chan error, 16) + go func() { + defer close(done) + RunHomeState(ctx, bHome, func(err error) { errors <- err }) + }() + t.Cleanup(func() { cancel(); <-done }) + if !atStart { + waitServeState(t, func() bool { return directoryRequests.Load() > 0 }) + mustNil(t, config.WriteAPIKey(aHome, fakeKeyTwo)) + // B owns stateMu during that first request. A's SyncState cannot + // publish this key until B's startup round releases it, so only + // a later interval round can receive the key on B. + mustNil(t, a.SyncState(t.Context())) + } + want := fakeKeyTwo + if atStart { + want = fakeKeyOne + } + waitServeState(t, func() bool { return config.PersistedAPIKey(bHome) == want }) + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("R4.3: serving home-state goroutine did not join") + } + select { + case err := <-errors: + t.Fatalf("home-state round: %v", err) + default: + } + if cluster.Load() != 0 { + t.Fatal("R4.3: serve started a cluster member") + } + }) + } +} + +func TestR42ServeHomeStateIneligibleHomesStaySilent(t *testing.T) { + t.Setenv(config.ProfileDirEnv, "") + for _, reason := range []string{"no identity", "cells unavailable", "quiet"} { + t.Run(reason, func(t *testing.T) { + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer server.Close() + home := machine(t, server.URL) + switch reason { + case "no identity": + mustNil(t, os.RemoveAll(home)) + mustNil(t, os.MkdirAll(home, 0o700)) + case "quiet": + // A solo home is made in a fresh folder, because codeaf now refuses to + // create an identity in a home this process saw and that was then + // deleted (internal/home Witness), which is what removing the paired + // machine's folder and reusing its path would look like. + home = filepath.Join(t.TempDir(), "solo") + _, err := identity.EnsureSolo(home) + mustNil(t, err) + case "cells unavailable": + t.Cleanup(cell.UseAvailabilityInTests(false)) + } + before, err := os.ReadDir(home) + mustNil(t, err) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + RunHomeState(ctx, home, func(err error) { t.Errorf("ineligible home reported: %v", err) }) + if ctx.Err() != nil { + t.Fatal("ineligible home kept its loop alive") + } + if requests.Load() != 0 { + t.Fatal("ineligible home contacted the relay") + } + entries, err := os.ReadDir(home) + mustNil(t, err) + if len(entries) != len(before) { + t.Fatal("ineligible home created state") + } + for i, entry := range entries { + if entry.Name() != before[i].Name() { + t.Errorf("ineligible home created %s", entry.Name()) + } + } + }) + } +} + +func TestR42ServeHomeStateRevokedDeviceStopsQuietly(t *testing.T) { + t.Setenv(config.ProfileDirEnv, "") + for _, known := range []bool{false, true} { + t.Run(map[bool]string{false: "relay refusal", true: "saved removal"}[known], func(t *testing.T) { + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"err":"revoked"}`)) + })) + defer server.Close() + home := machine(t, server.URL) + if known { + s, _, err := Open(home) + mustNil(t, err) + s.rememberRemoved() + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + RunHomeState(ctx, home, func(err error) { t.Errorf("revoked home reported: %v", err) }) + if ctx.Err() != nil { + t.Fatal("revoked device kept its loop alive") + } + if got := requests.Load(); (known && got != 0) || (!known && got != 1) { + t.Fatalf("removal requests = %d", got) + } + }) + } +} + +func TestR42ServeHomeStateBecomesQuietDuringRound(t *testing.T) { + t.Setenv(config.ProfileDirEnv, "") + started, release := make(chan struct{}, 1), make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case started <- struct{}{}: + default: + } + select { + case <-release: + http.Error(w, "relay retry fixture", http.StatusServiceUnavailable) + case <-r.Context().Done(): + } + })) + defer server.Close() + home := machine(t, server.URL) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + var reports []error + go func() { + defer close(done) + RunHomeState(ctx, home, func(err error) { reports = append(reports, err) }) + }() + defer func() { cancel(); <-done }() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("home-state round did not start") + } + // Leave the request alive while removing its identity. The response must + // end the loop silently even when the relay also reports a failure. + mustNil(t, os.Remove(filepath.Join(home, identity.File))) + close(release) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("quiet home did not stop its in-flight round") + } + if len(reports) != 0 { + t.Fatalf("quiet home reported %v", reports) + } +} + +func TestR41R42ServeHomeStateRetriesDistinctFailuresAndReopensIdentity(t *testing.T) { + server := relay(t) + t.Setenv(config.ProfileDirEnv, "") + other := machine(t, server.URL) + source, _, err := Open(other) + mustNil(t, err) + mustNil(t, config.WriteAPIKey(other, fakeKeyTwo)) + mustNil(t, source.SyncState(t.Context())) + home := machine(t, server.URL) + t.Setenv(IntervalVar, "10") + var status atomic.Int64 + status.Store(http.StatusServiceUnavailable) + var requests atomic.Int64 + next := server.Config.Handler + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if code := int(status.Load()); code != 0 { + http.Error(w, "relay retry fixture", code) + return + } + next.ServeHTTP(w, r) + }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + var reports []string + go func() { + defer close(done) + RunHomeState(ctx, home, func(err error) { reports = append(reports, err.Error()) }) + }() + t.Cleanup(func() { cancel(); <-done }) + for _, code := range []int{http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusServiceUnavailable} { + before := requests.Load() + status.Store(int64(code)) + waitServeState(t, func() bool { return requests.Load() >= before+3 }) + } + _, err = identity.Adopt(home, source.Identity, true) + mustNil(t, err) + status.Store(0) + waitServeState(t, func() bool { return config.PersistedAPIKey(home) == fakeKeyTwo }) + // Removing the joined identity while serve remains open ends its worker. + mustNil(t, os.Remove(filepath.Join(home, identity.File))) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("R4.2: removing the identity did not stop the loop") + } + if len(reports) != 2 || reports[0] == reports[1] { + t.Fatalf("expected each of two distinct failures once, got %v", reports) + } +} + +func waitServeState(t *testing.T, ready func() bool) { + t.Helper() + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + for !ready() { + select { + case <-deadline.C: + t.Fatal("serving state did not arrive") + case <-tick.C: + } + } +} + +// Stability: a serve nobody asked to keep state in step behaves exactly as it +// always has. Only the value 1 opts in; unset, empty and anything else do not. +func TestServeKeepsStateOnlyWhenADevBoxAsks(t *testing.T) { + t.Setenv(env.Legacy(ServeStateVar), "") + for value, want := range map[string]bool{"": false, "0": false, "true": false, "on": false, " 1 ": true, "1": true} { + t.Setenv(ServeStateVar, value) + if got := ServeKeepsState(); got != want { + t.Errorf("%s=%q keeps state = %v, want %v", ServeStateVar, value, got, want) + } + } +} diff --git a/internal/syncsetup/service.go b/internal/syncsetup/service.go new file mode 100644 index 0000000000..0ee6930a0c --- /dev/null +++ b/internal/syncsetup/service.go @@ -0,0 +1,55 @@ +package syncsetup + +import ( + "context" + "errors" + "os" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// ProbeService asks the sync service at url what this build needs it to +// promise ([directory.Capabilities]), and answers the promises it did not +// make. It acts as a new identity of its own, in a home it removes, so it +// reads nothing of any person and leaves only one empty archived chat record. +// The release gate runs it against the live service before a build ships. +func ProbeService(ctx context.Context, url string) ([]directory.Capability, error) { + base, err := relayBase(url) + if err != nil { + return nil, err + } + home, err := os.MkdirTemp("", "codeaf-probe-") + if err != nil { + return nil, err + } + defer os.RemoveAll(home) + id, err := identity.Ensure(home) + if err != nil { + return nil, err + } + dev, err := identity.Device(home) + if err != nil { + return nil, err + } + s := build(home, base, id, dev, 0) + if err := s.putDevice(ctx, "release check"); err != nil { + return nil, err + } + probe, err := cell.NewID(s.now()) + if err != nil { + return nil, err + } + if _, err := s.Dir.Create(ctx, probe, directory.CellInit{Class: string(cell.HostBound)}); err != nil { + return nil, err + } + reader, ok := s.Dir.(interface { + Missing(context.Context, string) ([]directory.Capability, error) + }) + if !ok { + return nil, errors.New("this sync client cannot read a service's promises") + } + missing, err := reader.Missing(ctx, probe) + return missing, errors.Join(err, s.Dir.Archive(ctx, probe)) +} diff --git a/internal/syncsetup/service_test.go b/internal/syncsetup/service_test.go new file mode 100644 index 0000000000..fededab543 --- /dev/null +++ b/internal/syncsetup/service_test.go @@ -0,0 +1,38 @@ +package syncsetup + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/relayserve" +) + +// TestProbeServiceNamesWhatAnOlderServiceLacks pins #2072's release gate: the +// probe finds every promise on the current service, and names the feature that +// waits on a service deployed before conditional takes. +func TestProbeServiceNamesWhatAnOlderServiceLacks(t *testing.T) { + svc := relayserve.New(relayserve.Config{Store: t.TempDir(), Grace: directory.GraceBounds{Min: time.Second, Max: time.Hour, Default: 10 * time.Second}}) + t.Cleanup(func() { _ = svc.Close() }) + for _, old := range []bool{false, true} { + var h http.Handler = svc.Handler + if old { + h = oldService(h) + } + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + missing, err := ProbeService(context.Background(), srv.URL) + if err != nil { + t.Fatalf("old=%v: probe: %v", old, err) + } + if !old && len(missing) != 0 { + t.Fatalf("the current service lacks %v", missing) + } + if old && (len(missing) != 1 || missing[0].Header != directory.ConditionalTakeHeader) { + t.Fatalf("the older service lacks %v; want conditional takes", missing) + } + } +} diff --git a/internal/syncsetup/staged_warmth_test.go b/internal/syncsetup/staged_warmth_test.go new file mode 100644 index 0000000000..3d8e4399cb --- /dev/null +++ b/internal/syncsetup/staged_warmth_test.go @@ -0,0 +1,26 @@ +package syncsetup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +func TestAStagedWarmChatKeepsItsHistory(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".cell"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".cell", "adopted.json"), []byte(`{"head":"local-head"}`), 0600); err != nil { + t.Fatal(err) + } + l := landing{Engine: cellstore.SyncEngine{}, rootFor: func(string) string { return root }} + if !l.Holds(cell.Cell{ID: "warm", Root: t.TempDir()}) { + t.Fatal("staging hid the chat's existing history") + } +} diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index 2120d0964f..f737b6639e 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -2,17 +2,72 @@ package syncsetup import ( "context" + "errors" "sync" "time" + "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/vaultsync" ) var stateMu sync.Mutex +// RunHomeState keeps a serving computer's saved keys, preferences and shared +// memories current without starting a cluster member. The caller owns and joins +// its lifetime. Quiet, unpaired and removed homes end silently; other failures +// retry after the sync interval and are reported once per distinct error. +func RunHomeState(ctx context.Context, home string, report func(error)) { + interval, err := flushInterval() + if err != nil { + interval = DefaultInterval + } + ticks := time.NewTicker(interval) + defer ticks.Stop() + var current *Sync + reported := make(map[string]bool) + for ctx.Err() == nil { + if cell.UnavailableReason() != "" || Quiet(home) { + return + } + next, ok, err := Open(home) + if errors.Is(err, ErrNoIdentity) || (err == nil && !ok) { + return + } + if err == nil { + if current == nil || next.Identity.ID() != current.Identity.ID() || next.Device.ID() != current.Device.ID() || next.Relay != current.Relay { + current = next + } + if current.isRemoved() { + return + } + err = current.SyncState(ctx) + } + // A pairing can be removed while a request is in flight. Its quiet + // refusal ends this lifetime without turning it into a sync warning. + if Quiet(home) || errors.Is(err, ErrQuiet) { + return + } + if errors.Is(err, directory.ErrRevoked) || (current != nil && current.isRemoved()) { + return + } + if err != nil && ctx.Err() == nil && !reported[err.Error()] { + reported[err.Error()] = true + if report != nil { + report(err) + } + } + select { + case <-ctx.Done(): + return + case <-ticks.C: + } + } +} + // SyncState completes one encrypted home-state round even when no chat is // open. The quiet gate is checked before any capture or request (C30.4). func (s *Sync) SyncState(ctx context.Context) error { @@ -27,6 +82,17 @@ func (s *Sync) SyncState(ctx context.Context) error { return err } err = s.withVault(func(v vaultsync.Syncer) error { + sharing := s.teamsSharing() + if sharing != s.teamsShared { + s.stateVaultKnown = false + s.teamsShared = sharing + } + // A continuation may leave only local paths to repair, with identical + // portable values and no new vault pointer to pull. Apply those + // bindings before capture; a failed repair waits for the next round. + if s.teamsSharing() { + teams.SharingError(s.profile(), (teamKeys{s.profile(), s.Home, s.Device.ID(), s.teamsCache}).Refresh()) + } if err := v.Capture(); err != nil { return err } diff --git a/internal/syncsetup/syncsetup.go b/internal/syncsetup/syncsetup.go index f42c02787d..716739bb3b 100644 --- a/internal/syncsetup/syncsetup.go +++ b/internal/syncsetup/syncsetup.go @@ -41,6 +41,12 @@ const ( URLVar = "CODEAF_SYNC_URL" // IntervalVar is how often unsaved turns are flushed, in milliseconds. IntervalVar = "CODEAF_SYNC_INTERVAL_MS" + // ServeStateVar opts `codeaf serve` into keeping this computer's saved keys, + // settings, shared memories and directory presence in step while it serves, + // the way an open terminal does. A dev box's boot script sets it to 1. + // UNSET, SERVE BEHAVES EXACTLY AS IT ALWAYS HAS: it syncs nothing and + // publishes no presence, so nobody who never asked sees a change. + ServeStateVar = "CODEAF_SERVE_SYNC" // DefaultInterval is the flush interval when IntervalVar is unset. DefaultInterval = 5 * time.Second // requestTimeout bounds one directory request so a silent relay cannot hang @@ -48,6 +54,10 @@ const ( requestTimeout = 30 * time.Second ) +// ServeKeepsState reports whether ServeStateVar asks `codeaf serve` to keep +// home state and presence in step; only the value 1 does. +func ServeKeepsState() bool { return strings.TrimSpace(env.Get(ServeStateVar)) == "1" } + // ErrNoIdentity is what a machine says when a relay is set but it has no // identity to sign with. Its words are the list's own, so every surface agrees. var ErrNoIdentity = errors.New(chatlist.NoIdentity) @@ -57,6 +67,8 @@ type Sync struct { publicationMu sync.Mutex publication map[string]*pendingPublication publicationSeen map[string]struct{} + teamsCache *teamIdentityCache // Guarded by stateMu across vault rounds. + teamsShared bool // Enabling sharing must restore an already-known vault. removed atomic.Bool memberRetries uint64 clock func() time.Time diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index fb0578048e..447047b98a 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -4,6 +4,7 @@ import ( "context" "errors" "github.com/Agent-Field/codeaf/internal/fsreplace" + "log" "os" "path/filepath" "strings" @@ -15,15 +16,20 @@ import ( "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/cellsync" "github.com/Agent-Field/codeaf/internal/chatfiles" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/inventory" "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/preflight" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/vaultsync" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // TakeOptions is what the surface tells the take side about this machine. @@ -95,7 +101,7 @@ func (s *Sync) Continuer(eng cellstore.Engine, opt TakeOptions) *Continuer { func (c *Continuer) taker(ctx context.Context, sc *Scope) handoff.Taker { s := c.sync sync := c.eng.Sync(cellstore.SyncKeys{CellKey: s.Identity.CellKey(), Dedup: s.Identity.DedupSecret()}, s.Ledger) - engine := landing{Engine: sync, workspace: c.eng.WorkspaceOf} + engine := landing{Engine: sync, workspace: c.eng.WorkspaceOf, rootFor: c.opt.RootFor} pub := &cellsync.Publisher{Engine: engine, Store: sc.Store, Dir: s.Dir} // THE TAKEOVER'S BRANCHER RUNS WITH Map NIL. The chat being taken keeps its // id on this machine; only the edits set aside live in the branch, and a @@ -147,7 +153,30 @@ func (c *Continuer) OpenBranch(ctx context.Context, id string) (Continued, error return c.take(ctx, id, nil, true) } -func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseExpectation, branchOnly bool) (Continued, error) { +func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseExpectation, branchOnly bool) (out Continued, err error) { + // Measure one attempt on its own copy: observers must not leak into another + // continue, and diagnostics must survive both early refusal and late failure. + started := time.Now() + phases := map[string]time.Duration{} + observed := *c + report := c.opt.OnPhase + observed.opt.OnPhase = func(p handoff.Phase) { + if !p.Ended.IsZero() { + phases[p.Name] += p.Ended.Sub(p.Started) + } + if report != nil { + report(p) + } + } + c = &observed + defer func() { + outcome := "done" + if err != nil { + outcome = "failed" + } + log.Printf("continue %s phases capture=%s directory=%s retirement=%s fetch=%s acquire=%s restore=%s hooks=%s filing=%s unpublished=%s dirty=%s kept_upload=%s admission=%s total=%s outcome=%s", id, + phases["local capture"], phases["directory"], phases["retirement"], phases["fetch"], phases["acquire"], phases["restore"], phases["hooks"], phases["filing"], phases["unpublished check"], phases["dirty check"], phases["kept-branch upload"], phases["staging/preflight"], time.Since(started), outcome) + }() root := c.opt.RootFor(id) defer handoff.Arrive(root)() localCapture := handoff.Trace(c.opt.OnPhase, "local capture") @@ -156,7 +185,9 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx return Continued{}, err } localCapture() + directoryRead := handoff.Trace(c.opt.OnPhase, "directory") view, err := c.sync.Dir.Cell(ctx, id) + directoryRead() if err != nil { return Continued{}, err } @@ -193,7 +224,9 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if seen == nil { seen = &directory.LeaseExpectation{Device: lease.Device, Fence: lease.Fence} } + directoryRead = handoff.Trace(c.opt.OnPhase, "directory") from := c.sync.deviceName(lease.Device) + directoryRead() if lease.Device == c.sync.Device.ID() { from = "" } @@ -201,7 +234,10 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx c.retireHolder(ctx, id) retired() sc := c.sync.scope(id) - defer sc.Settle() + defer func() { + defer handoff.Trace(c.opt.OnPhase, "filing")() + sc.Settle() + }() intendedPrimary := session.Place{Dir: root, Owned: true}.Work() if project, here := projectOf(root); here { intendedPrimary = project @@ -261,6 +297,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err != nil { return Continued{}, err } + defer handoff.Trace(c.opt.OnPhase, "filing")() place := session.Place{Dir: taken.Cell.Root, Workspace: intendedPrimary} workspacePlan, err := session.TaskWorkspaceTransferPlan(place) if err != nil { @@ -309,7 +346,14 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err := c.sync.recordSendLease(taken.Cell.ID, sendLease{Cell: id, Fence: taken.Driving.Fence}); err != nil { return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } - out := Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} + if c.sync.teamsSharing() { + if err := (teamKeys{c.sync.profile(), c.sync.Home, c.sync.Device.ID(), nil}).rebindChat(id, taken.Cell); err != nil { + if c.opt.Notify != nil { + c.opt.Notify("This chat continued here, but its teams could not be updated yet: " + err.Error()) + } + } + } + out = Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} if taken.Kept != "" { out.KeptTurns = c.orphanTurns(ctx, taken.Kept) } @@ -430,6 +474,16 @@ func (c *Continuer) branch(b cellsync.Brancher) func(context.Context, *cellsync. type landing struct { cellsync.Engine workspace func(cell.Cell) string + rootFor func(string) string +} + +// Holds consults the existing chat rather than its fresh staging folder, so a +// return still fetches only its delta before installing the replacement. +func (l landing) Holds(c cell.Cell) bool { + if l.rootFor != nil { + c.Root = l.rootFor(c.ID) + } + return l.Engine.Holds(c) } func (l landing) Want(ctx context.Context, c cell.Cell, head string) ([]string, error) { @@ -613,7 +667,7 @@ func (c *Continuer) pullVault(ctx context.Context, at cell.Cell) error { if err != nil { return err } - return c.sync.withVault(func(v vaultsync.Syncer) error { return v.Pull(ctx) }, files) + return c.sync.withVaultWithin(ctx, vaultBusyWait, func(v vaultsync.Syncer) error { return v.Pull(ctx) }, files) } // files is the withheld files of c as a carrier: the workspace the engine seals @@ -640,14 +694,38 @@ func (s *Sync) files(eng cellstore.Engine, c cell.Cell) (vaultsync.Files, error) // under the vault scope, so vaultsync stays unaware of counting, and what the // run moved is written when it ends. func (s *Sync) withVault(use func(vaultsync.Syncer) error, extra ...vaultsync.Carrier) error { + return s.withVaultWithin(context.Background(), 0, use, extra...) +} + +// A CONTINUE WAITS FOR THE VAULT; A BACKGROUND EXCHANGE DOES NOT. Every running +// chat's drive pushes the vault under state-sync.lock, and over a slow relay it +// holds the lock for seconds. A background exchange that finds it busy may skip, +// because the holder is already carrying the same state; a continue's pull may +// not, because it is the last step of a move the person asked for, and failing +// it handed the chat back with "could not be put in this folder" (D4 in the +// two-network test). So the continue waits up to vaultBusyWait, then fails as +// before. +const ( + vaultBusyWait = 30 * time.Second + vaultBusyRetry = 100 * time.Millisecond +) + +// withVaultWithin is withVault for a caller that waits up to within for another +// codeaf on this computer to finish its own exchange; zero never waits. +func (s *Sync) withVaultWithin(ctx context.Context, within time.Duration, use func(vaultsync.Syncer) error, extra ...vaultsync.Carrier) error { stateMu.Lock() defer stateMu.Unlock() + // Refuse before opening the lock file as well as before creating keys, so + // a replacement directory receives none of the previous home's state. + if home.Removed(s.Home) { + return identity.ErrHomeRemoved + } lock, err := os.OpenFile(filepath.Join(s.Home, "state-sync.lock"), os.O_CREATE|os.O_RDWR, 0600) if err != nil { return err } defer lock.Close() - if err := filelock.Lock(lock, true, true); err != nil { + if err := lockState(ctx, lock, within); err != nil { return err } @@ -661,6 +739,29 @@ func (s *Sync) withVault(use func(vaultsync.Syncer) error, extra ...vaultsync.Ca Carry: append(s.carried(), extra...)}) } +// lockState takes the vault's lock without blocking, and while another process +// holds it retries every vaultBusyRetry until within has passed or ctx ends. The +// answer after the bound is the busy refusal itself, so a caller that gives up +// says what it always said. +func lockState(ctx context.Context, lock *os.File, within time.Duration) error { + err := filelock.Lock(lock, true, true) + if within <= 0 || !filelock.IsBusy(err) { + return err + } + deadline := time.Now().Add(within) + tick := time.NewTicker(vaultBusyRetry) + defer tick.Stop() + for filelock.IsBusy(err) && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return errors.Join(err, ctx.Err()) + case <-tick.C: + } + err = filelock.Lock(lock, true, true) + } + return err +} + // profile is the directory the product reads config.json and credentials.json // from, the same one its own stores resolve. func (s *Sync) profile() string { @@ -670,10 +771,24 @@ func (s *Sync) profile() string { return s.Home } +// teamsSharing reads this computer's local opt-in for team sharing. +func (s *Sync) teamsSharing() bool { return config.TeamsShareAt(s.profile()) } + // carried is the profile state that travels in the vault beside the secrets. func (s *Sync) carried() []vaultsync.Carrier { dir := s.profile() - return []vaultsync.Carrier{vaultsync.Credentials(filepath.Join(dir, vaultsync.CredentialsFile)), providerKeys(dir, s.Home), preferences(dir, s.Home), sharedMemories(s.Home)} + carried := []vaultsync.Carrier{vaultsync.Credentials(filepath.Join(dir, vaultsync.CredentialsFile)), providerKeys(dir, s.Home), preferences(dir, s.Home), sharedMemories(s.Home)} + if !s.teamsSharing() { + return carried + } + if s.teamsCache == nil { + s.teamsCache = &teamIdentityCache{} + } + s.teamsCache.scanned = false + report := func(err error) { teams.SharingError(dir, err) } + return append(carried, + vaultsync.BestEffort(syncedTeams(dir, s.Home, s.Device.ID(), s.teamsCache), s.teamsSharing, report), + vaultsync.BestEffort(vaultsync.Windows("team-traffic", s.Device.ID(), &teamTraffic{teamKeys: teamKeys{dir, s.Home, s.Device.ID(), s.teamsCache}, peers: s.pairedTrafficDevices}, filepath.Join(s.Home, "teams-traffic-sync.ledger"), teams.TrafficSyncEvery), s.teamsSharing, report)) } // Discard sets a branch aside: it is archived, not deleted, so it leaves every @@ -725,3 +840,32 @@ func (s *Sync) Driving(ctx context.Context, c cell.Cell) (*cellsync.Driving, err } return d, nil } + +// ReleaseUnused gives up only the unused acquisition returned to this window. +// The directory checks the fence, so a later writer's ownership is never released. +func (c *Continuer) ReleaseUnused(ctx context.Context, id string, fence uint64) error { + // Match the close path's bounded retry: a transient refusal must not strand + // an otherwise successful automatic acquisition without an open conversation. + for { + if err := ctx.Err(); err != nil { + return err + } + err := c.sync.Dir.Release(ctx, id, fence) + if errors.Is(err, directory.ErrFenceStale) { + return nil + } + if !errors.Is(err, directory.ErrUnreachable) && !errors.Is(err, wireauth.ErrUnavailable) { + return err + } + if _, bounded := ctx.Deadline(); !bounded { + return err + } + timer := time.NewTimer(cellsync.ReleaseRetryEvery) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} diff --git a/internal/syncsetup/take_phases_test.go b/internal/syncsetup/take_phases_test.go index 020d9b62b5..b5a5e30cf4 100644 --- a/internal/syncsetup/take_phases_test.go +++ b/internal/syncsetup/take_phases_test.go @@ -106,7 +106,8 @@ func TestStagedWorkspaceNeverTargetsTheBorrowedFolder(t *testing.T) { t.Fatal(err) } target := &admissionTarget{} - sync := &Sync{} + // Take helpers derive local sync paths, so this fixture must own its home. + sync := &Sync{Home: t.TempDir()} continuer := sync.Continuer(cellstore.Engine{DataRoot: t.TempDir(), Transport: target}, TakeOptions{RootFor: func(string) string { return root }}) take := continuer.taker(t.Context(), &Scope{Store: blobstore.NewMemory()}) if err := take.Fetch.Fetch(t.Context(), cell.Cell{ID: "chat", Root: stage}, strings.Repeat("a", 64)); err != nil { diff --git a/internal/syncsetup/team_locality.go b/internal/syncsetup/team_locality.go new file mode 100644 index 0000000000..bcf2c92c0e --- /dev/null +++ b/internal/syncsetup/team_locality.go @@ -0,0 +1,165 @@ +package syncsetup + +import ( + "context" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/teams" +) + +const ( + teamComputerTimeout = 3 * time.Second + teamComputerCacheFor = 5 * time.Second +) + +type teamDirectoryCache struct { + sync.Mutex + at time.Time + listing directory.Listing + err error +} + +// Handles are reconstructed for tools. Share only the bounded directory reading +// across them, scoped to the current fleet, device, home and relay. +var teamDirectories sync.Map + +func (s *Sync) teamDirectory(ctx context.Context) (directory.Listing, error) { + key := s.teamDirectoryKey() + value, _ := teamDirectories.LoadOrStore(key, &teamDirectoryCache{}) + cached := value.(*teamDirectoryCache) + cached.Lock() + defer cached.Unlock() + if !cached.at.IsZero() && time.Since(cached.at) < teamComputerCacheFor { + return cached.listing, cached.err + } + ctx, cancel := context.WithTimeout(ctx, teamComputerTimeout) + defer cancel() + cached.listing, cached.err = s.Dir.List(ctx) + cached.at = time.Now() + return cached.listing, cached.err +} + +// TeamComputer reports only positive remote ownership; missing local paths +// say nothing about where a member runs, including sub-team manager claims. +func (s *Sync) TeamComputer(ctx context.Context, member teams.Member) (string, error) { + names, err := s.TeamComputers(ctx, []teams.Member{member}) + return names[member.Key], err +} + +func (s *Sync) TeamComputers(ctx context.Context, members []teams.Member) (map[string]string, error) { + out := map[string]string{} + if !s.teamsSharing() || Quiet(s.Home) { + return out, nil + } + ctx, cancel := context.WithTimeout(ctx, teamComputerTimeout) + defer cancel() + cells := map[string]string{} + devices := map[string]string{} + for _, member := range members { + if teams.OnOtherComputer(member.Key) { + out[member.Key] = "the other computer" + id := strings.TrimPrefix(member.Key, "other-computer:device:") + devices[member.Key], _, _ = strings.Cut(id, ":") + continue + } + id, remote := teams.RemoteCell(member.Key) + if remote { + out[member.Key] = "the other computer" + } + if !remote { + var ok bool + id, ok = strings.CutPrefix(member.SyncID, "cell:") + if !ok { + id = "" + } + } + if id == "" && member.File != "" && member.SyncID == "" { + root := filepath.Dir(member.File) + if filepath.Base(root) == cell.StateDir { + root = filepath.Dir(root) + } + if c, err := cell.OpenAt(root, filepath.Base(root)); err == nil { + id = c.ID + } + } + if id != "" { + cells[member.Key] = id + } + } + if len(cells) == 0 && len(devices) == 0 { + return out, nil + } + branches, err := cellsync.OpenBranchMap(s.Home) + if err != nil { + teams.SharingError(s.profile(), err) + return out, nil + } + // Local members use ownership already learned by Home or its follower. + // Only a remote sentinel can justify adding a directory request here. + listing, local, err := s.displayListing(ctx) + if !local && err == nil { + if value, ok := teamDirectories.Load(s.teamDirectoryKey()); ok { + cached := value.(*teamDirectoryCache) + cached.Lock() + if !cached.at.IsZero() && time.Since(cached.at) < teamComputerCacheFor { + listing, err, local = cached.listing, cached.err, true + } + cached.Unlock() + } + } + if !local && err == nil { + if len(out) == 0 { + return out, nil + } + listing, err = s.teamDirectory(ctx) + } + if err != nil { + teams.SharingError(s.profile(), err) + return out, nil + } + key := directory.MetadataKey(s.Identity.CellKey()) + open := func(sealed string) (string, error) { return directory.OpenName(key, sealed) } + name := func(id string) string { + if d, ok := listing.Devices[id]; ok && d.Name != "" { + if text, err := open(d.Name); err == nil && strings.TrimSpace(text) != "" { + return text + } + } + return "the other computer" + } + for member, id := range devices { + out[member] = name(id) + } + rows := chatlist.Rows(listing, s.Device.ID(), open) + for member, id := range cells { + id = branches.Resolve(id) + for _, row := range rows { + if row.Cell == id && row.DeviceID != s.Device.ID() && (row.Status == chatlist.Running || (row.Status == chatlist.Off && !row.Mine)) { + out[member] = name(row.DeviceID) + break + } + } + } + return out, nil +} + +func (s *Sync) teamDirectoryKey() string { + return s.Home + "\x00" + s.Identity.ID() + "\x00" + s.Device.ID() + "\x00" + s.Relay +} + +// Existing directory reads supply local ownership evidence without adding a +// request to local sends. A failed read is cached for the same short interval. +func (s *Sync) rememberTeamDirectory(l directory.Listing, err error) { + value, _ := teamDirectories.LoadOrStore(s.teamDirectoryKey(), &teamDirectoryCache{}) + cached := value.(*teamDirectoryCache) + cached.Lock() + defer cached.Unlock() + cached.listing, cached.err, cached.at = l, err, time.Now() +} diff --git a/internal/syncsetup/team_locality_test.go b/internal/syncsetup/team_locality_test.go new file mode 100644 index 0000000000..159607193c --- /dev/null +++ b/internal/syncsetup/team_locality_test.go @@ -0,0 +1,95 @@ +package syncsetup + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/teams" +) + +type countedTeamDirectory struct { + directory.Client + calls int + err error + deadline time.Duration +} + +func (d *countedTeamDirectory) List(ctx context.Context) (directory.Listing, error) { + d.calls++ + if at, ok := ctx.Deadline(); ok { + d.deadline = time.Until(at) + } + return directory.Listing{}, d.err +} + +// A failed lookup is cached too, so an outage cannot turn a multi-member send +// into a row of directory requests. Missing files are never remote evidence. +func TestTeamOwnershipUsesOneShortCachedReadingAndFailureAllowsLocalWork(t *testing.T) { + root := t.TempDir() + enableSyncTeamSharing(t, root) + id, err := identity.Ensure(root) + if err != nil { + t.Fatal(err) + } + dev, err := identity.LoadDevice(root) + if err != nil { + t.Fatal(err) + } + dir := &countedTeamDirectory{err: errors.New("offline")} + s := &Sync{Home: root, Identity: id, Device: dev, Dir: dir} + members := []teams.Member{{Key: "local-a", SyncID: "cell:a"}, {Key: "local-b", SyncID: "cell:b"}, {Key: teams.RemoteKey("c")}} + if names, err := s.TeamComputers(t.Context(), members[:2]); err != nil || len(names) != 0 || dir.calls != 0 { + t.Fatal("local team added a request", names, err, dir.calls) + } + for range 2 { + names, err := s.TeamComputers(t.Context(), members) + if err != nil || names["local-a"] != "" || names["local-b"] != "" || names[teams.RemoteKey("c")] != "the other computer" { + t.Fatal(names, err) + } + } + if dir.calls != 1 || dir.deadline <= 0 || dir.deadline > teamComputerTimeout { + t.Fatal("unbounded or repeated lookup", dir.calls, dir.deadline) + } + for _, key := range []string{"claim", "local-missing"} { + if name, err := s.TeamComputer(t.Context(), teams.Member{Key: key}); err != nil || name != "" { + t.Fatal(name, err) + } + } + if dir.calls != 1 { + t.Fatal("pathless claim did directory work", dir.calls) + } +} + +func TestTrafficRosterFilterNeverRequestsTheDirectory(t *testing.T) { + root := t.TempDir() + enableSyncTeamSharing(t, root) + id, err := identity.Ensure(root) + if err != nil { + t.Fatal(err) + } + dev, err := identity.LoadDevice(root) + if err != nil { + t.Fatal(err) + } + dir := &countedTeamDirectory{err: errors.New("must not be called")} + s := &Sync{Home: root, Identity: id, Device: dev, Dir: dir} + peers, err := s.pairedTrafficDevices() + if err != nil || peers != nil || dir.calls != 0 { + t.Fatal("mirror added a request", peers, err, dir.calls) + } + p := &teamTraffic{teamKeys: teamKeys{root, root, dev.ID(), nil}, peers: s.pairedTrafficDevices} + if err := teams.Update(root, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "alpha", Name: "alpha"}}; return nil }); err != nil { + t.Fatal(err) + } + if err := p.Apply(map[string]string{"alpha/elsewhere": teamValue([]teams.Entry{{ID: "elsewhere/1", Text: "history"}})}); err != nil { + t.Fatal(err) + } + lines, err := teams.ReadHistory(root, "alpha", 0) + if err != nil || len(lines) != 1 || dir.calls != 0 { + t.Fatal("fallback did not mirror other writers locally", lines, err, dir.calls) + } +} diff --git a/internal/syncsetup/teams.go b/internal/syncsetup/teams.go new file mode 100644 index 0000000000..2c92215087 --- /dev/null +++ b/internal/syncsetup/teams.go @@ -0,0 +1,781 @@ +package syncsetup + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/vaultsync" +) + +// teamKeys keeps field clocks separate from membership clocks. Only the wire +// values use portable identities; the local store keeps its transcript paths. +type teamKeys struct { + profile, home, device string + cache *teamIdentityCache +} + +// The cache lives on Sync under stateMu. Capture probes only members' metadata +// stamps; the expensive discovery index is built solely for incoming identities. +type teamIdentityCache struct { + ids map[string]cachedTeamIdentity + discovery string + index map[string]teams.Member + scans int + queried map[string]bool + opens int + scanned bool + identities *teamIdentities + input string + traffic map[string]teamTrafficWindow + trafficReads int +} + +// A quiet round need not decode the same tail again. Keep authored windows +// separate from identity translation, which can change after membership edits. +type teamTrafficWindow struct { + stamp string + lines []teams.Entry +} +type cachedTeamIdentity struct{ stamp, id string } +type teamIdentities struct { + device string + branches *cellsync.BranchMap + local map[string]teams.Member + reverse map[string]string + cache *teamIdentityCache +} + +func teamFileStamp(path string) string { + info, err := os.Stat(path) + if err != nil { + return "" + } + return info.ModTime().String() + "/" + teamValue(info.Size()) +} + +func (p teamKeys) identities(f *teams.File) (*teamIdentities, error) { + cache := p.cache + if cache == nil { + cache = &teamIdentityCache{} + } + input := teamValue(f.Teams) + teamFileStamp(filepath.Join(p.home, "v3", "sync", "branches.json")) + for _, t := range f.Teams { + for _, member := range append(append([]teams.Member(nil), t.Members...), t.FormerMembers...) { + if !strings.HasPrefix(member.SyncID, "cell:") && member.File != "" { + root := filepath.Dir(member.File) + if filepath.Base(root) == cell.StateDir { + root = filepath.Dir(root) + } + input += teamFileStamp(filepath.Join(root, cell.MetaPath)) + } + } + } + if cache.identities != nil && cache.input == input { + return cache.identities, nil + } + branches, err := cellsync.OpenBranchMap(p.home) + if err != nil { + return nil, err + } + if cache.ids == nil { + cache.ids = map[string]cachedTeamIdentity{} + } + m := &teamIdentities{device: p.device, branches: branches, local: map[string]teams.Member{}, reverse: map[string]string{}, cache: cache} + for _, t := range f.Teams { + for _, member := range append(append([]teams.Member(nil), t.Members...), t.FormerMembers...) { + if _, remote := teams.RemoteCell(member.Key); remote || teams.OnOtherComputer(member.Key) { + continue + } + m.add(m.id(member), member) + } + } + cache.input, cache.identities = input, m + return m, nil +} + +// Duplicate records choose one path lexically. A Go map's traversal order must +// never choose a portable identity or the local transcript that it opens. +func (m *teamIdentities) add(id string, member teams.Member) { + if old, ok := m.local[id]; !ok || member.Key < old.Key { + m.local[id] = member + } + if m.reverse == nil { + m.reverse = map[string]string{} + } + if old := m.reverse[member.Key]; old == "" || id < old { + m.reverse[member.Key] = id + } +} +func (m *teamIdentities) id(member teams.Member) string { + if id, ok := teams.RemoteCell(member.Key); ok { + return "cell:" + m.branches.Resolve(id) + } + if id, ok := strings.CutPrefix(member.Key, "other-computer:"); ok { + return id + } + if id, ok := strings.CutPrefix(member.SyncID, "cell:"); ok { + return "cell:" + m.branches.Resolve(id) + } + file := member.File + if file == "" { + file = member.Key + } + root := filepath.Dir(file) + if filepath.Base(root) == cell.StateDir { + root = filepath.Dir(root) + } + metaStamp := teamFileStamp(filepath.Join(root, cell.MetaPath)) + stamp := metaStamp + "/" + member.SyncID + // Memberships can preserve different old ids for one file until apply + // upgrades them. Cache each record's identity without evicting the other. + cacheKey := file + "\x00" + member.SyncID + if m.cache != nil { + if cached, ok := m.cache.ids[cacheKey]; ok && cached.stamp == stamp { + return cached.id + } + } + sum := sha256.Sum256([]byte(member.Key)) + id := member.SyncID + if id == "" { + id = "device:" + m.device + ":" + hex.EncodeToString(sum[:]) + } + if metaStamp != "" { + if m.cache != nil { + m.cache.opens++ + } + if c, err := cell.OpenAt(root, filepath.Base(root)); err == nil { + id = "cell:" + m.branches.Resolve(c.ID) + } + } + if m.cache != nil { + m.cache.ids[cacheKey] = cachedTeamIdentity{stamp, id} + } + return id +} + +// resolve is apply-only. A known member never asks the session world anything; +// multiple unresolved members share one discovery pass in the network round. +func (p teamKeys) resolve(m *teamIdentities, ids []string) { + need := false + for _, id := range ids { + if strings.HasPrefix(id, "cell:") { + if _, ok := m.local[id]; !ok { + need = true + } + } + } + if !need { + return + } + cache := m.cache + // A continuation can land inside an existing project bucket, without + // moving the projects directory's stamp. Its local lease receipt is + // always filed after landing, so that directory also invalidates discovery. + stamp := teamFileStamp(cell.Dir(p.home)) + teamFileStamp(filepath.Join(p.home, "v3", "projects")) + teamFileStamp(filepath.Join(p.home, "v3", "sync", "holds")) + fresh := false + for _, id := range ids { + if !cache.queried[id] { + fresh = true + } + } + if !cache.scanned && (cache.index == nil || cache.discovery != stamp || fresh) { + if cache.queried == nil { + cache.queried = map[string]bool{} + } + for _, id := range ids { + cache.queried[id] = true + } + cache.scanned = true + cache.scans++ + cache.discovery = stamp + cache.index = map[string]teams.Member{} + add := func(c cell.Cell, file, where string) { + id := "cell:" + m.branches.Resolve(c.ID) + member := teams.Member{Key: teams.ConversationIdentity(file), File: file, Where: where} + if old, ok := cache.index[id]; !ok || member.Key < old.Key { + cache.index[id] = member + } + } + for _, row := range session.ReadWorld(filepath.Join(p.home, "v3", "projects")).Sessions() { + root := filepath.Dir(row.Transcript) + if filepath.Base(root) == cell.StateDir { + root = filepath.Dir(root) + } + if c, err := cell.OpenAt(root, row.ID); err == nil { + add(c, row.Transcript, row.Workspace) + } + } + dirs, _ := os.ReadDir(cell.Dir(p.home)) + for _, dir := range dirs { + if dir.IsDir() { + if c, err := cell.Open(p.home, dir.Name()); err == nil { + add(c, filepath.Join(c.Root, cell.TranscriptPath), workspaceOf(c.Root)) + } + } + } + } + names := make([]string, 0, len(cache.index)) + for id := range cache.index { + names = append(names, id) + } + sort.Strings(names) + for _, id := range names { + if _, ok := m.local[id]; !ok { + m.add(id, cache.index[id]) + } + } +} + +// Portable records omit every machine path. Start boundaries retain their +// writer so a foreign sequence can never become a local delivery position. +func (m *teamIdentities) portable(member teams.Member) teams.Member { + id := m.id(member) + member.Key, member.File, member.Where, member.SyncID = id, "", "", "" + if member.StartID != "" { + member.StartID = m.device + "/" + member.StartID + } else { + member.StartID = member.StartOrigin + } + member.StartOrigin = "" + return member +} + +// Realization borrows only the receiver's own paths. A missing transcript +// takes the existing machine-row identity rather than guessing a local path. +func (m *teamIdentities) realized(member teams.Member) teams.Member { + id := member.Key + member.SyncID = id + member.StartOrigin = member.StartID + if localID, own := strings.CutPrefix(member.StartID, m.device+"/"); own { + member.StartID = localID + } else { + member.StartID = "" + } + if local, ok := m.local[id]; ok { + member.Key, member.File, member.Where = local.Key, local.File, local.Where + } else if cellID, ok := strings.CutPrefix(id, "cell:"); ok { + member.Key, member.File, member.Where = teams.RemoteKey(cellID), "", "" + } else { + member.Key, member.File, member.Where = teams.OtherComputerKey(id), "", "" + } + return member +} + +func teamValue(v any) string { raw, _ := json.Marshal(v); return string(raw) } + +// Fields have independent clocks, including unknown fields from a later +// build. Members carry their full record so unknown member fields survive too. +func (p teamKeys) values(f *teams.File, m *teamIdentities) (map[string]string, error) { + values := map[string]string{} + for _, t := range f.Teams { + t = t.Clone() + base := t.ID + "/" + values[base+"exists"] = "true" + byKey := map[string]string{} + var order []string + for _, member := range t.Members { + id := m.id(member) + byKey[member.Key] = id + values[base+"member/"+id] = teamValue(m.portable(member)) + order = append(order, id) + } + values[base+"order"] = teamValue(order) + if t.Manager != "" { + t.Manager = byKey[t.Manager] + } + for i, member := range t.FormerMembers { + byKey[member.Key] = m.id(member) + t.FormerMembers[i] = m.portable(member) + } + if t.FormerManager != "" { + former := byKey[t.FormerManager] + if former == "" { + former = m.id(teams.Member{Key: t.FormerManager}) + } + t.FormerManager = former + } + raw, err := json.Marshal(t) + if err != nil { + return nil, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, err + } + for key, value := range fields { + if key != "id" && key != "members" { + values[base+"field/"+key] = string(value) + } + } + } + return values, nil +} + +func (p teamKeys) Values() (map[string]string, error) { + f, err := teams.Snapshot(p.profile) + if err != nil { + teams.SharingError(p.profile, err) + return nil, vaultsync.ErrDamaged + } + if len(f.Teams) == 0 { + return map[string]string{}, nil + } + m, err := p.identities(f) + if err != nil { + return nil, err + } + return p.values(f, m) +} +func (p teamKeys) Holds(name string) bool { + id, rest, ok := strings.Cut(name, "/") + return ok && id != "" && (rest == "exists" || rest == "order" || strings.HasPrefix(rest, "field/") || strings.HasPrefix(rest, "member/")) +} +func (p teamKeys) Apply(changes map[string]string) error { return p.ApplyIf(changes, nil) } + +// ApplyIf compares each field inside Update's lock with the pre-network +// snapshot. A simultaneous local edit remains for the next capture to send. +func (p teamKeys) ApplyIf(changes, expected map[string]string) error { + return teams.Update(p.profile, func(f *teams.File) error { + m, err := p.identities(f) + if err != nil { + return err + } + current, err := p.values(f, m) + if err != nil { + return err + } + for key, value := range changes { + if !p.Holds(key) || (expected != nil && current[key] != expected[key]) { + continue + } + // A deleted team cannot be re-created by an edit to one of its fields. + if value == "" { + delete(current, key) + } else { + current[key] = value + } + } + var incoming []string + for key := range current { + if _, rest, ok := strings.Cut(key, "/member/"); ok { + incoming = append(incoming, rest) + } + } + p.resolve(m, incoming) + next, err := p.decode(current, m) + if err != nil { + return err + } + for _, old := range f.Teams { + found := false + for _, t := range next { + found = found || old.ID == t.ID + } + if !found { + if err := os.Remove(teams.TrafficMirrorPath(p.profile, old.ID)); err != nil && !os.IsNotExist(err) { + return err + } + } + } + f.Teams = next + f.RepairMerged() + return nil + }) +} + +// Decode orders every ambiguous choice before ordinary store repair runs. +// Existence is separate from fields, so an offline rename cannot revive a team. +func (p teamKeys) decode(values map[string]string, m *teamIdentities) ([]teams.Team, error) { + var result []teams.Team + for key, value := range values { + id, rest, _ := strings.Cut(key, "/") + if rest != "exists" || value != "true" { + continue + } + fields := map[string]json.RawMessage{"id": json.RawMessage(teamValue(id))} + members := map[string]teams.Member{} + base := id + "/" + for name, value := range values { + rest, ok := strings.CutPrefix(name, base) + if !ok { + continue + } + if field, ok := strings.CutPrefix(rest, "field/"); ok { + fields[field] = json.RawMessage(value) + } + if who, ok := strings.CutPrefix(rest, "member/"); ok { + var member teams.Member + if err := json.Unmarshal([]byte(value), &member); err != nil { + return nil, err + } + member.Key = who + members[who] = m.realized(member) + } + } + raw, err := json.Marshal(fields) + if err != nil { + return nil, err + } + var t teams.Team + if err := json.Unmarshal(raw, &t); err != nil { + return nil, err + } + var order []string + if raw := values[base+"order"]; raw != "" { + if err := json.Unmarshal([]byte(raw), &order); err != nil { + return nil, err + } + } + for _, who := range order { + if member, ok := members[who]; ok { + t.Members = append(t.Members, member) + delete(members, who) + } + } + var restMembers []teams.Member + for _, member := range members { + restMembers = append(restMembers, member) + } + sort.Slice(restMembers, func(i, j int) bool { + a, b := restMembers[i], restMembers[j] + if !a.JoinedAt.Equal(b.JoinedAt) { + return a.JoinedAt.Before(b.JoinedAt) + } + return a.SyncID < b.SyncID + }) + t.Members = append(t.Members, restMembers...) + manager := t.Manager + t.Manager = "" + for _, member := range t.Members { + if member.SyncID == manager { + t.Manager = member.Key + } + } + formerManager := t.FormerManager + if formerManager != "" { + t.FormerManager = m.realized(teams.Member{Key: formerManager}).Key + } + for i, member := range t.FormerMembers { + t.FormerMembers[i] = m.realized(member) + if member.Key == formerManager { + t.FormerManager = t.FormerMembers[i].Key + } + } + result = append(result, t) + } + sort.Slice(result, func(i, j int) bool { + if !result[i].Made.Equal(result[j].Made) { + return result[i].Made.Before(result[j].Made) + } + return result[i].ID < result[j].ID + }) + return result, nil +} + +// Refresh heals paths after a successful continuation whose immediate team +// bookkeeping failed. No discovery work occurs unless an unresolved cell is +// present and its discovery stamp has moved since the last apply. +func (p teamKeys) Refresh() error { + f, err := teams.Snapshot(p.profile) + if err != nil { + teams.SharingError(p.profile, err) + return vaultsync.ErrDamaged + } + var ids []string + for _, t := range f.Teams { + for _, member := range t.Members { + if id, ok := teams.RemoteCell(member.Key); ok { + ids = append(ids, "cell:"+id) + } + } + } + if len(ids) == 0 { + return nil + } + m, err := p.identities(f) + if err != nil { + return err + } + p.resolve(m, ids) + for _, id := range ids { + if member, ok := m.local[id]; ok { + root := filepath.Dir(member.File) + if filepath.Base(root) == cell.StateDir { + root = filepath.Dir(root) + } + if c, err := cell.OpenAt(root, strings.TrimPrefix(id, "cell:")); err == nil { + if err := p.rebindChat(strings.TrimPrefix(id, "cell:"), c); err != nil { + return err + } + } + } + } + return nil +} + +func syncedTeams(profile, home, device string, cache *teamIdentityCache) vaultsync.Keyed { + return vaultsync.GuardedKeys("teams", teamKeys{profile, home, device, cache}, filepath.Join(home, "teams-sync.ledger")) +} + +// A writer publishes only its own windows. The complete received set rebuilds +// the display mirror, so forgetting a transport window never edits a local log. +type teamTraffic struct { + teamKeys + peers func() (map[string]bool, error) + snapshot *teams.File + stamp string +} + +func (p *teamTraffic) load() (*teams.File, error) { + if p.snapshot != nil && p.stamp == teams.Stamp(p.profile) { + return p.snapshot, nil + } + f, err := teams.Snapshot(p.profile) + if err != nil { + teams.SharingError(p.profile, err) + return nil, vaultsync.ErrDamaged + } + p.snapshot = f + p.stamp = teams.Stamp(p.profile) + return f, nil +} +func (p *teamTraffic) Values() (map[string]string, error) { + f, err := p.load() + if err != nil { + return nil, err + } + m, err := p.identities(f) + if err != nil { + return nil, err + } + portable := func(key string) string { + if key == "" { + return "" + } + if id := m.reverse[key]; id != "" { + return id + } + return m.id(teams.Member{Key: key}) + } + live := map[string]bool{} + for _, t := range f.Teams { + live[p.profile+"\x00"+t.ID] = true + } + for key := range m.cache.traffic { + if strings.HasPrefix(key, p.profile+"\x00") && !live[key] { + delete(m.cache.traffic, key) + } + } + type window struct { + id, raw string + at time.Time + } + var windows []window + for _, t := range f.Teams { + key := p.profile + "\x00" + t.ID + stamp := teams.TrafficStamp(p.profile, t.ID) + cached, known := m.cache.traffic[key] + if !known || cached.stamp != stamp { + lines, err := teams.TrafficForSync(p.profile, t.ID, p.device) + if err != nil { + return nil, err + } + m.cache.trafficReads++ + cached = teamTrafficWindow{stamp: stamp, lines: lines} + if m.cache.traffic == nil { + m.cache.traffic = map[string]teamTrafficWindow{} + } + m.cache.traffic[key] = cached + } + lines := append([]teams.Entry(nil), cached.lines...) + for i := range lines { + e := &lines[i] + e.Member, e.FromKey, e.ToKey = portable(e.Member), portable(e.FromKey), portable(e.ToKey) + } + // Translation can lengthen keys, so bound again on their encoded form. + for len(lines) > 0 { + raw, _ := json.Marshal(lines) + if len(raw) <= teams.TrafficSyncBytes { + break + } + lines = lines[1:] + } + if len(lines) > 0 { + windows = append(windows, window{t.ID, teamValue(lines), lines[len(lines)-1].At}) + } + } + sort.Slice(windows, func(i, j int) bool { + if !windows[i].at.Equal(windows[j].at) { + return windows[i].at.After(windows[j].at) + } + return windows[i].id < windows[j].id + }) + out := map[string]string{} + size := 0 + for _, w := range windows { + // Account for JSON escaping and the entry's name, scope, stamp and id as + // well as its words. This keeps the encoded share below the device cap. + raw, _ := json.Marshal(w.raw) + cost := len(raw) + 2*(len(w.id)+len(p.device)) + 256 + if size+cost > teams.TrafficSyncDeviceBytes { + continue + } + out[w.id] = w.raw + size += cost + } + return out, nil +} +func (p *teamTraffic) Holds(name string) bool { + f, err := p.load() + if err != nil { + return false + } + _, ok := f.Team(name) + return ok +} +func (p *teamTraffic) Apply(values map[string]string) (err error) { + // Display refresh is optional even when called from capture before a push. + // Keep its failures out of DeviceWindows and every unrelated vault value. + defer func() { teams.SharingError(p.profile, err); err = nil }() + // Team records restore before Traffic, and may have added or deleted teams. + p.snapshot = nil + f, err := p.load() + if err != nil { + return err + } + // An empty profile has no mirror to filter. In particular, leave the + // existing hosted polling cost unchanged when no team uses this road. + if len(f.Teams) == 0 { + return nil + } + m, err := p.identities(f) + if err != nil { + return err + } + peers := map[string]bool{} + if p.peers != nil { + peers, err = p.peers() + if err != nil { + return err + } + } + byTeam := map[string][]teams.Entry{} + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + id, device, ok := strings.Cut(name, "/") + if !ok || device == p.device || (peers != nil && !peers[device]) { + continue + } + if _, ok := f.Team(id); !ok { + continue + } + var lines []teams.Entry + if err := json.Unmarshal([]byte(values[name]), &lines); err != nil { + return err + } + if len(lines) > teams.TrafficSyncLines || len(values[name]) > teams.TrafficSyncBytes { + continue + } + for _, e := range lines { + if !strings.HasPrefix(e.ID, device+"/") { + continue + } + realized := func(key string) string { + if key == "" { + return "" + } + return m.realized(teams.Member{Key: key}).Key + } + e.Member, e.FromKey, e.ToKey = realized(e.Member), realized(e.FromKey), realized(e.ToKey) + byTeam[id] = append(byTeam[id], e) + } + } + for _, t := range f.Teams { + if err := teams.WriteTrafficMirror(p.profile, t.ID, byTeam[t.ID]); err != nil { + teams.SharingError(p.profile, err) + } + } + return nil +} + +// pairedTrafficDevices reads only the roster already held by the local learner. +// Without that roster, display every other writer until ordinary fleet refresh +// supplies it; a vault round must never add a directory request. +func (s *Sync) pairedTrafficDevices() (map[string]bool, error) { + l, local, err := s.displayListing(context.Background()) + if err != nil { + teams.SharingError(s.profile(), err) + } + if !local || err != nil { + return nil, nil + } + peers := map[string]bool{} + for id, d := range l.Devices { + if !d.Revoked { + peers[id] = true + } + } + return peers, nil +} + +// rebindChat runs after continuation has chosen the final local folder. Keeping +// the portable identity beside the new path prevents a take from becoming a +// membership removal and addition on the next profile round. +func (p teamKeys) rebindChat(id string, at cell.Cell) error { + snapshot, err := teams.Snapshot(p.profile) + if err != nil { + return err + } + matches := func(member teams.Member) bool { + return member.SyncID == "cell:"+id || member.Key == teams.RemoteKey(id) + } + found := false + for _, t := range snapshot.Teams { + for _, member := range append(append([]teams.Member(nil), t.Members...), t.FormerMembers...) { + found = found || matches(member) + } + } + if !found { + return nil + } + file := filepath.Join(at.Root, cell.TranscriptPath) + key := teams.ConversationIdentity(file) + where := workspaceOf(at.Root) + return teams.Update(p.profile, func(f *teams.File) error { + for i := range f.Teams { + t := &f.Teams[i] + for _, members := range [][]teams.Member{t.Members, t.FormerMembers} { + for j := range members { + member := &members[j] + if !matches(*member) { + continue + } + old := member.Key + member.Key, member.File, member.Where, member.SyncID = key, file, where, "cell:"+id + if t.Manager == old { + t.Manager = key + } + if t.FormerManager == old { + t.FormerManager = key + } + } + } + } + return nil + }) +} diff --git a/internal/syncsetup/teams_test.go b/internal/syncsetup/teams_test.go new file mode 100644 index 0000000000..989cb41177 --- /dev/null +++ b/internal/syncsetup/teams_test.go @@ -0,0 +1,653 @@ +package syncsetup + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/teams/teamstest" +) + +func TestTeamFieldsAndUnknownMemberFieldsSurviveGuardedApply(t *testing.T) { + home := t.TempDir() + raw := `{"version":2,"teams":[{"id":"alpha","name":"alpha","parent":"","members":[{"key":"local","file":"","where":"","word":"storage","future_member":{"value":7}}],"manager":"","former_manager":"historical-leader","made":"2026-01-01T00:00:00Z","future_team":{"enabled":true}}]}` + // app/v3 keeps teams in an SQLite store (#2066): the old file's document + // goes into the store as stored, unknown fields and all. + teamstest.Write(t, home, []byte(raw)) + p := teamKeys{home, home, "device-a", nil} + expected, err := p.Values() + if err != nil { + t.Fatal(err) + } + // A person types while the other computer's response is in flight. + if err := teams.Update(home, func(f *teams.File) error { f.Teams[0].Name = "typed during request"; return nil }); err != nil { + t.Fatal(err) + } + changes := map[string]string{"alpha/field/name": `"remote name"`, "alpha/field/parent": `"missing-parent"`, "alpha/field/future_flag": `{"nested":[1,2]}`} + if err := p.ApplyIf(changes, expected); err != nil { + t.Fatal(err) + } + f, err := teams.Load(home) + if err != nil || f.Teams[0].Name != "typed during request" || f.Teams[0].Parent != "" { + t.Fatalf("guard: %+v %v", f, err) + } + saved := string(teamstest.JSON(t, home)) + for _, field := range []string{"future_member", "future_team", "future_flag"} { + if !strings.Contains(saved, field) { + t.Fatalf("lost %s", field) + } + } + values, err := p.Values() + if err != nil { + t.Fatal(err) + } + if values["alpha/field/former_manager"] == "" || values["alpha/field/former_manager"] == `""` { + t.Fatal("history without a former membership lost its manager identity") + } + if values["alpha/field/future_team"] != "{\"enabled\":true}" { + t.Fatalf("future field not carried independently: %s", values["alpha/field/future_team"]) + } +} + +func TestTeamsUseReceivingComputersOwnTranscriptAndBranchIdentity(t *testing.T) { + home := t.TempDir() + p := teamKeys{home, home, "device-a", nil} + f := &teams.File{Teams: []teams.Team{{ID: "alpha", Name: "alpha", Members: []teams.Member{{Key: "local-key", File: "/local/transcript", Where: "/local/work", Word: "storage", SyncID: "cell:shared-cell"}}}}} + m, err := p.identities(f) + if err != nil { + t.Fatal(err) + } + values, err := p.values(f, m) + if err != nil { + t.Fatal(err) + } + for _, value := range values { + if strings.Contains(value, "/local/") { + t.Fatal("machine paths reached the wire") + } + } + decoded, err := p.decode(values, m) + if err != nil { + t.Fatal(err) + } + member := decoded[0].Members[0] + if member.Key != "local-key" || member.File != "/local/transcript" || member.Where != "/local/work" { + t.Fatalf("lost receiver's path: %+v", member) + } +} + +func TestContinuedMemberRebindsToLocalChatWithoutChangingPortableMembership(t *testing.T) { + home := t.TempDir() + c, err := cell.Create(home, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + p := teamKeys{home, home, "this-device", nil} + remote := teams.RemoteKey(c.ID) + if err := teams.Update(home, func(f *teams.File) error { + f.Teams = []teams.Team{{ID: "alpha", Name: "alpha", Members: []teams.Member{{Key: remote, Word: "storage", SyncID: "cell:" + c.ID}}, Manager: remote}} + return nil + }); err != nil { + t.Fatal(err) + } + before, err := p.Values() + if err != nil { + t.Fatal(err) + } + if err := p.rebindChat(c.ID, c); err != nil { + t.Fatal(err) + } + after, err := p.Values() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("taking a chat changed portable membership") + } + f, err := teams.Load(home) + if err != nil { + t.Fatal(err) + } + m := f.Teams[0].Members[0] + if m.File != filepath.Join(c.Root, cell.TranscriptPath) || m.Key != m.File || f.Teams[0].Manager != m.Key { + t.Fatalf("continued member did not rebind: %+v", f.Teams[0]) + } +} + +func TestMembershipStartBoundsNeverReuseAForeignSequenceNumber(t *testing.T) { + left := &teamIdentities{device: "device-a", local: map[string]teams.Member{}} + right := &teamIdentities{device: "device-b", local: map[string]teams.Member{}} + member := teams.Member{Key: teams.RemoteKey("shared"), Word: "storage", StartID: "000000000001"} + portable := left.portable(member) + if portable.StartID != "device-a/000000000001" { + t.Fatal("start boundary lost its origin") + } + received := right.realized(portable) + if received.StartID != "" || received.StartOrigin != portable.StartID { + t.Fatal("foreign start reused a local delivery id") + } + returned := right.portable(received) + if returned.StartID != portable.StartID || left.realized(returned).StartID != member.StartID { + t.Fatal("start boundary did not survive round trip") + } +} + +// Capture cannot inspect unrelated sessions, including legacy rows whose +// directory id is not a transferable cell id. +func TestUnchangedTeamCaptureScansNoWorldAndUsesDeterministicIdentities(t *testing.T) { + home := t.TempDir() + for i := 0; i < 500; i++ { + root := filepath.Join(home, "v3", "projects", "project", fmt.Sprintf("%016x", i+1)) + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(root, session.Meta{ID: filepath.Base(root), Title: "legacy chat", Workspace: home, Created: time.Unix(int64(i+1), 0)}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "transcript.jsonl"), []byte("{}\n"), 0600); err != nil { + t.Fatal(err) + } + } + if err := teams.Update(home, func(f *teams.File) error { + for i := 0; i < 5; i++ { + key := filepath.Join(home, "v3", "projects", "project", fmt.Sprintf("%016x", i+1), "transcript.jsonl") + f.Teams = append(f.Teams, teams.Team{ID: fmt.Sprintf("team-%d", i), Name: fmt.Sprintf("team-%d", i), Members: []teams.Member{{Key: key, File: key, Word: "worker"}}}) + } + return nil + }); err != nil { + t.Fatal(err) + } + for i := 0; i < 5; i++ { + if err := teams.AppendTraffic(home, fmt.Sprintf("team-%d", i), teams.Entry{Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: "local activity"}); err != nil { + t.Fatal(err) + } + } + cache := &teamIdentityCache{} + if _, err := identity.Ensure(home); err != nil { + t.Fatal(err) + } + dev, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + p := teamKeys{home, home, dev.ID(), cache} + first, err := p.Values() + if err != nil { + t.Fatal(err) + } + second, err := p.Values() + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(first) + b, _ := json.Marshal(second) + if string(a) != string(b) || cache.scans != 0 || cache.opens != 0 { + t.Fatalf("unstable capture or %d world scans", cache.scans) + } + for _, raw := range second { + if strings.Contains(raw, "cell:legacy") { + t.Fatal("legacy row became a cell") + } + } + celltest.Available(t) + // One real cell can be in several teams while their stored portable ids + // are at different migration stages. An unchanged capture opens it zero + // times after warming, rather than alternating one file-cache entry. + c, err := cell.Create(home, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(c.Root, cell.TranscriptPath) + if err := teams.Update(home, func(f *teams.File) error { + for i := 0; i < 2; i++ { + member := teams.Member{Key: file, File: file, Word: "shared worker"} + if i == 1 { + member.SyncID = "device:" + dev.ID() + ":prior-local-id" + } + f.Teams[i].Members = append(f.Teams[i].Members, member) + } + return nil + }); err != nil { + t.Fatal(err) + } + warm, err := p.Values() + if err != nil { + t.Fatal(err) + } + opened := cache.opens + again, err := p.Values() + if err != nil || !reflect.DeepEqual(warm, again) || cache.opens != opened { + t.Fatalf("unchanged shared cell reopened metadata: %d -> %d, %v", opened, cache.opens, err) + } + t.Setenv(URLVar, relay(t).URL) + t.Setenv(IntervalVar, "") + if _, err := identity.Ensure(home); err != nil { + t.Fatal(err) + } + syncer, ok, err := Open(home) + if err != nil || !ok { + t.Fatal(err, ok) + } + syncer.teamsCache = cache + if err := syncer.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + scans, opens, reads := cache.scans, cache.opens, cache.trafficReads + if err := syncer.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + if cache.scans != scans || cache.opens != opens || cache.trafficReads != reads || string(before) != string(after) { + t.Fatal("unchanged round performed discovery or wrote vault") + } + +} + +// A later restore resolves the actual local cell after immediate rebinding +// was unavailable. The vault need not contain another team edit to retry it. +func TestTeamRestoreHealsMembershipAfterDamagedFileDelayedRebinding(t *testing.T) { + home := t.TempDir() + cache := &teamIdentityCache{} + p := teamKeys{home, home, "device-b", cache} + c, err := cell.Create(home, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + remote := teams.RemoteKey(c.ID) + if err := teams.Update(home, func(f *teams.File) error { + f.Teams = []teams.Team{{ID: "alpha", Name: "alpha", Manager: remote, Members: []teams.Member{{Key: remote, SyncID: "cell:" + c.ID, Word: "worker"}}}} + return nil + }); err != nil { + t.Fatal(err) + } + restore := teamstest.Break(t, home, []byte("damaged")) + if err := p.rebindChat(c.ID, c); err == nil { + t.Fatal("fixture did not fail rebind") + } + restore() + if err := p.Refresh(); err != nil { + t.Fatal(err) + } + f, err := teams.Snapshot(home) + if err != nil { + t.Fatal(err) + } + m := f.Teams[0].Members[0] + if m.File != filepath.Join(c.Root, cell.TranscriptPath) || f.Teams[0].Manager != m.Key { + t.Fatal("later restore did not heal", m) + } +} + +func TestMergedTeamsFollowCreationTimeWhileUnpairedFileKeepsItsOrder(t *testing.T) { + home := t.TempDir() + early := time.Unix(10, 0) + if err := teams.Update(home, func(f *teams.File) error { + f.Teams = []teams.Team{{ID: "aa", Name: "second", Made: early.Add(time.Second)}, {ID: "zz", Name: "first", Made: early}} + return nil + }); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(teams.Path(home)) + p := teamKeys{home, home, "dev", nil} + values, err := p.Values() + if err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(teams.Path(home)) + if string(before) != string(after) { + t.Fatal("capture reordered local file") + } + if err := p.Apply(values); err != nil { + t.Fatal(err) + } + f, _ := teams.Snapshot(home) + if f.Teams[0].ID != "zz" || f.Teams[1].ID != "aa" { + t.Fatal("merge sorted random ids", f.Teams) + } +} + +// When the real cell engine is available, this drives the continuation door +// that owns the lease, not a separate call to the bookkeeping helper. +func TestContinuingAChatKeepsItsLeaseDespiteDamagedTeamsAndLaterHeals(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + h := newTwoHomes(t) + enableSyncTeamSharing(t, h.a.Home) + enableSyncTeamSharing(t, h.b.Home) + // Land in the same home cell tree that the next profile round discovers. + h.rootsB = cell.Dir(h.b.Home) + ctx := t.Context() + // The receiver will open the actual session after the take. Start with a + // journal written by that public entry point, not the relay rig's header. + if err := os.Remove(transcriptOf(h.cell)); err != nil { + t.Fatal(err) + } + source, err := session.New(session.Config{Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), ProfileDir: h.a.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: "http://127.0.0.1:1", System: "Continuation fixture"}) + if err != nil { + t.Fatal(err) + } + if err := source.Close(); err != nil { + t.Fatal(err) + } + source.SettleWrites() + remote := teams.ConversationIdentity(transcriptOf(h.cell)) + if err := teams.Update(h.a.Home, func(f *teams.File) error { + off := false + f.Teams = []teams.Team{{ID: "alpha", Name: "alpha", Manager: remote, Settings: teams.Settings{Wake: &off}, Members: []teams.Member{{Key: remote, File: transcriptOf(h.cell), Where: h.work, SyncID: "cell:" + h.cell.ID, Word: "worker"}}}} + return nil + }); err != nil { + t.Fatal(err) + } + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n") + })) + t.Cleanup(model.Close) + // Save through the real session boundary while A is still unpaired. + solo := filepath.Join(h.a.Home, "identity-solo") + if err := os.WriteFile(solo, nil, 0600); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_HOME", h.a.Home) + for range 5 { + if err := teams.AppendTraffic(h.a.Home, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "A history"}); err != nil { + t.Fatal(err) + } + } + unpaired, err := session.New(session.Config{Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), ProfileDir: h.a.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: model.URL, System: "Continuation fixture"}) + if err != nil { + t.Fatal(err) + } + // Opening the session establishes its initial delivery position. + events, err := unpaired.Submit(ctx, "establish the unpaired delivery cursor") + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatal(event.Text) + } + } + if err := unpaired.Close(); err != nil { + t.Fatal(err) + } + unpaired.SettleWrites() + cursor, err := os.ReadFile(filepath.Join(h.cell.Root, ".cell", "team.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(cursor), "device") || !strings.Contains(string(cursor), "000000000005") { + t.Fatalf("unpaired cursor: %s", cursor) + } + if err := identity.EndSolo(h.a.Home); err != nil { + t.Fatal(err) + } + a := h.openA() + a.mustSay("publish with team positions") + h.durable(h.cell.ID, h.cell) + if err := h.a.SyncState(ctx); err != nil { + t.Fatal(err) + } + if err := h.b.SyncState(ctx); err != nil { + t.Fatal(err) + } + good, err := os.ReadFile(teams.Path(h.b.Home)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(teams.Path(h.b.Home), []byte("damaged"), 0600); err != nil { + t.Fatal(err) + } + h.lapse() + took, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatalf("teams failed continuation: %v", err) + } + view, err := h.b.Dir.Cell(ctx, h.cell.ID) + if err != nil || view.Cell.Lease.Device != h.b.Device.ID() || view.Cell.Lease.Fence != took.Taken.Driving.Fence { + t.Fatal("continuation lost lease", view, err) + } + if !strings.Contains(strings.Join(h.wall.all(), "\n"), "teams could not be updated yet") { + t.Fatal("failed rebind had no notice") + } + received, err := os.ReadFile(filepath.Join(took.Taken.Cell.Root, ".cell", "team.json")) + if err != nil || string(received) != string(cursor) { + t.Fatal("cursor fixture did not travel", string(received), err) + } + if err := os.WriteFile(teams.Path(h.b.Home), good, 0600); err != nil { + t.Fatal(err) + } + if err := h.b.SyncState(ctx); err != nil { + t.Fatal(err) + } + f, err := teams.Snapshot(h.b.Home) + if err != nil { + t.Fatal(err) + } + m := f.Teams[0].Members[0] + if m.File != filepath.Join(took.Taken.Cell.Root, cell.TranscriptPath) { + t.Fatalf("later round did not heal membership: %+v", m) + } + + // Drive delivery through a real received agent and a loopback scripted + // model. The carried cursor must skip earlier B work and admit new work. + t.Setenv("CODEAF_HOME", h.b.Home) + if err := teams.AppendTraffic(h.b.Home, "alpha", teams.Entry{At: time.Now().Add(-time.Second), Kind: teams.KindDirective, From: "helper", To: teams.ToManager, Text: "before B opened"}); err != nil { + t.Fatal(err) + } + work := workspaceOf(took.Taken.Cell.Root) + receiver, err := session.New(session.Config{Workspace: work, Place: session.Place{Dir: took.Taken.Cell.Root, Workspace: work}, SessionFile: m.File, ProfileDir: h.b.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: model.URL, System: "Continuation fixture"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = receiver.Close() }) + submit := func() { + events, err := receiver.Submit(ctx, "check local team messages") + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatalf("received agent: %s", event.Text) + } + } + } + submit() + if err := teams.AppendTraffic(h.b.Home, "alpha", teams.Entry{Kind: teams.KindDirective, From: "helper", To: teams.ToManager, Text: "after B opened"}); err != nil { + t.Fatal(err) + } + submit() + submit() + receiver.SettleWrites() + journal, err := os.ReadFile(m.File) + if err != nil { + t.Fatal(err) + } + var deliveries string + for _, line := range strings.Split(string(journal), "\n") { + var entry struct{ Content string } + if json.Unmarshal([]byte(line), &entry) == nil && strings.Contains(entry.Content, "Team traffic in") { + deliveries += entry.Content + "\n" + } + } + if strings.Contains(deliveries, "before B opened") || strings.Count(deliveries, "after B opened") != 1 { + t.Fatalf("continued cursor lost, replayed or admitted old work: %s", deliveries) + } +} + +func TestTrafficMirrorsRetireRevokedDevicesWithoutRepublishingThem(t *testing.T) { + home := t.TempDir() + if err := teams.Update(home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "alpha", Name: "alpha"}}; return nil }); err != nil { + t.Fatal(err) + } + paired := true + p := &teamTraffic{teamKeys: teamKeys{home, home, "here", nil}, peers: func() (map[string]bool, error) { return map[string]bool{"desktop": paired}, nil }} + values := map[string]string{"alpha/desktop": teamValue([]teams.Entry{{ID: "desktop/1", At: time.Now(), Kind: teams.KindNote, Text: "elsewhere"}})} + if err := p.Apply(values); err != nil { + t.Fatal(err) + } + got, _ := teams.ReadHistory(home, "alpha", 0) + if len(got) != 1 { + t.Fatal(got) + } + local, err := p.Values() + if err != nil || len(local) != 0 { + t.Fatal("received lines were republished", local, err) + } + paired = false + if err := p.Apply(values); err != nil { + t.Fatal(err) + } + got, _ = teams.ReadHistory(home, "alpha", 0) + if len(got) != 0 { + t.Fatal("revoked device retained mirror", got) + } +} + +func TestLegacyChatsNeverAcquireCellIdentityFromTheDiscoveryIndex(t *testing.T) { + home := t.TempDir() + p := teamKeys{home, home, "writer", &teamIdentityCache{}} + legacy := filepath.Join(home, "v3", "projects", "project", "0000000000000001", "transcript.jsonl") + if err := os.MkdirAll(filepath.Dir(legacy), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacy, []byte("legacy\n"), 0600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(filepath.Dir(legacy), session.Meta{ID: "0000000000000001", Title: "legacy", Workspace: home, Created: time.Now()}); err != nil { + t.Fatal(err) + } + c, err := cell.Create(home, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + realFile := filepath.Join(c.Root, cell.TranscriptPath) + f := &teams.File{Teams: []teams.Team{{ID: "alpha", Name: "alpha", Members: []teams.Member{{Key: legacy, File: legacy, Word: "legacy"}, {Key: realFile, File: realFile, Word: "real"}}}}} + m, err := p.identities(f) + if err != nil { + t.Fatal(err) + } + p.resolve(m, []string{"cell:" + c.ID, "cell:unresolved"}) + if !strings.HasPrefix(m.reverse[legacy], "device:writer:") || m.reverse[realFile] != "cell:"+c.ID { + t.Fatal("ambiguous reverse identities", m.reverse) + } + if _, ok := m.local["cell:0000000000000001"]; ok { + t.Fatal("legacy world row entered cell index") + } + first, _ := p.values(f, m) + for range 10 { + next, err := p.identities(f) + if err != nil { + t.Fatal(err) + } + values, _ := p.values(f, next) + if !reflect.DeepEqual(values, first) { + t.Fatal("identities depend on map traversal") + } + } +} + +func enableSyncTeamSharing(t *testing.T, profile string) { + t.Helper() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: profile}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } +} + +func TestSharingOffContinuesTeamMemberWithoutRebindingOrChangingLegacyFiles(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + h := newTwoHomes(t) + key := teams.ConversationIdentity(transcriptOf(h.cell)) + for _, s := range []*Sync{h.a, h.b} { + if config.TeamsShareAt(s.Home) { + t.Fatal("new paired home opted into sharing") + } + if err := teams.Update(s.Home, func(f *teams.File) error { + f.Teams = []teams.Team{{ID: "alpha", Name: "local", Members: []teams.Member{{Key: key, File: transcriptOf(h.cell), Word: "worker"}}}} + return nil + }); err != nil { + t.Fatal(err) + } + if err := teams.AppendTraffic(s.Home, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: "local history"}); err != nil { + t.Fatal(err) + } + } + before := map[string]string{} + for _, s := range []*Sync{h.a, h.b} { + for _, path := range []string{teams.Path(s.Home), teams.TrafficPath(s.Home, "alpha")} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + before[path] = string(raw) + } + } + cursor := []byte(`{"read":{"alpha":"000000000007"}}`) + if err := os.MkdirAll(filepath.Join(h.cell.Root, ".cell"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(h.cell.Root, ".cell", "team.json"), cursor, 0600); err != nil { + t.Fatal(err) + } + a := h.openA() + a.mustSay("publish with legacy team positions") + h.durable(h.cell.ID, h.cell) + for range 3 { + for _, s := range []*Sync{h.a, h.b} { + if err := s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + } + } + h.lapse() + took, err := h.continuerB().Take(t.Context(), h.cell.ID) + if err != nil { + t.Fatal(err) + } + carried, err := os.ReadFile(filepath.Join(took.Taken.Cell.Root, ".cell", "team.json")) + if err != nil || string(carried) != string(cursor) { + t.Fatalf("continuation changed legacy cursor: %s %v", carried, err) + } + for path, want := range before { + raw, err := os.ReadFile(path) + if err != nil || string(raw) != want { + t.Fatal("sharing-off continuation changed file", path, err) + } + } + for _, s := range []*Sync{h.a, h.b} { + vault, err := keys.Open(s.Home) + if err != nil { + t.Fatal(err) + } + for _, scope := range []string{"home:keys:teams", "home:windows:team-traffic"} { + entries, err := vault.Entries(scope) + if err != nil || len(entries) != 0 { + t.Fatal("sharing-off continuation carried teams", entries, err) + } + } + for _, path := range []string{filepath.Join(s.Home, "teams-sync.ledger"), teams.TrafficMirrorPath(s.Home, "alpha")} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("sharing-off continuation created sharing file", path, err) + } + } + } +} diff --git a/internal/syncsetup/twohomes_test.go b/internal/syncsetup/twohomes_test.go index e3e04af372..e7c33fa0a3 100644 --- a/internal/syncsetup/twohomes_test.go +++ b/internal/syncsetup/twohomes_test.go @@ -675,9 +675,11 @@ func TestTwoHomesTakeARunningChatAtOnce(t *testing.T) { } a.mustSay("orphaned") // A does not know it lost the chat; its publish is refused and becomes a branch waitFor(t, "A to learn it was superseded", func() bool { _, viewer := a.drive.Viewer(); return viewer }) - if _, ok := branchRowOf(t, h.a, h.cell.ID); !ok { - t.Fatal("A's refused turns did not become a branch") - } + // The drive refuses new work before its asynchronous branch upload finishes. + waitFor(t, "A's refused turns to become a branch", func() bool { + _, ok := branchRowOf(t, h.a, h.cell.ID) + return ok + }) if err := a.drive.Close(ctx); err != nil { t.Fatal(err) } diff --git a/internal/syncsetup/unused_take_test.go b/internal/syncsetup/unused_take_test.go new file mode 100644 index 0000000000..81fe5b9a58 --- /dev/null +++ b/internal/syncsetup/unused_take_test.go @@ -0,0 +1,50 @@ +package syncsetup + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +func TestAnUnusedTakeReleasesOnlyItsOwnHoldAndKeepsItsFiles(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + clock := directorytest.NewFakeClock() + dir := directory.NewMemory(clock.Now) + created, err := dir.For("source").Create(t.Context(), "chat", directory.CellInit{Head: "saved", Class: "chat"}) + mustNil(t, err) + receiver := dir.For("receiver") + taken, err := receiver.Acquire(t.Context(), "chat", directory.AcquireOpts{Force: true, RequireConditional: true, Expect: &directory.LeaseExpectation{Device: "source", Fence: created.Cell.Lease.Fence}}) + mustNil(t, err) + root := t.TempDir() + kept := filepath.Join(root, "kept-work") + mustNil(t, os.WriteFile(kept, []byte("all the files stay"), 0600)) + c := &Continuer{sync: &Sync{Dir: receiver}} + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + mustNil(t, c.ReleaseUnused(ctx, "chat", taken.Cell.Lease.Fence)) + released, err := receiver.Cell(t.Context(), "chat") + mustNil(t, err) + if released.Cell.Lease.Expires != 0 { + t.Fatal("unused acquisition stayed held") + } + later, err := receiver.Acquire(t.Context(), "chat", directory.AcquireOpts{RequireConditional: true, Expect: &directory.LeaseExpectation{Device: "receiver", Fence: taken.Cell.Lease.Fence}}) + mustNil(t, err) + mustNil(t, c.ReleaseUnused(ctx, "chat", taken.Cell.Lease.Fence)) + current, err := receiver.Cell(t.Context(), "chat") + mustNil(t, err) + if current.Cell.Lease != later.Cell.Lease { + t.Fatal("old cleanup released a later writer") + } + raw, err := os.ReadFile(kept) + mustNil(t, err) + if string(raw) != "all the files stay" { + t.Fatal("cleanup changed kept work") + } + mustNil(t, c.ReleaseUnused(ctx, "chat", later.Cell.Lease.Fence)) +} diff --git a/internal/syncsetup/vault_wait_test.go b/internal/syncsetup/vault_wait_test.go new file mode 100644 index 0000000000..c69cfe1df5 --- /dev/null +++ b/internal/syncsetup/vault_wait_test.go @@ -0,0 +1,95 @@ +package syncsetup + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/handoff" +) + +// TestAContinueWaitsForAnotherWindowsVaultExchange is D4 from the two-network +// test, reproduced the way it happened: a chat running in another window holds +// state-sync.lock while it pushes the vault over a slow relay, and the continue +// reaches its vault pull meanwhile. The pull took the lock without waiting, so +// the whole move failed with "resource temporarily unavailable" and the chat was +// handed back. The continue must wait for the exchange and arrive. +func TestAContinueWaitsForAnotherWindowsVaultExchange(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + h := newTwoHomes(t) + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + a := h.openA() + a.mustSay("before moving") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(ctx)) + + // Another window's exchange holds the lock through its own open file, which + // is exactly how a second process holds it. + held, err := os.OpenFile(filepath.Join(h.b.Home, "state-sync.lock"), os.O_CREATE|os.O_RDWR, 0o600) + mustNil(t, err) + defer held.Close() + mustNil(t, filelock.Lock(held, true, true)) + + // The lock is let go only after the move has restored its files, so the + // vault pull that follows meets it held. + var once sync.Once + released := make(chan struct{}) + b := h.continuerB() + b.opt.OnPhase = func(p handoff.Phase) { + if p.Name == "restore" { + once.Do(func() { + time.AfterFunc(time.Second, func() { + _ = filelock.Unlock(held) + close(released) + }) + }) + } + } + got, err := b.Take(ctx, h.cell.ID) + if err != nil { + t.Fatalf("the continue failed while another window held the vault: %v", err) + } + select { + case <-released: + default: + t.Fatal("the continue arrived without meeting the held vault lock, so it proved nothing") + } + if got.Taken.Cell.ID != h.cell.ID { + t.Fatalf("a different chat arrived: %s", got.Taken.Cell.ID) + } +} + +// TestAVaultWaitIsBoundedAndBackgroundExchangesNeverWait pins both sides of the +// rule: a background exchange (no wait) is refused at once as before, and a +// continue's wait ends at its bound with the same busy refusal. +func TestAVaultWaitIsBoundedAndBackgroundExchangesNeverWait(t *testing.T) { + path := filepath.Join(t.TempDir(), "state-sync.lock") + held, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + mustNil(t, err) + defer held.Close() + mustNil(t, filelock.Lock(held, true, true)) + mine, err := os.OpenFile(path, os.O_RDWR, 0o600) + mustNil(t, err) + defer mine.Close() + + started := time.Now() + if err := lockState(t.Context(), mine, 0); !filelock.IsBusy(err) { + t.Fatalf("a background exchange did not get the busy refusal at once: %v", err) + } + if waited := time.Since(started); waited > vaultBusyRetry { + t.Fatalf("a background exchange waited %s", waited) + } + started = time.Now() + if err := lockState(t.Context(), mine, 3*vaultBusyRetry); !filelock.IsBusy(err) { + t.Fatalf("a bounded wait did not end in the busy refusal: %v", err) + } + if waited := time.Since(started); waited < 3*vaultBusyRetry { + t.Fatalf("the bounded wait gave up after %s", waited) + } +} diff --git a/internal/syncwords/errors.go b/internal/syncwords/errors.go new file mode 100644 index 0000000000..b7eb49cb7b --- /dev/null +++ b/internal/syncwords/errors.go @@ -0,0 +1,116 @@ +package syncwords + +import ( + "context" + "errors" + "net" + "net/url" + "strings" + "syscall" + + "github.com/Agent-Field/codeaf/internal/clusterctl" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +type explained struct { + line string + cause error +} + +func (e explained) Error() string { return e.line } +func (e explained) Unwrap() error { return e.cause } + +// Explain gives every command the same diagnosis while retaining the typed cause. +func Explain(err error, address string) error { + if err == nil || errors.Is(err, directory.ErrRevoked) || errors.Is(err, context.Canceled) { + return err + } + var request *url.Error + var network net.Error + if address == "" && errors.As(err, &request) { + if u, e := url.Parse(request.URL); e == nil { + address = u.Scheme + "://" + u.Host + } + } + if address == "" { + address = HostedURL + } + switch { + case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout(): + return explained{"the sync service at " + address + " did not answer in time — try again", err} + case IsStruggling(err): + return explained{Struggling(address), err} + case network != nil, errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, syscall.ENETUNREACH), errors.Is(err, syscall.EHOSTUNREACH), errors.Is(err, relay.ErrUnreachable): + return explained{Unreachable(address), err} + case errors.Is(err, directory.ErrUnreachable): + return explained{Struggling(address), err} + } + return err +} + +// NetworkFailure is the boundary used by saving: these failures leave local +// transcripts intact, and a local seal is not evidence of network recovery. +func NetworkFailure(err error) bool { + var upload interface{ SyncFailure() bool } + if errors.As(err, &upload) && upload.SyncFailure() { + return true + } + var local *net.OpError + if errors.As(err, &local) && strings.HasPrefix(local.Net, "unix") { + return false + } + var request *url.Error + var operation *net.OpError + var dns *net.DNSError + return errors.As(err, &request) || errors.As(err, &operation) || errors.As(err, &dns) || errors.Is(err, directory.ErrUnreachable) || errors.Is(err, wireauth.ErrUnavailable) || errors.Is(err, clusterctl.ErrUnavailable) || errors.Is(err, relay.ErrUnreachable) || errors.Is(err, relay.ErrStruggling) +} + +// UploadFailure marks a failure after local durability succeeded. It survives +// error wrapping and is also carried explicitly by remote saving replies. +func UploadFailure(err error) error { + if err == nil { + return nil + } + return uploadFailure{err} +} + +type uploadFailure struct{ cause error } + +func (e uploadFailure) Error() string { return e.cause.Error() } +func (e uploadFailure) Unwrap() error { return e.cause } +func (e uploadFailure) SyncFailure() bool { return true } + +// UploadPending requires positive local durability, rather than a network-shaped error. +func UploadPending(err error) bool { + if err == nil { + return false + } + if upload, ok := err.(interface{ SyncFailure() bool }); ok { + return upload.SyncFailure() + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + causes := joined.Unwrap() + if len(causes) == 0 { + return false + } + for _, cause := range causes { + if !UploadPending(cause) { + return false + } + } + return true + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return UploadPending(wrapped.Unwrap()) + } + return false +} + +// IsStruggling identifies an answered service failure without changing the +// relay client's established error classes. +func IsStruggling(err error) bool { + var response interface{ HTTPStatus() int } + return errors.Is(err, wireauth.ErrUnavailable) || errors.Is(err, clusterctl.ErrUnavailable) || errors.Is(err, relay.ErrStruggling) || errors.As(err, &response) && response.HTTPStatus() >= 500 +} diff --git a/internal/syncwords/l4_test.go b/internal/syncwords/l4_test.go new file mode 100644 index 0000000000..fe1ce4bfdb --- /dev/null +++ b/internal/syncwords/l4_test.go @@ -0,0 +1,87 @@ +package syncwords + +import ( + "context" + "errors" + "net" + "net/url" + "os" + "syscall" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// TestMain stands in what internal/syncsetup hands over at startup: this build's +// hosted default, and the hosts of every release channel's hosted service. +func TestMain(m *testing.M) { + UseHosted("https://codeaf.agentfield.ai/fabric", []string{"codeaf.agentfield.ai", "fabric-staging.example.test"}) + os.Exit(m.Run()) +} + +// L4-M9a: another release channel's hosted service is hosted too, never "your own", +// and an unknown address names nothing. +func TestL4EveryChannelsServiceIsHosted(t *testing.T) { + if got := ServiceLine("https://fabric-staging.example.test"); got != "sync service: fabric-staging.example.test (hosted)" { + t.Fatal(got) + } + if Hosted("") || Hosted("http://127.0.0.1:8901") { + t.Fatal("an empty or personal address was called hosted") + } +} + +// L4-H6a through L4-H6f: connection, deadline and agreement have different remedies. +func TestL4ServiceFailures(t *testing.T) { + for _, tc := range []struct { + cause error + address, want string + }{ + {&url.Error{Op: "Get", URL: "http://127.0.0.1:8901/private", Err: syscall.ECONNREFUSED}, "http://127.0.0.1:8901", "nothing is answering at http://127.0.0.1:8901 — if that is your own sync service, start it there with codeaf relay"}, + {&net.DNSError{Err: "no such host", Name: "mine"}, HostedURL, "cannot reach codeaf.agentfield.ai — check this computer's internet connection"}, + {context.DeadlineExceeded, "http://127.0.0.1:8901", "the sync service at http://127.0.0.1:8901 did not answer in time — try again"}, + {wireauth.ErrUnavailable, "http://127.0.0.1:8901", "the sync service at 127.0.0.1:8901 is up but cannot agree on changes right now; it usually recovers within a minute"}, + {directory.ErrUnreachable, "http://127.0.0.1:8901", "the sync service at 127.0.0.1:8901 is up but cannot agree on changes right now; it usually recovers within a minute"}, + } { + got := Explain(tc.cause, tc.address) + if got.Error() != tc.want || !errors.Is(got, tc.cause) { + t.Fatalf("%v", got) + } + } +} + +// L4-M9a: terminal and future chat lists use one exported service label. +func TestL4ServiceLine(t *testing.T) { + if got := ServiceLine(HostedURL); got != "sync service: codeaf.agentfield.ai (hosted)" { + t.Fatal(got) + } + if got := ServiceLine("http://127.0.0.1:8901"); got != "sync service: 127.0.0.1:8901 (your own)" { + t.Fatal(got) + } +} + +// L4-H4c: a failure talking to the local storage socket is still a local save failure. +func TestL4LocalStorageIsNotSyncOutage(t *testing.T) { + err := &net.OpError{Op: "dial", Net: "unix", Addr: &net.UnixAddr{Name: "storage.sock", Net: "unix"}, Err: syscall.ECONNREFUSED} + if NetworkFailure(err) { + t.Fatal("local storage failure was called an upload outage") + } +} + +// L4-H4c: a bare local drain deadline does not prove network use or local durability. +func TestL4LocalDeadlineIsNotAnUpload(t *testing.T) { + if NetworkFailure(context.DeadlineExceeded) || UploadPending(context.DeadlineExceeded) { + t.Fatal("local deadline was called an upload") + } + if !UploadPending(UploadFailure(context.DeadlineExceeded)) { + t.Fatal("explicit local receipt was lost") + } +} + +// L4-H4c: a joined local failure cannot be hidden by a successful local receipt for another part. +func TestL4JoinedLocalFailureIsNotUploadOnly(t *testing.T) { + err := errors.Join(errors.New("local disk full"), UploadFailure(context.DeadlineExceeded)) + if UploadPending(err) { + t.Fatal("a mixed local/upload failure was marked safe") + } +} diff --git a/internal/syncwords/words.go b/internal/syncwords/words.go new file mode 100644 index 0000000000..4a17fc4553 --- /dev/null +++ b/internal/syncwords/words.go @@ -0,0 +1,98 @@ +// Package syncwords owns the sentences shared by sync's terminal and chat doors. +package syncwords + +import ( + "fmt" + "net/url" + "strings" +) + +// HostedURL is this build's default hosted sync service. Its address differs by +// release channel and internal/syncsetup/relayurl.go is the ONE place that spells +// it, so that package hands it over at startup through [UseHosted]. Empty means +// no default is known, and the sentences below then name no address. +var HostedURL string + +// hostedHosts is every release channel's hosted service, so a computer synced +// through another channel's service still calls it hosted rather than its own. +var hostedHosts []string + +// UseHosted is called once, by internal/syncsetup, before any sentence is built. +func UseHosted(url string, hosts []string) { + HostedURL, hostedHosts = url, append([]string(nil), hosts...) +} + +// Host leaves protocol paths out of sentences a person reads. +func Host(address string) string { + if u, err := url.Parse(address); err == nil && u.Host != "" { + return u.Host + } + return strings.TrimRight(address, "/") +} + +// Hosted says whether a resolved address is one of the hosted services. +func Hosted(address string) bool { + if address = strings.TrimRight(address, "/"); address == "" { + return false + } + if address == strings.TrimRight(HostedURL, "/") { + return true + } + u, err := url.Parse(address) + if err != nil || u.Host == "" { + return false + } + for _, host := range hostedHosts { + if strings.EqualFold(u.Host, host) { + return true + } + } + return false +} + +// ServiceLine is shared with the device screens so all lists name the same service. +func ServiceLine(address string) string { + kind := "your own" + if Hosted(address) { + kind = "hosted" + } + return fmt.Sprintf("sync service: %s (%s)", Host(address), kind) +} + +// Unreachable distinguishes a personal service from the hosted internet service. +func Unreachable(address string) string { + if Hosted(address) { + return "cannot reach " + Host(address) + " — check this computer's internet connection" + } + return "nothing is answering at " + address + " — if that is your own sync service, start it there with codeaf relay" +} + +// Struggling is a service that answered but could not finish a coordinated change. +func Struggling(address string) string { + return "the sync service at " + Host(address) + " is up but cannot agree on changes right now; it usually recovers within a minute" +} + +// Recovered is emitted only after a successful sync-service call. +const Recovered = "syncing again" + +// UnknownOutage omits a service host when that fact is unavailable. +// LegacyOutage remains the wire spelling for surfaces that predate this vocabulary. +const LegacyOutage = "this chat is not syncing because other machines are unreachable; it keeps working here" + +const outagePrefix = "the sync service at " +const outageSuffix = " is not answering; this chat keeps working here and catches up when it is back" + +const UnknownOutage = "the sync service is not answering; this chat keeps working here and catches up when it is back" + +// Outage is said once while local work continues through a network outage. +func Outage(address string) string { + return outagePrefix + Host(address) + outageSuffix +} + +// Asking is the delayed progress sentence for terminal commands. +func Asking(address string) string { return "asking the sync service at " + Host(address) + "…" } + +// IsOutage recognizes both generations so mixed builds share one notice ledger. +func IsOutage(text string) bool { + return text == UnknownOutage || text == LegacyOutage || strings.HasPrefix(text, outagePrefix) && strings.HasSuffix(text, outageSuffix) +} diff --git a/internal/taskcopy/copy.go b/internal/taskcopy/copy.go new file mode 100644 index 0000000000..6f34c6aa0a --- /dev/null +++ b/internal/taskcopy/copy.go @@ -0,0 +1,173 @@ +package taskcopy + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/fsreplace" +) + +// CaptureCopy uses the continue-here carrier for one task without needing the +// chat or the source project on the receiving computer. The full reachable +// history makes a linked worktree independent of its original repository. +func CaptureCopy(tree, carrier string) error { + if err := carryWithHistory(tree, carrier, true); err != nil { + return err + } + dirs, err := captureWorkspaceDirectories(tree) + if err != nil { + return err + } + return writeWorkspaceJSON(filepath.Join(carrier, "directories.json"), dirs) +} + +// ImportCopyHistory runs before the last conflict check because a large fetch +// must not provide a window in which a local edit can be overwritten. +func ImportCopyHistory(original, prepared string) error { + info, err := os.Lstat(filepath.Join(original, ".git")) + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return errors.New("the task copy's repository link is not a file") + } + _, err = git(original, "-c", "core.hooksPath=/dev/null", "fetch", "--quiet", "--no-tags", "--no-write-fetch-head", prepared, "HEAD") + return err +} + +// BindLinkedResult preserves the origin's worktree registration after the +// returned commits have been imported. Normal landing expects +// that task branch to live in the ground repository on this copy rung. +// The caller must run undo if it cannot install prepared over original. +func BindLinkedResult(original, prepared string) (undo func() error, err error) { + info, err := os.Lstat(filepath.Join(original, ".git")) + if err != nil { + return nil, err + } + if info.IsDir() { + return func() error { return nil }, nil + } + if !info.Mode().IsRegular() { + return nil, errors.New("the task copy's repository link is not a file") + } + pointer, err := os.ReadFile(filepath.Join(original, ".git")) + if err != nil { + return nil, err + } + oldHead, err := git(original, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + newHead, err := git(prepared, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + gitDir, err := git(original, "rev-parse", "--absolute-git-dir") + if err != nil { + return nil, err + } + index := filepath.Join(strings.TrimSpace(gitDir), "index") + oldIndex, err := os.ReadFile(index) + if err != nil { + return nil, err + } + if err := os.RemoveAll(filepath.Join(prepared, ".git")); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(prepared, ".git"), pointer, info.Mode().Perm()); err != nil { + return nil, err + } + oldHead, newHead = strings.TrimSpace(oldHead), strings.TrimSpace(newHead) + if _, err := git(original, "update-ref", "HEAD", newHead, oldHead); err != nil { + return nil, err + } + undo = func() error { + if _, err := git(original, "update-ref", "HEAD", oldHead, newHead); err != nil { + return err + } + return replaceCopyIndex(index, oldIndex) + } + // Git builds the receiving index itself because a copied split index + // could refer to shared-index files owned by the disposable repository. + if _, err := git(original, "read-tree", "--reset", newHead); err != nil { + return nil, errors.Join(err, undo()) + } + return undo, nil +} + +func replaceCopyIndex(path string, raw []byte) error { + file, err := os.CreateTemp(filepath.Dir(path), ".task-index-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, writeErr := file.Write(raw) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + return fsreplace.Rename(file.Name(), path) +} + +// RestoreCopy validates the complete carrier before creating an independent +// repository. Its destination must be absent or empty; replacing an existing +// copy is the caller's responsibility after its conflict check. +func RestoreCopy(carrier, destination string) error { + rec, err := readWorkspaceRecord(carrier) + if err != nil { + return err + } + if !rec.FullHistory { + return errors.New("the task copy is missing its independent history") + } + if err := validateWorkspaceRecord(carrier, rec); err != nil { + return err + } + if err := screenFullHistory(filepath.Join(carrier, bundleName)); err != nil { + return err + } + var root workspaceRoot + root.DirectoryVersion = 2 + if rec.Symlinks == nil { + root.DirectoryVersion = 1 + } + raw, err := readWorkspaceFile(filepath.Join(carrier, "directories.json"), workspaceManifestLimit) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &root.Directories); err != nil { + return err + } + if _, err := validateWorkspaceDirectories(root, rec); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return err + } + if err := restoreStandaloneCopy("", destination, carrier, rec); err != nil { + return err + } + if err := validateWorkspaceOverlay(destination, carrier, rec); err != nil { + return err + } + if err := prepareWorkspaceDirectories(destination, root.Directories); err != nil { + return err + } + if err := errors.Join(applyEOL(destination, rec.EOL), overlay(filepath.Join(carrier, filesDir), destination, rec)); err != nil { + return err + } + if err := applyModes(destination, rec.Modes); err != nil { + return err + } + if err := restorePortableBaseline(destination, rec); err != nil { + return err + } + return finishWorkspaceDirectories(destination, root.Directories) +} diff --git a/internal/taskcopy/review_metadata_darwin.go b/internal/taskcopy/review_metadata_darwin.go index e1c2a1003c..2a071f2224 100644 --- a/internal/taskcopy/review_metadata_darwin.go +++ b/internal/taskcopy/review_metadata_darwin.go @@ -79,3 +79,12 @@ func SetPortableSymlinkModeAt(parentFD int, name string, mode fs.FileMode) error } return unix.Fchmodat(parentFD, name, uint32(mode.Perm()), unix.AT_SYMLINK_NOFOLLOW) } + +// OSManagedAttribute reports whether an extended attribute is one macOS +// stamps on its own and that portable apply may leave behind. macOS 15 adds +// com.apple.provenance to every file a process tree started from certain apps +// creates, and it cannot be removed by its owner; the replacements apply +// writes are created by the same process tree and are stamped the same way, +// so nothing a person set is lost. Every other attribute, quarantine included, +// is still refused. +func OSManagedAttribute(name string) bool { return name == "com.apple.provenance" } diff --git a/internal/taskcopy/review_metadata_linux.go b/internal/taskcopy/review_metadata_linux.go index ca37614f16..bedeb21763 100644 --- a/internal/taskcopy/review_metadata_linux.go +++ b/internal/taskcopy/review_metadata_linux.go @@ -50,3 +50,8 @@ func SetPortableSymlinkModeAt(parentFD int, name string, mode fs.FileMode) error } return nil } + +// OSManagedAttribute reports whether an extended attribute is one the +// operating system stamps on its own and that portable apply may leave +// behind. Linux stamps none, so every attribute is refused as before. +func OSManagedAttribute(string) bool { return false } diff --git a/internal/taskcopy/review_metadata_other.go b/internal/taskcopy/review_metadata_other.go index 015e963506..eea6184f13 100644 --- a/internal/taskcopy/review_metadata_other.go +++ b/internal/taskcopy/review_metadata_other.go @@ -21,3 +21,8 @@ func portableUnsupportedNativeMetadata(path string, info fs.FileInfo) error { func SetPortableSymlinkModeAt(parentFD int, name string, mode fs.FileMode) error { return errors.New("this platform cannot preserve symlink permissions") } + +// OSManagedAttribute reports whether an extended attribute is one the +// operating system stamps on its own and that portable apply may leave +// behind. This platform does not read extended attributes here, so none is. +func OSManagedAttribute(string) bool { return false } diff --git a/internal/taskcopy/review_metadata_unix.go b/internal/taskcopy/review_metadata_unix.go index ed21ed96cd..d93ec3b17b 100644 --- a/internal/taskcopy/review_metadata_unix.go +++ b/internal/taskcopy/review_metadata_unix.go @@ -6,6 +6,7 @@ import ( "errors" "io/fs" "os" + "strings" "syscall" "golang.org/x/sys/unix" @@ -31,7 +32,42 @@ func portableUnsupportedNativeMetadata(path string, info fs.FileInfo) error { return err } if size > 0 { - return errors.New("portable apply cannot preserve extended attributes or access-control metadata") + names, err := portableAttributeNames(path, size) + if err != nil { + return err + } + for _, name := range names { + if !OSManagedAttribute(name) { + return errors.New("portable apply cannot preserve extended attributes or access-control metadata") + } + } } return portableFilesystemFlags(path, info) } + +// portableAttributeNames reads the extended attribute names on path without +// following a symlink. The list can grow between the size query and the read, +// so a short buffer is retried once with the new size before it is an error. +func portableAttributeNames(path string, size int) ([]string, error) { + for attempt := 0; attempt < 2; attempt++ { + buffer := make([]byte, size) + n, err := unix.Llistxattr(path, buffer) + if errors.Is(err, unix.ERANGE) { + if size, err = unix.Llistxattr(path, nil); err != nil { + return nil, err + } + continue + } + if err != nil { + return nil, err + } + var names []string + for _, name := range strings.Split(string(buffer[:n]), "\x00") { + if name != "" { + names = append(names, name) + } + } + return names, nil + } + return nil, errors.New("extended attributes changed while they were being read") +} diff --git a/internal/taskdoor/attempts.go b/internal/taskdoor/attempts.go new file mode 100644 index 0000000000..eba99660f0 --- /dev/null +++ b/internal/taskdoor/attempts.go @@ -0,0 +1,454 @@ +package taskdoor + +import ( + "bufio" + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/fsreplace" +) + +// MaxRunning is shared by admission and the sentence describing its limit. +const MaxRunning = 2 + +// Executor owns a run until it finishes or Serve's context ends. Ending that +// context must leave a surviving child's running state available for Adopt. +// Neither a lost watch nor an origin disconnect cancels a run. +type Executor interface { + Run(context.Context, *Attempt, Journal) error + Stop(attemptID string) error + Adopt(context.Context, *Attempt, Journal) error +} + +// Journal saves a step, spend, phase or finish before waking any watchers. +// Executors do not choose sequence numbers; one journal orders all events. +type Journal interface{ Append(event any) error } + +type State struct { + Phase string `json:"phase"` + PID int `json:"pid,omitempty"` + StartedAt time.Time `json:"started_at,omitzero"` + FinishedAt time.Time `json:"finished_at,omitzero"` + ChildStartedAt time.Time `json:"child_started_at,omitzero"` + ProcessStart string `json:"process_start,omitempty"` + CommandHash string `json:"command_hash,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + StopRequestedAt time.Time `json:"stop_requested_at,omitzero"` + LedgerOffset int64 `json:"ledger_offset,omitempty"` + LaunchStartedAt time.Time `json:"launch_started_at,omitzero"` + Abandoned bool `json:"abandoned,omitempty"` +} + +// Attempt is an admitted immutable request and its private working folder. +// Child metadata goes through UpdateState so adoption cannot see half a write. +type Attempt struct { + ID string + Dir string + Request Start + mu sync.Mutex + state State + changed chan struct{} + removed bool + clock Clock +} + +func (a *Attempt) State() State { a.mu.Lock(); defer a.mu.Unlock(); return a.state } +func (a *Attempt) UpdateState(update func(*State)) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return os.ErrNotExist + } + next := a.state + update(&next) + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), next); err != nil { + return err + } + a.state = next + return nil +} +func (a *Attempt) notify() { close(a.changed); a.changed = make(chan struct{}) } + +type attemptStore struct { + root string + clock Clock + mu sync.Mutex + attempts map[string]*Attempt + finished map[string]time.Time +} + +func validAttempt(id string) bool { + if len(id) != len("att_")+16 || id[:4] != "att_" { + return false + } + _, err := hex.DecodeString(id[4:]) + return err == nil +} + +func openAttempts(root string, clock Clock) (*attemptStore, error) { + if root == "" { + return nil, errors.New("taskdoor: an attempt store folder is required") + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + store := &attemptStore{root: root, clock: clock, attempts: map[string]*Attempt{}, finished: map[string]time.Time{}} + if err := readJSON(filepath.Join(root, "finished.json"), &store.finished); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if store.finished == nil { + store.finished = map[string]time.Time{} + } + for _, entry := range entries { + if !validAttempt(entry.Name()) { + continue + } + if !entry.IsDir() { + return nil, fmt.Errorf("taskdoor: attempt %s is not a folder", entry.Name()) + } + a := &Attempt{ID: entry.Name(), Dir: filepath.Join(root, entry.Name()), changed: make(chan struct{}), clock: clock} + _, finished := store.finished[a.ID] + stateErr := readJSON(filepath.Join(a.Dir, "state.json"), &a.state) + // A completed acknowledgement can be interrupted halfway through RemoveAll. + // Its durable tombstone outranks a now-missing state or request file. + if finished && (errors.Is(stateErr, os.ErrNotExist) || stateErr == nil && a.state.Phase != "running") { + if err := os.RemoveAll(a.Dir); err != nil { + return nil, err + } + continue + } + if stateErr != nil { + return nil, stateErr + } + if err := readJSON(filepath.Join(a.Dir, "request.json"), &a.Request); err != nil { + if finished && errors.Is(err, os.ErrNotExist) { + if err := os.RemoveAll(a.Dir); err != nil { + return nil, err + } + continue + } + return nil, err + } + if finished { + a.state.Abandoned = true + } + a.removed = a.state.Abandoned + if a.removed { + if err := store.rememberFinished(a.ID); err != nil { + return nil, err + } + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), a.state); err != nil { + return nil, err + } + } + events, err := readEvents(a.Dir) + if err != nil { + if finished && errors.Is(err, os.ErrNotExist) { + if err := os.RemoveAll(a.Dir); err != nil { + return nil, err + } + continue + } + return nil, err + } + // A crash after the journal's rename but before its derived state write + // must not hand a completed child to Adopt or admit another execution. + if len(events) > 0 && events[len(events)-1].Type == "finished" { + var end Finished + if err := events[len(events)-1].Decode(&end); err != nil { + return nil, err + } + if err := a.saveResult(end); err != nil { + return nil, err + } + } + store.attempts[a.ID] = a + } + return store, nil +} +func (s *attemptStore) running() []*Attempt { + s.mu.Lock() + defer s.mu.Unlock() + var out []*Attempt + for _, a := range s.attempts { + if a.State().Phase == "running" { + out = append(out, a) + } + } + return out +} +func (s *attemptStore) get(id string) *Attempt { + s.mu.Lock() + defer s.mu.Unlock() + a := s.attempts[id] + if a == nil { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return nil + } + return a +} + +// admit publishes the complete admission directory before returning permission +// to call Run. A duplicate wins over the concurrency check, even when busy. +func (s *attemptStore) admit(id string, request Start, busy string) (*Attempt, bool, error) { + if !validAttempt(id) { + return nil, false, errors.New("that task attempt id is not readable") + } + s.mu.Lock() + defer s.mu.Unlock() + if _, finished := s.finished[id]; finished { + return nil, false, errors.New("that task has already finished") + } + if a := s.attempts[id]; a != nil { + a.mu.Lock() + removed := a.removed + a.mu.Unlock() + if removed { + return nil, false, errors.New("that task is still stopping") + } + return a, false, nil + } + running := 0 + for _, a := range s.attempts { + if a.State().Phase == "running" { + running++ + } + } + if running >= MaxRunning { + return nil, false, errors.New(busy) + } + a := &Attempt{ID: id, Dir: filepath.Join(s.root, id), Request: request, state: State{Phase: "running", StartedAt: s.clock.Now()}, changed: make(chan struct{}), clock: s.clock} + staging, err := os.MkdirTemp(s.root, ".admit-*") + if err != nil { + return nil, false, err + } + defer os.RemoveAll(staging) + if err := atomicJSON(filepath.Join(staging, "request.json"), request); err != nil { + return nil, false, err + } + if err := atomicJSON(filepath.Join(staging, "state.json"), a.state); err != nil { + return nil, false, err + } + if err := atomicWrite(filepath.Join(staging, "events.jsonl"), nil); err != nil { + return nil, false, err + } + if err := os.Rename(staging, a.Dir); err != nil { + return nil, false, err + } + s.attempts[id] = a + return a, true, nil +} + +// forget does not let a late executor recreate an acknowledged directory. +// A still-stopping executor retains its admission slot until it returns. +func (s *attemptStore) forget(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + a := s.attempts[id] + if a == nil { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + if err := s.rememberFinished(id); err != nil { + return err + } + // A real child can still write its private run after SIGINT. Keep a + // durable tombstone until it is joined, including across a serving restart. + if a.state.Phase == "running" && (a.state.PID > 0 || !a.state.LaunchStartedAt.IsZero() || !a.state.StopRequestedAt.IsZero()) { + next := a.state + next.Abandoned = true + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), next); err != nil { + return err + } + a.state = next + a.removed = true + a.notify() + return nil + } + if err := os.RemoveAll(a.Dir); err != nil { + return err + } + a.removed = true + a.notify() + if a.state.Phase != "running" { + delete(s.attempts, id) + } + return nil +} +func (s *attemptStore) released(a *Attempt) { + s.mu.Lock() + defer s.mu.Unlock() + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + _ = os.RemoveAll(a.Dir) + delete(s.attempts, a.ID) + } +} + +type attemptJournal struct{ a *Attempt } + +func (j attemptJournal) Append(event any) error { + a := j.a + kind := payloadType(event) + switch kind { + case "step", "spend", "phase", "finished": + default: + return errors.New("taskdoor: only task events belong in a journal") + } + if end, ok := event.(Finished); ok { + switch end.Outcome { + case "done", "stopped", "incomplete", "interrupted": + default: + return errors.New("taskdoor: unknown task outcome") + } + } + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return os.ErrNotExist + } + if a.state.Phase != "running" { + return errors.New("taskdoor: that task has already finished") + } + events, err := readEvents(a.Dir) + if err != nil { + return err + } + if len(events) > 0 && events[len(events)-1].Type == "finished" { + return errors.New("taskdoor: that task has already finished") + } + frame, err := makeFrame(a.ID, uint64(len(events)+1), event) + if err != nil { + return err + } + raw, err := os.ReadFile(filepath.Join(a.Dir, "events.jsonl")) + if err != nil { + return err + } + raw = append(raw, frame.Payload...) + raw = append(raw, '\n') + // Replacing the JSONL file, rather than appending in place, makes a power + // loss leave either the previous journal or the whole new event. + if err := atomicWrite(filepath.Join(a.Dir, "events.jsonl"), raw); err != nil { + return err + } + if end, ok := event.(Finished); ok { + err = a.saveResult(end) + } + // The event is committed even if a derived result write failed. Watchers + // still receive it, and reopen repairs result.json from the journal. + a.notify() + return err +} +func (a *Attempt) saveResult(end Finished) error { + if err := atomicJSON(filepath.Join(a.Dir, "result.json"), end); err != nil { + return err + } + next := a.state + next.Phase = end.Outcome + if next.FinishedAt.IsZero() { + next.FinishedAt = a.clock.Now() + } + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), next); err != nil { + return err + } + a.state = next + return nil +} + +// after takes the replay and the next wakeup under the same lock, so an event +// written as a watch changes from replay to live can neither vanish nor repeat. +func (a *Attempt) after(from uint64) ([]Frame, <-chan struct{}, bool, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return nil, nil, true, os.ErrNotExist + } + events, err := readEvents(a.Dir) + if err != nil { + return nil, nil, false, err + } + finished := len(events) > 0 && events[len(events)-1].Type == "finished" + first := len(events) + for i, event := range events { + if event.Seq > from { + first = i + break + } + } + return events[first:], a.changed, finished, nil +} +func readEvents(dir string) ([]Frame, error) { + file, err := os.Open(filepath.Join(dir, "events.jsonl")) + if err != nil { + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 4096), MaxFrameBytes+1) + var events []Frame + for scanner.Scan() { + raw := bytes.Clone(scanner.Bytes()) + if len(raw) > MaxFrameBytes { + return nil, frameTooLarge() + } + event, err := parseFrame(raw) + if err != nil { + return nil, err + } + if event.Seq != uint64(len(events)+1) { + return nil, errors.New("taskdoor: the saved task events are out of order") + } + if len(events) > 0 && events[len(events)-1].Type == "finished" { + return nil, errors.New("taskdoor: saved events follow the task's finish") + } + events = append(events, event) + } + return events, scanner.Err() +} +func readJSON(path string, into any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(raw, into) +} +func atomicJSON(path string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + return atomicWrite(path, raw) +} +func atomicWrite(path string, raw []byte) error { + file, err := os.CreateTemp(filepath.Dir(path), ".write-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, writeErr := file.Write(raw) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + return fsreplace.Rename(file.Name(), path) +} diff --git a/internal/taskdoor/clock.go b/internal/taskdoor/clock.go new file mode 100644 index 0000000000..80ec8c5322 --- /dev/null +++ b/internal/taskdoor/clock.go @@ -0,0 +1,35 @@ +package taskdoor + +import "time" + +const ( + PingEvery = 15 * time.Second + SilentAfter = 3 * PingEvery + RosterFor = time.Minute + DialWithin = 10 * time.Second +) + +// Clock supplies both time readings and wakeups: swapping only Now would +// leave a silent-watch test waiting for the wall clock's ticker. +type Clock interface { + Now() time.Time + NewTicker(time.Duration) Ticker +} +type Ticker interface { + C() <-chan time.Time + Stop() +} +type wallClock struct{} + +func (wallClock) Now() time.Time { return time.Now() } +func (wallClock) NewTicker(d time.Duration) Ticker { return wallTicker{time.NewTicker(d)} } + +type wallTicker struct{ *time.Ticker } + +func (t wallTicker) C() <-chan time.Time { return t.Ticker.C } +func clockOrWall(clock Clock) Clock { + if clock != nil { + return clock + } + return wallClock{} +} diff --git a/internal/taskdoor/control_test.go b/internal/taskdoor/control_test.go new file mode 100644 index 0000000000..cbc5d529ee --- /dev/null +++ b/internal/taskdoor/control_test.go @@ -0,0 +1,224 @@ +package taskdoor_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Contract 12 requires the original request and running admission to be on +// disk before any executor action, even if the origin never reads started. +func TestAdmissionIsSavedBeforeTheExecutorAndSurvivesALostReply(t *testing.T) { + r := newRig(t) + entered := make(chan struct{}) + problem := make(chan error, 1) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + var saved taskdoor.Start + raw, err := os.ReadFile(filepath.Join(a.Dir, "request.json")) + if err == nil { + err = json.Unmarshal(raw, &saved) + } + if err != nil { + problem <- err + return err + } + if saved.Instruction != request().Instruction || saved.Input.Head != request().Input.Head { + t.Error("executor saw a different saved request") + } + var state taskdoor.State + raw, err = os.ReadFile(filepath.Join(a.Dir, "state.json")) + if err == nil { + err = json.Unmarshal(raw, &state) + } + if err != nil { + problem <- err + return err + } + if state.Phase != "running" || state.StartedAt.IsZero() { + t.Error("executor ran before admission") + } + close(entered) + <-ctx.Done() + return ctx.Err() + } + r.serve(r.config()) + c := r.dial() + id := attempt(30) + send(t, c, id, request()) + _ = c.Close() + select { + case <-entered: + case err := <-problem: + t.Fatal(err) + case <-time.After(5 * time.Second): + t.Fatal("lost start reply lost the admission") + } + c = r.dial() + send(t, c, id, request()) + receive(t, c, "started") + if r.executor.calls.Load() != 1 { + t.Fatal("retry after a lost reply ran twice") + } +} + +// DESIGN section 4's stop road, supporting contract 9 recovery, returns the +// stopping receipt and then journals the +// executor's stopped result for every reconnecting watcher. +func TestAStopAsksTheExecutorAndItsResultCanBeWatched(t *testing.T) { + r := newRig(t) + r.executor.stops = make(chan string, 1) + stopped := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + select { + case <-stopped: + return j.Append(taskdoor.Finished{Outcome: "stopped", Report: "stopped by you"}) + case <-ctx.Done(): + return ctx.Err() + } + } + r.serve(r.config()) + c := r.dial() + id := attempt(31) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Stop{}) + receive(t, c, "stopping") + select { + case got := <-r.executor.stops: + if got != id { + t.Fatalf("stopped %s", got) + } + case <-time.After(5 * time.Second): + t.Fatal("executor did not receive stop") + } + close(stopped) + send(t, c, id, taskdoor.Watch{}) + var result taskdoor.Finished + decode(t, receive(t, c, "finished"), &result) + if result.Outcome != "stopped" { + t.Fatalf("stop result = %+v", result) + } +} + +// Contracts 11 and 12: abandoning a running attempt erases it and prevents a +// late journal write from recreating its files. +func TestAnAbandonedRunningAttemptCannotRecreateItsJournal(t *testing.T) { + r := newRig(t) + r.executor.stops = make(chan string, 1) + late := make(chan struct{}) + written := make(chan error, 1) + entered := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + close(entered) + select { + case <-late: + err := j.Append(taskdoor.Finished{Outcome: "done"}) + written <- err + return err + case <-ctx.Done(): + return ctx.Err() + } + } + r.serve(r.config()) + c := r.dial() + id := attempt(32) + send(t, c, id, request()) + receive(t, c, "started") + <-entered + send(t, c, id, taskdoor.Ack{Disposition: "abandoned"}) + receive(t, c, "acked") + close(late) + if err := <-written; !os.IsNotExist(err) { + t.Fatalf("late write = %v", err) + } + if _, err := os.Stat(filepath.Join(r.root, id)); !os.IsNotExist(err) { + t.Fatalf("late result recreated its folder: %v", err) + } +} + +// Contract 12 includes simultaneous retries arriving on different streams. +func TestConcurrentStartsForOneAttemptRunOnce(t *testing.T) { + r := newRig(t) + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + <-ctx.Done() + return ctx.Err() + } + r.serve(r.config()) + a, b := r.dial(), r.dial() + id := attempt(33) + var group sync.WaitGroup + for _, c := range []*taskdoor.Conn{a, b} { + group.Add(1) + go func() { defer group.Done(); send(t, c, id, request()); receive(t, c, "started") }() + } + group.Wait() + // The executor is asynchronous; a stop receipt is a connection-level fence, + // while this channel-free wait only waits for the single admission to run. + deadline := time.Now().Add(5 * time.Second) + for r.executor.calls.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if r.executor.calls.Load() != 1 { + t.Fatalf("concurrent retries ran %d times", r.executor.calls.Load()) + } +} + +// Contract 12's never-rerun rule survives a crash after a committed finished +// event but before the corresponding result and state writes. +func TestACommittedFinishIsRecoveredWithoutAdoptingOrRerunning(t *testing.T) { + r := newRig(t) + stop := r.serve(r.config()) + c := r.dial() + id := attempt(34) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + _ = c.Close() + stop() + path := filepath.Join(r.root, id, "state.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + state.Phase = "running" + state.FinishedAt = time.Time{} + raw, err = json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(r.root, id, "result.json")); err != nil { + t.Fatal(err) + } + r.serve(r.config()) + c = r.dial() + send(t, c, id, request()) + var started taskdoor.Started + decode(t, receive(t, c, "started"), &started) + if started.State.Phase != "done" { + t.Fatalf("committed finish recovered as %s", started.State.Phase) + } + send(t, c, id, taskdoor.Watch{}) + if frame := receive(t, c, "finished"); frame.Seq != 1 { + t.Fatalf("recovered finish seq = %d", frame.Seq) + } + if _, err := os.Stat(filepath.Join(r.root, id, "result.json")); err != nil { + t.Fatal("result was not repaired:", err) + } + if r.executor.calls.Load() != 1 || r.executor.adopts.Load() != 0 { + t.Fatalf("completed journal ran/adopted %d/%d", r.executor.calls.Load(), r.executor.adopts.Load()) + } +} diff --git a/internal/taskdoor/copy.go b/internal/taskdoor/copy.go new file mode 100644 index 0000000000..e92764727e --- /dev/null +++ b/internal/taskdoor/copy.go @@ -0,0 +1,484 @@ +package taskdoor + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +const copyCarrier = cell.StateDir + "/task-copy" + +// Input retains the origin's conflict fingerprint locally; only Head travels +// on the wire. Result uses the same task-only cell after the work finishes. +type Input = Head +type Result = Head + +// CopyDeps keeps task transport separate from chat heads and leases. Engine's +// shared object store may be the profile's ordinary store for identity-wide +// deduplication; Root holds only task cells, locks and transfer scratch. +type CopyDeps struct { + Root string + Service string + Identity identity.Identity + Device identity.Dev + Engine cellstore.Engine + Store blobstore.Store + Measure func(CopyMeasure) + // BeforeInstall observes the prepared result before filesystem replacement. + // Tests use this boundary to reproduce an editor racing installation. + BeforeInstall func() +} + +type CopyMeasure struct { + Seal, Upload, Fetch, Restore time.Duration + Bytes int64 +} + +func (d CopyDeps) engine() cellstore.Engine { + e := d.Engine + e.Workspace, e.WorkspaceOf, e.Compose = "", nil, nil + e.Identity = cellstore.Identity{Device: d.Device.ID()} + return e +} +func (d CopyDeps) sync() cellstore.SyncEngine { + s := d.engine().Sync(cellstore.SyncKeys{CellKey: d.Identity.CellKey(), Dedup: d.Identity.DedupSecret()}, cellstore.LedgerName(d.Service, d.Identity.ID())) + // Small source files need bounded frames too: the engine's default can + // exceed the blob wire's header bound before reaching its payload bound. + s.MaxFrame = 64 << 10 + return s +} +func (d CopyDeps) cell(id string) (cell.Cell, error) { + if !cell.ValidID(id) { + return cell.Cell{}, errors.New("the task copy's cell is not readable") + } + return cell.Cell{ID: id, Root: filepath.Join(d.Root, "cells", id)}, nil +} +func (d CopyDeps) measure(m CopyMeasure) { + if d.Measure != nil { + d.Measure(m) + } +} + +func (d CopyDeps) lock(ctx context.Context, name string) (func(), error) { + if d.Root == "" || d.Store == nil || d.Service == "" { + return nil, errors.New("task copies need private storage and a relay blob store") + } + dir := filepath.Join(d.Root, "locks") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + for { + if err := ctx.Err(); err != nil { + f.Close() + return nil, err + } + if err := filelock.Lock(f, true, true); err == nil { + return func() { _ = filelock.Unlock(f); _ = f.Close() }, nil + } else if !filelock.IsBusy(err) { + _ = f.Close() + return nil, err + } + select { + case <-ctx.Done(): + f.Close() + return nil, ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } +} + +// SendCopy seals a carrier in its own cell. Upload deliberately has no +// directory client: this road cannot publish a chat head or acquire a lease. +func SendCopy(ctx context.Context, d CopyDeps, copyDir string) (Input, error) { + path, err := allowedCopy(copyDir) + if err != nil { + return Input{}, err + } + sum := sha256.Sum256([]byte(path)) + key := hex.EncodeToString(sum[:]) + unlock, err := d.lock(ctx, "source-"+key) + if err != nil { + return Input{}, err + } + defer unlock() + ref := filepath.Join(d.Root, "sources", key+".json") + var previous Input + err = readJSON(ref, &previous) + var c cell.Cell + if errors.Is(err, os.ErrNotExist) { + c, err = cell.CreateIn(filepath.Join(d.Root, "cells"), cell.Options{Class: cell.Sandboxed, KeyID: d.Identity.CellKeyID()}) + } else if err == nil { + c, err = d.cell(previous.Cell) + } + if err != nil { + return Input{}, err + } + out, err := sendCopy(ctx, d, path, c, "") + if err != nil { + return Input{}, err + } + if err := os.MkdirAll(filepath.Dir(ref), 0700); err != nil { + return Input{}, err + } + return out, atomicJSON(ref, out) +} + +func sendCopy(ctx context.Context, d CopyDeps, path string, c cell.Cell, baseHead string) (Input, error) { + unlock, err := d.lock(ctx, c.ID) + if err != nil { + return Input{}, err + } + defer unlock() + if baseHead != "" { + // A retry or another attempt using the same input must still make + // this result a delta from that input, never from its sibling's work. + if err := d.sync().Materialize(ctx, c, baseHead); err != nil { + return Input{}, err + } + } + c, err = cell.OpenAt(c.Root, c.ID) + if err != nil { + return Input{}, err + } + before, err := copyFingerprint(ctx, path) + if err != nil { + return Input{}, err + } + start := time.Now() + if err := taskcopy.CaptureCopy(path, filepath.Join(c.Root, copyCarrier)); err != nil { + return Input{}, err + } + after, err := copyFingerprint(ctx, path) + if err != nil { + return Input{}, err + } + if before != after { + return Input{}, errors.New("the task copy changed while it was being sent; try again") + } + sealed, err := d.engine().Seal(ctx, c, cellstore.TurnInfo{Trigger: cellstore.AgentRun}) + if err != nil { + return Input{}, err + } + m := CopyMeasure{Seal: time.Since(start)} + start = time.Now() + publisher := cellsync.Publisher{Engine: d.sync(), Store: d.Store, CheckHeld: true} + exported, err := publisher.Upload(ctx, c, sealed.Turn.ID) + if err != nil { + return Input{}, err + } + m.Upload = time.Since(start) + for _, frame := range exported.Frames { + m.Bytes += frame.Bytes + } + d.measure(m) + return Input{Head: sealed.Turn.ID, Cell: c.ID, Fingerprint: before}, nil +} + +// ReceiveCopy restores only the task carrier; capture metadata stays beside +// the work folder so landing cannot mistake it for the task's changes. +func ReceiveCopy(ctx context.Context, d CopyDeps, in Input, dest string) error { + return receiveCopy(ctx, d, in, dest, "") +} + +func receiveCopy(ctx context.Context, d CopyDeps, in Head, dest, fingerprint string) error { + prepared, err := prepareCopy(ctx, d, in, dest, fingerprint) + if err != nil { + return err + } + defer prepared.Close() + return prepared.Install(ctx) +} + +// PreparedResult owns fetched files and their cell lock until installation or discard. +// Preparing never replaces the origin copy, so an abandonment can still fence installation. +type PreparedResult struct { + deps CopyDeps + path, stage, prepared, previous, fingerprint string + measure CopyMeasure + restoreStarted time.Time + unlock func() + keep bool +} + +func prepareCopy(ctx context.Context, d CopyDeps, in Head, dest, fingerprint string) (_ *PreparedResult, err error) { + path, err := allowedCopy(dest) + if err != nil { + return nil, err + } + c, err := d.cell(in.Cell) + if err != nil { + return nil, err + } + if raw, err := hex.DecodeString(in.Head); err != nil || len(raw) != sha256.Size { + return nil, errors.New("the task copy's head is not readable") + } + unlock, err := d.lock(ctx, c.ID) + if err != nil { + return nil, err + } + result := &PreparedResult{deps: d, path: path, fingerprint: fingerprint, unlock: unlock} + defer func() { + if err != nil { + result.Close() + } + }() + if fingerprint != "" { + if err := unchangedCopy(ctx, path, fingerprint); err != nil { + return nil, err + } + } + if err := os.MkdirAll(c.Root, 0700); err != nil { + return nil, err + } + start := time.Now() + syncer := d.sync() + fetcher := cellsync.Fetcher{Engine: syncer, Store: d.Store, Inbox: syncer.Inbox} + if err := fetcher.Complete(ctx, c, in.Head); err != nil { + return nil, err + } + result.measure.Fetch = time.Since(start) + result.restoreStarted = time.Now() + if err := syncer.Materialize(ctx, c, in.Head); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, err + } + result.stage, err = os.MkdirTemp(filepath.Dir(path), ".task-arrival-") + if err != nil { + return nil, err + } + result.prepared = filepath.Join(result.stage, "copy") + if err := taskcopy.RestoreCopy(filepath.Join(c.Root, copyCarrier), result.prepared); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + if fingerprint != "" { + if err := taskcopy.ImportCopyHistory(path, result.prepared); err != nil { + return nil, err + } + } + return result, nil +} + +// Install holds no network work; the caller can guard this final boundary with its abandonment lock. +func (r *PreparedResult) Install(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if r.fingerprint != "" { + if err := unchangedCopy(ctx, r.path, r.fingerprint); err != nil { + return err + } + } else if entries, err := os.ReadDir(r.path); err == nil && len(entries) > 0 { + return errors.New("the task's destination already contains files") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if r.deps.BeforeInstall != nil { + r.deps.BeforeInstall() + } + // A sibling keeps relative Git worktree pointers based at the same parent. + previous, err := os.MkdirTemp(filepath.Dir(r.path), ".task-previous-") + if err != nil { + return err + } + // Some filesystems reject renaming over even the empty reservation. + if err := os.Remove(previous); err != nil { + return err + } + moved := false + if err := os.Rename(r.path, previous); err == nil { + moved = true + r.previous = previous + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + // The moved original is now the installation boundary's authoritative snapshot. + // Check it before binding the returned branch or deleting any earlier files. + undo := func() error { return nil } + if r.fingerprint != "" { + if err := unchangedCopy(ctx, previous, r.fingerprint); err != nil { + return r.rollback(previous, moved, err) + } + var err error + undo, err = taskcopy.BindLinkedResult(previous, r.prepared) + if err != nil { + return r.rollback(previous, moved, err) + } + } + if err := os.Rename(r.prepared, r.path); err != nil { + return r.rollback(previous, moved, errors.Join(err, undo())) + } + r.measure.Restore = time.Since(r.restoreStarted) + r.deps.measure(r.measure) + return nil +} + +func (r *PreparedResult) rollback(previous string, moved bool, cause error) error { + if !moved { + return cause + } + if err := os.Rename(previous, r.path); err != nil { + r.keep = true + return fmt.Errorf("could not put the result in place; the earlier copy is kept at %s: %w", previous, errors.Join(cause, err)) + } + r.previous = "" + return cause +} + +// Close discards an uninstalled result, but preserves a previous copy whose rollback failed. +func (r *PreparedResult) Close() { + if !r.keep { + if r.stage != "" { + _ = os.RemoveAll(r.stage) + } + if r.previous != "" { + _ = os.RemoveAll(r.previous) + } + } + if r.unlock != nil { + r.unlock() + r.unlock = nil + } +} + +func SendResult(ctx context.Context, d CopyDeps, workDir string, in Input) (Result, error) { + path, err := allowedCopy(workDir) + if err != nil { + return Result{}, err + } + c, err := d.cell(in.Cell) + if err != nil { + return Result{}, err + } + return sendCopy(ctx, d, path, c, in.Head) +} + +var ErrCopyChanged = errors.New("the task copy changed here while the other computer worked; both copies were kept") + +func ReceiveResult(ctx context.Context, d CopyDeps, result Result, localCopyDir, sentFingerprint string) error { + prepared, err := PrepareResult(ctx, d, result, localCopyDir, sentFingerprint) + if err != nil { + return err + } + defer prepared.Close() + return prepared.Install(ctx) +} + +// PrepareResult fetches and materializes outside the session's abandonment mutex. +func PrepareResult(ctx context.Context, d CopyDeps, result Result, localCopyDir, sentFingerprint string) (*PreparedResult, error) { + if sentFingerprint == "" { + return nil, errors.New("the sent task copy's fingerprint is missing") + } + return prepareCopy(ctx, d, result, localCopyDir, sentFingerprint) +} +func unchangedCopy(ctx context.Context, path, fingerprint string) error { + now, err := copyFingerprint(ctx, path) + if err != nil { + return err + } + if now != fingerprint { + return ErrCopyChanged + } + return nil +} + +func allowedCopy(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + // Resolve existing parents too, so a missing destination through a symlink + // cannot bypass the same home-folder refusal the capture engine applies. + parent := absolute + for { + resolved, err := filepath.EvalSymlinks(parent) + if err == nil { + rel, _ := filepath.Rel(parent, absolute) + absolute = filepath.Join(resolved, rel) + break + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + next := filepath.Dir(parent) + if next == parent { + return "", err + } + parent = next + } + house, err := os.UserHomeDir() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(house); err == nil { + house = resolved + } + rel, err := filepath.Rel(absolute, house) + if filepath.Dir(absolute) == absolute || err == nil && filepath.IsLocal(rel) { + return "", errors.New("a home folder or a folder above it cannot be sent as a task copy") + } + return absolute, nil +} + +func copyFingerprint(ctx context.Context, root string) (string, error) { + hash := sha256.New() + for _, args := range [][]string{{"rev-parse", "HEAD"}, {"symbolic-ref", "-q", "HEAD"}, {"diff", "--cached", "--binary"}} { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...) + out, err := cmd.Output() + if err != nil && args[0] != "symbolic-ref" { + return "", err + } + fmt.Fprintf(hash, "%d:", len(out)) + _, _ = hash.Write(out) + } + if err := ctx.Err(); err != nil { + return "", err + } + state, err := taskcopy.InspectPortableState(root) + if err != nil { + return "", err + } + digest, err := state.Digest() + if err != nil { + return "", err + } + _, _ = hash.Write([]byte(digest)) + return hex.EncodeToString(hash.Sum(nil)), ctx.Err() +} + +// CheckCopy fences a person's local replacement before any remote abandonment +// is recorded or stop is sent. It uses the same fingerprint as result restore. +func CheckCopy(ctx context.Context, path, fingerprint string) error { + if fingerprint == "" { + return errors.New("the sent task copy's fingerprint is missing") + } + return unchangedCopy(ctx, path, fingerprint) +} + +// CopyFingerprint lets a failed transfer retain a local replacement check +// even when no input head reached the other computer. +func CopyFingerprint(ctx context.Context, path string) (string, error) { + return copyFingerprint(ctx, path) +} diff --git a/internal/taskdoor/copy_test.go b/internal/taskdoor/copy_test.go new file mode 100644 index 0000000000..e1c908ebde --- /dev/null +++ b/internal/taskdoor/copy_test.go @@ -0,0 +1,333 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +func copyHomes(t *testing.T) (taskdoor.CopyDeps, taskdoor.CopyDeps) { + t.Helper() + r := newRig(t) + bin := taskEngineBinary(t) + mem := blobstore.NewMemory() + srv := httptest.NewServer(blobstore.Handler(func(*http.Request, []byte) (string, string, error) { + return r.id.ID(), r.a.ID(), nil + }, func(string) (blobstore.Store, error) { return mem, nil })) + t.Cleanup(srv.Close) + makeHome := func(devHome string) taskdoor.CopyDeps { + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(devHome, "stores") + engine.Binary = bin + // The fixture owns its daemon so no engine can outlive the test's store. + socketHome, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + daemon := cellstore.Daemon{Binary: bin, Socket: filepath.Join(socketHome, "e.sock")} + engine.Transport = daemon + t.Cleanup(func() { _ = daemon.Stop(context.Background()); _ = os.RemoveAll(socketHome) }) + return taskdoor.CopyDeps{Root: filepath.Join(devHome, "copies"), Service: srv.URL, + Identity: r.id, Device: r.a, Engine: engine, Store: blobstore.NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client())} + } + a, b := makeHome(t.TempDir()), makeHome(t.TempDir()) + b.Device = r.b + return a, b +} + +func copyGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=task test", "-c", "user.email=task@example.test"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func copyFile(t *testing.T, dir, name, words string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(words), 0640); err != nil { + t.Fatal(err) + } +} + +func copyRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + copyGit(t, dir, "init", "-q", "-b", "task-copy") + copyFile(t, dir, "edit.txt", "before\n") + copyFile(t, dir, "delete.txt", "remove this\n") + copyFile(t, dir, ".gitignore", "ignored/\n") + copyGit(t, dir, "add", "--all") + copyGit(t, dir, "commit", "-qm", "baseline") + return dir +} + +// Contract 3: the real carrier, engine and blob wire preserve files and commits +// across two homes while the chat and its directory never enter the transfer. +func TestATaskCopyGoesOutAndComesBackWithItsEdits(t *testing.T) { + a, b := copyHomes(t) + local, work := copyRepo(t), filepath.Join(t.TempDir(), "work") + copyFile(t, local, "ignored/cache", "only here") + copyFile(t, local, "uncommitted.txt", "carried too") + if err := os.Symlink("edit.txt", filepath.Join(local, "link")); err != nil { + t.Fatal(err) + } + in, err := taskdoor.SendCopy(context.Background(), a, local) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveCopy(context.Background(), b, in, work); err != nil { + t.Fatal(err) + } + if got := copyGit(t, work, "rev-parse", "HEAD"); got != copyGit(t, local, "rev-parse", "HEAD") { + t.Fatal("HEAD did not travel") + } + if got := copyGit(t, work, "branch", "--show-current"); got != "task-copy" { + t.Fatal(got) + } + if info, err := os.Stat(filepath.Join(work, "uncommitted.txt")); err != nil || info.Mode().Perm() != 0640 { + t.Fatalf("mode: %v, %v", info, err) + } + if link, err := os.Readlink(filepath.Join(work, "link")); err != nil || link != "edit.txt" { + t.Fatalf("link: %s, %v", link, err) + } + if _, err := os.Stat(filepath.Join(work, "ignored")); !os.IsNotExist(err) { + t.Fatalf("ignored files traveled: %v", err) + } + copyFile(t, work, "edit.txt", "from B\n") + copyFile(t, work, "added.txt", "new on B\n") + if err := os.Remove(filepath.Join(work, "delete.txt")); err != nil { + t.Fatal(err) + } + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "finished on B") + result, err := taskdoor.SendResult(context.Background(), b, work, in) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveResult(context.Background(), a, result, local, in.Fingerprint); err != nil { + t.Fatal(err) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != copyGit(t, work, "rev-parse", "HEAD") { + t.Fatal("B's commit did not come home") + } + for _, dir := range []string{local, work} { + for _, name := range []string{".cell", ".furrow", ".furrowpolicy", "delete.txt"} { + if _, err := os.Lstat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Errorf("%s left %s: %v", dir, name, err) + } + } + for name, want := range map[string]string{"edit.txt": "from B\n", "added.txt": "new on B\n", "uncommitted.txt": "carried too"} { + got, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || string(got) != want { + t.Errorf("%s/%s = %q, %v", dir, name, got, err) + } + } + } +} + +// Contract 3: work done here while B runs cannot be overwritten by its result. +func TestAResultIsRefusedWhenTheCopyChangedHere(t *testing.T) { + a, b := copyHomes(t) + local, work := copyRepo(t), filepath.Join(t.TempDir(), "work") + in, err := taskdoor.SendCopy(context.Background(), a, local) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveCopy(context.Background(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "B's work") + result, err := taskdoor.SendResult(context.Background(), b, work, in) + if err != nil { + t.Fatal(err) + } + copyFile(t, local, "edit.txt", "changed here") + before := copyGit(t, local, "status", "--porcelain") + if err := taskdoor.ReceiveResult(context.Background(), a, result, local, in.Fingerprint); err == nil { + t.Fatal("changed copy accepted") + } + got, err := os.ReadFile(filepath.Join(local, "edit.txt")) + if err != nil || string(got) != "changed here" { + t.Fatalf("local work changed: %q, %v", got, err) + } + if after := copyGit(t, local, "status", "--porcelain"); after != before { + t.Fatalf("refusal changed copy: %s", after) + } +} + +// Contract 3 includes linked worktrees: normal landing reads the ground's +// shared task branch, so receiving a result must retain that registration. +func TestALinkedTaskCopyKeepsItsBranchForNormalLanding(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "worktree", "add", "-q", "-b", "task-linked", local, "HEAD") + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "the returned commit\n") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "the remote task") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(filepath.Join(local, ".git")); err != nil || !info.Mode().IsRegular() { + t.Fatalf("lost linked registration: %v, %v", info, err) + } + want := copyGit(t, work, "rev-parse", "HEAD") + if got := copyGit(t, ground, "rev-parse", "task-linked"); got != want { + t.Fatalf("landing branch=%s want %s", got, want) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != want { + t.Fatalf("task copy=%s want %s", got, want) + } + if status := copyGit(t, local, "status", "--porcelain"); status != "" { + t.Fatalf("returned commit has spurious edits: %s", status) + } +} + +// Contract 3's refusal check follows the potentially slow history import, so +// an editor active during that import cannot lose its work to the result. +func TestAResultKeepsAnEditMadeWhileItsHistoryArrives(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "worktree", "add", "-q", "-b", "task-linked", local, "HEAD") + oldHead := copyGit(t, local, "rev-parse", "HEAD") + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "B's change") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "remote work") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + wrapper := t.TempDir() + script := "#!/bin/sh\nfor arg in \"$@\"; do\n if [ \"$arg\" = fetch ]; then printf 'edited during import' > \"$CODEAF_TEST_EDIT_DURING_FETCH\"; fi\ndone\nexec \"$CODEAF_TEST_REAL_GIT\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(wrapper, "git"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_TEST_REAL_GIT", realGit) + t.Setenv("CODEAF_TEST_EDIT_DURING_FETCH", filepath.Join(local, "edit.txt")) + t.Setenv("PATH", wrapper+string(os.PathListSeparator)+os.Getenv("PATH")) + if err := taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); !errors.Is(err, taskdoor.ErrCopyChanged) { + t.Fatalf("concurrent edit accepted: %v", err) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != oldHead { + t.Fatal("refusal moved task branch") + } + if raw, err := os.ReadFile(filepath.Join(local, "edit.txt")); err != nil || string(raw) != "edited during import" { + t.Fatalf("local edit lost: %q, %v", raw, err) + } +} + +// Contract 15: a second send pays for the edited file and new structure only. +func TestASecondSendUploadsOnlyTheChange(t *testing.T) { + if testing.Short() { + t.Skip("10,000-file measurement") + } + started := time.Now() + ctx := t.Context() + a, b := copyHomes(t) + local := copyRepo(t) + for n := 0; n < 10000; n++ { + copyFile(t, local, fmt.Sprintf("files/%05d.txt", n), fmt.Sprintf("project file %d\n", n)) + } + copyGit(t, local, "add", "--all") + copyGit(t, local, "commit", "-qm", "project files") + for n := 0; n < 2; n++ { + if n == 1 { + copyFile(t, local, "files/00001.txt", "one changed file\n") + } + var seal, upload, fetch, restore taskdoor.CopyMeasure + a.Measure = func(m taskdoor.CopyMeasure) { seal, upload = m, m } + b.Measure = func(m taskdoor.CopyMeasure) { fetch, restore = m, m } + in, err := taskdoor.SendCopy(ctx, a, local) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveCopy(ctx, b, in, filepath.Join(t.TempDir(), "work")); err != nil { + t.Fatal(err) + } + t.Logf("delta-measure: send=%d seal=%s upload=%s fetch=%s restore=%s bytes=%d", n+1, seal.Seal, upload.Upload, fetch.Fetch, restore.Restore, upload.Bytes) + if n == 1 && upload.Bytes >= 256<<10 { + t.Fatalf("second upload = %d bytes", upload.Bytes) + } + } + t.Logf("delta-measure: total=%s", time.Since(started)) +} + +// R7-5 and contract 3 keep an edit arriving after the last optimistic check. +func TestAnEditMadeWhileTheResultInstallsIsKept(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "worktree", "add", "-q", "-b", "task-r8", local, "HEAD") + head, index := copyGit(t, local, "rev-parse", "HEAD"), copyGit(t, local, "diff", "--cached", "--binary") + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err = taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "remote edit") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "remote edit") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + a.BeforeInstall = func() { copyFile(t, local, "edit.txt", "edit made during installation") } + if err = taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); !errors.Is(err, taskdoor.ErrCopyChanged) { + t.Fatalf("racing edit accepted: %v", err) + } + raw, err := os.ReadFile(filepath.Join(local, "edit.txt")) + if err != nil || string(raw) != "edit made during installation" { + t.Fatalf("edit lost: %q, %v", raw, err) + } + if copyGit(t, local, "rev-parse", "HEAD") != head || copyGit(t, local, "diff", "--cached", "--binary") != index { + t.Fatal("refusal changed the branch or index") + } +} diff --git a/internal/taskdoor/dial.go b/internal/taskdoor/dial.go new file mode 100644 index 0000000000..cef26cf00b --- /dev/null +++ b/internal/taskdoor/dial.go @@ -0,0 +1,216 @@ +package taskdoor + +import ( + "context" + "errors" + "io" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" +) + +// Dial reaches the chosen device within one bound covering relay, Noise and +// hello. The returned connection lives until it or the caller's context closes. +func Dial(ctx context.Context, service string, id identity.Identity, dev identity.Dev, targetDeviceID, targetName string) (*Conn, error) { + return dial(ctx, service, id, dev, targetDeviceID, targetName, wallClock{}) +} + +// DialWithClock supplies watch wakeups as well as time readings for callers +// that need a deterministic clock. Dial uses the wall clock. +func DialWithClock(ctx context.Context, service string, id identity.Identity, dev identity.Dev, targetDeviceID, targetName string, clock Clock) (*Conn, error) { + return dial(ctx, service, id, dev, targetDeviceID, targetName, clockOrWall(clock)) +} +func dial(ctx context.Context, service string, id identity.Identity, dev identity.Dev, targetDeviceID, targetName string, clock Clock) (*Conn, error) { + timeout, cancel := context.WithTimeout(ctx, DialWithin) + defer cancel() + key, err := DoorKey(id, dev.ID()) + if err != nil { + return nil, err + } + target, err := DoorKey(id, targetDeviceID) + if err != nil { + return nil, err + } + stream, err := relay.Dial(timeout, service, relay.NameFor(target.PublicKey().Bytes())) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, errors.New(NotTakingTasks(targetName)) + } + failed := true + defer func() { + if failed { + _ = stream.Close() + } + }() + stop := context.AfterFunc(timeout, func() { _ = stream.Close() }) + defer stop() + deadline, _ := timeout.Deadline() + setDeadline(stream, deadline) + tunnel, err := pair.ConnectWithKey(stream, target.PublicKey().Bytes(), key) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if errors.Is(err, pair.ErrNotThatMachine) { + return nil, errors.New(CouldNotConfirm(targetName)) + } + return nil, errors.New(NotTakingTasks(targetName)) + } + if err := sendHello(tunnel, dev); err != nil { + return nil, errors.New(NotTakingTasks(targetName)) + } + frame, err := ReadFrame(tunnel) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, errors.New(NotTakingTasks(targetName)) + } + if frame.Type == "refused" { + var no Refused + if frame.Decode(&no) != nil || no.Reason == "" { + return nil, errors.New(CouldNotConfirm(targetName)) + } + return nil, errors.New(no.Reason) + } + var hello Hello + reason := "" + switch { + case frame.Type != "hello" || frame.Decode(&hello) != nil: + reason = CouldNotConfirm(targetName) + case hello.V != Version: + reason = DifferentVersion(targetName) + case hello.Cert.DeviceID() != targetDeviceID: + reason = CouldNotConfirm(targetName) + default: + if err := checkHello(id, tunnel, hello); err != nil { + reason = CouldNotConfirm(targetName) + } + } + if reason != "" { + _ = refuse(tunnel, reason) + return nil, errors.New(reason) + } + // Stop the handshake cancellation callback before handing ownership on. A + // callback already running has won the timeout and cannot yield a live Conn. + if !stop() || timeout.Err() != nil { + return nil, errors.New(NotTakingTasks(targetName)) + } + setDeadline(stream, time.Time{}) + failed = false + return newConn(ctx, tunnel, clock), nil +} +func setDeadline(stream io.ReadWriteCloser, when time.Time) { + if conn, ok := stream.(interface{ SetDeadline(time.Time) error }); ok { + _ = conn.SetDeadline(when) + } +} + +type readResult struct { + frame Frame + err error +} + +// Conn serializes writes across replies, watches and keepalive. A single reader +// answers pings even when the application is busy consuming earlier events. +type Conn struct { + tunnel *pair.Tunnel + clock Clock + sendMu sync.Mutex + incoming chan readResult + done chan struct{} + once sync.Once + stopContext func() bool + watching atomic.Bool + lastFrame atomic.Int64 + heartbeatOnce sync.Once + ping chan struct{} + pong chan struct{} + activity chan struct{} +} + +func newConn(ctx context.Context, tunnel *pair.Tunnel, clock Clock) *Conn { + c := &Conn{tunnel: tunnel, clock: clock, incoming: make(chan readResult, 32), done: make(chan struct{}), activity: make(chan struct{}, 1)} + c.lastFrame.Store(clock.Now().UnixNano()) + // Installing this callback before starting the reader avoids a concurrent + // Close observing a half-constructed connection. + c.stopContext = context.AfterFunc(ctx, func() { c.closeTransport() }) + go c.read() + return c +} +func (c *Conn) closeTransport() { c.once.Do(func() { close(c.done); _ = c.tunnel.Close() }) } +func (c *Conn) Close() error { c.stopContext(); c.closeTransport(); return nil } +func (c *Conn) read() { + defer close(c.incoming) + defer c.closeTransport() + for { + frame, err := ReadFrame(c.tunnel) + if err != nil { + select { + case c.incoming <- readResult{err: err}: + case <-c.done: + } + return + } + c.lastFrame.Store(c.clock.Now().UnixNano()) + c.noteActivity() + switch frame.Type { + case "ping": + // A blocked writer must not prevent the reader noticing other frames or + // the watchdog closing a silent peer. One queued pong is enough. + c.startHeartbeat() + c.queuePong() + continue + case "pong": + continue + case "finished", "unknown": + c.watching.Store(false) + } + select { + case c.incoming <- readResult{frame: frame}: + case <-c.done: + return + } + } +} +func (c *Conn) Receive() (Frame, error) { + result, ok := <-c.incoming + if !ok { + return Frame{}, io.EOF + } + return result.frame, result.err +} +func (c *Conn) sendFrame(frame Frame) error { + c.sendMu.Lock() + defer c.sendMu.Unlock() + select { + case <-c.done: + return io.ErrClosedPipe + default: + } + if err := WriteFrame(c.tunnel, frame); err != nil { + c.closeTransport() + return err + } + return nil +} +func (c *Conn) Send(attempt string, payload any) error { + frame, err := NewFrame(attempt, payload) + if err != nil { + return err + } + if frame.Type == "watch" { + c.startWatch() + } + return c.sendFrame(frame) +} + +// LastActivity includes keepalive replies, so the origin distinguishes a quiet +// worker from a computer whose connection stopped answering. +func (c *Conn) LastActivity() time.Time { return time.Unix(0, c.lastFrame.Load()) } diff --git a/internal/taskdoor/door_test.go b/internal/taskdoor/door_test.go new file mode 100644 index 0000000000..08a52bb740 --- /dev/null +++ b/internal/taskdoor/door_test.go @@ -0,0 +1,431 @@ +package taskdoor_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type fakeExecutor struct { + calls atomic.Int32 + adopts atomic.Int32 + stops chan string + run func(context.Context, *taskdoor.Attempt, taskdoor.Journal) error + adopt func(context.Context, *taskdoor.Attempt, taskdoor.Journal) error +} + +func (f *fakeExecutor) Run(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + f.calls.Add(1) + if f.run != nil { + return f.run(ctx, a, j) + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "the work is home", Model: "test-model"}) +} +func (f *fakeExecutor) Adopt(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + f.adopts.Add(1) + if f.adopt != nil { + return f.adopt(ctx, a, j) + } + <-ctx.Done() + return ctx.Err() +} +func (f *fakeExecutor) Stop(id string) error { + if f.stops != nil { + f.stops <- id + } + return nil +} + +type rig struct { + t *testing.T + id identity.Identity + a, b identity.Dev + home, root, service string + executor *fakeExecutor + rosterMu sync.Mutex + active map[string]bool +} + +func newRig(t *testing.T) *rig { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + homeA, homeB := t.TempDir(), t.TempDir() + id, err := identity.Ensure(homeA) + if err != nil { + t.Fatal(err) + } + a, err := identity.LoadDevice(homeA) + if err != nil { + t.Fatal(err) + } + b, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + if err := identity.Install(homeB, id, b); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(homeB, "work computer"); err != nil { + t.Fatal(err) + } + relayServer := httptest.NewServer(&relay.Server{}) + t.Cleanup(relayServer.Close) + t.Cleanup(relayServer.CloseClientConnections) + return &rig{t: t, id: id, a: a, b: b, home: homeB, root: filepath.Join(homeB, "v3", "taskdoor"), service: relayServer.URL, executor: &fakeExecutor{}, active: map[string]bool{a.ID(): true, b.ID(): true}} +} +func (r *rig) config() taskdoor.Config { + return taskdoor.Config{Service: r.service, Identity: r.id, Device: r.b, Home: r.home, Root: r.root, Executor: r.executor, + Roster: func(context.Context) (map[string]bool, error) { + r.rosterMu.Lock() + defer r.rosterMu.Unlock() + out := map[string]bool{} + for k, v := range r.active { + out[k] = v + } + return out, nil + }} +} +func (r *rig) serve(config taskdoor.Config) func() { + r.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + ready := make(chan struct{}, 1) + say := config.Say + config.Say = func(line string) { + if say != nil { + say(line) + } + if line == taskdoor.TakingTasksLine { + select { + case ready <- struct{}{}: + default: + } + } + } + go func() { done <- taskdoor.Serve(ctx, config) }() + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + select { + case err := <-done: + if err != nil { + r.t.Errorf("Serve: %v", err) + } + case <-time.After(5 * time.Second): + r.t.Error("Serve did not stop") + } + }) + } + r.t.Cleanup(stop) + select { + case <-ready: + case err := <-done: + cancel() + r.t.Fatalf("Serve before ready: %v", err) + case <-time.After(5 * time.Second): + cancel() + r.t.Fatal("door never registered") + } + return stop +} +func (r *rig) dial() *taskdoor.Conn { + r.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + r.t.Cleanup(cancel) + c, err := taskdoor.Dial(ctx, r.service, r.id, r.a, r.b.ID(), "work computer") + if err != nil { + r.t.Fatal(err) + } + r.t.Cleanup(func() { _ = c.Close() }) + return c +} +func send(t *testing.T, c *taskdoor.Conn, id string, payload any) { + t.Helper() + if err := c.Send(id, payload); err != nil { + t.Fatal(err) + } +} +func receive(t *testing.T, c *taskdoor.Conn, kind string) taskdoor.Frame { + t.Helper() + result := make(chan taskdoor.Frame, 1) + failed := make(chan error, 1) + go func() { + frame, err := c.Receive() + if err != nil { + failed <- err + } else { + result <- frame + } + }() + select { + case frame := <-result: + if frame.Type != kind { + t.Fatalf("frame = %s, want %s (%s)", frame.Type, kind, frame.Payload) + } + return frame + case err := <-failed: + t.Fatal(err) + case <-time.After(5 * time.Second): + _ = c.Close() + t.Fatalf("no %s frame", kind) + } + return taskdoor.Frame{} +} +func request() taskdoor.Start { + return taskdoor.Start{Chat: "opaque-chat", Task: 7, Title: "secret title violet heron", Instruction: "secret instruction amber apples", Input: taskdoor.Head{Head: "head-input", Cell: "cell-task"}, OriginName: "home laptop"} +} +func attempt(n int) string { return fmt.Sprintf("att_%016x", n) } +func decode(t *testing.T, frame taskdoor.Frame, into any) { + t.Helper() + if err := frame.Decode(into); err != nil { + t.Fatal(err) + } +} + +// Contract 12: retrying admission, including after a door restart, must never +// replay an executor's tools. +func TestADoorTaskStartedTwiceRunsOnce(t *testing.T) { + r := newRig(t) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + for _, name := range []string{"request.json", "state.json"} { + if _, err := os.Stat(filepath.Join(a.Dir, name)); err != nil { + return err + } + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "finished once", Model: "test-model"}) + } + stop := r.serve(r.config()) + c := r.dial() + id := attempt(1) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + _ = c.Close() + stop() + r.serve(r.config()) + c = r.dial() + send(t, c, id, request()) + var started taskdoor.Started + decode(t, receive(t, c, "started"), &started) + if started.State.Phase != "done" { + t.Fatalf("recorded state = %+v", started.State) + } + if got := r.executor.calls.Load(); got != 1 { + t.Fatalf("executor ran %d times", got) + } + if got := r.executor.adopts.Load(); got != 0 { + t.Fatalf("completed attempt adopted %d times", got) + } +} + +// Contract 13: holding the identity seed is insufficient after device removal. +func TestARemovedComputerIsTurnedAwayAtTheDoor(t *testing.T) { + r := newRig(t) + r.active[r.a.ID()] = false + r.serve(r.config()) + c, err := taskdoor.Dial(context.Background(), r.service, r.id, r.a, r.b.ID(), "work computer") + if c != nil { + _ = c.Close() + } + if err == nil || err.Error() != "this computer was removed from your devices" { + t.Fatalf("removed device heard %v", err) + } + if r.executor.calls.Load() != 0 { + t.Fatal("removed device started work") + } +} + +// Contract 8's wire half: an absent target gives the actionable serving line. +func TestADoorThatIsNotServingSaysSo(t *testing.T) { + r := newRig(t) + since := time.Now() + c, err := taskdoor.Dial(context.Background(), r.service, r.id, r.a, r.b.ID(), "work computer") + if c != nil { + _ = c.Close() + } + if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" { + t.Fatalf("absent door heard %v", err) + } + if time.Since(since) > 10*time.Second { + t.Fatal("absent door exceeded the dial timeout") + } +} + +// Contract 9's wire half: a watch replays only the durable events the origin +// has not received, even when it reconnects while the worker is still running. +func TestAWatchResumesFromItsLastSeq(t *testing.T) { + r := newRig(t) + advance := make(chan struct{}) + first := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Step{Step: run.Step{Kind: "step", Step: 1, Command: "first step"}}); err != nil { + return err + } + close(first) + select { + case <-advance: + case <-ctx.Done(): + return ctx.Err() + } + if err := j.Append(taskdoor.Step{Step: run.Step{Kind: "step", Step: 2, Command: "second step"}}); err != nil { + return err + } + if err := j.Append(taskdoor.Spend{Line: taskdoor.SpendLine{Model: "test-model", Seat: "worker", InputTokens: 10, OutputTokens: 20, CachedTokens: 3, USD: 0.01}}); err != nil { + return err + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "all done", Changed: []string{"out.txt"}, Model: "test-model", Result: &taskdoor.Head{Head: "result-head", Cell: "task-cell"}}) + } + r.serve(r.config()) + c := r.dial() + id := attempt(2) + send(t, c, id, request()) + receive(t, c, "started") + <-first + send(t, c, id, taskdoor.Watch{}) + frame := receive(t, c, "step") + if frame.Seq != 1 { + t.Fatalf("first seq = %d", frame.Seq) + } + _ = c.Close() + close(advance) + c = r.dial() + send(t, c, id, taskdoor.Watch{From: frame.Seq}) + for i, kind := range []string{"step", "spend", "finished"} { + frame = receive(t, c, kind) + if frame.Seq != uint64(i+2) { + t.Fatalf("replay seq = %d", frame.Seq) + } + } + var end taskdoor.Finished + decode(t, frame, &end) + if end.Result == nil || end.Result.Head != "result-head" || end.Model != "test-model" { + t.Fatalf("lost result: %+v", end) + } +} + +// Contract 12's admission boundary also limits work to two concurrent attempts. +func TestAThirdTaskAtOnceIsRefused(t *testing.T) { + r := newRig(t) + running := make(chan struct{}, 2) + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + running <- struct{}{} + <-ctx.Done() + return ctx.Err() + } + r.serve(r.config()) + c := r.dial() + for n := 1; n <= 2; n++ { + send(t, c, attempt(n), request()) + receive(t, c, "started") + <-running + } + send(t, c, attempt(3), request()) + var refusal taskdoor.Refused + decode(t, receive(t, c, "refused"), &refusal) + if refusal.Reason != "work computer is already running two tasks" { + t.Fatalf("busy line = %q", refusal.Reason) + } + if r.executor.calls.Load() != 2 { + t.Fatal("third executor ran") + } +} + +// Contracts 11 and 12 retain attempts until the origin explicitly acknowledges +// either landing the result or abandoning it. +func TestAnAckRemovesTheAttempt(t *testing.T) { + for _, disposition := range []string{"landed", "abandoned"} { + t.Run(disposition, func(t *testing.T) { + r := newRig(t) + r.serve(r.config()) + c := r.dial() + id := attempt(4) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + send(t, c, id, taskdoor.Ack{Disposition: disposition}) + receive(t, c, "acked") + if _, err := os.Stat(filepath.Join(r.root, id)); !os.IsNotExist(err) { + t.Fatalf("ack left attempt folder: %v", err) + } + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "unknown") + }) + } +} + +// Contract 12's restart rule adopts running attempts instead of replaying Run. +func TestARunningAttemptIsAdoptedAfterServeRestarts(t *testing.T) { + r := newRig(t) + running := make(chan struct{}, 1) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := a.UpdateState(func(s *taskdoor.State) { s.PID = 123 }); err != nil { + return err + } + if err := j.Append(taskdoor.Phase{Words: "running before restart"}); err != nil { + return err + } + running <- struct{}{} + <-ctx.Done() + return ctx.Err() + } + r.executor.adopt = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if a.State().PID != 123 { + return fmt.Errorf("lost child pid") + } + return j.Append(taskdoor.Finished{Outcome: "interrupted", Report: "the child stopped"}) + } + stop := r.serve(r.config()) + c := r.dial() + id := attempt(5) + send(t, c, id, request()) + receive(t, c, "started") + <-running + _ = c.Close() + stop() + r.serve(r.config()) + c = r.dial() + send(t, c, id, taskdoor.Watch{}) + if frame := receive(t, c, "phase"); frame.Seq != 1 { + t.Fatalf("saved phase seq = %d", frame.Seq) + } + if frame := receive(t, c, "finished"); frame.Seq != 2 { + t.Fatalf("adopted finish seq = %d", frame.Seq) + } + send(t, c, id, request()) + receive(t, c, "started") + if r.executor.calls.Load() != 1 || r.executor.adopts.Load() != 1 { + t.Fatalf("run/adopt = %d/%d", r.executor.calls.Load(), r.executor.adopts.Load()) + } + raw, err := os.ReadFile(filepath.Join(r.root, id, "result.json")) + if err != nil { + t.Fatal(err) + } + var result taskdoor.Finished + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + if result.Outcome != "interrupted" { + t.Fatalf("saved outcome = %q", result.Outcome) + } +} diff --git a/internal/taskdoor/engine_fixture_test.go b/internal/taskdoor/engine_fixture_test.go new file mode 100644 index 0000000000..24529e841d --- /dev/null +++ b/internal/taskdoor/engine_fixture_test.go @@ -0,0 +1,30 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// taskEngineBinary keeps a source-only CI checkout useful without replacing the real engine. +func taskEngineBinary(t *testing.T) string { + t.Helper() + binary, configured, err := furrowbin.ConfiguredBinary() + if err != nil { + t.Fatal(err) + } + if configured { + return binary + } + binary, err = furrowbin.Ensure() + if errors.Is(err, furrowbin.ErrNotEmbedded) { + t.Skip("task-copy proof needs a real engine: run make build first, or set " + furrowbin.BinaryEnvVar) + } + if err != nil { + t.Fatal(err) + } + return binary +} diff --git a/internal/taskdoor/executor.go b/internal/taskdoor/executor.go new file mode 100644 index 0000000000..227ffbae01 --- /dev/null +++ b/internal/taskdoor/executor.go @@ -0,0 +1,643 @@ +package taskdoor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/run" +) + +const ( + DefaultTaskTimeout = 6 * time.Hour + ChildStopGrace = 30 * time.Second + // TaskStoreFlag is private plumbing between serve and do, hidden from help + // and deliberately absent from the manual because it is not a user option. + TaskStoreFlag = "task-store" + UnsupportedComputerLine = "this computer cannot take tasks from other computers yet" + childPoll = 100 * time.Millisecond +) + +type ExecutorConfig struct { + Copies CopyDeps + Binary string + // Command substitutes a child program at the operating-system boundary. + // It must retain args, including the attempt's work and run paths, so + // adoption can check that a recorded pid still belongs to this attempt. + Command func(args []string) *exec.Cmd + Ledger string + Clock Clock +} + +// ChildExecutor follows durable files rather than pipes. A serving process +// can therefore stop without killing the task or losing its eventual receipt. +type ChildExecutor struct { + config ExecutorConfig + clock Clock + mu sync.Mutex + active map[string]*Attempt + stops map[string]bool +} + +func NewExecutor(config ExecutorConfig) *ChildExecutor { + if config.Ledger == "" { + config.Ledger = home.Join("v3", "usage.jsonl") + } + return &ChildExecutor{config: config, clock: clockOrWall(config.Clock), active: map[string]*Attempt{}, stops: map[string]bool{}} +} + +func (e *ChildExecutor) attach(a *Attempt) error { + e.mu.Lock() + defer e.mu.Unlock() + e.active[a.ID] = a + if e.stops[a.ID] && a.State().StopRequestedAt.IsZero() { + return a.UpdateState(func(s *State) { s.StopRequestedAt = e.clock.Now() }) + } + return nil +} +func (e *ChildExecutor) detach(a *Attempt) { e.mu.Lock(); delete(e.active, a.ID); e.mu.Unlock() } + +func (e *ChildExecutor) Run(ctx context.Context, a *Attempt, j Journal) error { + if err := e.attach(a); err != nil { + return err + } + defer e.detach(a) + if err := a.UpdateState(func(s *State) { s.LaunchStartedAt = e.clock.Now() }); err != nil { + return err + } + work, record := filepath.Join(a.Dir, "work"), filepath.Join(a.Dir, "run") + if err := ReceiveCopy(ctx, e.config.Copies, a.Request.Input, work); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if !a.State().StopRequestedAt.IsZero() { + return e.finish(ctx, a, j, nil, false) + } + if err := os.MkdirAll(record, 0700); err != nil { + return err + } + wall := DefaultTaskTimeout + if a.Request.Limits.Hours > 0 { + wall = time.Duration(a.Request.Limits.Hours * float64(time.Hour)) + } + args := []string{"do", "--dir", work, "--json", "--" + TaskStoreFlag, record, "--timeout", wall.String()} + if a.Request.Model != "" { + args = append(args, "--model", a.Request.Model) + } + // A named quality preference belongs to B's crew and provider catalog. + if choice, ok := crewroute.ParseEffort(a.Request.Effort); ok && choice != crewroute.EffortKnee { + args = append(args, "--"+string(choice)) + } + // A brief can itself be a flag-shaped word; it remains positional text. + args = append(args, "--", a.Request.Instruction) + var cmd *exec.Cmd + if e.config.Command != nil { + cmd = e.config.Command(args) + } else { + binary := e.config.Binary + if binary == "" { + var err error + binary, err = os.Executable() + if err != nil { + return err + } + } + cmd = exec.Command(binary, args...) + } + stdout, err := os.OpenFile(filepath.Join(a.Dir, "do.json"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer stdout.Close() + stderr, err := os.OpenFile(filepath.Join(a.Dir, "do.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer stderr.Close() + cmd.Stdout, cmd.Stderr = stdout, stderr + separateProcessGroup(cmd) + // Record the ledger boundary before starting, so other runs' historical + // charges never become work to scan on every beat of this attempt. + var ledgerOffset int64 + if info, err := os.Stat(e.config.Ledger); err == nil { + ledgerOffset = info.Size() + } + if err := a.UpdateState(func(s *State) { s.LedgerOffset = ledgerOffset; s.LaunchStartedAt = e.clock.Now() }); err != nil { + return err + } + e.mu.Lock() + err = cmd.Start() + if err != nil { + e.mu.Unlock() + return err + } + pid := cmd.Process.Pid + birth, hash, identityErr := childIdentity(pid, work, record) + // A launcher can still be replacing its argv immediately after Start. + // Give that handoff a bounded moment before recording the pinned identity. + for tries := 0; identityErr != nil && tries < 20; tries++ { + time.Sleep(10 * time.Millisecond) + birth, hash, identityErr = childIdentity(pid, work, record) + } + if identityErr != nil { + // A refusal can print its complete receipt and exit before inspection. + // Preserve that reason instead of replacing it with a pid-read error. + var receipt childReceipt + if err := readJSON(filepath.Join(a.Dir, "do.json"), &receipt); err != nil || receipt.OK == nil && receipt.Stop == "" && receipt.Deliverable == "" { + _ = signalChildGroup(pid, true) + _ = cmd.Wait() + e.mu.Unlock() + return fmt.Errorf("could not record the task's child: %w", identityErr) + } + } + err = a.UpdateState(func(s *State) { + s.PID = pid + s.ChildStartedAt = e.clock.Now() + s.ProcessStart = birth + s.CommandHash = hash + }) + if err == nil && !a.State().StopRequestedAt.IsZero() { + err = signalChildGroup(pid, false) + } + e.mu.Unlock() + if err != nil { + _ = signalChildGroup(pid, true) + _ = cmd.Wait() + return err + } + // Only Wait reaps our own child. An adopter polls the same pid without + // trying to wait on a process that belongs to a previous serving process. + done := make(chan int, 1) + go func() { + err := cmd.Wait() + code := 0 + if err != nil { + code = 1 + var exited *exec.ExitError + if errors.As(err, &exited) { + code = exited.ExitCode() + } + } + done <- code + }() + return e.follow(ctx, a, j, done) +} + +func (e *ChildExecutor) Adopt(ctx context.Context, a *Attempt, j Journal) error { + if err := e.attach(a); err != nil { + return err + } + defer e.detach(a) + if a.State().PID == 0 && !a.State().LaunchStartedAt.IsZero() { + // A child waits at do's hidden launch gate until its pid is durable. + // Discovery closes the crash window between Start and that state write. + pid, birth, hash, err := findAttemptChild(filepath.Join(a.Dir, "work"), filepath.Join(a.Dir, "run")) + if err == nil && pid > 0 { + if err := a.UpdateState(func(s *State) { + s.PID = pid + s.ProcessStart = birth + s.CommandHash = hash + s.ChildStartedAt = e.clock.Now() + }); err != nil { + return err + } + } + } + if !a.State().StopRequestedAt.IsZero() && e.alive(a) { + _ = signalChildGroup(a.State().PID, false) + } + return e.follow(ctx, a, j, nil) +} + +// Stop's timestamp survives a serving restart; the adopter owes the same +// thirty-second grace rather than granting an unresponsive child a new one. +func (e *ChildExecutor) Stop(id string) error { + e.mu.Lock() + defer e.mu.Unlock() + e.stops[id] = true + a := e.active[id] + if a == nil { + return nil + } + if err := a.UpdateState(func(s *State) { + if s.StopRequestedAt.IsZero() { + s.StopRequestedAt = e.clock.Now() + } + }); err != nil { + return err + } + if e.alive(a) { + return signalChildGroup(a.State().PID, false) + } + return nil +} + +func (e *ChildExecutor) alive(a *Attempt) bool { + s := a.State() + if s.PID <= 0 || s.ProcessStart == "" || s.CommandHash == "" { + return false + } + birth, hash, err := childIdentity(s.PID, filepath.Join(a.Dir, "work"), filepath.Join(a.Dir, "run")) + return err == nil && birth == s.ProcessStart && hash == s.CommandHash +} + +func (e *ChildExecutor) follow(ctx context.Context, a *Attempt, j Journal, done <-chan int) (followErr error) { + defer func() { + if followErr != nil && ctx.Err() == nil && e.alive(a) { + e.endUnfollowedChild(ctx, a, done) + } + }() + follower, err := newChildFollower(a, e.config.Ledger) + if err != nil { + return err + } + beat := e.clock.NewTicker(childPoll) + defer beat.Stop() + var observedExit *int + for { + if err := ctx.Err(); err != nil { + return err + } + if err := follower.poll(j); err != nil { + return err + } + code := observedExit + ended := code != nil + if done != nil && !ended { + select { + case exit := <-done: + code = &exit + ended = true + default: + } + } else if done == nil { + ended = !e.alive(a) + code = a.State().ExitCode + } + if ended { + if done != nil { + if err := a.UpdateState(func(s *State) { s.ExitCode = code }); err != nil { + return err + } + } + if err := follower.poll(j); err != nil { + return err + } + return e.finish(ctx, a, j, code, done == nil) + } + state := a.State() + if !state.StopRequestedAt.IsZero() && e.clock.Now().Sub(state.StopRequestedAt) >= ChildStopGrace && e.alive(a) { + _ = signalChildGroup(state.PID, true) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-beat.C(): + case exit := <-done: + // A reaped child must report its receipt without waiting for another poll tick. + observedExit = &exit + } + } +} + +// A broken trajectory or journal cannot free admission while its child keeps +// spending. Reporting failures stop and join that child before Serve records +// an incomplete ending; ordinary serving shutdown still leaves it adoptable. +func (e *ChildExecutor) endUnfollowedChild(ctx context.Context, a *Attempt, done <-chan int) { + started := e.clock.Now() + if e.alive(a) { + _ = signalChildGroup(a.State().PID, false) + } + beat := e.clock.NewTicker(childPoll) + defer beat.Stop() + for e.alive(a) { + if e.clock.Now().Sub(started) >= ChildStopGrace { + _ = signalChildGroup(a.State().PID, true) + } + select { + case <-ctx.Done(): + return + case <-done: + return + case <-beat.C(): + } + } + if done != nil { + select { + case <-done: + case <-ctx.Done(): + } + } +} + +type childReceipt struct { + OK *bool `json:"ok"` + Stop string `json:"stop"` + Answer string `json:"answer"` + Deliverable string `json:"deliverable"` + Error string `json:"error"` + BlockedOn string `json:"blocked_on"` + Incomplete string `json:"incomplete"` + Model string `json:"model"` + Files []string `json:"files"` +} + +func (e *ChildExecutor) finish(ctx context.Context, a *Attempt, j Journal, code *int, adopted bool) error { + var receipt childReceipt + err := readJSON(filepath.Join(a.Dir, "do.json"), &receipt) + complete := err == nil && (receipt.OK != nil || receipt.Stop != "" || receipt.Deliverable != "") + end := Finished{Outcome: "incomplete", Model: receipt.Model} + for _, path := range receipt.Files { + if filepath.IsAbs(path) { + relative, err := filepath.Rel(filepath.Join(a.Dir, "work"), path) + if err != nil { + continue + } + path = relative + } + if path != "." && filepath.IsLocal(path) { + end.Changed = append(end.Changed, filepath.ToSlash(path)) + } + } + sort.Strings(end.Changed) + if receipt.Answer != "" { + end.Report = receipt.Answer + } else { + end.Report = receipt.Deliverable + } + if end.Report == "" { + end.Report = firstWords(receipt.Error, receipt.BlockedOn, receipt.Incomplete, "the task ended without an answer") + } + if complete { + status := 1 + if code != nil { + status = *code + } else { + switch receipt.Stop { + case "done": + status = 0 + case "budget", "turn-cap", "deadline", "price": + status = 3 + default: + if receipt.OK != nil && *receipt.OK { + status = 0 + } + } + } + switch status { + case 0: + end.Outcome = "done" + case 3, 124: + end.Outcome = "stopped" + } + } else if adopted { + end.Outcome = "interrupted" + end.Report = "the task was interrupted on this computer before it left an answer" + } + if !a.State().StopRequestedAt.IsZero() { + end.Outcome = "stopped" + } + if _, err := os.Stat(filepath.Join(a.Dir, "work")); err == nil { + result, err := SendResult(ctx, e.config.Copies, filepath.Join(a.Dir, "work"), a.Request.Input) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + end.Report += "\nThe work could not be sent back: " + err.Error() + } else { + end.Result = &result + } + } + return j.Append(end) +} +func firstWords(words ...string) string { + for _, s := range words { + if strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +type childFollower struct { + attempt *Attempt + ledger string + offsets map[string]int64 + nextOrder uint64 + pending map[uint64]Step + sessions map[string]bool + headers map[string]bool +} + +func newChildFollower(a *Attempt, ledger string) (*childFollower, error) { + f := &childFollower{attempt: a, ledger: ledger, offsets: map[string]int64{"usage": a.State().LedgerOffset}, nextOrder: 1, pending: map[uint64]Step{}, sessions: map[string]bool{}, headers: map[string]bool{}} + events, err := readEvents(a.Dir) + if err != nil { + return nil, err + } + for _, event := range events { + var mark struct { + Source string `json:"source"` + Offset int64 `json:"offset"` + } + if err := event.Decode(&mark); err != nil { + return nil, err + } + if mark.Source != "" && mark.Offset > f.offsets[mark.Source] { + f.offsets[mark.Source] = mark.Offset + } + if event.Type == "step" { + var line Step + if err := event.Decode(&line); err != nil { + return nil, err + } + if line.Step.RecordedOrder >= f.nextOrder { + f.nextOrder = line.Step.RecordedOrder + 1 + } + } + } + return f, nil +} + +type pendingStep struct { + step Step + at time.Time +} + +func (f *childFollower) poll(j Journal) error { + paths, err := filepath.Glob(filepath.Join(f.attempt.Dir, "run", "tasks", "*", "trajectory.jsonl")) + if err != nil { + return err + } + var pending []pendingStep + for _, path := range paths { + rel, _ := filepath.Rel(filepath.Join(f.attempt.Dir, "run"), path) + info, err := os.Stat(path) + if err != nil { + return err + } + err = f.lines(path, rel, func(raw []byte, offset int64) error { + var step run.Step + if err := json.Unmarshal(raw, &step); err != nil { + return err + } + at := step.RecordedAt + if at.IsZero() { + at = info.ModTime() + } + line := Step{Step: step, Source: rel, Offset: offset} + if step.RecordedOrder > 0 { + if step.RecordedOrder >= f.nextOrder { + f.pending[step.RecordedOrder] = line + } + } else { + pending = append(pending, pendingStep{line, at}) + } + return nil + }) + if err != nil { + return err + } + } + sort.SliceStable(pending, func(i, k int) bool { return pending[i].at.Before(pending[k].at) }) + for _, p := range pending { + if err := j.Append(p.step); err != nil { + return err + } + f.offsets[p.step.Source] = p.step.Offset + } + for { + line, ok := f.pending[f.nextOrder] + if !ok { + break + } + if err := j.Append(line); err != nil { + return err + } + f.offsets[line.Source] = line.Offset + delete(f.pending, f.nextOrder) + f.nextOrder++ + } + return f.lines(f.ledger, "usage", func(raw []byte, offset int64) error { + var line struct { + At time.Time `json:"at"` + Model string `json:"model"` + Seat string `json:"seat"` + Session string `json:"session"` + Input int `json:"in"` + Output int `json:"out"` + USD float64 `json:"usd"` + } + if err := json.Unmarshal(raw, &line); err != nil { + return err + } + // The child's own worker headers identify its provider sessions. A + // different conversation opened in the same folder is not its bill. + if line.Session != "" { + if _, known := f.sessions[line.Session]; !known { + if err := f.learnSessions(); err != nil { + return err + } + if _, known := f.sessions[line.Session]; !known { + f.sessions[line.Session] = false + } + } + } + if f.sessions[line.Session] { + if err := j.Append(Spend{Line: SpendLine{Model: line.Model, Seat: line.Seat, InputTokens: line.Input, OutputTokens: line.Output, USD: line.USD}, Source: "usage", Offset: offset}); err != nil { + return err + } + } + f.offsets["usage"] = offset + return nil + }) +} + +func (f *childFollower) learnSessions() error { + paths, err := filepath.Glob(filepath.Join(f.attempt.Dir, "run", "tasks", "*", "*_worker.jsonl")) + if err != nil { + return err + } + for _, path := range paths { + if f.headers[path] { + continue + } + file, err := os.Open(path) + if err != nil { + return err + } + raw, readErr := bufio.NewReader(file).ReadBytes('\n') + closeErr := file.Close() + if readErr != nil && !errors.Is(readErr, io.EOF) { + return readErr + } + if closeErr != nil { + return closeErr + } + var header struct { + Type string `json:"type"` + ID string `json:"id"` + } + // Another worker can still be creating its first line. Its provider + // call cannot precede that header, so leave partial headers to retry. + if json.Unmarshal(raw, &header) != nil || header.Type != "session" || header.ID == "" { + continue + } + f.sessions[header.ID] = true + f.headers[path] = true + } + return nil +} + +// lines ignores an unfinished final write and advances only after its event +// is durable. The journal itself is the cursor, closing the crash window +// between copying an event and saving a separate offset file. +func (f *childFollower) lines(path, source string, visit func([]byte, int64) error) error { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer file.Close() + offset := f.offsets[source] + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return err + } + reader := bufio.NewReader(file) + for { + raw, err := reader.ReadBytes('\n') + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + offset += int64(len(raw)) + if len(raw) > MaxFrameBytes { + return fmt.Errorf("the task's saved event is too large") + } + if len(bytes.TrimSpace(raw)) == 0 { + continue + } + if err := visit(raw, offset); err != nil { + return err + } + } +} diff --git a/internal/taskdoor/executor_test.go b/internal/taskdoor/executor_test.go new file mode 100644 index 0000000000..03634cfda8 --- /dev/null +++ b/internal/taskdoor/executor_test.go @@ -0,0 +1,508 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// The scripted child exercises the real process road without buying a provider +// call. All control files and its home belong to this attempt's fixture. +func TestTaskChildHelper(t *testing.T) { + if os.Getenv("CODEAF_TASK_CHILD_HELPER") != "1" { + return + } + args := os.Args + value := func(flag string) string { + for i, arg := range args { + if arg == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" + } + work, store := value("--dir"), value("--task-store") + if work == "" || store == "" { + os.Exit(1) + } + var request taskdoor.Start + requestRaw, err := os.ReadFile(filepath.Join(filepath.Dir(store), "request.json")) + if err != nil || json.Unmarshal(requestRaw, &request) != nil || args[len(args)-1] != request.Instruction { + os.Exit(1) + } + for _, arg := range args { + if arg == "--yes-spend" { + os.Exit(1) + } + } + if value("--timeout") != taskdoor.DefaultTaskTimeout.String() { + os.Exit(1) + } + if pgid, err := syscall.Getpgid(os.Getpid()); err != nil || pgid != os.Getpid() { + os.Exit(1) + } + for _, stream := range []*os.File{os.Stdout, os.Stderr} { + if info, err := stream.Stat(); err != nil || !info.Mode().IsRegular() { + os.Exit(1) + } + } + _ = os.MkdirAll(filepath.Join(store, "tasks", "1"), 0700) + _ = os.MkdirAll(filepath.Join(store, "tasks", "2"), 0700) + header, _ := os.Create(filepath.Join(store, "tasks", "2", "fixture_worker.jsonl")) + _ = json.NewEncoder(header).Encode(map[string]string{"type": "session", "id": "child-session"}) + _ = header.Close() + f, _ := os.OpenFile(filepath.Join(filepath.Dir(store), "starts"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + _, _ = fmt.Fprintln(f, os.Getpid()) + _ = f.Close() + steps, _ := os.Create(filepath.Join(store, "tasks", "2", "trajectory.jsonl")) + if os.Getenv("CODEAF_TASK_CHILD_MODE") == "corrupt" { + _, _ = steps.WriteString("unreadable step\n") + } else { + _ = json.NewEncoder(steps).Encode(run.Step{Kind: "step", Step: 1, Command: "first", RecordedOrder: 1}) + } + _ = steps.Close() + ledger, _ := os.OpenFile(os.Getenv("CODEAF_TASK_CHILD_LEDGER"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + _ = json.NewEncoder(ledger).Encode(map[string]any{"session": "another-session", "workspace": work, "model": "other-model", "calls": 1, "usd": 9.0}) + _ = json.NewEncoder(ledger).Encode(map[string]any{"session": "child-session", "workspace": work, "model": "test-model", "seat": "worker", "calls": 1, "usd": 0.125, "in": 20, "out": 5}) + _ = ledger.Close() + signals := make(chan os.Signal, 1) + if os.Getenv("CODEAF_TASK_CHILD_MODE") == "deaf" { + signal.Ignore(os.Interrupt) + } else { + signal.Notify(signals, os.Interrupt) + } + _ = os.WriteFile(filepath.Join(filepath.Dir(store), "ready"), []byte("ready"), 0600) + stopped := false + for { + if _, err := os.Stat(filepath.Join(filepath.Dir(store), "finish")); err == nil { + break + } + select { + case <-signals: + stopped = true + case <-time.After(10 * time.Millisecond): + } + if stopped { + break + } + } + if os.Getenv("CODEAF_TASK_CHILD_DIE") == "1" { + os.Exit(9) + } + if os.Getenv("CODEAF_TASK_CHILD_MODE") == "late" { + for { + if _, err := os.Stat(filepath.Join(filepath.Dir(store), "allow-exit")); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + } + steps, _ = os.Create(filepath.Join(store, "tasks", "1", "trajectory.jsonl")) + _ = json.NewEncoder(steps).Encode(run.Step{Kind: "step", Step: 2, Command: "second", RecordedOrder: 2}) + _ = steps.Close() + ledger, _ = os.OpenFile(os.Getenv("CODEAF_TASK_CHILD_LEDGER"), os.O_APPEND|os.O_WRONLY, 0600) + _ = json.NewEncoder(ledger).Encode(map[string]any{"session": "child-session", "workspace": work, "model": "test-model", "seat": "worker", "calls": 1, "usd": 0.375, "in": 30, "out": 7}) + _ = ledger.Close() + _ = os.WriteFile(filepath.Join(work, "child.txt"), []byte("the child worked"), 0640) + stop, code := "done", 0 + if stopped { + stop, code = "incomplete", 2 + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": !stopped, "stop": stop, "answer": "the child's report", "spend_usd": 0.5, "model": "test-model", "files": []string{"child.txt"}}) + os.Exit(code) +} + +type childRig struct { + r *rig + copies taskdoor.CopyDeps + input taskdoor.Input + ledger, dir, childHome string + id string + mode string + clock taskdoor.Clock +} + +func newChildRig(t *testing.T) *childRig { + t.Helper() + a, b := copyHomes(t) + in, err := taskdoor.SendCopy(context.Background(), a, copyRepo(t)) + if err != nil { + t.Fatal(err) + } + r := newRig(t) + short, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(short) }) + return &childRig{r: r, copies: b, input: in, ledger: filepath.Join(short, "usage.jsonl"), childHome: short, id: attempt(70), dir: filepath.Join(r.root, attempt(70))} +} + +func (c *childRig) config(t *testing.T, die bool) taskdoor.Config { + t.Helper() + config := c.r.config() + config.Executor = taskdoor.NewExecutor(taskdoor.ExecutorConfig{Copies: c.copies, Ledger: c.ledger, Clock: c.clock, + Command: func(args []string) *exec.Cmd { + cmd := exec.Command(os.Args[0], append([]string{"-test.run=^TestTaskChildHelper$", "--"}, args...)...) + cmd.Env = append(os.Environ(), "HOME="+c.childHome, "CODEAF_HOME="+c.childHome, "CODEAF_TASK_CHILD_HELPER=1", "CODEAF_TASK_CHILD_LEDGER="+c.ledger, "CODEAF_TASK_CHILD_MODE="+c.mode, fmt.Sprintf("CODEAF_TASK_CHILD_DIE=%d", map[bool]int{true: 1}[die])) + return cmd + }}) + return config +} + +// Contract 12 also covers a crash after launch intent but before the pid +// rename: the waiting child is found by its attempt paths, never restarted. +func TestAnAdoptedLaunchIntentFindsItsChild(t *testing.T) { + c := newChildRig(t) + stop := c.r.serve(c.config(t, false)) + conn := c.start(t) + _ = conn.Close() + stop() + path := filepath.Join(c.dir, "state.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + state.PID = 0 + state.ProcessStart = "" + state.CommandHash = "" + raw, err = json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + c.r.serve(c.config(t, false)) + _ = os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600) + _, _, done := childFinish(t, c.r.dial(), c.id) + if done.Outcome != "done" { + t.Fatalf("launch was not adopted: %+v", done) + } + starts, _ := os.ReadFile(filepath.Join(c.dir, "starts")) + if len(strings.Fields(string(starts))) != 1 { + t.Fatalf("child restarted: %q", starts) + } +} + +// A follower error cannot report completion and release a running slot while +// its child is still making provider calls outside the saved journal. +func TestAFollowingErrorStopsTheChildBeforeFinishing(t *testing.T) { + c := newChildRig(t) + c.mode = "corrupt" + c.r.serve(c.config(t, false)) + conn := c.r.dial() + req := request() + req.Input = c.input + req.Instruction = "--help" + send(t, conn, c.id, req) + receive(t, conn, "started") + _, _, done := childFinish(t, conn, c.id) + if done.Outcome != "incomplete" { + t.Fatalf("finish: %+v", done) + } + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(state.PID, 0); err == nil { + t.Fatal("unfollowed child still lives after finished") + } +} + +type childGraceClock struct{ *manualClock } + +func (c childGraceClock) NewTicker(d time.Duration) taskdoor.Ticker { + if d < time.Second { + d = time.Second + } + return c.manualClock.NewTicker(d) +} + +// DESIGN section 3.4 gives an interrupt thirty seconds to land partial work; +// an unresponsive process is then killed without waiting thirty real seconds. +func TestAChildIgnoringStopIsKilledAfterItsGrace(t *testing.T) { + c := newChildRig(t) + c.mode = "deaf" + clock := newClock() + c.clock = childGraceClock{clock} + c.r.serve(c.config(t, false)) + conn := c.start(t) + clock.awaitTicker(t) + send(t, conn, c.id, taskdoor.Stop{}) + receive(t, conn, "stopping") + clock.advance(taskdoor.ChildStopGrace - time.Second) + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(state.PID, 0); err != nil { + t.Fatal("child killed before grace", err) + } + clock.advance(time.Second) + _, _, done := childFinish(t, conn, c.id) + if done.Outcome != "stopped" { + t.Fatalf("finish: %+v", done) + } +} +func (c *childRig) start(t *testing.T) *taskdoor.Conn { + t.Helper() + conn := c.r.dial() + req := request() + req.Input = c.input + send(t, conn, c.id, req) + receive(t, conn, "started") + untilFile(t, filepath.Join(c.dir, "ready")) + return conn +} +func untilFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(45 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + if raw, err := os.ReadFile(filepath.Join(filepath.Dir(path), "result.json")); err == nil { + t.Fatalf("child ended before ready: %s", raw) + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("did not write %s", path) +} +func childFinish(t *testing.T, conn *taskdoor.Conn, id string) ([]taskdoor.Step, []taskdoor.Spend, taskdoor.Finished) { + t.Helper() + send(t, conn, id, taskdoor.Watch{}) + var steps []taskdoor.Step + var spend []taskdoor.Spend + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + for { + f, err := conn.Receive() + if err != nil { + t.Fatal(err) + } + switch f.Type { + case "step": + var s taskdoor.Step + decode(t, f, &s) + steps = append(steps, s) + case "spend": + var s taskdoor.Spend + decode(t, f, &s) + spend = append(spend, s) + case "finished": + var done taskdoor.Finished + decode(t, f, &done) + return steps, spend, done + case "phase": + default: + t.Fatalf("unexpected event %s", f.Payload) + } + } +} + +// Contract 12: a serving restart follows the surviving process and replays +// each journaled step and charge once, without re-running the command. +func TestAnAdoptedChildIsFollowedNotRestarted(t *testing.T) { + c := newChildRig(t) + stop := c.r.serve(c.config(t, false)) + conn := c.start(t) + send(t, conn, c.id, taskdoor.Watch{}) + receive(t, conn, "step") + _ = conn.Close() + stop() + var saved taskdoor.State + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &saved); err != nil { + t.Fatal(err) + } + if saved.ProcessStart == "" || saved.CommandHash == "" { + t.Fatalf("child identity missing: %+v", saved) + } + c.r.serve(c.config(t, false)) + if err := os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600); err != nil { + t.Fatal(err) + } + steps, spend, done := childFinish(t, c.r.dial(), c.id) + if len(steps) != 2 || steps[0].Step.Command != "first" || steps[1].Step.Command != "second" { + t.Fatalf("steps: %+v, finish: %+v, state: %+v", steps, done, saved) + } + if len(spend) != 2 || done.Outcome != "done" || done.Result == nil || done.Model != "test-model" { + t.Fatalf("finish: %+v, spend=%+v", done, spend) + } + starts, err := os.ReadFile(filepath.Join(c.dir, "starts")) + if err != nil || len(strings.Fields(string(starts))) != 1 { + t.Fatalf("child restarted: %q, %v", starts, err) + } +} + +// Contract 12: a dead child without a complete receipt is interrupted; a +// serving restart must never repeat the tools it may already have run. +func TestAChildThatDiedWithoutAnAnswerIsInterrupted(t *testing.T) { + c := newChildRig(t) + stop := c.r.serve(c.config(t, true)) + conn := c.start(t) + _ = conn.Close() + stop() + var state taskdoor.State + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(state.PID, syscall.SIGKILL); err != nil { + t.Fatal(err) + } + c.r.serve(c.config(t, true)) + _, _, done := childFinish(t, c.r.dial(), c.id) + if done.Outcome != "interrupted" { + t.Fatalf("finish: %+v", done) + } + starts, _ := os.ReadFile(filepath.Join(c.dir, "starts")) + if len(strings.Fields(string(starts))) != 1 { + t.Fatalf("child restarted: %q", starts) + } +} + +// Contract 5: the origin receives one charge per provider call, and the sum +// is the child's own receipt rather than an estimate from step counts. +func TestRemoteSpendSumsToTheRunsOwnBill(t *testing.T) { + c := newChildRig(t) + c.r.serve(c.config(t, false)) + conn := c.start(t) + _ = os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600) + _, spend, _ := childFinish(t, conn, c.id) + var bill struct { + Spend float64 `json:"spend_usd"` + } + raw, err := os.ReadFile(filepath.Join(c.dir, "do.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &bill); err != nil { + t.Fatal(err) + } + var sum float64 + for _, s := range spend { + sum += s.Line.USD + } + if len(spend) != 2 || math.Abs(sum-bill.Spend) > 1e-9 { + t.Fatalf("spend=%+v sum=%g bill=%g", spend, sum, bill.Spend) + } +} + +// DESIGN section 4's stop road must interrupt the child's group, keep its +// partial work and publish a stopped result that can still come home. +func TestAStopEndsTheChildAndFinishesStopped(t *testing.T) { + c := newChildRig(t) + c.r.serve(c.config(t, false)) + conn := c.start(t) + send(t, conn, c.id, taskdoor.Stop{}) + receive(t, conn, "stopping") + _, _, done := childFinish(t, conn, c.id) + if done.Outcome != "stopped" || done.Result == nil { + t.Fatalf("finish: %+v", done) + } + if _, err := os.Stat(filepath.Join(c.dir, "work", "child.txt")); err != nil { + t.Fatal("partial work was lost", err) + } +} + +// Contracts 11 and 12: abandonment remains durable while the child lands its +// interrupt, and its final writes cannot recreate a broken attempt on restart. +func TestAnAbandonedRealChildIsRemovedAfterItStops(t *testing.T) { + c := newChildRig(t) + c.mode = "late" + stop := c.r.serve(c.config(t, false)) + conn := c.start(t) + send(t, conn, c.id, taskdoor.Ack{Disposition: "abandoned"}) + receive(t, conn, "acked") + _ = conn.Close() + stop() + c.r.serve(c.config(t, false)) + if err := os.WriteFile(filepath.Join(c.dir, "allow-exit"), nil, 0600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for { + if _, err := os.Stat(c.dir); os.IsNotExist(err) { + break + } + if time.Now().After(deadline) { + t.Fatal("abandoned child left an attempt folder") + } + time.Sleep(10 * time.Millisecond) + } + conn = c.r.dial() + send(t, conn, c.id, taskdoor.Watch{}) + receive(t, conn, "unknown") +} + +// Contract 3's ignore rule applies to local capture checks too: a build's +// ignored socket is neither task input nor a reason to refuse the transfer. +func TestIgnoredRuntimeFilesDoNotPreventSendingATaskCopy(t *testing.T) { + a, b := copyHomes(t) + local := copyRepo(t) + if err := os.MkdirAll(filepath.Join(local, "ignored"), 0700); err != nil { + t.Fatal(err) + } + // A short socket path avoids platform limits even with a long test name. + socketDir, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(socketDir) + listener, err := net.Listen("unix", filepath.Join(socketDir, "socket")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + if err := os.Rename(filepath.Join(socketDir, "socket"), filepath.Join(local, "ignored", "socket")); err != nil { + t.Fatal(err) + } + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + dest := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, dest); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dest, "ignored")); !os.IsNotExist(err) { + t.Fatalf("ignored runtime files traveled: %v", err) + } +} diff --git a/internal/taskdoor/finished.go b/internal/taskdoor/finished.go new file mode 100644 index 0000000000..4333a5476d --- /dev/null +++ b/internal/taskdoor/finished.go @@ -0,0 +1,50 @@ +package taskdoor + +import ( + "errors" + "os" + "path/filepath" + "sort" + "time" +) + +// MaxFinishedAttempts bounds durable replay protection after copy cleanup. +const MaxFinishedAttempts = 1000 + +func (s *attemptStore) rememberFinished(id string) error { + next := make(map[string]time.Time, len(s.finished)+1) + for key, at := range s.finished { + next[key] = at + } + next[id] = s.clock.Now() + if len(next) > MaxFinishedAttempts { + ids := make([]string, 0, len(next)) + for key := range next { + if key != id { + ids = append(ids, key) + } + } + sort.Slice(ids, func(i, j int) bool { + if next[ids[i]].Equal(next[ids[j]]) { + return ids[i] < ids[j] + } + return next[ids[i]].Before(next[ids[j]]) + }) + for _, key := range ids[:len(next)-MaxFinishedAttempts] { + delete(next, key) + } + } + if err := atomicJSON(filepath.Join(s.root, "finished.json"), next); err != nil { + return err + } + // The tombstone's directory entry must survive before the bulky attempt is removed. + directory, err := os.Open(s.root) + if err != nil { + return err + } + if err = errors.Join(directory.Sync(), directory.Close()); err != nil { + return err + } + s.finished = next + return nil +} diff --git a/internal/taskdoor/finished_test.go b/internal/taskdoor/finished_test.go new file mode 100644 index 0000000000..4fa079841d --- /dev/null +++ b/internal/taskdoor/finished_test.go @@ -0,0 +1,38 @@ +package taskdoor + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// R7-3 keeps the compact newest-attempt bound durable across a store restart. +func TestAcknowledgedAttemptReplayProtectionIsBounded(t *testing.T) { + clock := wallClock{} + store, err := openAttempts(t.TempDir(), clock) + if err != nil { + t.Fatal(err) + } + now := clock.Now() + for n := 0; n < MaxFinishedAttempts; n++ { + store.finished[fmt.Sprintf("att_%016x", n)] = now.Add(time.Duration(n-MaxFinishedAttempts) * time.Second) + } + newest := fmt.Sprintf("att_%016x", MaxFinishedAttempts) + if err := store.rememberFinished(newest); err != nil { + t.Fatal(err) + } + reopened, err := openAttempts(store.root, clock) + if err != nil { + t.Fatal(err) + } + if len(reopened.finished) != MaxFinishedAttempts { + t.Fatalf("retained %d acknowledgements", len(reopened.finished)) + } + if _, old := reopened.finished["att_0000000000000000"]; old { + t.Fatal("oldest acknowledgement was not compacted") + } + if _, _, err := reopened.admit(newest, Start{}, "busy"); err == nil || !strings.Contains(err.Error(), "already finished") { + t.Fatalf("newest replay = %v", err) + } +} diff --git a/internal/taskdoor/frames.go b/internal/taskdoor/frames.go new file mode 100644 index 0000000000..4f7f99ee67 --- /dev/null +++ b/internal/taskdoor/frames.go @@ -0,0 +1,239 @@ +package taskdoor + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/run" +) + +const ( + // Version is the task wire version, checked before accepting any work. + Version = 1 + // MaxFrameBytes bounds allocation before an untrusted length is read. + MaxFrameBytes = 1 << 20 +) + +// Frame retains the complete JSON object so new readers can decode a typed +// payload without losing the envelope that is also saved in the journal. +type Frame struct { + Type string `json:"t"` + Attempt string `json:"attempt,omitempty"` + Seq uint64 `json:"seq,omitempty"` + Payload json.RawMessage `json:"-"` +} + +func (f Frame) Decode(into any) error { return json.Unmarshal(f.Payload, into) } + +// Head identifies a task-only cell, independently of the chat's own head. +type Head struct { + Head string `json:"head"` + Cell string `json:"cell"` + Fingerprint string `json:"-"` +} +type Limits struct { + USD float64 `json:"usd,omitempty"` + Hours float64 `json:"hours,omitempty"` +} +type Hello struct { + V int `json:"v"` + Cert identity.Cert `json:"cert"` + Sig []byte `json:"sig"` +} +type Start struct { + Chat string `json:"chat"` + Task int `json:"task"` + Title string `json:"title"` + Instruction string `json:"instruction"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + MaxSteps int `json:"max_steps,omitempty"` + NoProgress int `json:"no_progress,omitempty"` + Input Head `json:"input"` + Limits Limits `json:"limits"` + OriginName string `json:"origin_name"` +} +type Started struct { + State State `json:"state"` +} +type Refused struct { + Reason string `json:"reason"` +} +type Watch struct { + From uint64 `json:"from"` +} +type Step struct { + Step run.Step `json:"step"` + Source string `json:"source,omitempty"` + Offset int64 `json:"offset,omitempty"` +} + +// SpendLine carries one charge, so the origin can attribute it once rather +// than estimating spend again from an attempt's final totals. +type SpendLine struct { + Model string `json:"model"` + Seat string `json:"seat"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + CachedTokens int `json:"cached_tokens,omitempty"` + USD float64 `json:"usd,omitempty"` +} +type Spend struct { + Line SpendLine `json:"line"` + Source string `json:"source,omitempty"` + Offset int64 `json:"offset,omitempty"` +} +type Phase struct { + Words string `json:"words"` +} +type Finished struct { + Outcome string `json:"outcome"` + Report string `json:"report"` + Changed []string `json:"changed"` + Model string `json:"model"` + Result *Head `json:"result,omitempty"` +} +type Unknown struct{} +type Stop struct{} +type Stopping struct{} +type Ack struct { + Disposition string `json:"disposition"` +} +type Acked struct{} +type Ping struct{} +type Pong struct{} + +func payloadType(payload any) string { + switch payload.(type) { + case Hello: + return "hello" + case Start: + return "start" + case Started: + return "started" + case Refused: + return "refused" + case Watch: + return "watch" + case Step: + return "step" + case Spend: + return "spend" + case Phase: + return "phase" + case Finished: + return "finished" + case Unknown: + return "unknown" + case Stop: + return "stop" + case Stopping: + return "stopping" + case Ack: + return "ack" + case Acked: + return "acked" + case Ping: + return "ping" + case Pong: + return "pong" + default: + return "" + } +} + +// NewFrame gives each payload its wire type, so callers cannot accidentally +// label a stop request as a start. +func NewFrame(attempt string, payload any) (Frame, error) { return makeFrame(attempt, 0, payload) } +func makeFrame(attempt string, seq uint64, payload any) (Frame, error) { + kind := payloadType(payload) + if kind == "" { + return Frame{}, errors.New("taskdoor: unsupported frame payload") + } + raw, err := json.Marshal(payload) + if err != nil { + return Frame{}, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return Frame{}, err + } + fields["t"], _ = json.Marshal(kind) + if attempt != "" { + fields["attempt"], _ = json.Marshal(attempt) + } + if seq != 0 { + fields["seq"], _ = json.Marshal(seq) + } + raw, err = json.Marshal(fields) + if err != nil { + return Frame{}, err + } + if len(raw) > MaxFrameBytes { + return Frame{}, frameTooLarge() + } + return Frame{Type: kind, Attempt: attempt, Seq: seq, Payload: raw}, nil +} +func frameTooLarge() error { + return fmt.Errorf("taskdoor: a frame exceeds the %d-byte limit", MaxFrameBytes) +} +func parseFrame(raw []byte) (Frame, error) { + var f Frame + if err := json.Unmarshal(raw, &f); err != nil { + return Frame{}, err + } + if f.Type == "" { + return Frame{}, errors.New("taskdoor: a frame needs a type") + } + f.Payload = raw + return f, nil +} + +// ReadFrame checks the length before allocating, including on encrypted but +// not yet authorized hello connections. +func ReadFrame(r io.Reader) (Frame, error) { + var head [4]byte + if _, err := io.ReadFull(r, head[:]); err != nil { + return Frame{}, err + } + size := binary.BigEndian.Uint32(head[:]) + if size > MaxFrameBytes { + return Frame{}, frameTooLarge() + } + if size == 0 { + return Frame{}, errors.New("taskdoor: empty frame") + } + raw := make([]byte, int(size)) + if _, err := io.ReadFull(r, raw); err != nil { + return Frame{}, err + } + return parseFrame(raw) +} + +// WriteFrame writes one complete length-prefixed object. Connections serialize +// this call because a large JSON object may span several encrypted records. +func WriteFrame(w io.Writer, f Frame) error { + if len(f.Payload) > MaxFrameBytes { + return frameTooLarge() + } + if len(f.Payload) == 0 { + return errors.New("taskdoor: empty frame") + } + data := binary.BigEndian.AppendUint32(nil, uint32(len(f.Payload))) + data = append(data, f.Payload...) + for len(data) > 0 { + n, err := w.Write(data) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} diff --git a/internal/taskdoor/frames_test.go b/internal/taskdoor/frames_test.go new file mode 100644 index 0000000000..b85cca7d6d --- /dev/null +++ b/internal/taskdoor/frames_test.go @@ -0,0 +1,48 @@ +package taskdoor_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Contracts 3, 5 and 9 need the settled task inputs, exact worker model and +// per-call charge fields preserved on the task wire, independently of a chat. +func TestTheWirePreservesTaskInputsAndOneSpendLine(t *testing.T) { + cases := []struct { + payload any + want string + }{ + {taskdoor.Start{Chat: "chat", Task: 9, Title: "title", Instruction: "brief", Model: "model", Effort: "high", MaxSteps: 17, NoProgress: 4, Input: taskdoor.Head{Head: "h", Cell: "c"}, Limits: taskdoor.Limits{USD: 2.5, Hours: 0.5}, OriginName: "origin"}, `{"t":"start","attempt":"att_0000000000000001","chat":"chat","task":9,"title":"title","instruction":"brief","model":"model","effort":"high","max_steps":17,"no_progress":4,"input":{"head":"h","cell":"c"},"limits":{"usd":2.5,"hours":0.5},"origin_name":"origin"}`}, + {taskdoor.Spend{Line: taskdoor.SpendLine{Model: "model", Seat: "worker", InputTokens: 100, OutputTokens: 20, CachedTokens: 7, USD: 0.03}}, `{"t":"spend","attempt":"att_0000000000000001","line":{"model":"model","seat":"worker","input_tokens":100,"output_tokens":20,"cached_tokens":7,"usd":0.03}}`}, + {taskdoor.Finished{Outcome: "done", Report: "report", Changed: []string{"out.txt"}, Model: "model", Result: &taskdoor.Head{Head: "result", Cell: "cell"}}, `{"t":"finished","attempt":"att_0000000000000001","outcome":"done","report":"report","changed":["out.txt"],"model":"model","result":{"head":"result","cell":"cell"}}`}, + } + for _, test := range cases { + frame, err := taskdoor.NewFrame(attempt(1), test.payload) + if err != nil { + t.Fatal(err) + } + var stream bytes.Buffer + if err := taskdoor.WriteFrame(&stream, frame); err != nil { + t.Fatal(err) + } + got, err := taskdoor.ReadFrame(&stream) + if err != nil { + t.Fatal(err) + } + var actual, expected any + if err := json.Unmarshal(got.Payload, &actual); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(test.want), &expected); err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(actual) + b, _ := json.Marshal(expected) + if !bytes.Equal(a, b) { + t.Fatalf("wire = %s, want %s", a, b) + } + } +} diff --git a/internal/taskdoor/hello.go b/internal/taskdoor/hello.go new file mode 100644 index 0000000000..90e851b0a2 --- /dev/null +++ b/internal/taskdoor/hello.go @@ -0,0 +1,130 @@ +package taskdoor + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "errors" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" +) + +const ( + RemovedComputerLine = "this computer was removed from your devices" + TakingTasksLine = "takes tasks from your paired computers" + TakeTasksFlag = "take-tasks" + notTakingTasksSuffix = " is not taking tasks right now — run codeaf serve --" + TakeTasksFlag + " on it" +) + +func NotTakingTasks(name string) string { + return name + notTakingTasksSuffix +} +func CouldNotConfirm(name string) string { return "could not confirm this is " + name } +func DifferentVersion(name string) string { + return name + " runs a different codeaf that cannot take this task — update both" +} +func Busy(name string) string { + // The words come from the admission cap, rather than a second limit hidden + // in a sentence that could become wrong when capacity changes. + return name + " is already running " + numberWord(MaxRunning) + " tasks" +} +func numberWord(n int) string { + if n == 2 { + return "two" + } + return strconv.Itoa(n) +} + +// rosterCache refuses stale authority after its bounded lifetime. Failed +// refreshes never extend the previous roster, even during an outage. +type rosterCache struct { + fetch func(context.Context) (map[string]bool, error) + clock Clock + mu sync.Mutex + active map[string]bool + fetched time.Time +} + +func (r *rosterCache) allows(ctx context.Context, device string) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + now := r.clock.Now() + if r.active == nil || now.Before(r.fetched) || now.Sub(r.fetched) >= RosterFor { + rows, err := r.fetch(ctx) + if err != nil { + return false, err + } + r.active = make(map[string]bool, len(rows)) + for k, v := range rows { + r.active[k] = v + } + r.fetched = now + } + return r.active[device], nil +} +func helloFor(dev identity.Dev, tunnel *pair.Tunnel) Hello { + return Hello{V: Version, Cert: dev.Cert, Sig: dev.Sign(tunnel.ChannelBinding())} +} +func checkHello(id identity.Identity, tunnel *pair.Tunnel, hello Hello) error { + if hello.Cert.V != 1 || hello.Cert.Verify(id.PublicKey()) != nil { + return errors.New("could not confirm this computer") + } + pub, err := hex.DecodeString(hello.Cert.Device) + if err != nil || len(pub) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(pub), tunnel.ChannelBinding(), hello.Sig) { + return errors.New("could not confirm this computer") + } + key, err := DoorKey(id, hello.Cert.DeviceID()) + if err != nil || !bytes.Equal(key.PublicKey().Bytes(), tunnel.Peer()) { + return errors.New("could not confirm this computer") + } + return nil +} +func sendHello(tunnel *pair.Tunnel, dev identity.Dev) error { + frame, err := NewFrame("", helloFor(dev, tunnel)) + if err != nil { + return err + } + return WriteFrame(tunnel, frame) +} +func refuse(tunnel *pair.Tunnel, reason string) error { + frame, err := NewFrame("", Refused{Reason: reason}) + if err != nil { + return err + } + return WriteFrame(tunnel, frame) +} +func acceptHello(ctx context.Context, tunnel *pair.Tunnel, id identity.Identity, dev identity.Dev, roster *rosterCache, name string) (string, error) { + frame, err := ReadFrame(tunnel) + if err != nil { + return "", err + } + var hello Hello + reason := "" + switch { + case frame.Type != "hello" || frame.Decode(&hello) != nil: + reason = "could not confirm this computer" + case hello.V != Version: + reason = DifferentVersion(name) + default: + if err := checkHello(id, tunnel, hello); err != nil { + reason = err.Error() + } else { + active, err := roster.allows(ctx, hello.Cert.DeviceID()) + if err != nil { + reason = "could not check your devices right now" + } else if !active { + reason = RemovedComputerLine + } + } + } + if reason != "" { + _ = refuse(tunnel, reason) + return "", errors.New(reason) + } + return hello.Cert.DeviceID(), sendHello(tunnel, dev) +} diff --git a/internal/taskdoor/keepalive.go b/internal/taskdoor/keepalive.go new file mode 100644 index 0000000000..632ed70832 --- /dev/null +++ b/internal/taskdoor/keepalive.go @@ -0,0 +1,93 @@ +package taskdoor + +import "time" + +// Keepalive has its own writer so a peer that stops reading cannot block the +// clock loop that must eventually close that very peer. +func (c *Conn) startHeartbeat() { + c.heartbeatOnce.Do(func() { + c.ping = make(chan struct{}, 1) + c.pong = make(chan struct{}, 1) + ticker := c.clock.NewTicker(PingEvery) + silence := c.clock.NewTicker(SilentAfter) + go func() { + defer ticker.Stop() + defer func() { silence.Stop() }() + resetSilence := func() bool { + remaining := SilentAfter - time.Duration(c.clock.Now().UnixNano()-c.lastFrame.Load()) + if remaining <= 0 { + c.closeTransport() + return false + } + silence.Stop() + silence = c.clock.NewTicker(remaining) + return true + } + for { + select { + case <-c.done: + return + case <-c.activity: + if c.watching.Load() && !resetSilence() { + return + } + case <-silence.C(): + if c.watching.Load() && !resetSilence() { + return + } + case <-ticker.C(): + if !c.watching.Load() { + continue + } + if c.clock.Now().UnixNano()-c.lastFrame.Load() >= int64(SilentAfter) { + c.closeTransport() + return + } + select { + case c.ping <- struct{}{}: + default: + } + } + } + }() + go func() { + for { + var payload any + select { + case <-c.done: + return + case <-c.ping: + payload = Ping{} + case <-c.pong: + payload = Pong{} + } + frame, err := NewFrame("", payload) + if err != nil || c.sendFrame(frame) != nil { + return + } + } + }() + }) +} +func (c *Conn) queuePong() { + select { + case c.pong <- struct{}{}: + default: + } +} +func (c *Conn) startWatch() { + if !c.watching.Swap(true) { + c.lastFrame.Store(c.clock.Now().UnixNano()) + } + c.startHeartbeat() + c.noteActivity() +} + +// A separate silence wakeup preserves the full interval after every frame; +// checking only on probe ticks could otherwise close a connection late. +func (c *Conn) noteActivity() { + select { + case c.activity <- struct{}{}: + default: + } +} diff --git a/internal/taskdoor/keepalive_test.go b/internal/taskdoor/keepalive_test.go new file mode 100644 index 0000000000..a40200d376 --- /dev/null +++ b/internal/taskdoor/keepalive_test.go @@ -0,0 +1,278 @@ +package taskdoor_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type manualClock struct { + mu sync.Mutex + now time.Time + tickers map[*manualTicker]bool + created chan struct{} +} +type manualTicker struct { + clock *manualClock + every time.Duration + next time.Time + ch chan time.Time +} + +func newClock() *manualClock { + return &manualClock{now: time.Now(), tickers: map[*manualTicker]bool{}, created: make(chan struct{}, 64)} +} +func (c *manualClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *manualClock) NewTicker(d time.Duration) taskdoor.Ticker { + c.mu.Lock() + defer c.mu.Unlock() + t := &manualTicker{clock: c, every: d, next: c.now.Add(d), ch: make(chan time.Time, 64)} + c.tickers[t] = true + c.created <- struct{}{} + return t +} +func (t *manualTicker) C() <-chan time.Time { return t.ch } +func (t *manualTicker) Stop() { + t.clock.mu.Lock() + defer t.clock.mu.Unlock() + delete(t.clock.tickers, t) +} +func (c *manualClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) + for ticker := range c.tickers { + for !ticker.next.After(c.now) { + ticker.ch <- ticker.next + ticker.next = ticker.next.Add(ticker.every) + } + } +} +func (c *manualClock) awaitTicker(t *testing.T) { + t.Helper() + select { + case <-c.created: + case <-time.After(5 * time.Second): + t.Fatal("watch never enabled keepalive") + } +} + +// Contract 9's watch boundary must detect a silent peer after 45 seconds and +// send its own probes at 15 and 30, without waiting for wall-clock time. +func TestASilentWatchIsClosedAfterFortyFiveSeconds(t *testing.T) { + r := newRig(t) + clock := newClock() + config := r.config() + config.Clock = clock + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + <-ctx.Done() + return ctx.Err() + } + r.serve(config) + control := r.dial() + id := attempt(20) + send(t, control, id, request()) + receive(t, control, "started") + key, err := taskdoor.DoorKey(r.id, r.a.ID()) + if err != nil { + t.Fatal(err) + } + tunnel := rawTunnel(t, r, key) + rawHello(t, tunnel, r.a) + rawSend(t, tunnel, id, taskdoor.Watch{}) + clock.awaitTicker(t) + for i := 0; i < 2; i++ { + clock.advance(15 * time.Second) + rawReceive(t, tunnel, "ping") + } + closed := make(chan error, 1) + go func() { _, err := taskdoor.ReadFrame(tunnel); closed <- err }() + clock.advance(14 * time.Second) + select { + case err := <-closed: + t.Fatalf("watch closed before 45 seconds: %v", err) + case <-time.After(10 * time.Millisecond): + } + clock.advance(time.Second) + select { + case err := <-closed: + if err == nil { + t.Fatal("silent watch sent another frame instead of closing") + } + case <-time.After(5 * time.Second): + t.Fatal("silent watch stayed open after 45 seconds") + } + if r.executor.calls.Load() != 1 { + t.Fatal("losing the watch restarted the attempt") + } +} + +// Contract 13 allows a bounded roster cache, never a stale roster extended +// beyond sixty seconds after another computer was removed. +func TestARosterRefreshTurnsAwayAComputerWithinSixtySeconds(t *testing.T) { + r := newRig(t) + clock := newClock() + config := r.config() + config.Clock = clock + r.serve(config) + c := r.dial() + _ = c.Close() + r.rosterMu.Lock() + r.active[r.a.ID()] = false + r.rosterMu.Unlock() + clock.advance(59 * time.Second) + c = r.dial() + _ = c.Close() + clock.advance(time.Second) + c, err := taskdoor.Dial(context.Background(), r.service, r.id, r.a, r.b.ID(), "work computer") + if c != nil { + _ = c.Close() + } + if err == nil || err.Error() != "this computer was removed from your devices" { + t.Fatalf("stale roster allowed a removed computer: %v", err) + } +} + +// Contract 9 measures silence from any received frame, rather than rounding a +// fresh arrival to the next fifteen-second probe interval. +func TestEveryWatchFrameRestartsTheFortyFiveSecondSilenceLimit(t *testing.T) { + r := newRig(t) + clock := newClock() + config := r.config() + config.Clock = clock + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + <-ctx.Done() + return ctx.Err() + } + r.serve(config) + control := r.dial() + id := attempt(21) + send(t, control, id, request()) + receive(t, control, "started") + key, err := taskdoor.DoorKey(r.id, r.a.ID()) + if err != nil { + t.Fatal(err) + } + tunnel := rawTunnel(t, r, key) + rawHello(t, tunnel, r.a) + rawSend(t, tunnel, id, taskdoor.Watch{}) + clock.awaitTicker(t) + clock.advance(7 * time.Second) + rawSend(t, tunnel, "", taskdoor.Ping{}) + rawReceive(t, tunnel, "pong") + closed := make(chan error, 1) + go func() { + for { + _, err := taskdoor.ReadFrame(tunnel) + if err != nil { + closed <- err + return + } + } + }() + clock.advance(38 * time.Second) + select { + case err := <-closed: + t.Fatalf("closed only 38 seconds after a frame: %v", err) + case <-time.After(10 * time.Millisecond): + } + clock.advance(7 * time.Second) + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("silence timeout was rounded beyond 45 seconds since the last frame") + } +} + +// Contract 9 applies the same probes and silence limit at the origin; a +// responder that stops producing frames must not leave the origin waiting. +func TestTheOriginAlsoProbesAndClosesASilentWatch(t *testing.T) { + r := newRig(t) + clock := newClock() + key, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + registration, err := relay.Register(ctx, r.service, key) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + frames := make(chan string, 8) + failed := make(chan error, 1) + go func() { + stream, err := registration.Accept() + if err != nil { + failed <- err + return + } + defer stream.Close() + tunnel, err := pair.AcceptWithKey(stream, key) + if err != nil { + failed <- err + return + } + if _, err := taskdoor.ReadFrame(tunnel); err != nil { + failed <- err + return + } + hello, err := taskdoor.NewFrame("", taskdoor.Hello{V: 1, Cert: r.b.Cert, Sig: r.b.Sign(tunnel.ChannelBinding())}) + if err != nil { + failed <- err + return + } + if err := taskdoor.WriteFrame(tunnel, hello); err != nil { + failed <- err + return + } + for { + f, err := taskdoor.ReadFrame(tunnel) + if err != nil { + return + } + frames <- f.Type + } + }() + conn, err := taskdoor.DialWithClock(ctx, r.service, r.id, r.a, r.b.ID(), "work computer", clock) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + send(t, conn, attempt(22), taskdoor.Watch{}) + next := func(want string) { + t.Helper() + select { + case got := <-frames: + if got != want { + t.Fatalf("origin sent %s, want %s", got, want) + } + case err := <-failed: + t.Fatal(err) + case <-time.After(5 * time.Second): + t.Fatalf("origin did not send %s", want) + } + } + next("watch") + for n := 0; n < 2; n++ { + clock.advance(15 * time.Second) + next("ping") + } + clock.advance(15 * time.Second) + closed := make(chan error, 1) + go func() { _, err := conn.Receive(); closed <- err }() + select { + case err := <-closed: + if err == nil { + t.Fatal("origin returned a task event from a silent peer") + } + case <-time.After(5 * time.Second): + t.Fatal("origin did not close its silent watch") + } +} diff --git a/internal/taskdoor/keys.go b/internal/taskdoor/keys.go new file mode 100644 index 0000000000..e9b4665976 --- /dev/null +++ b/internal/taskdoor/keys.go @@ -0,0 +1,34 @@ +// Package taskdoor carries task attempts between devices of one identity. +// The relay only routes encrypted streams; device certificates decide who may +// submit work, independently of the machine-access pairing book. +package taskdoor + +import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/sha256" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relay" +) + +const doorKeySalt = "codeaf task door v1" + +// DoorKey derives a device's task key without publishing another directory +// entry. A cell-key rotation therefore also changes every task door. +func DoorKey(id identity.Identity, deviceID string) (*ecdh.PrivateKey, error) { + raw, err := hkdf.Key(sha256.New, id.CellKey(), []byte(doorKeySalt), deviceID, 32) + if err != nil { + return nil, err + } + return ecdh.X25519().NewPrivateKey(raw) +} + +// DoorName is the relay rendezvous name of the device's derived public key. +func DoorName(id identity.Identity, deviceID string) (string, error) { + key, err := DoorKey(id, deviceID) + if err != nil { + return "", err + } + return relay.NameFor(key.PublicKey().Bytes()), nil +} diff --git a/internal/taskdoor/keys_test.go b/internal/taskdoor/keys_test.go new file mode 100644 index 0000000000..4703de3ecc --- /dev/null +++ b/internal/taskdoor/keys_test.go @@ -0,0 +1,40 @@ +package taskdoor_test + +import ( + "encoding/hex" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Contract 14 requires a private door derived from the shared identity, with a +// different key for each device so the relay can register both at once. +func TestDoorKeysHaveStableDeviceSpecificNames(t *testing.T) { + id, err := identity.Unmarshal([]byte(`{"V":1,"signing_seed":"0000000000000000000000000000000000000000000000000000000000000000","dedup_secret":"0000000000000000000000000000000000000000000000000000000000000000","cell_key":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}`)) + if err != nil { + t.Fatal(err) + } + key, err := taskdoor.DoorKey(id, "dev_0123456789abcdef0123456789abcdef") + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(key.Bytes()); got != "ab6e375a971e40662d79e86158ad7876fb3578b9d6c99b2a74bfb2d9b1989bdd" { + t.Fatalf("derived private key = %s", got) + } + name, err := taskdoor.DoorName(id, "dev_0123456789abcdef0123456789abcdef") + if err != nil { + t.Fatal(err) + } + if name != relay.NameFor(key.PublicKey().Bytes()) { + t.Fatalf("name %q does not register under its public key", name) + } + other, err := taskdoor.DoorName(id, "dev_another") + if err != nil { + t.Fatal(err) + } + if name == other { + t.Fatal("two device doors have the same name") + } +} diff --git a/internal/taskdoor/performance_r8_test.go b/internal/taskdoor/performance_r8_test.go new file mode 100644 index 0000000000..8a584d79f4 --- /dev/null +++ b/internal/taskdoor/performance_r8_test.go @@ -0,0 +1,49 @@ +package taskdoor_test + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// R7-17 enforces PERF.md at the measurement's source boundary: time is diagnostic. +func TestTheTaskCopyDeltaMeasurementNeverGatesOnWallClockSpeed(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "copy_test.go", nil, 0) + if err != nil { + t.Fatal(err) + } + ast.Inspect(file, func(node ast.Node) bool { + branch, ok := node.(*ast.IfStmt) + if !ok { + return true + } + clock, fatal := false, false + inspectClock := func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok { + if method, ok := call.Fun.(*ast.SelectorExpr); ok { + if owner, ok := method.X.(*ast.Ident); ok && owner.Name == "time" && method.Sel.Name == "Since" { + clock = true + } + } + } + return true + } + if branch.Init != nil { + ast.Inspect(branch.Init, inspectClock) + } + ast.Inspect(branch.Cond, inspectClock) + ast.Inspect(branch.Body, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok { + if method, ok := call.Fun.(*ast.SelectorExpr); ok && (method.Sel.Name == "Fatal" || method.Sel.Name == "Fatalf" || method.Sel.Name == "Error" || method.Sel.Name == "Errorf") { + fatal = true + } + } + return true + }) + if clock && fatal { + t.Error("task-copy measurement fails on elapsed wall-clock time") + } + return true + }) +} diff --git a/internal/taskdoor/process_darwin.go b/internal/taskdoor/process_darwin.go new file mode 100644 index 0000000000..3a4fab99bd --- /dev/null +++ b/internal/taskdoor/process_darwin.go @@ -0,0 +1,45 @@ +package taskdoor + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os/exec" + "strconv" + "strings" +) + +func childIdentity(pid int, work, record string) (string, string, error) { + raw, err := exec.Command("ps", "-ww", "-p", strconv.Itoa(pid), "-o", "lstart=,stat=,command=").Output() + if err != nil { + return "", "", err + } + fields := strings.Fields(string(raw)) + if len(fields) < 7 || strings.HasPrefix(fields[5], "Z") || !strings.Contains(string(raw), work) || !strings.Contains(string(raw), record) { + return "", "", errors.New("the recorded process is not this task's child") + } + sum := sha256.Sum256([]byte(strings.Join(fields[6:], " "))) + return strings.Join(fields[:5], " "), hex.EncodeToString(sum[:]), nil +} + +func findAttemptChild(work, record string) (int, string, string, error) { + raw, err := exec.Command("ps", "-axo", "pid=,command=").Output() + if err != nil { + return 0, "", "", err + } + for _, line := range strings.Split(string(raw), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || !strings.Contains(line, work) || !strings.Contains(line, record) { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + birth, hash, err := childIdentity(pid, work, record) + if err == nil { + return pid, birth, hash, nil + } + } + return 0, "", "", errors.New("this task's child is no longer running") +} diff --git a/internal/taskdoor/process_linux.go b/internal/taskdoor/process_linux.go new file mode 100644 index 0000000000..63f51e1892 --- /dev/null +++ b/internal/taskdoor/process_linux.go @@ -0,0 +1,69 @@ +package taskdoor + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strconv" + "strings" +) + +func childIdentity(pid int, work, record string) (string, string, error) { + root := filepath.Join("/proc", strconv.Itoa(pid)) + raw, err := os.ReadFile(filepath.Join(root, "cmdline")) + if err != nil { + return "", "", err + } + workArg, recordArg := false, false + for _, arg := range bytes.Split(raw, []byte{0}) { + if bytes.Equal(arg, []byte(work)) { + workArg = true + } + if bytes.Equal(arg, []byte(record)) { + recordArg = true + } + } + if !workArg { + return "", "", errors.New("the recorded process does not name this task's work folder") + } + if !recordArg { + return "", "", errors.New("the recorded process does not name this task's private record") + } + stat, err := os.ReadFile(filepath.Join(root, "stat")) + if err != nil { + return "", "", err + } + // The command name may contain spaces and parentheses. Fields after its + // last ')' start at field 3; field 22 is the kernel's process birth tick. + end := strings.LastIndexByte(string(stat), ')') + if end < 0 { + return "", "", errors.New("the child's process start is not readable") + } + fields := strings.Fields(string(stat[end+1:])) + if len(fields) <= 19 || fields[0] == "Z" { + return "", "", os.ErrNotExist + } + sum := sha256.Sum256(raw) + return fields[19], hex.EncodeToString(sum[:]), nil +} + +func findAttemptChild(work, record string) (int, string, string, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0, "", "", err + } + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + birth, hash, err := childIdentity(pid, work, record) + if err == nil { + return pid, birth, hash, nil + } + } + return 0, "", "", os.ErrNotExist +} diff --git a/internal/taskdoor/process_other.go b/internal/taskdoor/process_other.go new file mode 100644 index 0000000000..a413ac6868 --- /dev/null +++ b/internal/taskdoor/process_other.go @@ -0,0 +1,20 @@ +//go:build !linux && !darwin + +package taskdoor + +import ( + "errors" + "os/exec" +) + +func separateProcessGroup(*exec.Cmd) {} +func signalChildGroup(int, bool) error { + return errors.New(UnsupportedComputerLine) +} +func childIdentity(int, string, string) (string, string, error) { + return "", "", errors.New(UnsupportedComputerLine) +} + +func findAttemptChild(string, string) (int, string, string, error) { + return 0, "", "", errors.New(UnsupportedComputerLine) +} diff --git a/internal/taskdoor/process_unix.go b/internal/taskdoor/process_unix.go new file mode 100644 index 0000000000..a2202e73e4 --- /dev/null +++ b/internal/taskdoor/process_unix.go @@ -0,0 +1,17 @@ +//go:build linux || darwin + +package taskdoor + +import ( + "os/exec" + "syscall" +) + +func separateProcessGroup(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } +func signalChildGroup(pid int, force bool) error { + signal := syscall.SIGINT + if force { + signal = syscall.SIGKILL + } + return syscall.Kill(-pid, signal) +} diff --git a/internal/taskdoor/registration.go b/internal/taskdoor/registration.go new file mode 100644 index 0000000000..9bafa59483 --- /dev/null +++ b/internal/taskdoor/registration.go @@ -0,0 +1,139 @@ +package taskdoor + +import ( + "context" + "crypto/ecdh" + "errors" + "fmt" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" +) + +// The task door deliberately duplicates pairing's registration loop: stability +// of pairing outranks sharing the loop. +type registrationLoop struct { + Service string + Key *ecdh.PrivateKey + Say func(string) + Now func() time.Time + Wait func(context.Context, time.Duration) error + Ready func(*relay.Registration) + Hold func(context.Context, *relay.Registration) +} + +func (r *registrationLoop) say(line string) { + if r.Say != nil { + r.Say(line) + } +} +func (r *registrationLoop) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} +func (r *registrationLoop) wait(ctx context.Context, d time.Duration) error { + if r.Wait != nil { + return r.Wait(ctx, d) + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// Run returns only on cancellation or a refusal that retrying cannot fix. +// Hold owns accepted streams until the registration ends. +func (r *registrationLoop) Run(ctx context.Context) error { + if r.Key == nil || r.Hold == nil { + return errors.New("pair: a registration needs a key and a stream handler") + } + wait := time.Second + announced := false + // told and throttled are each said once per spell, not once per retry. + // told is set when the person has been told sync was lost and not yet + // that it came back, so the two lines always arrive as a pair. + told := false + throttled := false + tellLost := func() { + if !told { + r.say(pair.LostSyncLine) + } + told = true + } + for { + registration, err := relay.Register(ctx, r.Service, r.Key) + switch { + case err == nil: + case errors.Is(err, relay.ErrTooMany): + if !throttled { + r.say(pair.TooManyRegistrationsLine) + } + throttled = true + case errors.Is(err, relay.ErrNameTaken) && announced: + // This machine held the name a moment ago, so what holds it now is + // its own dead connection, which the relay forgets when it times + // out. That is the outage still going on, and is waited out. + tellLost() + case errors.Is(err, relay.ErrNameTaken): + // RULE THREE OF THE NAME POLICY, MET FROM THIS SIDE. Something is + // already connected under this machine's name, which in practice + // means a second `codeaf serve` on this same machine. Trying again + // forever would be two processes fighting over one name. + return fmt.Errorf("this machine is already connected to sync as %s — there is only one of it, so close the other `codeaf serve`", relay.NameFor(r.Key.PublicKey().Bytes())) + case errors.Is(err, relay.ErrUnreachable): + if !announced { + return pair.Unreachable(r.Service) + } + tellLost() + case errors.Is(err, relay.ErrNoDoor) && announced: + // The door was there a moment ago, so this is a relay being + // redeployed, and is waited out like any other outage. + tellLost() + case errors.Is(err, relay.ErrNoDoor): + // Trying again cannot add a door the service does not have. + return pair.NoDoor(r.Service) + default: + if !announced { + return err + } + r.say("sync refused this machine: " + err.Error()) + } + + if err == nil { + announced = true + // A LOST LINE IS ANSWERED BY A BACK LINE. The banner is reprinted only + // when the name or code changed, so without this the last thing on + // the screen of a machine that recovered would still say it was lost. + if told { + r.say(pair.BackLine(registration.Name())) + told = false + } + if r.Ready != nil { + r.Ready(registration) + } + since := r.now() + r.Hold(ctx, registration) + // Backoff resets only after the carrier stayed up for a meaningful + // stretch; an upgrade that drops right after Register keeps + // backing off instead of hammering the relay every second. + if r.now().Sub(since) >= time.Minute { + wait = time.Second + throttled = false + } + } + + if err := r.wait(ctx, wait); err != nil { + return nil + } + if wait *= 2; wait > time.Minute { + wait = time.Minute + } + } +} diff --git a/internal/taskdoor/relative_copy_r8_test.go b/internal/taskdoor/relative_copy_r8_test.go new file mode 100644 index 0000000000..a6fca7fc32 --- /dev/null +++ b/internal/taskdoor/relative_copy_r8_test.go @@ -0,0 +1,63 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// R7-5 preserves the linked-copy landing contract when Git uses relative worktree links. +func TestARelativeWorktreeLinkSurvivesResultInstallation(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "-c", "worktree.useRelativePaths=true", "worktree", "add", "-q", "-b", "task-relative", local, "HEAD") + raw, err := os.ReadFile(filepath.Join(local, ".git")) + if err != nil { + t.Fatal(err) + } + target := strings.TrimSpace(strings.TrimPrefix(string(raw), "gitdir: ")) + if filepath.IsAbs(target) { + relative, err := filepath.Rel(local, target) + if err != nil { + t.Fatal(err) + } + raw = []byte("gitdir: " + relative + "\n") + if err := os.WriteFile(filepath.Join(local, ".git"), raw, 0644); err != nil { + t.Fatal(err) + } + } + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "returned through a relative worktree") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "relative return") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); err != nil { + t.Fatalf("relative worktree result refused: %v", err) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != copyGit(t, work, "rev-parse", "HEAD") { + t.Fatal("returned commit was not installed") + } + after, err := os.ReadFile(filepath.Join(local, ".git")) + if err != nil || string(after) != string(raw) { + t.Fatalf("relative worktree link changed: %q, %v", after, err) + } + if got := copyGit(t, local, "branch", "--show-current"); got != "task-relative" { + t.Fatalf("landing branch = %q", got) + } +} diff --git a/internal/taskdoor/review_r8_test.go b/internal/taskdoor/review_r8_test.go new file mode 100644 index 0000000000..61ceb63f13 --- /dev/null +++ b/internal/taskdoor/review_r8_test.go @@ -0,0 +1,118 @@ +package taskdoor_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// R7-3 and contract 12 forbid re-executing an acknowledged attempt, even after restart. +func TestAnAttemptStartedAgainAfterItsAckNeverRunsAgain(t *testing.T) { + r := newRig(t) + stop := r.serve(r.config()) + c := r.dial() + id := attempt(801) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + send(t, c, id, taskdoor.Ack{Disposition: "landed"}) + receive(t, c, "acked") + check := func() { + t.Helper() + send(t, c, id, request()) + var no taskdoor.Refused + decode(t, receive(t, c, "refused"), &no) + if !strings.Contains(no.Reason, "already finished") { + t.Fatalf("reason = %q", no.Reason) + } + if r.executor.calls.Load() != 1 { + t.Fatal("acknowledged task ran again") + } + } + check() + _ = c.Close() + stop() + // A killed cleanup can leave only a directory fragment behind the tombstone. + if err := os.MkdirAll(filepath.Join(r.root, id), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(r.root, id, "cleanup-fragment"), []byte("kept until restart"), 0600); err != nil { + t.Fatal(err) + } + r.serve(r.config()) + c = r.dial() + check() +} + +// R7-4 and contract 13 recheck authority on an already authenticated connection. +func TestARevokedComputerCannotStartWorkOnAConnectionItAlreadyHolds(t *testing.T) { + r := newRig(t) + clock := newClock() + cfg := r.config() + cfg.Clock = clock + r.serve(cfg) + c := r.dial() + r.rosterMu.Lock() + r.active[r.a.ID()] = false + r.rosterMu.Unlock() + clock.advance(taskdoor.RosterFor + time.Second) + send(t, c, attempt(802), request()) + var no taskdoor.Refused + decode(t, receive(t, c, "refused"), &no) + if no.Reason != taskdoor.RemovedComputerLine { + t.Fatalf("reason = %q", no.Reason) + } + if _, err := c.Receive(); err == nil { + t.Fatal("revoked connection stayed open") + } + if r.executor.calls.Load() != 0 { + t.Fatal("revoked device ran work") + } +} + +// R7-4 also fences the live replay road after the bounded permission expires. +func TestARevokedComputerCannotKeepWatchingAnExistingAttempt(t *testing.T) { + r := newRig(t) + clock := newClock() + cfg := r.config() + cfg.Clock = clock + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Phase{Words: "running"}); err != nil { + return err + } + <-ctx.Done() + return ctx.Err() + } + r.serve(cfg) + c := r.dial() + id := attempt(803) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "phase") + r.rosterMu.Lock() + r.active[r.a.ID()] = false + r.rosterMu.Unlock() + // Keep activity current while advancing the permission lifetime, so an + // unrelated keepalive timeout cannot satisfy the revocation check. + for n := 0; n < 3; n++ { + clock.advance(taskdoor.PingEvery) + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "phase") + } + clock.advance(taskdoor.PingEvery) + var no taskdoor.Refused + decode(t, receive(t, c, "refused"), &no) + if no.Reason != taskdoor.RemovedComputerLine { + t.Fatalf("reason = %q", no.Reason) + } + if _, err := c.Receive(); err == nil { + t.Fatal("revoked watcher stayed connected") + } +} diff --git a/internal/taskdoor/security_test.go b/internal/taskdoor/security_test.go new file mode 100644 index 0000000000..040858b2b1 --- /dev/null +++ b/internal/taskdoor/security_test.go @@ -0,0 +1,424 @@ +package taskdoor_test + +import ( + "bytes" + "context" + "crypto/ecdh" + "encoding/binary" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type wireTap struct { + address string + mu sync.Mutex + seen bytes.Buffer + connections []net.Conn +} + +func (w *wireTap) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.seen.Write(p) +} +func (w *wireTap) bytes() []byte { + w.mu.Lock() + defer w.mu.Unlock() + return bytes.Clone(w.seen.Bytes()) +} +func tapRelay(t *testing.T, upstream string) *wireTap { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + tap := &wireTap{address: "http://" + listener.Addr().String()} + go func() { + for { + near, err := listener.Accept() + if err != nil { + return + } + far, err := net.Dial("tcp", strings.TrimPrefix(upstream, "http://")) + if err != nil { + _ = near.Close() + return + } + tap.mu.Lock() + tap.connections = append(tap.connections, near, far) + tap.mu.Unlock() + go func() { _, _ = io.Copy(io.MultiWriter(far, tap), near); _ = far.Close() }() + go func() { _, _ = io.Copy(io.MultiWriter(near, tap), far); _ = near.Close() }() + } + }() + t.Cleanup(tap.drop) + return tap +} + +// Contract 14: recording every byte at the actual relay sockets must reveal +// neither admission text nor streamed work, even while a watch is attached. +func TestTheRelaySeesNoTaskWords(t *testing.T) { + r := newRig(t) + tap := tapRelay(t, r.service) + r.service = tap.address + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Step{Step: run.Step{Kind: "step", Step: 1, Command: "secret step cobalt cabbage"}}); err != nil { + return err + } + if err := j.Append(taskdoor.Phase{Words: "secret phase copper carrots"}); err != nil { + return err + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "secret report salmon squash", Model: "test-model"}) + } + r.serve(r.config()) + c := r.dial() + id := attempt(10) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "step") + receive(t, c, "phase") + receive(t, c, "finished") + traffic := tap.bytes() + if len(traffic) == 0 { + t.Fatal("relay recorder saw no traffic") + } + for _, secret := range []string{request().Instruction, request().Title, "secret step cobalt cabbage", "secret phase copper carrots", "secret report salmon squash"} { + if bytes.Contains(traffic, []byte(secret)) { + t.Fatalf("relay read %q", secret) + } + } +} + +func rawTunnel(t *testing.T, r *rig, key *ecdh.PrivateKey) *pair.Tunnel { + t.Helper() + name, err := taskdoor.DoorName(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + stream, err := relay.Dial(context.Background(), r.service, name) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = stream.Close() }) + target, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + tunnel, err := pair.ConnectWithKey(stream, target.PublicKey().Bytes(), key) + if err != nil { + t.Fatal(err) + } + return tunnel +} +func rawSend(t *testing.T, w io.Writer, id string, payload any) { + t.Helper() + frame, err := taskdoor.NewFrame(id, payload) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.WriteFrame(w, frame); err != nil { + t.Fatal(err) + } +} +func rawReceive(t *testing.T, r io.Reader, kind string) taskdoor.Frame { + t.Helper() + type result struct { + f taskdoor.Frame + err error + } + done := make(chan result, 1) + go func() { f, err := taskdoor.ReadFrame(r); done <- result{f, err} }() + select { + case result := <-done: + if result.err != nil { + t.Fatal(result.err) + } + if result.f.Type != kind { + t.Fatalf("frame %s, want %s", result.f.Type, kind) + } + return result.f + case <-time.After(5 * time.Second): + t.Fatalf("no raw %s frame", kind) + } + return taskdoor.Frame{} +} +func rawHello(t *testing.T, tunnel *pair.Tunnel, dev identity.Dev) { + t.Helper() + rawSend(t, tunnel, "", taskdoor.Hello{V: 1, Cert: dev.Cert, Sig: dev.Sign(tunnel.ChannelBinding())}) + rawReceive(t, tunnel, "hello") +} + +// Contract 13's authentication boundary includes certificates from another +// identity, a device impersonating a different static, and old-channel proofs. +func TestAHelloFromAnotherIdentityIsRefused(t *testing.T) { + for _, kind := range []string{"another identity", "another static", "another channel"} { + t.Run(kind, func(t *testing.T) { + r := newRig(t) + r.serve(r.config()) + id, dev := r.id, r.a + if kind == "another identity" { + var err error + id, err = identity.Mint() + if err != nil { + t.Fatal(err) + } + dev, err = identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + } + key, err := taskdoor.DoorKey(id, dev.ID()) + if err != nil { + t.Fatal(err) + } + if kind == "another static" { + key, err = taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + } + tunnel := rawTunnel(t, r, key) + binding := tunnel.ChannelBinding() + if kind == "another channel" { + binding = bytes.Repeat([]byte{1}, len(binding)) + } + rawSend(t, tunnel, "", taskdoor.Hello{V: 1, Cert: dev.Cert, Sig: dev.Sign(binding)}) + var refused taskdoor.Refused + decode(t, rawReceive(t, tunnel, "refused"), &refused) + if refused.Reason != "could not confirm this computer" { + t.Fatalf("authentication failure = %q", refused.Reason) + } + if r.executor.calls.Load() != 0 { + t.Fatal("unauthorized hello started work") + } + }) + } +} + +// Contract 8 requires distinct actionable errors for a different build and a +// valid certificate that belongs to a computer other than the selected target. +func TestTheChosenComputerAndWireVersionAreChecked(t *testing.T) { + for _, kind := range []string{"wrong computer", "wrong version", "wrong signature"} { + t.Run(kind, func(t *testing.T) { + r := newRig(t) + key, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + registration, err := relay.Register(ctx, r.service, key) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + done := make(chan struct{}) + go func() { + defer close(done) + stream, err := registration.Accept() + if err != nil { + return + } + defer stream.Close() + tunnel, err := pair.AcceptWithKey(stream, key) + if err != nil { + return + } + if _, err := taskdoor.ReadFrame(tunnel); err != nil { + return + } + dev := r.b + if kind == "wrong computer" { + dev = r.a + } + hello := taskdoor.Hello{V: 1, Cert: dev.Cert, Sig: dev.Sign(tunnel.ChannelBinding())} + if kind == "wrong version" { + hello.V = 2 + } + if kind == "wrong signature" { + hello.Sig = bytes.Repeat([]byte{1}, 64) + } + frame, err := taskdoor.NewFrame("", hello) + if err == nil { + _ = taskdoor.WriteFrame(tunnel, frame) + } + _, _ = taskdoor.ReadFrame(tunnel) + }() + c, err := taskdoor.Dial(ctx, r.service, r.id, r.a, r.b.ID(), "chosen computer") + if c != nil { + _ = c.Close() + } + want := "could not confirm this is chosen computer" + if kind == "wrong version" { + want = "chosen computer runs a different codeaf that cannot take this task — update both" + } + if err == nil || err.Error() != want { + t.Fatalf("heard %v, want %s", err, want) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("refused peer stayed open") + } + }) + } +} + +// Contract 14's wire boundary caps memory before reading a claimed payload. +func TestFramesRefuseAnOversizedLengthBeforeReadingItsBody(t *testing.T) { + raw := binary.BigEndian.AppendUint32(nil, 1<<20+1) + if _, err := taskdoor.ReadFrame(bytes.NewReader(raw)); err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("oversized header returned %v", err) + } + _, err := taskdoor.NewFrame(attempt(1), taskdoor.Phase{Words: strings.Repeat("x", 1<<20)}) + if err == nil { + t.Fatal("oversized outgoing frame accepted") + } +} + +func (tap *wireTap) drop() { + tap.mu.Lock() + connections := tap.connections + tap.connections = nil + tap.mu.Unlock() + for _, connection := range connections { + _ = connection.Close() + } +} + +// Contract 9's carrier loss must leave the executor running while the shared +// registration loop emits one lost/back pair and reopens the same task door. +func TestATaskDoorComesBackWithoutRestartingItsAttempt(t *testing.T) { + r := newRig(t) + var unavailable atomic.Bool + service := &relay.Server{} + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if unavailable.Load() { + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + return + } + service.ServeHTTP(w, request) + })) + t.Cleanup(front.Close) + tap := tapRelay(t, front.URL) + r.service = tap.address + finish := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Phase{Words: "running"}); err != nil { + return err + } + select { + case <-finish: + return j.Append(taskdoor.Finished{Outcome: "done", Report: "continued through the outage"}) + case <-ctx.Done(): + return ctx.Err() + } + } + lines := make(chan string, 32) + config := r.config() + config.Say = func(line string) { lines <- line } + r.serve(config) + c := r.dial() + id := attempt(40) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "phase") + unavailable.Store(true) + tap.drop() + name, err := taskdoor.DoorName(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + lost, back := 0, 0 + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for back == 0 { + select { + case line := <-lines: + if line == pair.LostSyncLine { + lost++ + unavailable.Store(false) + } + if line == pair.BackLine(name) { + back++ + } + case <-deadline.C: + t.Fatal("task door did not say it came back") + } + } + if lost != 1 { + t.Fatalf("outage emitted %d lost lines", lost) + } + _ = c.Close() + c = r.dial() + send(t, c, id, taskdoor.Watch{From: 1}) + close(finish) + frame := receive(t, c, "finished") + if frame.Seq != 2 { + t.Fatalf("resumed finish seq = %d", frame.Seq) + } + if r.executor.calls.Load() != 1 || r.executor.adopts.Load() != 0 { + t.Fatalf("outage ran/adopted %d/%d", r.executor.calls.Load(), r.executor.adopts.Load()) + } +} + +// Contract 8 bounds a registered but silent door as well as an absent one; +// the timeout covers Noise and hello together, not just connecting the relay. +func TestARegisteredDoorThatDoesNotAnswerHasTheServingSentence(t *testing.T) { + r := newRig(t) + key, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + registration, err := relay.Register(ctx, r.service, key) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + done := make(chan struct{}) + go func() { + defer close(done) + stream, err := registration.Accept() + if err != nil { + return + } + defer stream.Close() + <-ctx.Done() + }() + since := time.Now() + conn, err := taskdoor.Dial(ctx, r.service, r.id, r.a, r.b.ID(), "work computer") + if conn != nil { + _ = conn.Close() + } + if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" { + t.Fatalf("silent door heard %v", err) + } + // Scheduling adds a small observation margin to the ten-second I/O bound. + if elapsed := time.Since(since); elapsed > 11*time.Second { + t.Fatalf("silent dial took %s", elapsed) + } + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("silent fixture did not close") + } +} diff --git a/internal/taskdoor/serve.go b/internal/taskdoor/serve.go new file mode 100644 index 0000000000..ba1fa8e1ca --- /dev/null +++ b/internal/taskdoor/serve.go @@ -0,0 +1,375 @@ +package taskdoor + +import ( + "context" + "crypto/ecdh" + "errors" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" +) + +type Config struct { + Service string + Identity identity.Identity + Device identity.Dev + Roster func(context.Context) (map[string]bool, error) + Root string + Executor Executor + Say func(string) + Clock Clock + // Home is the profile whose chosen computer name admission uses. Empty + // uses the configured codeaf home, independently of the attempt store root. + Home string +} + +// Serve holds this device's task door beside the separate machine door. Runs +// are children of Serve's lifetime, while watches are children of a connection; +// losing one watch therefore cannot stop the work it was following. +func Serve(ctx context.Context, config Config) error { + if config.Service == "" || config.Roster == nil || config.Executor == nil { + return errors.New("taskdoor: a door needs a relay, roster and executor") + } + if config.Device.Cert.Verify(config.Identity.PublicKey()) != nil { + return errors.New("taskdoor: this device does not belong to the identity") + } + if config.Root == "" { + return errors.New("taskdoor: an attempt store folder is required") + } + root, err := filepath.Abs(config.Root) + if err != nil { + return err + } + config.Root = root + if err := os.MkdirAll(config.Root, 0o700); err != nil { + return err + } + lock, err := os.OpenFile(filepath.Join(config.Root, ".lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lock.Close() + if err := filelock.Lock(lock, true, true); err != nil { + return errors.New("this computer is already taking tasks") + } + defer filelock.Unlock(lock) + clock := clockOrWall(config.Clock) + store, err := openAttempts(config.Root, clock) + if err != nil { + return err + } + key, err := DoorKey(config.Identity, config.Device.ID()) + if err != nil { + return err + } + if config.Home == "" { + config.Home = home.Dir() + } + server := &door{config: config, store: store, clock: clock, name: devname.Name(config.Home), roster: &rosterCache{fetch: config.Roster, clock: clock}} + runCtx, cancel := context.WithCancel(ctx) + defer server.workers.Wait() + // Defer order matters: workers must see cancellation before Serve waits, + // including when the registration fails for a reason other than shutdown. + defer cancel() + for _, a := range store.running() { + server.execute(runCtx, a, true) + } + announced := false + return (®istrationLoop{ + Service: config.Service, Key: key, Say: config.Say, Now: clock.Now, + Wait: func(ctx context.Context, d time.Duration) error { + ticker := clock.NewTicker(d) + defer ticker.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C(): + return nil + } + }, + Ready: func(*relay.Registration) { + if !announced { + announced = true + server.say(TakingTasksLine) + } + }, + Hold: func(ctx context.Context, r *relay.Registration) { server.hold(runCtx, r, key) }, + }).Run(ctx) +} + +type door struct { + config Config + store *attemptStore + roster *rosterCache + clock Clock + name string + workers sync.WaitGroup +} + +func (d *door) say(line string) { + if d.config.Say != nil { + d.config.Say(line) + } +} + +func (d *door) hold(ctx context.Context, registration *relay.Registration, key *ecdh.PrivateKey) { + stop := context.AfterFunc(ctx, func() { _ = registration.Close() }) + defer stop() + var connections sync.WaitGroup + defer connections.Wait() + defer registration.Close() + for { + stream, err := registration.Accept() + if err != nil { + return + } + connections.Add(1) + go func() { defer connections.Done(); d.answer(ctx, stream, key) }() + } +} +func (d *door) answer(ctx context.Context, stream io.ReadWriteCloser, key *ecdh.PrivateKey) { + defer stream.Close() + setDeadline(stream, time.Now().Add(pair.HandshakeWithin)) + handshakeCtx, endHandshake := context.WithTimeout(ctx, pair.HandshakeWithin) + defer endHandshake() + stop := context.AfterFunc(ctx, func() { _ = stream.Close() }) + defer stop() + tunnel, err := pair.AcceptWithKey(stream, key) + if err != nil { + return + } + device, err := acceptHello(handshakeCtx, tunnel, d.config.Identity, d.config.Device, d.roster, d.name) + if err != nil { + return + } + endHandshake() + setDeadline(stream, time.Time{}) + conn := newConn(ctx, tunnel, d.clock) + var watch *doorWatch + // The connection closes before waiting for watches, so a blocked event + // write and a waiting replay are both released on every return path. + defer func() { _ = conn.Close(); watch.close() }() + for { + frame, err := conn.Receive() + if err != nil { + return + } + if !d.allowConnection(ctx, conn, device, frame.Attempt) { + return + } + reply := func(payload any) error { return conn.Send(frame.Attempt, payload) } + switch frame.Type { + case "start": + var request Start + if err := frame.Decode(&request); err != nil { + _ = reply(Refused{Reason: "that task request is not readable"}) + continue + } + a, fresh, err := d.store.admit(frame.Attempt, request, Busy(d.name)) + if err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + // Run is launched after the durable admission even if the origin drops + // before it can hear started. Retrying then finds the same attempt. + if fresh { + d.execute(ctx, a, false) + } + if reply(Started{State: a.State()}) != nil { + return + } + case "watch": + var request Watch + if frame.Decode(&request) != nil { + return + } + a := d.store.get(frame.Attempt) + if a == nil { + if reply(Unknown{}) != nil { + return + } + continue + } + watch.close() + conn.startWatch() + watch = d.startWatch(ctx, conn, a, request.From, device) + case "stop": + if d.store.get(frame.Attempt) == nil { + if reply(Unknown{}) != nil { + return + } + continue + } + if err := d.config.Executor.Stop(frame.Attempt); err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + if reply(Stopping{}) != nil { + return + } + case "ack": + var ack Ack + if frame.Decode(&ack) != nil || (ack.Disposition != "landed" && ack.Disposition != "abandoned") { + if reply(Refused{Reason: "choose landed or abandoned for this task"}) != nil { + return + } + continue + } + if a := d.store.get(frame.Attempt); a != nil && a.State().Phase == "running" { + if err := d.config.Executor.Stop(frame.Attempt); err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + } + watch.close() + watch = nil + conn.watching.Store(false) + if err := d.store.forget(frame.Attempt); err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + if reply(Acked{}) != nil { + return + } + default: + return + } + } +} +func (d *door) execute(ctx context.Context, a *Attempt, adopt bool) { + d.workers.Add(1) + go func() { + defer d.workers.Done() + defer func() { + if ctx.Err() == nil { + d.store.released(a) + } + }() + journal := attemptJournal{a: a} + var err error + if adopt { + err = d.config.Executor.Adopt(ctx, a, journal) + } else { + err = d.config.Executor.Run(ctx, a, journal) + } + if ctx.Err() != nil { + return + } + if a.State().Phase != "running" { + return + } + report := "the task ended without a result" + if err != nil { + report = err.Error() + } + outcome := "incomplete" + if adopt { + outcome = "interrupted" + } + if err := journal.Append(Finished{Outcome: outcome, Report: report}); err != nil && !errors.Is(err, os.ErrNotExist) { + d.say("could not save the task result: " + err.Error()) + } + }() +} +func (d *door) watch(ctx context.Context, conn *Conn, a *Attempt, from uint64, device string) { + check := d.clock.NewTicker(time.Second) + defer check.Stop() + for { + if !d.allowConnection(ctx, conn, device, a.ID) { + return + } + frames, changed, finished, err := a.after(from) + if err != nil { + _ = conn.Send(a.ID, Unknown{}) + return + } + for _, frame := range frames { + if !d.allowConnection(ctx, conn, device, a.ID) { + return + } + select { + case <-ctx.Done(): + return + default: + } + if err := conn.sendFrame(frame); err != nil { + return + } + from = frame.Seq + } + if finished { + conn.watching.Store(false) + return + } + waiting: + for { + select { + case <-ctx.Done(): + return + case <-conn.done: + return + case <-changed: + break waiting + case <-check.C(): + // Permission refresh must not turn a quiet watch into a journal scan every second. + if !d.allowConnection(ctx, conn, device, a.ID) { + return + } + } + } + } +} + +// doorWatch joins a replaced watch before opening its successor, so two +// replay goroutines can never interleave events on one connection. +type doorWatch struct { + cancel context.CancelFunc + done chan struct{} +} + +func (w *doorWatch) close() { + if w != nil { + w.cancel() + <-w.done + } +} +func (d *door) startWatch(ctx context.Context, conn *Conn, a *Attempt, from uint64, device string) *doorWatch { + ctx, cancel := context.WithCancel(ctx) + watch := &doorWatch{cancel: cancel, done: make(chan struct{})} + go func() { defer close(watch.done); defer cancel(); d.watch(ctx, conn, a, from, device) }() + return watch +} + +// A handshake establishes identity; the bounded roster still owns continuing permission. +func (d *door) allowConnection(ctx context.Context, conn *Conn, device, attempt string) bool { + active, err := d.roster.allows(ctx, device) + reason := "" + if err != nil { + reason = "could not check your devices right now" + } else if !active { + reason = RemovedComputerLine + } + if reason == "" { + return true + } + _ = conn.Send(attempt, Refused{Reason: reason}) + _ = conn.Close() + return false +} diff --git a/internal/taskplace/computers.go b/internal/taskplace/computers.go new file mode 100644 index 0000000000..e71abd3134 --- /dev/null +++ b/internal/taskplace/computers.go @@ -0,0 +1,95 @@ +// Package taskplace adapts paired task doors to the conversation's run engine. +package taskplace + +import ( + "context" + "errors" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Computers uses the same identity roster as pairing. The separate machine +// access book is deliberately uninvolved: pairing is the task permission. +type Computers struct { + Directory directory.Client + Copies taskdoor.CopyDeps + Home string + Clock session.TaskWatchClock +} + +func (c *Computers) ThisComputer() string { return devname.Name(c.Home) } +func (c *Computers) List(ctx context.Context) ([]session.TaskComputer, error) { + listing, err := c.Directory.List(ctx) + if err != nil { + return nil, err + } + key := directory.MetadataKey(c.Copies.Identity.CellKey()) + var out []session.TaskComputer + for id, device := range listing.Devices { + if device.Revoked || id == c.Copies.Device.ID() { + continue + } + name, _ := directory.OpenName(key, device.Name) + if strings.TrimSpace(name) == "" { + name = "a device" + } + row := session.TaskComputer{Device: id, Name: devname.Shown(name), CanTakeTasks: device.Platform != "windows"} + if !row.CanTakeTasks { + row.Reason = UnsupportedTarget(row.Name) + } + out = append(out, row) + } + out = session.TaskComputerNames(out, c.ThisComputer()) + for i := range out { + if !out[i].CanTakeTasks { + out[i].Reason = UnsupportedTarget(out[i].Name) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Name == out[j].Name { + return out[i].Device < out[j].Device + } + return out[i].Name < out[j].Name + }) + return out, nil +} +func UnsupportedTarget(name string) string { + return name + " cannot take tasks from other computers yet" +} +func (c *Computers) Engine(ctx context.Context, device string) (session.TaskRunEngine, error) { + computers, err := c.List(ctx) + if err != nil { + return nil, err + } + for _, computer := range computers { + if computer.Device == device { + if !computer.CanTakeTasks { + return nil, errors.New(computer.Reason) + } + return c.RecoverEngine(session.TaskExecutor{Device: computer.Device, Name: computer.Name}), nil + } + } + return nil, errors.New(taskdoor.RemovedComputerLine) +} + +// RecoverEngine needs only the saved executor to install an offline watcher or +// compare the local copy. Each dial checks current roster permission separately. +func (c *Computers) RecoverEngine(executor session.TaskExecutor) session.TaskRunEngine { + return &Engine{Copies: c.Copies, Computer: session.TaskComputer{Device: executor.Device, Name: executor.Name, CanTakeTasks: true}, OriginName: c.ThisComputer(), Clock: c.Clock, Allowed: func(ctx context.Context) error { + computers, err := c.List(ctx) + if err != nil { + return err + } + for _, computer := range computers { + if computer.Device == executor.Device && computer.CanTakeTasks { + return nil + } + } + return errors.New(taskdoor.RemovedComputerLine) + }} +} diff --git a/internal/taskplace/computers_r8_test.go b/internal/taskplace/computers_r8_test.go new file mode 100644 index 0000000000..2762f62d79 --- /dev/null +++ b/internal/taskplace/computers_r8_test.go @@ -0,0 +1,46 @@ +package taskplace_test + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +type namedComputerDirectory struct { + directory.Client + rows directory.Listing +} + +func (d namedComputerDirectory) List(context.Context) (directory.Listing, error) { return d.rows, nil } + +// R7-12 lists the same labels used to distinguish devices on the devices page. +func TestPairedComputersWithTheSameNameUseDeviceLabels(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + device, err := identity.LoadDevice(home) + if err != nil { + t.Fatal(err) + } + sealed, err := directory.SealName(directory.MetadataKey(id.CellKey()), "mac mini.local") + if err != nil { + t.Fatal(err) + } + c := taskplace.Computers{Home: home, Copies: taskdoor.CopyDeps{Identity: id, Device: device}, Directory: namedComputerDirectory{rows: directory.Listing{Devices: map[string]directory.Device{ + "dev_0123456789abcdef0123456789ab1111": {Name: sealed, Platform: "linux"}, + "dev_0123456789abcdef0123456789ab2222": {Name: sealed, Platform: "darwin"}, + }}}} + rows, err := c.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].Name != "mac mini #1111" || rows[1].Name != "mac mini #2222" { + t.Fatalf("computers = %+v", rows) + } +} diff --git a/internal/taskplace/engine.go b/internal/taskplace/engine.go new file mode 100644 index 0000000000..925c20031b --- /dev/null +++ b/internal/taskplace/engine.go @@ -0,0 +1,354 @@ +package taskplace + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Engine owns the out/run/back road. It never runs tools on the origin; the +// session retains copy preparation, ledger ownership and local landing. +type Engine struct { + Copies taskdoor.CopyDeps + Computer session.TaskComputer + OriginName string + Clock session.TaskWatchClock + Allowed func(context.Context) error +} + +func (e *Engine) dial(ctx context.Context) (*taskdoor.Conn, error) { + if e.Allowed != nil { + if err := e.Allowed(ctx); err != nil { + return nil, err + } + } + return taskdoor.Dial(ctx, e.Copies.Service, e.Copies.Identity, e.Copies.Device, e.Computer.Device, e.Computer.Name) +} +func (e *Engine) Land(context.Context, *plandb.Store, string, string, string) (session.RunLanding, error) { + return session.RunLanding{}, errors.New("the returned task copy must be landed by this computer") +} + +func (e *Engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummary { + record := session.TaskRemoteRecord{Executor: session.TaskExecutor{Device: e.Computer.Device, Name: e.Computer.Name}} + if spec.Remote != nil { + record = *spec.Remote + } + stopped := func() bool { return spec.RemoteStopped != nil && spec.RemoteStopped() } + if stopped() { + return session.RunSummary{Outcome: "stopped", KeepCopy: true, StorageSafe: true} + } + input, err := taskdoor.SendCopy(ctx, e.Copies, spec.Workspace) + if err != nil { + record.Fingerprint, _ = taskdoor.CopyFingerprint(ctx, spec.Workspace) + return e.startFailed(spec, record, err) + } + record.Fingerprint = input.Fingerprint + var nonce [8]byte + if _, err := rand.Read(nonce[:]); err != nil { + return e.startFailed(spec, record, err) + } + record.Attempt = "att_" + hex.EncodeToString(nonce[:]) + // Record before start as well as watch: a lost started reply is an uncertain + // attempt, and recovery must ask for it rather than launch another child. + if err := save(spec, record, true); err != nil { + return failed(err) + } + conn, err := e.dial(ctx) + if err != nil { + return e.startFailed(spec, record, err) + } + defer conn.Close() + if stopped() { + return session.RunSummary{Outcome: "stopped", KeepCopy: true, StorageSafe: true} + } + request := taskdoor.Start{Chat: spec.Conversation, Task: int(spec.TaskNumber), Title: spec.Title, Instruction: spec.Brief, Model: spec.NamedModel, Effort: spec.NamedEffort, MaxSteps: spec.StepsPerTask, NoProgress: spec.NoProgress, Input: input, Limits: taskdoor.Limits{USD: spec.CostUSD, Hours: spec.Elapsed.Hours()}, OriginName: e.OriginName} + startCtx, cancel := context.WithTimeout(ctx, taskdoor.DialWithin) + stop := context.AfterFunc(startCtx, func() { _ = conn.Close() }) + err = conn.Send(record.Attempt, request) + var frame taskdoor.Frame + if err == nil { + frame, err = conn.Receive() + } + stop() + cancel() + if err != nil { + return e.startFailed(spec, record, err) + } + if frame.Type != "started" { + var no taskdoor.Refused + if frame.Type == "refused" && frame.Decode(&no) == nil && no.Reason != "" { + return e.startFailed(spec, record, errors.New(no.Reason)) + } + return e.startFailed(spec, record, errors.New(taskdoor.DifferentVersion(e.Computer.Name))) + } + if stopped() { + stopCtx, cancel := context.WithTimeout(context.Background(), taskdoor.DialWithin) + _ = e.Stop(stopCtx, record) + cancel() + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(session.TaskRunningOn(e.Computer.Name)) + } + _ = conn.Close() + return e.follow(ctx, spec, record) +} +func save(spec session.RunSpec, record session.TaskRemoteRecord, force bool) error { + if spec.SaveRemote != nil { + return spec.SaveRemote(record, force) + } + return errors.New("the task's remote record cannot be saved here") +} +func failed(err error) session.RunSummary { + return session.RunSummary{Outcome: "incomplete", Result: err.Error(), KeepCopy: true} +} +func (e *Engine) startFailed(spec session.RunSpec, record session.TaskRemoteRecord, err error) session.RunSummary { + record.StartFailed = true + if saveErr := save(spec, record, true); saveErr != nil { + return failed(saveErr) + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(err.Error() + " — run it here") + } + return failed(err) +} +func (e *Engine) Resume(ctx context.Context, spec session.RunSpec) session.RunSummary { + if spec.Remote == nil { + return failed(errors.New("the task's remote record is missing")) + } + if spec.Remote.Abandoned { + return failed(session.ErrTaskAttemptAbandoned) + } + return e.follow(ctx, spec, *spec.Remote) +} +func (e *Engine) follow(ctx context.Context, spec session.RunSpec, record session.TaskRemoteRecord) session.RunSummary { + var final taskdoor.Finished + if len(record.Final) > 0 { + if err := json.Unmarshal(record.Final, &final); err != nil { + return failed(err) + } + } else { + var current taskdoor.Frame + attempt := record.Attempt + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + conn, err := e.dial(ctx) + if err != nil { + return err + } + defer conn.Close() + if err = conn.Send(attempt, taskdoor.Watch{From: from}); err != nil { + return err + } + alive() + // Conn consumes ping/pong itself. Poll its actual activity rather than + // mistaking a quiet model call for a computer that stopped answering. + keepCtx, cancel := context.WithCancel(ctx) + keepDone := make(chan struct{}) + defer func() { cancel(); <-keepDone }() + go func() { + defer close(keepDone) + tick := time.NewTicker(time.Second) + defer tick.Stop() + last := conn.LastActivity() + for { + select { + case <-keepCtx.Done(): + return + case <-tick.C: + now := conn.LastActivity() + if now.After(last) { + last = now + alive() + } + } + } + }() + for { + frame, err := conn.Receive() + if err != nil { + return err + } + alive() + if frame.Attempt != attempt { + continue + } + if frame.Type == "unknown" { + current = frame + if err := deliver(from + 1); err != nil { + return err + } + return session.ErrTaskWatchEnded + } + if frame.Seq <= from { + continue + } + current = frame + if err = deliver(frame.Seq); err != nil { + return err + } + if frame.Type == "finished" { + return session.ErrTaskWatchEnded + } + } + } + consume := func(seq uint64) error { + record.Unreachable = false + switch current.Type { + case "unknown": + final = taskdoor.Finished{Outcome: "interrupted", Report: "the other computer no longer has this task's answer"} + record.Final, _ = json.Marshal(final) + record.Finished = true + case "step": + var event taskdoor.Step + if err := current.Decode(&event); err != nil { + return err + } + event.Step.FullOutput = "" + // Only the root trajectory travels; B's child ids name no local rows. + event.Step.Children = nil + raw, _ := json.Marshal(event.Step) + if spec.OnRemoteStep != nil { + if err := spec.OnRemoteStep(seq, raw); err != nil { + return err + } + } + case "spend": + var event taskdoor.Spend + if err := current.Decode(&event); err != nil { + return err + } + line := event.Line + charge := session.RunCharge{Model: line.Model, TokensIn: line.InputTokens, TokensOut: line.OutputTokens, Cached: line.CachedTokens, USD: line.USD} + if spec.OnRemoteCharge != nil { + if err := spec.OnRemoteCharge(seq, charge, line.Seat); err != nil { + return err + } + } + record.USD += line.USD + case "phase": + var phase taskdoor.Phase + if err := current.Decode(&phase); err != nil { + return err + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(phase.Words) + } + case "finished": + if err := current.Decode(&final); err != nil { + return err + } + record.Final, _ = json.Marshal(final) + record.Finished = true + default: + return fmt.Errorf("the other computer sent an unreadable task event") + } + record.Seq = seq + return save(spec, record, current.Type == "finished") + } + reach := func(missing bool) { + record.Unreachable = missing + _ = save(spec, record, true) + if spec.OnRemotePhase != nil { + words := session.TaskRunningOn(e.Computer.Name) + if missing { + words = session.TaskComputerStoppedAnswering(e.Computer.Name) + } + spec.OnRemotePhase(words) + } + } + if err := session.FollowTaskWatch(ctx, e.Clock, record.Seq, watch, consume, reach); err != nil { + _ = save(spec, record, true) + return session.RunSummary{Outcome: "interrupted", Result: err.Error(), USD: record.USD, KeepCopy: true} + } + } + if final.Result == nil && final.Outcome != "interrupted" { + return session.RunSummary{Outcome: "incomplete", Result: final.Report + "\nThe result could not be brought from " + e.Computer.Name + "; the copy here was kept", USD: record.USD, KeepCopy: true, NeedsDecision: true} + } + if final.Result != nil && !record.Restored && final.Outcome != "interrupted" { + prepared, err := taskdoor.PrepareResult(ctx, e.Copies, *final.Result, spec.Workspace, record.Fingerprint) + if err == nil { + defer prepared.Close() + apply := func() error { return prepared.Install(ctx) } + if spec.WithRemoteCopy != nil { + err = spec.WithRemoteCopy(apply) + } else { + err = apply() + } + } + if err != nil { + summary := failed(err) + summary.USD = record.USD + summary.NeedsDecision = errors.Is(err, taskdoor.ErrCopyChanged) + return summary + } + record.Restored = true + } + record.Finished = true + record.Unreachable = false + if err := save(spec, record, true); err != nil { + return failed(err) + } + + outcome := final.Outcome + if outcome == "interrupted" && final.Result != nil { + final.Report += "\nThe partial work stayed on the other computer; run it here starts from the sent copy." + } + switch outcome { + case "done": + _ = spec.Store.CompleteRoot(final.Report) + case "stopped": + _ = spec.Store.StopRoot(outcome) + case "incomplete", "interrupted": + _ = spec.Store.EndRoot(outcome) + default: + outcome = "incomplete" + _ = spec.Store.EndRoot(outcome) + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(session.TaskRanOn(e.Computer.Name)) + } + return session.RunSummary{Outcome: outcome, Result: final.Report, Model: final.Model, Nodes: 1, USD: record.USD, StorageSafe: true, KeepCopy: outcome == "interrupted"} +} +func (e *Engine) control(ctx context.Context, record session.TaskRemoteRecord, payload any) error { + if record.Attempt == "" { + return nil + } + conn, err := e.dial(ctx) + if err != nil { + return err + } + defer conn.Close() + if err = conn.Send(record.Attempt, payload); err != nil { + return err + } + frame, err := conn.Receive() + if err != nil { + return err + } + if frame.Type == "refused" { + var no taskdoor.Refused + _ = frame.Decode(&no) + return errors.New(no.Reason) + } + return nil +} +func (e *Engine) Stop(ctx context.Context, r session.TaskRemoteRecord) error { + return e.control(ctx, r, taskdoor.Stop{}) +} +func (e *Engine) Abandon(ctx context.Context, r session.TaskRemoteRecord) error { + _ = e.Stop(ctx, r) + return e.control(ctx, r, taskdoor.Ack{Disposition: "abandoned"}) +} +func (e *Engine) Acknowledge(ctx context.Context, r session.TaskRemoteRecord) error { + return e.control(ctx, r, taskdoor.Ack{Disposition: "landed"}) +} +func (e *Engine) CheckCopy(ctx context.Context, workspace, fingerprint string) error { + return taskdoor.CheckCopy(ctx, workspace, fingerprint) +} diff --git a/internal/taskplace/engine_fixture_test.go b/internal/taskplace/engine_fixture_test.go new file mode 100644 index 0000000000..d7d40a909a --- /dev/null +++ b/internal/taskplace/engine_fixture_test.go @@ -0,0 +1,30 @@ +//go:build linux || darwin + +package taskplace_test + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// taskEngineBinary keeps a source-only CI checkout useful without replacing the real engine. +func taskEngineBinary(t *testing.T) string { + t.Helper() + binary, configured, err := furrowbin.ConfiguredBinary() + if err != nil { + t.Fatal(err) + } + if configured { + return binary + } + binary, err = furrowbin.Ensure() + if errors.Is(err, furrowbin.ErrNotEmbedded) { + t.Skip("task-copy proof needs a real engine: run make build first, or set " + furrowbin.BinaryEnvVar) + } + if err != nil { + t.Fatal(err) + } + return binary +} diff --git a/internal/taskplace/engine_test.go b/internal/taskplace/engine_test.go new file mode 100644 index 0000000000..755d447592 --- /dev/null +++ b/internal/taskplace/engine_test.go @@ -0,0 +1,253 @@ +//go:build linux || darwin + +package taskplace_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +// The helper is a real isolated child driven only by B's injected process +// seam. A transcript sentinel on A is never among its task-copy inputs. +func TestOtherHomeTaskChild(t *testing.T) { + if os.Getenv("CODEAF_OTHER_HOME_CHILD") != "1" { + return + } + value := func(flag string) string { + for i, arg := range os.Args { + if arg == flag && i+1 < len(os.Args) { + return os.Args[i+1] + } + } + return "" + } + work, record := value("--dir"), value("--task-store") + if work == "" || record == "" { + os.Exit(1) + } + var request taskdoor.Start + raw, err := os.ReadFile(filepath.Join(filepath.Dir(record), "request.json")) + if err != nil || json.Unmarshal(raw, &request) != nil || request.Instruction != "settled instruction with acceptance and checks" || request.Chat != "chat-on-A" || request.Task != 1 || request.Model != "B/model" || request.Effort != "cheap" || request.MaxSteps != 17 || request.NoProgress != 9 || request.Limits.USD != .75 || request.Limits.Hours != .5 || request.OriginName != "chat computer" { + os.Exit(2) + } + if value("--model") != "B/model" { + os.Exit(6) + } + cheap := false + for _, arg := range os.Args { + if arg == "--cheap" { + cheap = true + } + } + if !cheap { + os.Exit(7) + } + if _, err := os.Stat(filepath.Join(work, "conversation-only.txt")); !os.IsNotExist(err) { + os.Exit(3) + } + dir := filepath.Join(record, "tasks", "1") + _ = os.MkdirAll(dir, 0700) + file, _ := os.Create(filepath.Join(dir, "trajectory.jsonl")) + enc := json.NewEncoder(file) + _ = enc.Encode(run.Step{Kind: "step", Step: 1, Command: "inspect on B", Observation: "first", RecordedOrder: 1, FullOutput: filepath.Join(record, "private-output")}) + _ = enc.Encode(run.Step{Kind: "step", Step: 2, Command: "write on B", Observation: "second", RecordedOrder: 2}) + _ = file.Close() + // The child cannot finish until A has actually consumed both live steps. + // Buffering the trajectory until finished would therefore fail this proof. + gate := os.Getenv("CODEAF_OTHER_HOME_GATE") + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(gate); err == nil { + break + } + if time.Now().After(deadline) { + os.Exit(8) + } + time.Sleep(10 * time.Millisecond) + } + if err := os.WriteFile(filepath.Join(work, "made-on-B.txt"), []byte("B's child wrote this\n"), 0644); err != nil { + os.Exit(4) + } + gitChild := func(args ...string) { + cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=task test", "-c", "user.email=task@example.test"}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintln(os.Stderr, string(out)) + os.Exit(5) + } + } + gitChild("add", "made-on-B.txt") + gitChild("commit", "-qm", "change made on B") + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": true, "answer": "the change is made on B", "model": "B/model", "files": []string{"made-on-B.txt"}}) + os.Exit(0) +} + +// Contracts 1–3 use the real relay, authenticated door, child executor and +// encrypted copy transport. No remote engine or filesystem transfer is faked. +func TestATaskSentToTheOtherHomeRunsThereAndItsChangeComesBack(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + shortHome := func(prefix string) string { + path, err := os.MkdirTemp("", prefix) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(path) }) + return path + } + homeA, homeB := shortHome("rn-l1-a-"), shortHome("rn-l1-b-") + t.Setenv("HOME", homeA) + t.Setenv("CODEAF_HOME", homeA) + id, err := identity.Ensure(homeA) + if err != nil { + t.Fatal(err) + } + a, err := identity.LoadDevice(homeA) + if err != nil { + t.Fatal(err) + } + b, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + if err := identity.Install(homeB, id, b); err != nil { + t.Fatal(err) + } + _, _ = devname.Set(homeB, "work computer") + relayServer := httptest.NewServer(&relay.Server{}) + defer relayServer.Close() + defer relayServer.CloseClientConnections() + memory := blobstore.NewMemory() + blobs := httptest.NewServer(blobstore.Handler(func(*http.Request, []byte) (string, string, error) { return id.ID(), a.ID(), nil }, func(string) (blobstore.Store, error) { return memory, nil })) + defer blobs.Close() + bin := taskEngineBinary(t) + makeCopy := func(home string, device identity.Dev) taskdoor.CopyDeps { + socketHome, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + daemon := cellstore.Daemon{Binary: bin, Socket: filepath.Join(socketHome, "e.sock")} + t.Cleanup(func() { _ = daemon.Stop(context.Background()); _ = os.RemoveAll(socketHome) }) + engine := cellstore.EngineFor("") + engine.Binary = bin + engine.Transport = daemon + engine.DataRoot = filepath.Join(home, "stores") + engine.Guard.Home = home + return taskdoor.CopyDeps{Root: filepath.Join(home, "copies"), Service: relayServer.URL, Identity: id, Device: device, Engine: engine, Store: blobstore.NewHTTP(blobs.URL, func(*http.Request, []byte) {}, blobs.Client())} + } + copiesA, copiesB := makeCopy(homeA, a), makeCopy(homeB, b) + executor := taskdoor.NewExecutor(taskdoor.ExecutorConfig{Copies: copiesB, Ledger: filepath.Join(homeB, "usage.jsonl"), Command: func(args []string) *exec.Cmd { + cmd := exec.Command(os.Args[0], append([]string{"-test.run=^TestOtherHomeTaskChild$", "--"}, args...)...) + cmd.Env = append(os.Environ(), "CODEAF_OTHER_HOME_CHILD=1", "HOME="+homeB, "CODEAF_HOME="+homeB, "CODEAF_OTHER_HOME_GATE="+filepath.Join(homeB, "steps-arrived")) + return cmd + }}) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + serving := make(chan error, 1) + ready := make(chan struct{}, 1) + go func() { + defer close(serving) + serving <- taskdoor.Serve(ctx, taskdoor.Config{Service: relayServer.URL, Identity: id, Device: b, Home: homeB, Root: filepath.Join(homeB, "door"), Executor: executor, Roster: func(context.Context) (map[string]bool, error) { + return map[string]bool{a.ID(): true, b.ID(): true}, nil + }, Say: func(line string) { + if line == taskdoor.TakingTasksLine { + select { + case ready <- struct{}{}: + default: + } + } + }}) + }() + defer func() { + cancel() + if err := <-serving; err != nil { + t.Error(err) + } + }() + select { + case <-ready: + case err := <-serving: + t.Fatalf("serve: %v", err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + work := t.TempDir() + git := func(args ...string) string { + cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=task test", "-c", "user.email=task@example.test"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + git("init", "-qb", "task-copy") + _ = os.WriteFile(filepath.Join(work, "input.txt"), []byte("input\n"), 0644) + git("add", "input.txt") + git("commit", "-qm", "input") + _ = os.WriteFile(filepath.Join(homeA, "conversation-only.txt"), []byte("A's transcript stays here"), 0600) + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "task", "1", "change", "brief") + if err != nil { + t.Fatal(err) + } + defer store.Close() + remote := &taskplace.Engine{Copies: copiesA, Computer: session.TaskComputer{Device: b.ID(), Name: "work computer", CanTakeTasks: true}, OriginName: "chat computer"} + var saved session.TaskRemoteRecord + var commands []string + summary := remote.Start(ctx, session.RunSpec{Store: store, Workspace: work, Conversation: "chat-on-A", TaskNumber: 1, Title: "change", Brief: "settled instruction with acceptance and checks", NamedModel: "B/model", NamedEffort: "cheap", StepsPerTask: 17, NoProgress: 9, CostUSD: .75, Elapsed: time.Minute * 30, SaveRemote: func(r session.TaskRemoteRecord, force bool) error { saved = r; return nil }, OnRemoteStep: func(seq uint64, raw json.RawMessage) error { + if saved.Attempt == "" { + t.Error("watch began before persistence") + } + var step run.Step + if err := json.Unmarshal(raw, &step); err != nil { + return err + } + if step.FullOutput != "" { + t.Error("remote output path offered locally") + } + commands = append(commands, step.Command) + if len(commands) == 2 { + return os.WriteFile(filepath.Join(homeB, "steps-arrived"), []byte("A has both live steps"), 0600) + } + return nil + }}) + if summary.Outcome != "done" || summary.Result != "the change is made on B" { + t.Fatalf("summary = %+v", summary) + } + body, err := os.ReadFile(filepath.Join(work, "made-on-B.txt")) + if err != nil || string(body) != "B's child wrote this\n" { + t.Fatalf("returned file = %q, %v", body, err) + } + if got := git("log", "-1", "--format=%s"); got != "change made on B" { + t.Fatalf("returned commit = %s", got) + } + if strings.Join(commands, ",") != "inspect on B,write on B" { + t.Fatalf("steps = %v", commands) + } + if !saved.Restored || saved.Seq == 0 { + t.Fatalf("record = %+v", saved) + } + if err := remote.Acknowledge(ctx, saved); err != nil { + t.Fatal(err) + } +} diff --git a/internal/taskplace/results_r8_test.go b/internal/taskplace/results_r8_test.go new file mode 100644 index 0000000000..e4b3851cf6 --- /dev/null +++ b/internal/taskplace/results_r8_test.go @@ -0,0 +1,174 @@ +//go:build linux || darwin + +package taskplace_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +func resultCopyFixture(t *testing.T) (taskdoor.CopyDeps, string, *plandb.Store, session.TaskRemoteRecord) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + device, err := identity.LoadDevice(home) + if err != nil { + t.Fatal(err) + } + bin := taskEngineBinary(t) + socketHome, err := os.MkdirTemp("", "rn-l1-r8-") + if err != nil { + t.Fatal(err) + } + daemon := cellstore.Daemon{Binary: bin, Socket: filepath.Join(socketHome, "e.sock")} + t.Cleanup(func() { _ = daemon.Stop(context.Background()); _ = os.RemoveAll(socketHome) }) + engine := cellstore.EngineFor("") + engine.Binary = bin + engine.Transport = daemon + engine.DataRoot = filepath.Join(home, "stores") + engine.Guard.Home = home + copies := taskdoor.CopyDeps{Root: filepath.Join(home, "copies"), Service: "http://127.0.0.1:8900", Identity: id, Device: device, Engine: engine, Store: blobstore.NewMemory()} + work := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=test", "-c", "user.email=test@example.test"}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } + git("init", "-qb", "task-copy") + if err = os.WriteFile(filepath.Join(work, "edit.txt"), []byte("sent baseline"), 0600); err != nil { + t.Fatal(err) + } + git("add", "--all") + git("commit", "-qm", "baseline") + fingerprint, err := taskdoor.CopyFingerprint(t.Context(), work) + if err != nil { + t.Fatal(err) + } + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "task", "1", "repair", "brief") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return copies, work, store, session.TaskRemoteRecord{Executor: session.TaskExecutor{Device: "other", Name: "work computer"}, Attempt: "att_0123456789abcdef", Fingerprint: fingerprint, Finished: true} +} + +type delayedResultStore struct { + blobstore.Store + entered, release chan struct{} +} + +func (s *delayedResultStore) GetMany(ctx context.Context, _ []string) ([]blobstore.Object, error) { + close(s.entered) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-s.release: + return nil, blobstore.ErrUnreachable + } +} + +// R7-14 and contract 11 allow stop to acquire the session's copy fence during a real fetch. +func TestAStopIsNotBlockedByAResultStillDownloading(t *testing.T) { + copies, work, store, record := resultCopyFixture(t) + blobs := &delayedResultStore{Store: copies.Store, entered: make(chan struct{}), release: make(chan struct{})} + copies.Store = blobs + cellID, err := cell.NewID(time.Now()) + if err != nil { + t.Fatal(err) + } + record.Final, _ = json.Marshal(taskdoor.Finished{Outcome: "done", Result: &taskdoor.Head{Cell: cellID, Head: strings.Repeat("0", 64)}}) + remote := &taskplace.Engine{Copies: copies, Computer: session.TaskComputer{Name: "work computer"}} + var copyFence sync.Mutex + done := make(chan session.RunSummary, 1) + go func() { + done <- remote.Resume(t.Context(), session.RunSpec{Workspace: work, Store: store, Remote: &record, SaveRemote: func(session.TaskRemoteRecord, bool) error { return nil }, WithRemoteCopy: func(install func() error) error { copyFence.Lock(); defer copyFence.Unlock(); return install() }}) + }() + defer func() { close(blobs.release); <-done }() + select { + case <-blobs.entered: + case summary := <-done: + done <- summary + t.Fatalf("fetch ended before the gate: %+v", summary) + case <-time.After(5 * time.Second): + t.Fatal("result never reached its download") + } + stopped := make(chan struct{}) + go func() { + copyFence.Lock() + defer copyFence.Unlock() + _ = remote.Stop(t.Context(), session.TaskRemoteRecord{}) + close(stopped) + }() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("stop waited for the result download") + } +} + +// R7-9 and contract 11 preserve the actual sent fingerprint for local fallback. +func TestRunItHereWorksAfterAnInterruptedRemoteRun(t *testing.T) { + copies, work, store, record := resultCopyFixture(t) + other, _, _, _ := resultCopyFixture(t) + other.Identity = copies.Identity + var err error + other.Device, err = identity.NewDevice(copies.Identity) + if err != nil { + t.Fatal(err) + } + other.Store = copies.Store + input, err := taskdoor.SendCopy(t.Context(), copies, work) + if err != nil { + t.Fatal(err) + } + partial := filepath.Join(t.TempDir(), "partial") + if err := taskdoor.ReceiveCopy(t.Context(), other, input, partial); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(partial, "edit.txt"), []byte("unfinished remote edit"), 0600); err != nil { + t.Fatal(err) + } + result, err := taskdoor.SendResult(t.Context(), other, partial, input) + if err != nil { + t.Fatal(err) + } + record.Final, _ = json.Marshal(taskdoor.Finished{Outcome: "interrupted", Report: "the worker stopped", Result: &result}) + remote := &taskplace.Engine{Copies: copies, Computer: session.TaskComputer{Name: "work computer"}} + summary := remote.Resume(t.Context(), session.RunSpec{Workspace: work, Store: store, Remote: &record, SaveRemote: func(r session.TaskRemoteRecord, _ bool) error { record = r; return nil }}) + if summary.Outcome != "interrupted" || !strings.Contains(summary.Result, "partial work stayed on the other computer") { + t.Fatalf("interrupted result = %+v", summary) + } + if err := remote.CheckCopy(t.Context(), work, record.Fingerprint); err != nil { + t.Fatalf("run it here refused its baseline: %v", err) + } + raw, err := os.ReadFile(filepath.Join(work, "edit.txt")) + if err != nil || string(raw) != "sent baseline" { + t.Fatalf("baseline changed: %q, %v", raw, err) + } + if record.Restored { + t.Fatal("partial work marked restored") + } +} diff --git a/internal/teams/dissolve.go b/internal/teams/dissolve.go index eed976276c..d90adf800e 100644 --- a/internal/teams/dissolve.go +++ b/internal/teams/dissolve.go @@ -86,11 +86,11 @@ func Dissolve(profileDir, id, manager string) error { return nil } -// HasTraffic reports whether team id's Traffic has anything in it, either -// file of it. An unreadable log counts as said, so a doubt keeps the team. +// HasTraffic reports whether either local log or the display mirror has +// history. An unreadable file counts as said, so a doubt keeps the team. func HasTraffic(profileDir, id string) bool { path := TrafficPath(profileDir, id) - for _, file := range []string{path, trafficRotated(path)} { + for _, file := range []string{path, trafficRotated(path), TrafficMirrorPath(profileDir, id)} { info, err := os.Stat(file) if err == nil && info.Size() > 0 { return true diff --git a/internal/teams/doc.go b/internal/teams/doc.go index 95f37e231a..8e6975e976 100644 --- a/internal/teams/doc.go +++ b/internal/teams/doc.go @@ -38,6 +38,25 @@ // - The Traffic log is append-only JSON lines, one file per team, rotated // once past a few megabytes. // +// THE SYNC LAWS APPLY ONLY AFTER THIS COMPUTER OPTS IN. Sharing is off by +// default and both paired computers must turn it on. Portable identities name cells, never another computer's +// paths. A team's deletion beats a stale copy. Merge repairs parents, handles, +// managers and reporting homes deterministically; teams sort by Made then id. +// Unpaired computers retain their existing file order and encodings. +// +// THE LOCAL LOG HOLDS ONLY LOCAL LINES. AppendTraffic clears Origin. Received +// windows replace elsewhere.jsonl atomically; that mirror is display-only. +// ReadTraffic never reads it. ReadHistory is the display reader, and a display +// cursor remains a local line id. Old builds cannot execute mirror history. +// +// THE TRAFFIC BOUNDS. TrafficSyncLines, TrafficSyncBytes, TrafficSyncTextBytes, +// TrafficSyncDeviceBytes and TrafficSyncEvery own the transport limits. Only +// each writing device publishes its bounded local tail across rotation. Eviction trims shared +// windows, never local history; deleted teams remove their writers' values on +// the next round. Mirrors filter writers through the locally available paired +// roster; without one, they include every other writer without a network read. +// Sharing failures are logged once and retried without blocking other state. +// // WHAT THIS PACKAGE DOES NOT KNOW. It imports neither the interface nor the // session. A team's colour is kept here as data (a hue angle and a tier) with // the pure arithmetic that spaces colours apart; which hues a palette reserves diff --git a/internal/teams/member_json.go b/internal/teams/member_json.go index 77e8480c74..90d442a15d 100644 --- a/internal/teams/member_json.go +++ b/internal/teams/member_json.go @@ -16,8 +16,8 @@ type wireMember struct { } var knownMemberFields = map[string]bool{ - "key": true, "file": true, "where": true, "word": true, "handle": true, - "home": true, "independent": true, "joined_at": true, "start_id": true, "started": true, "handle_by": true, "start_entry": true, "briefs": true, + "sync_id": true, "key": true, "file": true, "where": true, "word": true, "handle": true, + "home": true, "independent": true, "joined_at": true, "start_id": true, "start_origin": true, "started": true, "handle_by": true, "start_entry": true, "briefs": true, "factory_stage": true, "factory_brief": true, } diff --git a/internal/teams/relay.go b/internal/teams/relay.go new file mode 100644 index 0000000000..f7796d296e --- /dev/null +++ b/internal/teams/relay.go @@ -0,0 +1,124 @@ +package teams + +import ( + "sort" + "strings" +) + +// RemoteKey is the chat list's existing machine row identity. A synced member +// without a local transcript takes this identity so opening it asks to continue. +func RemoteKey(id string) string { return "machine:" + id } + +// RemoteCell reads only the machine row identity, never another computer's path. +func RemoteCell(key string) (string, bool) { + id, ok := strings.CutPrefix(key, "machine:") + return id, ok && id != "" +} + +// OtherComputerKey identifies a conversation that has no transferable cell. +// Its title and membership travel, but this identity never opens a local path. +func OtherComputerKey(id string) string { return "other-computer:" + id } + +// OnOtherComputer identifies a member whose chat cannot be continued here yet. +func OnOtherComputer(key string) bool { return strings.HasPrefix(key, "other-computer:") } + +// RepairMerged restores file laws in a stable identity order before Update's +// ordinary tidy runs. The member order is restored after handle repair because +// collision ownership depends on join time rather than a concurrent reorder. +func (f *File) RepairMerged() { + sort.Slice(f.Teams, func(i, j int) bool { + if !f.Teams[i].Made.Equal(f.Teams[j].Made) { + return f.Teams[i].Made.Before(f.Teams[j].Made) + } + return f.Teams[i].ID < f.Teams[j].ID + }) + index := map[string]int{} + for i, t := range f.Teams { + index[t.ID] = i + } + for i := range f.Teams { + if _, ok := index[f.Teams[i].Parent]; !ok { + f.Teams[i].Parent = "" + } + } + for _, t := range f.Teams { + path := []string{} + seen := map[string]int{} + for id := t.ID; id != ""; { + if start, ok := seen[id]; ok { + loop := append([]string(nil), path[start:]...) + sort.Strings(loop) + f.Teams[index[loop[0]]].Parent = "" + break + } + seen[id] = len(path) + path = append(path, id) + id = f.Teams[index[id]].Parent + } + } + for i := range f.Teams { + t := &f.Teams[i] + order := map[string]int{} + for j, m := range t.Members { + order[m.Key] = j + } + sort.SliceStable(t.Members, func(i, j int) bool { + a, b := t.Members[i], t.Members[j] + if !a.JoinedAt.Equal(b.JoinedAt) { + return a.JoinedAt.Before(b.JoinedAt) + } + return memberSyncIdentity(a) < memberSyncIdentity(b) + }) + used := map[string]bool{} + for j := range t.Members { + m := &t.Members[j] + if m.Handle != "" && !used[m.Handle] && ValidHandle(m.Handle) == nil { + used[m.Handle] = true + continue + } + m.Handle = "" + } + assignHandles(t) + sort.SliceStable(t.Members, func(i, j int) bool { return order[t.Members[i].Key] < order[t.Members[j].Key] }) + if !t.Holds(t.Manager) { + t.Manager = "" + } + } + tidy(f.Teams) + // Two individually legal appointments can merge into separate duties for + // one manager. Keep the smaller team id so Update can accept the merge. + for { + conflicts := f.managementConflicts() + if len(conflicts) == 0 { + break + } + pairs := make([]string, 0, len(conflicts)) + for pair := range conflicts { + pairs = append(pairs, pair) + } + sort.Strings(pairs) + parts := strings.Split(pairs[0], "\x00") + f.Teams[index[parts[len(parts)-1]]].Manager = "" + } + assignHomes(f.Teams) +} + +func memberSyncIdentity(m Member) string { + if m.SyncID != "" { + return m.SyncID + } + return m.Key +} + +// DeliveryRefusal is shared by the tools and surface, so a refused send never +// sounds like a receipt for work that only another computer could receive. +func DeliveryRefusal(member Member, computer string) string { + who := member.Word + if member.Handle != "" { + who = "@" + member.Handle + } + if who == "" { + who = "This chat" + } + return who + " is on " + computer + "; the message would not reach it, so continue the chat here first." +} diff --git a/internal/teams/relay_test.go b/internal/teams/relay_test.go new file mode 100644 index 0000000000..40740f89ca --- /dev/null +++ b/internal/teams/relay_test.go @@ -0,0 +1,55 @@ +package teams + +import ( + "encoding/json" + "testing" + "time" +) + +func TestMergeRepairsParentsHandlesManagerAndHomeDeterministically(t *testing.T) { + early := time.Unix(10, 0) + f := &File{Teams: []Team{ + {ID: "bb", Parent: "aa", Members: []Member{{Key: "cell:y", Word: "storage", Handle: "same", JoinedAt: early.Add(time.Second)}, {Key: "cell:x", Word: "storage", Handle: "same", JoinedAt: early}}, Manager: "gone"}, + {ID: "aa", Parent: "bb"}, + }} + f.RepairMerged() + if f.Teams[0].ID != "aa" || f.Teams[0].Parent != "" || f.Teams[1].Parent != "aa" { + t.Fatalf("parent loop: %+v", f.Teams) + } + team := f.Teams[1] + if team.Manager != "" || team.Members[1].Handle != "same" || team.Members[0].Handle == "same" { + t.Fatalf("membership repair: %+v", team) + } + before, _ := json.Marshal(f.Teams) + f.RepairMerged() + after, _ := json.Marshal(f.Teams) + if string(before) != string(after) { + t.Fatal("repair is not stable") + } +} + +func TestMergeKeepsOneReportingHomeAndRepairsConcurrentManagerDuties(t *testing.T) { + f := &File{Teams: []Team{ + {ID: "bb", Manager: "manager-b", Members: []Member{{Key: "manager-b", Word: "leader-b"}, {Key: "shared", Word: "storage", Home: true}}}, + {ID: "aa", Manager: "manager-a", Members: []Member{{Key: "manager-a", Word: "leader-a"}, {Key: "shared", Word: "storage", Home: true}}}, + }} + f.RepairMerged() + count := 0 + for _, team := range f.Teams { + for _, m := range team.Members { + if m.Key == "shared" && m.Home { + count++ + } + } + } + if count != 1 { + t.Fatalf("%d reporting homes survived", count) + } + for i := range f.Teams { + f.Teams[i].Manager = "shared" + } + f.RepairMerged() + if f.Teams[0].Manager != "shared" || f.Teams[1].Manager != "" { + t.Fatal("concurrent manager duties were not repaired deterministically") + } +} diff --git a/internal/teams/sharing_notice.go b/internal/teams/sharing_notice.go new file mode 100644 index 0000000000..d3969a77a0 --- /dev/null +++ b/internal/teams/sharing_notice.go @@ -0,0 +1,19 @@ +package teams + +import ( + "log" + "sync" +) + +var sharingNotices sync.Map + +// SharingError uses the application's existing log destination, once per +// profile and process. Retrying optional sharing must not flood that log. +func SharingError(profile string, err error) { + if err == nil { + return + } + if _, seen := sharingNotices.LoadOrStore(profile, true); !seen { + log.Printf("teams: sharing will retry: %v", err) + } +} diff --git a/internal/teams/stamp.go b/internal/teams/stamp.go index 60b6bb8374..a51b83f6e5 100644 --- a/internal/teams/stamp.go +++ b/internal/teams/stamp.go @@ -206,3 +206,34 @@ func (w *Watch) mark(key, stamp, after string) { } w.marks[key] = watchMark{stamp: stamp, after: after} } + +// History refreshes only the display tail. Each consumer returns its own mirror +// version, so one hosted window cannot acknowledge another window's change. +// Local news and its cursor are read separately through Traffic. +func (w *Watch) History(profile, team, after, version string, limit int) ([]Entry, string, string, bool, error) { + if err := safeTeamID(team); err != nil { + return nil, after, version, false, err + } + mirror := stampOf(TrafficMirrorPath(profile, team)) + reset := after == "" || version != mirror + if !reset { + return nil, after, mirror, false, nil + } + entries, err := ReadHistory(profile, team, limit) + if err != nil { + return nil, after, version, false, err + } + next := after + // Only an opening tail establishes a position. A mirror refresh cannot + // acknowledge local entries that have not passed through the news reader. + if after == "" { + local, err := ReadTraffic(profile, team, "", 1) + if err != nil { + return nil, after, version, false, err + } + if len(local) > 0 { + next = local[len(local)-1].ID + } + } + return entries, next, mirror, true, nil +} diff --git a/internal/teams/store.go b/internal/teams/store.go index d47dbf07c8..4976092867 100644 --- a/internal/teams/store.go +++ b/internal/teams/store.go @@ -347,33 +347,58 @@ func SetAside(profileDir string) (string, error) { return err } forget(path) - to := path + ".unreadable-" + strconv.FormatInt(time.Now().UnixNano(), 10) - var moved []string - undo := func() { - for _, suffix := range moved { - _ = os.Rename(to+suffix, path+suffix) - } - } - for _, suffix := range []string{"-wal", "-shm"} { - if _, err := os.Lstat(path + suffix); err != nil { - continue - } - if err := os.Rename(path+suffix, to+suffix); err != nil { - undo() - return err - } - moved = append(moved, suffix) - } - if err := os.Rename(path, to); err != nil { - undo() + aside, err = moveStoreAside(path) + return err + }) + return aside, err +} + +// MoveAside moves this computer's teams store out of the way whatever it +// holds, to .unreadable- beside it with its WAL and shared-memory +// files, for a confirmed replacement of the shared state: the teams stay on +// disk, outside the new set. A home with no store moves nothing. +func MoveAside(profileDir string) (string, error) { + path := Path(profileDir) + aside := "" + err := withLock(profileDir, lockWait, func() error { + forget(path) + if _, err := os.Lstat(path); err != nil { return err } - aside = to - return nil + var err error + aside, err = moveStoreAside(path) + return err }) return aside, err } +// moveStoreAside renames the store, WAL first, and undoes a partial move. The +// caller holds the teams lock. +func moveStoreAside(path string) (string, error) { + to := path + ".unreadable-" + strconv.FormatInt(time.Now().UnixNano(), 10) + var moved []string + undo := func() { + for _, suffix := range moved { + _ = os.Rename(to+suffix, path+suffix) + } + } + for _, suffix := range []string{"-wal", "-shm"} { + if _, err := os.Lstat(path + suffix); err != nil { + continue + } + if err := os.Rename(path+suffix, to+suffix); err != nil { + undo() + return "", err + } + moved = append(moved, suffix) + } + if err := os.Rename(path, to); err != nil { + undo() + return "", err + } + return to, nil +} + // ── THE LOCK ──────────────────────────────────────────────────────────────── // withLock runs fn holding the exclusive lock on the teams store, waiting up to diff --git a/internal/teams/teams.go b/internal/teams/teams.go index d582ba8611..4e91e1b975 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -20,10 +20,12 @@ const Version = 2 type Member struct { // ReportsTo records the proposed reporting handle while the crew stays flat. ReportsTo string `json:"reports_to,omitempty"` - Key string `json:"key"` - File string `json:"file"` - Where string `json:"where"` - Word string `json:"word"` + // SyncID preserves a portable identity when this computer has its own path. + SyncID string `json:"sync_id,omitempty"` + Key string `json:"key"` + File string `json:"file"` + Where string `json:"where"` + Word string `json:"word"` // Handle is the member's short name inside this team (handle.go). It is // empty only while the member has no title to derive one from. Handle string `json:"handle,omitempty"` @@ -42,6 +44,8 @@ type Member struct { // StartID is the Traffic start admitted when this membership was created. // Only that brief may cross its join boundary; handles can be reused. StartID string `json:"start_id,omitempty"` + // StartOrigin keeps a foreign start boundary out of this computer's sequence. + StartOrigin string `json:"start_origin,omitempty"` // Started says this membership was made by the team manager's team_start // (a [KindStart] the interface carried out), which is the second rule a // home is picked by. diff --git a/internal/teams/thread.go b/internal/teams/thread.go index 4312950de2..663a768951 100644 --- a/internal/teams/thread.go +++ b/internal/teams/thread.go @@ -117,19 +117,24 @@ const threadHops = 16 // threads, the thread with the newest activity FIRST and the entries inside // each in the order they were written. An entry answering an id that is not // among entries (older than the window, or never written) begins a thread of -// its own, as does every entry that answers nothing. +// its own, as does every entry that answers nothing. Display history arrives +// in time order: portable ids from different writers are never compared as +// positions. The hop bound also protects a malformed mirror's reference graph. func Threads(entries []Entry) []Thread { if len(entries) == 0 { return nil } at := make(map[string]int, len(entries)) + mirrored := false for i, e := range entries { at[e.ID] = i + mirrored = mirrored || e.Origin != "" } rootOf := func(i int) int { for hop := 0; hop < threadHops; hop++ { up, ok := at[entries[i].Answers] - if entries[i].Answers == "" || !ok || up == i || entries[up].ID >= entries[i].ID { + if entries[i].Answers == "" || !ok || up == i || + (entries[up].Origin == "" && entries[i].Origin == "" && entries[up].ID >= entries[i].ID) { return i } i = up @@ -149,10 +154,15 @@ func Threads(entries []Entry) []Thread { if root != i { out[n].Replies = append(out[n].Replies, e) } - if e.ID > out[n].Latest { + if (!mirrored && e.ID > out[n].Latest) || (mirrored && at[e.ID] > at[out[n].Latest]) { out[n].Latest = e.ID } } - sort.SliceStable(out, func(i, j int) bool { return out[i].Latest > out[j].Latest }) + sort.SliceStable(out, func(i, j int) bool { + if !mirrored { + return out[i].Latest > out[j].Latest + } + return at[out[i].Latest] > at[out[j].Latest] + }) return out } diff --git a/internal/teams/traffic.go b/internal/teams/traffic.go index 6c123ebb1d..ac014fdfd2 100644 --- a/internal/teams/traffic.go +++ b/internal/teams/traffic.go @@ -71,6 +71,9 @@ var kinds = map[string]bool{ // Entry is one line of the Traffic log. This shape is the contract between // the interface and the team tools. type Entry struct { + // Origin identifies a display-only line in the other computers' mirror. + // Local appends clear it so delivery has only locally authored lines. + Origin string `json:"origin,omitempty"` // ID is assigned by [AppendTraffic]; any ID given is replaced. ID string `json:"id"` // At is when it happened; [AppendTraffic] fills a zero one with now. @@ -219,6 +222,7 @@ func appendTrafficIDLocked(profileDir, teamID string, e Entry) (string, error) { // Only closing-report acceptance may append its final event after disbanding. func appendTrafficLifecycleLocked(profileDir, teamID string, e Entry, closing bool) (string, error) { + e.Origin = "" if f, exists, err := lookTeams(profileDir, "", teamID); err != nil { return "", err } else if exists { diff --git a/internal/teams/traffic_mirror_test.go b/internal/teams/traffic_mirror_test.go new file mode 100644 index 0000000000..8069613928 --- /dev/null +++ b/internal/teams/traffic_mirror_test.go @@ -0,0 +1,217 @@ +package teams + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + "time" + "unicode/utf8" +) + +// A mirror must remain invisible to every old and current local delivery reader. +func TestTrafficMirrorNeverChangesLocalLogAndKeepsPortableThreads(t *testing.T) { + dir := t.TempDir() + if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromYou, To: ToManager, Text: "local", Origin: "forged"}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(TrafficPath(dir, "alpha")) + if bytes.Contains(before, []byte("origin")) { + t.Fatal("local append retained origin") + } + lines := []Entry{{ID: "a/1", At: time.Unix(10, 0), Kind: KindQuestion, Text: "remote"}, {ID: "a/2", At: time.Unix(11, 0), Kind: KindAnswer, Reply: "a/1", Answers: "a/1"}} + for range 3 { + if err := WriteTrafficMirror(dir, "alpha", lines); err != nil { + t.Fatal(err) + } + } + after, _ := os.ReadFile(TrafficPath(dir, "alpha")) + if !bytes.Equal(before, after) { + t.Fatal("mirror changed local log") + } + local, _ := ReadTraffic(dir, "alpha", "", 0) + if len(local) != 1 { + t.Fatal(local) + } + got, err := ReadHistory(dir, "alpha", 0) + if err != nil || len(got) != 3 { + t.Fatalf("%+v %v", got, err) + } + if got[0].Origin != "a/1" || got[1].Reply != "a/1" || got[1].Answers != "a/1" { + t.Fatal(got) + } + info, _ := os.Stat(TrafficMirrorPath(dir, "alpha")) + if err := WriteTrafficMirror(dir, "alpha", lines); err != nil { + t.Fatal(err) + } + again, _ := os.Stat(TrafficMirrorPath(dir, "alpha")) + if !info.ModTime().Equal(again.ModTime()) { + t.Fatal("unchanged mirror rewritten") + } +} + +func TestTrafficExportBoundsTextAtRuneBoundaryAndReadsBoundedTail(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 100; i++ { + if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromYou, To: ToManager, Text: strings.Repeat("界", 2000), Handles: []string{strings.Repeat("x", 20000)}}); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(trafficRotated(TrafficPath(dir, "alpha")), []byte("not json\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := TrafficForSync(dir, "alpha", "a") + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(got) + if len(raw) > TrafficSyncBytes || len(got) > TrafficSyncLines || len(got) == 0 { + t.Fatalf("%d lines %d bytes", len(got), len(raw)) + } + for _, e := range got { + if !utf8.ValidString(e.Text) || len(e.Text) > TrafficSyncTextBytes+len(" …") || !strings.HasSuffix(e.Text, " …") { + t.Fatal("text dropped or cut badly") + } + } + // Encoded bytes, rather than string lengths, decide whether optional + // fields fit. Escaped metadata must not evict a shortened line's text. + escaped := t.TempDir() + if err := AppendTraffic(escaped, "alpha", Entry{Kind: KindNote, From: strings.Repeat("\x01", TrafficSyncTextBytes), To: strings.Repeat("\x02", TrafficSyncTextBytes), Text: strings.Repeat("\x03", TrafficSyncTextBytes+1)}); err != nil { + t.Fatal(err) + } + got, err = TrafficForSync(escaped, "alpha", "a") + if err != nil || len(got) != 1 || got[0].Text != strings.Repeat("\x03", TrafficSyncTextBytes)+" …" { + t.Fatalf("encoded metadata excluded the text: %+v %v", got, err) + } + raw, _ = json.Marshal(got) + if len(raw) > TrafficSyncBytes { + t.Fatal("encoded window exceeded its bound") + } +} + +func TestDeletingATeamRemovesItsDisplayMirrorAlongWithItsFolder(t *testing.T) { + dir := t.TempDir() + if err := Update(dir, func(f *File) error { f.Teams = []Team{{ID: "alpha", Name: "alpha", State: TeamClosed}}; return nil }); err != nil { + t.Fatal(err) + } + if err := WriteTrafficMirror(dir, "alpha", []Entry{{ID: "a/1", At: time.Now(), Kind: KindNote, Text: "remote history"}}); err != nil { + t.Fatal(err) + } + if _, err := Delete(dir, "alpha"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(TrafficMirrorPath(dir, "alpha")); !os.IsNotExist(err) { + t.Fatal("delete left mirror behind") + } +} + +func TestOverviewTailCannotHideAMirrorChangeFromTheTrafficPane(t *testing.T) { + dir := t.TempDir() + if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromManager, To: ToRoom, Text: "local"}); err != nil { + t.Fatal(err) + } + var w Watch + _, cursor, version, _, err := w.History(dir, "alpha", "", "", 100) + if err != nil { + t.Fatal(err) + } + if err := WriteTrafficMirror(dir, "alpha", []Entry{{ID: "a/1", At: time.Now(), Text: "new mirror"}}); err != nil { + t.Fatal(err) + } + if _, _, _, _, err := w.History(dir, "alpha", "", "", 100); err != nil { + t.Fatal(err) + } + lines, next, _, reset, err := w.History(dir, "alpha", cursor, version, 100) + if err != nil || !reset || next != cursor || len(lines) != 2 { + t.Fatal("overview consumed the pane's refresh", lines, next, reset, err) + } +} + +func TestTrafficExportTakesOnlyTheNewestLineCount(t *testing.T) { + dir := t.TempDir() + for range 100 { + if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromYou, To: ToRoom, Text: "short"}); err != nil { + t.Fatal(err) + } + } + lines, err := TrafficForSync(dir, "alpha", "a") + if err != nil || len(lines) != TrafficSyncLines || lines[len(lines)-1].ID != "a/000000000100" { + t.Fatal(len(lines), err) + } +} + +func TestMirrorSurvivesLocalRotationWithoutDuplicatingRemoteLines(t *testing.T) { + dir := t.TempDir() + old := trafficRotateBytes + trafficRotateBytes = 500 + t.Cleanup(func() { trafficRotateBytes = old }) + remote := []Entry{{ID: "a/1", At: time.Now(), Kind: KindDirective, From: FromManager, To: "worker", Text: "history only"}} + if err := WriteTrafficMirror(dir, "alpha", remote); err != nil { + t.Fatal(err) + } + for range 10 { + if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromYou, To: ToRoom, Text: strings.Repeat("x", 800)}); err != nil { + t.Fatal(err) + } + } + for range 3 { + if err := WriteTrafficMirror(dir, "alpha", remote); err != nil { + t.Fatal(err) + } + } + local, _ := ReadTraffic(dir, "alpha", "", 0) + for _, e := range local { + if e.Origin != "" || e.Text == "history only" { + t.Fatal("rotation admitted mirror into local log") + } + } + all, _ := ReadHistory(dir, "alpha", 0) + count := 0 + for _, e := range all { + if e.Origin == "a/1" { + count++ + } + } + if count != 1 { + t.Fatalf("%d remote copies after rotation", count) + } +} + +func TestHistoryThreadsFollowTimeAcrossWritersAndKeepReferences(t *testing.T) { + entries := []Entry{ + {ID: "z/1", Origin: "z/1", At: time.Unix(1, 0), Text: "question"}, + {ID: "z/2", Origin: "z/2", At: time.Unix(2, 0), Answers: "z/1", Text: "first reply"}, + {ID: "a/1", Origin: "a/1", At: time.Unix(3, 0), Answers: "z/1", Text: "later reply"}, + {ID: "000000000001", At: time.Unix(4, 0), Text: "newest local"}, + } + threads := Threads(entries) + if len(threads) != 2 || threads[0].Root.Text != "newest local" || threads[1].Root.ID != "z/1" || len(threads[1].Replies) != 2 || threads[1].Latest != "a/1" { + t.Fatalf("portable ids decided thread order or broke references: %+v", threads) + } +} + +func TestHistoryWithoutMirrorKeepsLocalSequenceWhenClockMovesBack(t *testing.T) { + profile := t.TempDir() + for _, at := range []time.Time{time.Unix(200, 0), time.Unix(100, 0)} { + if err := AppendTraffic(profile, "alpha", Entry{Kind: KindNote, From: FromYou, To: ToRoom, At: at, Text: "local"}); err != nil { + t.Fatal(err) + } + } + local, err := ReadTraffic(profile, "alpha", "", 0) + if err != nil { + t.Fatal(err) + } + history, err := ReadHistory(profile, "alpha", 0) + if err != nil { + t.Fatal(err) + } + if len(history) != len(local) { + t.Fatal("history changed local length") + } + for i := range local { + if history[i].ID != local[i].ID { + t.Fatal("history changed local sequence") + } + } +} diff --git a/internal/teams/traffic_refresh_test.go b/internal/teams/traffic_refresh_test.go new file mode 100644 index 0000000000..df1aceed19 --- /dev/null +++ b/internal/teams/traffic_refresh_test.go @@ -0,0 +1,77 @@ +package teams + +import ( + "fmt" + "os" + "testing" + "time" +) + +// Rotation must not withdraw recent local context while it still fits the window. +func TestTrafficExportKeepsNewestWindowAcrossRotation(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 31; i++ { + if err := AppendTraffic(dir, "alpha", Entry{From: FromManager, To: ToRoom, Kind: KindNote, Text: fmt.Sprint(i)}); err != nil { + t.Fatal(err) + } + } + if err := os.Rename(TrafficPath(dir, "alpha"), trafficRotated(TrafficPath(dir, "alpha"))); err != nil { + t.Fatal(err) + } + if err := AppendTraffic(dir, "alpha", Entry{From: FromManager, To: ToRoom, Kind: KindNote, Text: "after rotation"}); err != nil { + t.Fatal(err) + } + lines, err := TrafficForSync(dir, "alpha", "here") + if err != nil || len(lines) != TrafficSyncLines || lines[0].Text != "0" || lines[len(lines)-1].Text != "after rotation" { + t.Fatalf("window collapsed: %d %+v %v", len(lines), lines, err) + } +} + +func TestMirrorRefreshCannotAdvancePastUnprocessedLocalNews(t *testing.T) { + dir := t.TempDir() + if err := AppendTraffic(dir, "alpha", Entry{From: FromManager, To: ToRoom, Kind: KindNote}); err != nil { + t.Fatal(err) + } + var w Watch + _, cursor, version, _, err := w.History(dir, "alpha", "", "", 200) + if err != nil { + t.Fatal(err) + } + for range 201 { + if err := AppendTraffic(dir, "alpha", Entry{From: FromManager, To: ToRoom, Kind: KindStart}); err != nil { + t.Fatal(err) + } + } + if err := WriteTrafficMirror(dir, "alpha", []Entry{{ID: "elsewhere/1", At: time.Now()}}); err != nil { + t.Fatal(err) + } + _, next, _, _, err := w.History(dir, "alpha", cursor, version, 200) + if err != nil || next != cursor { + t.Fatalf("mirror acknowledged unread local lines: %q -> %q: %v", cursor, next, err) + } +} + +func TestEachHistoryConsumerSeesMirrorVersionIndependently(t *testing.T) { + dir := t.TempDir() + if err := AppendTraffic(dir, "alpha", Entry{From: FromManager, To: ToRoom, Kind: KindNote}); err != nil { + t.Fatal(err) + } + var w Watch + _, cursor, v1, _, err := w.History(dir, "alpha", "", "", 200) + if err != nil { + t.Fatal(err) + } + _, _, v2, _, err := w.History(dir, "alpha", "", "", 200) + if err != nil { + t.Fatal(err) + } + if err := WriteTrafficMirror(dir, "alpha", []Entry{{ID: "elsewhere/1", At: time.Now(), Text: "new"}}); err != nil { + t.Fatal(err) + } + for _, version := range []string{v1, v2} { + lines, next, current, reset, err := w.History(dir, "alpha", cursor, version, 200) + if err != nil || !reset || next != cursor || current == version || len(lines) != 2 { + t.Fatal("consumer missed mirror", lines, next, current, reset, err) + } + } +} diff --git a/internal/teams/traffic_sync.go b/internal/teams/traffic_sync.go new file mode 100644 index 0000000000..ae7b005814 --- /dev/null +++ b/internal/teams/traffic_sync.go @@ -0,0 +1,212 @@ +package teams + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/fsreplace" +) + +// These bounds keep recent context useful without allowing Traffic to crowd +// credentials and preferences out of the sealed object shared by the computers. +const ( + TrafficSyncLines = 32 + TrafficSyncBytes = 16 << 10 + TrafficSyncTextBytes = 2 << 10 + TrafficSyncDeviceBytes = 64 << 10 + TrafficSyncEvery = 30 * time.Second +) + +// TrafficForSync reads this computer's bounded tail across rotation. Received +// windows live in a separate file and can never be republished here. +func TrafficForSync(profile, team, device string) ([]Entry, error) { + if err := safeTeamID(team); err != nil { + return nil, err + } + all, err := tailTraffic([]string{trafficRotated(TrafficPath(profile, team)), TrafficPath(profile, team)}, TrafficSyncLines) + if err != nil { + return nil, err + } + if len(all) > TrafficSyncLines { + all = all[len(all)-TrafficSyncLines:] + } + out := make([]Entry, 0, len(all)) + for _, e := range all { + if e.Origin != "" { + continue + } + e.ID = device + "/" + e.ID + if e.Answers != "" && !strings.Contains(e.Answers, "/") { + e.Answers = device + "/" + e.Answers + } + if e.Reply != "" && !strings.Contains(e.Reply, "/") { + e.Reply = device + "/" + e.Reply + } + if len(e.Text) > TrafficSyncTextBytes { + end := TrafficSyncTextBytes + for end > 0 && !utf8.RuneStart(e.Text[end]) { + end-- + } + e.Text = e.Text[:end] + " …" + } + // Optional metadata must not exclude the words of an oversized line. + raw, _ := json.Marshal(e) + if len(raw) > TrafficSyncBytes/2 { + e = Entry{ID: e.ID, At: e.At, Kind: e.Kind, From: e.From, To: e.To, Text: e.Text, Answers: e.Answers, Reply: e.Reply} + if len(e.Answers) > TrafficSyncTextBytes { + e.Answers = "" + } + if len(e.Reply) > TrafficSyncTextBytes { + e.Reply = "" + } + if len(e.From) > TrafficSyncTextBytes { + e.From = "" + } + if len(e.To) > TrafficSyncTextBytes { + e.To = "" + } + } + // A short optional field can still expand sixfold when JSON escapes + // it. Preserve the text even when those encoded fields do not fit. + if raw, _ := json.Marshal(e); len(raw)+2 > TrafficSyncBytes { + e.From, e.To, e.Answers, e.Reply = "", "", "", "" + } + out = append(out, e) + } + for len(out) > 0 { + raw, _ := json.Marshal(out) + if len(raw) <= TrafficSyncBytes { + break + } + out = out[1:] + } + return out, nil +} + +// TrafficMirrorPath is deliberately outside both names the local log reader +// knows. Older builds ignore it, including when they tidy a team's directory. +func TrafficMirrorPath(profile, team string) string { + return filepath.Join(TeamDir(profile, team), "elsewhere.jsonl") +} + +// WriteTrafficMirror replaces the other computers' current windows as one +// atomic file. Portable ids keep threads intact without consuming local ids. +func WriteTrafficMirror(profile, team string, incoming []Entry) error { + if err := safeTeamID(team); err != nil { + return err + } + // Serialize with deletion so a late network response cannot recreate a + // removed team's folder. Reading here performs no store repairs. + return withLock(profile, lockWait, func() error { + f, err := read(profile) + if err != nil { + return err + } + if f != nil { + if _, ok := f.Team(team); !ok { + return nil + } + } + return writeTrafficMirror(profile, team, incoming) + }) +} + +func writeTrafficMirror(profile, team string, incoming []Entry) error { + byID := map[string]Entry{} + for _, e := range incoming { + if e.ID == "" { + continue + } + e.Origin = e.ID + if old, ok := byID[e.ID]; !ok || string(mustTrafficJSON(e)) > string(mustTrafficJSON(old)) { + byID[e.ID] = e + } + } + lines := make([]Entry, 0, len(byID)) + for _, e := range byID { + lines = append(lines, e) + } + sortHistory(lines) + var raw []byte + for _, e := range lines { + raw = append(raw, mustTrafficJSON(e)...) + raw = append(raw, '\n') + } + path := TrafficMirrorPath(profile, team) + have, err := os.ReadFile(path) + if err == nil && bytes.Equal(have, raw) { + return nil + } + if os.IsNotExist(err) && len(raw) == 0 { + return nil + } + if err != nil && !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".elsewhere-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + before := modTime(path) + if err := fsreplace.Rename(tmp.Name(), path); err != nil { + return err + } + advance(path, before) + return nil +} + +func mustTrafficJSON(e Entry) []byte { raw, _ := json.Marshal(e); return raw } + +func sortHistory(lines []Entry) { + sort.Slice(lines, func(i, j int) bool { + if !lines[i].At.Equal(lines[j].At) { + return lines[i].At.Before(lines[j].At) + } + if lines[i].Origin != lines[j].Origin { + return lines[i].Origin < lines[j].Origin + } + return lines[i].ID < lines[j].ID + }) +} + +// ReadHistory merges a local tail with the display mirror. Delivery always +// uses ReadTraffic instead; a mirror id is never a delivery position. +func ReadHistory(profile, team string, limit int) ([]Entry, error) { + local, err := ReadTraffic(profile, team, "", limit) + if err != nil { + return nil, err + } + remote, err := readTrafficFile(TrafficMirrorPath(profile, team)) + if err != nil { + SharingError(profile, err) + remote = nil + } + // Local sequence numbers retain their original order when no mirrored + // history participates, even if the writing computer's clock moved. + if len(remote) == 0 { + return local, nil + } + all := append(local, remote...) + sortHistory(all) + if limit > 0 && len(all) > limit { + all = all[len(all)-limit:] + } + return all, nil +} diff --git a/internal/tui2/tokens/glyph.go b/internal/tui2/tokens/glyph.go index 56231b84f1..c821e962d5 100644 --- a/internal/tui2/tokens/glyph.go +++ b/internal/tui2/tokens/glyph.go @@ -24,6 +24,13 @@ const ( GlyphMapUp = "▲" GlyphMapDown = "▼" + // Platform floors distinguish systems without borrowing shell or model marks. + GlyphPlatformMac = "⌘" + GlyphPlatformLinux = "△" + GlyphPlatformWindows = "⊞" + GlyphPlatformMobile = "▯" + GlyphPlatformOther = "□" + // State vocabulary. Shape encodes state CATEGORY and may change at a true // state transition; shape never animates on a long-lived row (8.1.6). GlyphQueued = "○" @@ -458,6 +465,11 @@ type GlyphInfo struct { // Tests, the `?` help surface, and a glyph-preview screen all walk it. func Glyphs() []GlyphInfo { return []GlyphInfo{ + {"PlatformMac", GlyphPlatformMac, '⌘', false}, + {"PlatformLinux", GlyphPlatformLinux, '△', true}, + {"PlatformWindows", GlyphPlatformWindows, '⊞', false}, + {"PlatformMobile", GlyphPlatformMobile, '▯', false}, + {"PlatformOther", GlyphPlatformOther, '□', true}, {"MapLeft", GlyphMapLeft, '◀', true}, {"MapUp", GlyphMapUp, '▲', true}, {"MapDown", GlyphMapDown, '▼', true}, diff --git a/internal/tui2/tokens/glyphset.go b/internal/tui2/tokens/glyphset.go index fdaa3a3f0e..1bc8e32053 100644 --- a/internal/tui2/tokens/glyphset.go +++ b/internal/tui2/tokens/glyphset.go @@ -204,6 +204,12 @@ const ( GMapLeft GMapUp GMapDown + // Platform marks accompany a system word on device approval cards. + GPlatformMac + GPlatformLinux + GPlatformWindows + GPlatformMobile + GPlatformOther glyphIDCount ) diff --git a/internal/tui2/tokens/glyphset_test.go b/internal/tui2/tokens/glyphset_test.go index afcc4d0025..d75ef72701 100644 --- a/internal/tui2/tokens/glyphset_test.go +++ b/internal/tui2/tokens/glyphset_test.go @@ -140,6 +140,7 @@ func TestVocabularyIsCompleteAndFallsBackToFiveSeventeen(t *testing.T) { // because a painted cell that is exactly one of these is plausible CONTENT and // a mechanism that rewrote it would be a mechanism that can lie (12.7 D.3). var typedPlainSides = map[string]string{ + GlyphPlatformMac: "the Mac Command key label is keyboard text, even when a platform slot shares its floor", GlyphRemove: "the multiplication sign, which is ordinary mathematical content", GlyphActionCommunicate: "the guillemet, which is a quotation mark in half of Europe", } diff --git a/internal/tui2/tokens/l4b2_platform_test.go b/internal/tui2/tokens/l4b2_platform_test.go new file mode 100644 index 0000000000..54016950d6 --- /dev/null +++ b/internal/tui2/tokens/l4b2_platform_test.go @@ -0,0 +1,32 @@ +package tokens + +import ( + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" +) + +// L4-H8a, L4-H8d: Every platform slot has three single-cell spellings without depending on removed design documents. +func TestL4B2PlatformVocabulary(t *testing.T) { + if NerdFont.Upgrade(GlyphPlatformMac) != GlyphPlatformMac { + t.Fatal("the Command key label became a platform icon") + } + t.Run("complete vocabulary", TestVocabularyIsCompleteAndFallsBackToFiveSeventeen) + t.Run("single cell", TestEveryBindingMeasuresOneCellInBothTiers) + t.Run("distinct meanings", TestOneGlyphOneMeaning) + t.Run("pinned release", TestProvenance) + for _, id := range []GlyphID{GPlatformMac, GPlatformLinux, GPlatformWindows, GPlatformMobile, GPlatformOther} { + binding, ok := bindingOf(id) + if !ok || binding.NFName == "" || binding.ASCII == "" { + t.Fatalf("incomplete binding: %+v", binding) + } + for _, tier := range []GlyphSet{Plain, NerdFont, ASCII} { + mark := tier.Glyph(id) + if mark == "$" || ansi.StringWidth(mark) != 1 || runewidth.StringWidth(mark) != 1 { + t.Fatalf("invalid mark %q", mark) + } + } + + } +} diff --git a/internal/tui2/tokens/nerdfont.go b/internal/tui2/tokens/nerdfont.go index ff79112e2e..0ee5587c82 100644 --- a/internal/tui2/tokens/nerdfont.go +++ b/internal/tui2/tokens/nerdfont.go @@ -41,6 +41,12 @@ package tokens const NFFailedCell = "nf-fa-times_circle_o" var vocabulary = []GlyphBinding{ + // The Command key label is content too, so only the platform door upgrades it. + {ID: GPlatformMac, Name: "PlatformMac", Meaning: "device platform (mac)", Plain: GlyphPlatformMac, NerdFont: "\uF179", NFName: "nf-fa-apple", ASCII: "M", UsualTint: TextTertiary, PlainAmbiguous: false, NFAmbiguous: true, AutoUpgrade: false}, + {ID: GPlatformLinux, Name: "PlatformLinux", Meaning: "device platform (linux)", Plain: GlyphPlatformLinux, NerdFont: "\uF17C", NFName: "nf-fa-linux", ASCII: "L", UsualTint: TextTertiary, PlainAmbiguous: true, NFAmbiguous: true, AutoUpgrade: true}, + {ID: GPlatformWindows, Name: "PlatformWindows", Meaning: "device platform (windows)", Plain: GlyphPlatformWindows, NerdFont: "\uF17A", NFName: "nf-fa-windows", ASCII: "W", UsualTint: TextTertiary, PlainAmbiguous: false, NFAmbiguous: true, AutoUpgrade: true}, + {ID: GPlatformMobile, Name: "PlatformMobile", Meaning: "device platform (mobile)", Plain: GlyphPlatformMobile, NerdFont: "\uF10B", NFName: "nf-fa-mobile_phone", ASCII: "P", UsualTint: TextTertiary, PlainAmbiguous: false, NFAmbiguous: true, AutoUpgrade: true}, + {ID: GPlatformOther, Name: "PlatformOther", Meaning: "device platform (other)", Plain: GlyphPlatformOther, NerdFont: "\uF108", NFName: "nf-fa-desktop", ASCII: "?", UsualTint: TextTertiary, PlainAmbiguous: true, NFAmbiguous: true, AutoUpgrade: true}, {ID: GMapLeft, Name: "MapLeft", Meaning: "a connection travelling left", Plain: GlyphMapLeft, NerdFont: "\uF0D9", NFName: "nf-fa-caret_left", ASCII: "<", UsualTint: TextTertiary, PlainAmbiguous: true, NFAmbiguous: true, AutoUpgrade: true}, {ID: GMapUp, Name: "MapUp", Meaning: "a connection travelling up", Plain: GlyphMapUp, NerdFont: "\uF0D8", NFName: "nf-fa-caret_up", ASCII: "^", UsualTint: TextTertiary, PlainAmbiguous: true, NFAmbiguous: true, AutoUpgrade: true}, {ID: GMapDown, Name: "MapDown", Meaning: "a connection travelling down", Plain: GlyphMapDown, NerdFont: "\uF0D7", NFName: "nf-fa-caret_down", ASCII: "v", UsualTint: TextTertiary, PlainAmbiguous: true, NFAmbiguous: true, AutoUpgrade: true}, diff --git a/internal/tui2/tokens/testdata/nerdfont_glyphnames.json b/internal/tui2/tokens/testdata/nerdfont_glyphnames.json index 9e6447a369..353c92deac 100644 --- a/internal/tui2/tokens/testdata/nerdfont_glyphnames.json +++ b/internal/tui2/tokens/testdata/nerdfont_glyphnames.json @@ -187,5 +187,20 @@ }, "nf-fa-thumb_tack": { "code": "f08d" + }, + "nf-fa-apple": { + "code": "f179" + }, + "nf-fa-linux": { + "code": "f17c" + }, + "nf-fa-windows": { + "code": "f17a" + }, + "nf-fa-desktop": { + "code": "f108" + }, + "nf-fa-mobile_phone": { + "code": "f10b" } } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 48f7b00c09..b959115329 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -886,6 +886,10 @@ type ( ) type app struct { + taskComputerHere string + taskHerePending tasksKey + taskComputerRows []session.TaskComputer + taskComputers session.TaskComputers waitForRelease func() bool chatStarts map[string]bool conversationRequest uint64 @@ -1290,6 +1294,8 @@ type app struct { stamps map[int]turnStamp timestamps string workMode string + // teamsShare is adopted with profile preferences, keeping config reads off the loop. + teamsShare bool // Icon repertoire is independent of colour; detection is a boot fact and // the person can override it through the live Display setting. actionAuto tokens.GlyphSet @@ -1485,8 +1491,26 @@ type app struct { homeTakenSaid func() tea.Cmd // homeTaking keeps a retiring session on its own continuation until arrival. homeTaking string + // homeTakeSeq keeps each answer and cleanup tied to the operation that created it. + homeTakeSeq uint64 + // homeTakeCancel belongs to the active mutation; automatic cleanup keeps it until settlement. + homeTakeCancel context.CancelFunc + // Abandonment bounds the outstanding answer without shortening an unattended move. + homeTakeAbandonTimer *time.Timer // homeTakeDraft moves the source composer only after the arriving door opens. homeTakeDraft *homeTakenMsg + // Automatic navigation remains subordinate to a confirmed choice until its door lands. + homeTakeAutomatic bool + // The origin survives an explicit join so cancellation still settles its acquisition. + homeTakeOriginAutomatic bool + homeTakeAbandoned bool + homeTakeOpening bool + homeTakeRow chatlist.Row + homeTakePending *homeTakenMsg + homeTakeResult *homeTakenMsg + // continueAskSeq gives each surface card its own token so re-raising inside + // an answer closure is not wiped by closeQuestion. + continueAskSeq uint64 // teamsDisk is where the teams are kept and the edits not yet written // there (teamseam.go). teamsDisk teamsDisk @@ -2142,6 +2166,7 @@ type app struct { // every local session and before the first hosted answer, which the // emptiness law draws as no segment at all. linkLatency time.Duration + linkLatencyAt time.Time linkPingAsking bool // spell is the spell-it-out block under the draft, and the call that made it // while one is out (spellout.go). Its resting state is the zero value, which @@ -2319,6 +2344,7 @@ type app struct { branches BranchActions machineRead machineReading machinesAsking bool + machinesAskSeq uint64 // machinesRetaken replaces a listing begun before a completed take, so // its late answer cannot put the previous holder back on screen. machinesRetaken bool @@ -2515,7 +2541,9 @@ type app struct { landing bool autoContinue bool continueGeneration uint64 - pickSession bool + // A safety answer belongs to one explicit choice, so later navigation can withdraw it. + continueChecking *continuationChecked + pickSession bool // takeOverAt is a conversation this launch met a lock on // ([Options.TakeOver]). It is a transcript path, and the launch lands on // home with that row armed instead of quietly starting a second @@ -2715,6 +2743,7 @@ type app struct { // cannot; closed, the panel costs the frame nothing. cellsUnavailable string pairing Pairing + syncService string pair pairPanel // approvals answers a new device's request and revokes devices, and @@ -2848,6 +2877,8 @@ type app struct { deviceID string // syncOutages remembers which chats have already announced their current outage. syncOutages map[string]bool + // renewalFailures binds recovery evidence to a failure observed during this outage. + renewalFailures map[string]bool // conversationClosing discards keys while a departing chat finishes closing. conversationClosing bool // quitting keeps new input out of teardown while allowing another interrupt to leave. @@ -2855,7 +2886,9 @@ type app struct { // saveFailure retains closing errors for the report after the screen is restored. saveFailure func(error) // closingStarted retains pending engine replies after the terminal is restored. - closingStarted func(<-chan struct{}) + closingStarted func(<-chan struct{}) + savingDurability func(func() bool) + savingCompatibility func(func() bool) // forceQuit requests immediate process exit after a second interrupt restores the screen. forceQuit func() // handedApproval is the tool-approval posture this launch knows the @@ -3070,8 +3103,10 @@ func newApp(ctx context.Context, opts Options) *app { machines: opts.Machines, taker: opts.Takeover, pairing: opts.Pairing, + syncService: opts.SyncService, cellsUnavailable: opts.CellsUnavailable, approvals: opts.Approvals, + taskComputers: opts.TaskComputers, fleet: opts.Fleet, branches: opts.Branches, ctx: ctx, @@ -3099,6 +3134,8 @@ func newApp(ctx context.Context, opts Options) *app { deviceID: opts.DeviceID, saveFailure: opts.SaveFailure, closingStarted: opts.ClosingStarted, + savingDurability: opts.SavingDurability, + savingCompatibility: opts.SavingCompatibility, forceQuit: opts.ForceQuit, landing: opts.Landing, autoContinue: opts.Landing && !opts.PickSession && opts.Host == "", @@ -3272,6 +3309,7 @@ func newApp(ctx context.Context, opts Options) *app { a.mouse = config.MouseEnabledAt(a.profileDir) a.timestamps = config.TimestampsAt(a.profileDir) a.workMode = config.WorkAt(a.profileDir) + a.teamsShare = config.TeamsShareAt(a.profileDir) a.actionAuto, _ = tokens.DetectGlyphSet(env) a.adoptIcons() // AND THE COLUMN'S POSTURE IS READ HERE AND NOWHERE ELSE — at boot, never at @@ -3601,7 +3639,7 @@ func (a *app) Init() tea.Cmd { // raised, and on the settings page — and reading it on the way up means // neither of those ever pays a round trip. The fold re-stamps whatever // questions the attach already replayed. - a.readAutonomy(), + a.readAutonomy(), a.readPreferredTaskComputers(), // AND THE PULSE'S OWN BEAT, whose first reading is taken now rather than // ten seconds from now (pulsebeat.go). pulseNow, @@ -3749,7 +3787,7 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // message, and this is the one place every one of them has happened by // (teamtraffic.go). It costs a walk of the loaded teams when it is not // turning and nothing when it is. - if tick := a.trafficArm(); tick != nil { + if tick := tea.Batch(a.trafficArm(), a.teamsRefreshArm()); tick != nil { cmd = tea.Batch(cmd, tick) } // AND AN EDIT TO THE TEAMS IS WRITTEN HERE, off the loop, for the same @@ -3964,7 +4002,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // A repeated enter keeps the pending conversation; ctrl+enter does // the same only on the start page, where it is another enter spelling. // Draft edits stay with the pending chat; navigation cancels the transition. - if a.conversationOpening { + if a.conversationOpening && !(a.homeTakeAutomatic && !a.homeTakeAbandoned) { if a.openingDraftKey(msg) { return a, nil } @@ -4281,6 +4319,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case wallTickMsg: return a, a.wallTick() + case teamsRefreshTickMsg: + return a, a.teamsRefreshTick() + case trafficTickMsg: // A TURN THAT FOUND NOTHING DRAWS NOTHING: the frame before it stands // (teamtraffic.go), which is what a quiet second over ssh costs. @@ -4655,7 +4696,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { cmd, _ := a.teamMembershipMouse(msg, msg.Mouse()) return a, cmd } - if a.conversationOpening { + if a.conversationOpening && !(a.homeTakeAutomatic && !a.homeTakeAbandoned) { previewing := a.conversationPreview != nil if previewing { mouse := msg.Mouse() @@ -5543,6 +5584,10 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { a.tookHomeLeftOff(msg) return a, nil + case continueConflictMsg: + return a, a.continueConflictRead(msg) + case homeTakeSettledMsg: + return a, a.automaticTakeSettled(msg) case homeTakenMsg: return a, a.tookTakeover(msg) @@ -5557,6 +5602,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case continuationChecked: return a, a.continuationReady(msg) + case homeMachinesSlowMsg: + a.machinesStillChecking(msg) + return a, nil case homeMachinesMsg: // THE OTHER MACHINES' CHATS, COMING BACK, off the update loop for the // reason the repository's reading is (homepanel_machines.go). @@ -5976,7 +6024,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case linkPingMsg: a.linkPingBack(msg) - return a, a.linkPingTick() + return a, tea.Batch(a.linkPingTick(), a.linkLatencyExpiry()) + case linkLatencyExpiredMsg: + if msg.at == a.linkLatencyAt { + a.touch() + } + return a, nil case levelsMsg: // What each of a batch of models is dialled to, asked off this loop @@ -6484,6 +6537,12 @@ func (a *app) apply(ev session.Event) tea.Cmd { // clock should walk (reveal.go). Only [waitEvent] can answer that for a folded // run, which is why the bit is a parameter rather than a length read here. func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd { + if ev.Kind == session.EventNotice { + ev.Text = a.chatNotice(ev.Text) + if !a.acceptSyncNotice(ev.Text) { + return nil + } + } if owner, ok := a.agent.(interface{ ReplayCovers(session.Event) bool }); ok && owner.ReplayCovers(ev) { return nil } @@ -7293,6 +7352,11 @@ func (a *app) take(u session.Usage) { // note answers a UI action or reports something requiring the person's attention. // Engine bookkeeping uses feed.note so its audience is explicit at the producer. func (a *app) note(text string) { + text = a.chatNotice(text) + if !a.acceptSyncNotice(text) { + return + } + a.blockDisplacedSend(text) a.noteWritten(text, false, nil) } @@ -8889,6 +8953,7 @@ func (a *app) renew() (tea.Cmd, bool) { return a.renewRefusing(a.note) } // the new conversation's entry line, because by then that conversation is what is // on screen. func (a *app) renewRefusing(say func(string)) (tea.Cmd, bool) { + a.abandonAutomaticTake() if !a.canStart() { say(newUnavailableWord) return nil, false @@ -8970,14 +9035,15 @@ func (a *app) finishRenew(conv Conversation, whole, replacing bool) tea.Cmd { // AND THE NOTE SAYS WHICH OF THE TWO HAPPENED. A count appearing on the // status line is not enough on its own to tell somebody whether the // conversation they were in is still running. - switch { - case replacing && a.file != "": - a.toldNote("new session · " + a.hostedPath(a.file)) - case replacing: - a.toldNote("new session") - default: - a.toldNote("new conversation · " + a.place) + project := filepath.Base(a.workspace) + if a.workspace == "" { + project = "" + } + note := "new conversation" + if project != "" { + note += " · " + project } + a.toldNote(note) if conv.Notice != "" { // The door had something to say about HOW this conversation came to be // open — "session open elsewhere — started a new one" is the sentence @@ -9586,6 +9652,14 @@ func (a *app) paste(text string) tea.Cmd { if a.copy.on { return nil } + if a.pair.open { + if (a.pair.offering || a.pair.rejoining) && a.pair.ask == nil && !a.pair.done && a.pair.card == nil { + a.pair.linkBox.insert(strings.TrimSpace(text)) + a.pair.linkFocus = true + a.touch() + } + return nil + } // A PASTE IS SOMEBODY STARTING WORK, so it dismisses the welcome box on the // same terms every other input does (welcome.go): everything puts the box // away except the two keys that walk its list, and a paste is not one of diff --git a/internal/tui3/approve.go b/internal/tui3/approve.go index 21629da302..096df9a746 100644 --- a/internal/tui3/approve.go +++ b/internal/tui3/approve.go @@ -34,6 +34,8 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) // PendingDevice is a request to join as the person sees it. Code is what the @@ -93,9 +95,20 @@ const ( ) // linkShape is what link pairing is typed as (contract section 2): a short -// code with an optional key, or a codeaf URL. The six-digit code of the -// older pairing never matches. -var linkShape = regexp.MustCompile(`(?i)^(https?://)?(codeaf\.agentfield\.ai/p/)?[0-9a-hjkmnp-tv-z]{8}([.#].+)?$|^(https?://)?(codeaf\.agentfield\.ai|codeaf\.link)/p/.+$`) +// code with an optional key, or a codeaf URL from any channel's hosted origin +// (plus the retired host). The six-digit code of the older pairing never matches. +var linkShape = buildLinkShape() + +func buildLinkShape() *regexp.Regexp { + hosts := syncsetup.HostedLinkHosts() + quoted := make([]string, 0, len(hosts)+1) + for _, h := range hosts { + quoted = append(quoted, regexp.QuoteMeta(h)) + } + live := strings.Join(quoted, "|") + all := strings.Join(append(quoted, regexp.QuoteMeta("codeaf.link")), "|") + return regexp.MustCompile(`(?i)^(https?://)?((?:` + live + `)/p/)?[0-9a-hjkmnp-tv-z]{8}([.#].+)?$|^(https?://)?(?:` + all + `)/p/.+$`) +} // isPairLink is the one recogniser for a link pasted or typed where no pairing // was asked for: it carries the key, or it is a codeaf URL (the retired host too, so it is answered, not typed into a chat). A bare @@ -136,15 +149,18 @@ func isLinkShape(typed string) bool { // platformInfo is the icon and word a platform is shown as. A platform not // listed reads as `other`, so a new one needs a row here and no code. -type platformInfo struct{ icon, word string } +type platformInfo struct { + icon tokens.GlyphID + word string +} var platforms = map[string]platformInfo{ - "darwin": {"⌘", "Mac"}, - "linux": {"$", "Linux"}, - "windows": {"▦", "Windows"}, - "ios": {"▯", "iPhone or iPad"}, - "android": {"▯", "Android"}, - "other": {"·", "unknown system"}, + "darwin": {tokens.GPlatformMac, "Mac"}, + "linux": {tokens.GPlatformLinux, "Linux"}, + "windows": {tokens.GPlatformWindows, "Windows"}, + "ios": {tokens.GPlatformMobile, "iPhone or iPad"}, + "android": {tokens.GPlatformMobile, "Android"}, + "other": {tokens.GPlatformOther, "unknown system"}, } func platformOf(name string) platformInfo { @@ -213,7 +229,7 @@ func (m decidedMsg) land(a *app) tea.Cmd { a.toastJoined(m.req.Name, m.req.Device) default: a.pair.close() - a.note(fmt.Sprintf(approveDenied, m.req.Name)) + a.toast(fmt.Sprintf(approveDenied, m.req.Name)) } return nil } @@ -247,7 +263,7 @@ func (c *approveCard) rows(width int, now time.Time, pal palette) []string { } r, info := c.req, platformOf(c.req.Platform) out := dressed(wrap(approveAsk, max(width, 4)), pal.ink) - out = append(out, pal.accent(fit(" "+info.icon+" "+r.Name+" · "+info.word, width))) + out = append(out, pal.accent(fit(" "+pal.glyph(info.icon)+" "+r.Name+" · "+info.word, width))) out = append(out, pal.dim(fit(" "+requestAge(r, now), width))) out = append(out, dressed(wrap(fmt.Sprintf(approveCheckWord, r.Check), max(width, 4)), pal.ink)...) line := c.line @@ -342,9 +358,7 @@ func (a *app) openApprove(typed string) tea.Cmd { func (a *app) showCard(c panelCard) { a.closeLists() a.dismissWelcome() - // HOME IS THE WHOLE SCREEN and draws no panel, so a card opened from it - // would be state nobody can see: the person steps out to the box first. - a.closeHome() + // Home hosts this panel in its standing card, so opening it needs no conversation. a.pair.close() a.pair.open, a.pair.card = true, c a.touch() diff --git a/internal/tui3/approve_test.go b/internal/tui3/approve_test.go index 1cdb61c4e1..ce0e676a37 100644 --- a/internal/tui3/approve_test.go +++ b/internal/tui3/approve_test.go @@ -99,7 +99,7 @@ func TestApproveCardShowsWhoIsAsking(t *testing.T) { r.slash("/pair " + testLink) r.until("the card", func() bool { c, ok := a.pair.card.(*approveCard); return ok && c.req != nil }) got := plain(frame(a)) - for _, want := range []string{"tower", "Linux", "$", "Check number 4821", "asked 2m ago", "min left"} { + for _, want := range []string{"tower", "Linux", "△", "Check number 4821", "asked 2m ago", "min left"} { if !strings.Contains(got, want) { t.Fatalf("card lacks %q:\n%s", want, got) } @@ -189,7 +189,7 @@ func devicesFake() *fakeApprovals { }} } -func TestDeviceListShowsPresenceAndRevokesWithOneKey(t *testing.T) { +func TestDeviceListShowsPresenceAndConfirmsRemoval(t *testing.T) { door := devicesFake() a, r := approveApp(t, door) r.slash("/devices") @@ -207,7 +207,7 @@ func TestDeviceListShowsPresenceAndRevokesWithOneKey(t *testing.T) { if !strings.Contains(plain(frame(a)), devicesOwnRow) { t.Fatalf("r on this device said nothing:\n%s", plain(frame(a))) } - r.press("down", "down", "r") + r.press("down", "down", "r", "y") r.until("revoked", func() bool { return strings.Contains(plain(frame(a)), "tower was removed") }) if door.did() != "revoke:dev_B" { t.Fatalf("door: %s", door.did()) @@ -334,7 +334,7 @@ func TestARevokedRowNeverShowsAnOnlineDot(t *testing.T) { a.dirFeed = stillFeed{online: []string{"dev_B"}} r.slash("/devices") r.until("the list", func() bool { c, ok := a.pair.card.(*deviceCard); return ok && c.loaded }) - r.press("down", "r") + r.press("down", "r", "y") r.until("revoked", func() bool { return strings.Contains(plain(frame(a)), "was removed") }) got := plain(frame(a)) if !strings.Contains(got, "○ tower") || strings.Contains(got, "● tower") || !strings.Contains(got, "removed") { diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index 42b42f592a..d2f4ef303e 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -1423,6 +1423,9 @@ func (a *app) tabPress(x, y int) (tea.Cmd, bool) { // refusals said in the same words, because one gesture with two spellings of // "that folder is gone" is two features to keep in step. func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { + if cmd, remote := a.teamsRemoteGo(teamMember{Key: tab.key, File: tab.file, Word: tab.word}, func() tea.Cmd { return a.tabGo(tab) }); remote { + return cmd + } defer func() { if !a.conversationOpening { a.tabSettleTeam() diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go index d6a59fb460..1960f34963 100644 --- a/internal/tui3/chattip_test.go +++ b/internal/tui3/chattip_test.go @@ -251,7 +251,7 @@ func TestTheWayHomeTipIsSaidAwayFromHomeOnly(t *testing.T) { if row.id == "" { t.Fatal("the way-home tip is not on the table") } - if row.text != "space space takes you back to home" || row.retire != eventHomeGesture { + if row.text != "space space brings you back to home" || row.retire != eventHomeGesture { t.Fatalf("the way-home tip reads %q and retires on %q", row.text, row.retire) } a, _ := chatTipLab(t) diff --git a/internal/tui3/chordregistry_test.go b/internal/tui3/chordregistry_test.go index 038570cf64..8430b09ffe 100644 --- a/internal/tui3/chordregistry_test.go +++ b/internal/tui3/chordregistry_test.go @@ -69,6 +69,7 @@ var chordRegistry = []chordClaim{ {"closeTabChord", []string{ctxTabs}}, {"completionToastKey", []string{ctxConversation}}, {"deviceKey", []string{ctxHome}}, + {"taskComputerCycleKey", []string{ctxQuestion}}, {"effortKey", []string{ctxBox}}, {"filesCopyKey", []string{ctxFiles}}, {"filesRevealKey", []string{ctxFiles}}, diff --git a/internal/tui3/connectcaps_test.go b/internal/tui3/connectcaps_test.go index b061ab5227..e369fc944c 100644 --- a/internal/tui3/connectcaps_test.go +++ b/internal/tui3/connectcaps_test.go @@ -105,8 +105,8 @@ func TestTheConnectionsTabIsOnTheBarAndOwnsNoRegistryRow(t *testing.T) { if !found { t.Fatalf("the tab bar has no %s tab: %v", tabConnections, settingTabs) } - if at != len(settingTabs)-2 { - t.Fatalf("%s sits at %d of %d, want the end of the bar", tabConnections, at, len(settingTabs)) + if at != len(settingTabs)-3 { + t.Fatalf("%s sits at %d of %d, want before Devices and Privacy", tabConnections, at, len(settingTabs)) } registry := config.NewSettings(config.SettingsOptions{ProfileDir: t.TempDir()}) for _, row := range registry.Rows() { diff --git a/internal/tui3/continuation_listing_test.go b/internal/tui3/continuation_listing_test.go index c61669e47b..829646ba61 100644 --- a/internal/tui3/continuation_listing_test.go +++ b/internal/tui3/continuation_listing_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" ) @@ -139,7 +141,13 @@ func TestContinueTakeReplacesAnOutstandingListing(t *testing.T) { src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} taker := &fakeTaker{} a, _ := heldLab(t, src, taker) - old := a.askChats()() + // The listing travels with its five-second progress tick, so the answer is + // read now, before the take, exactly as an answer already in flight would be. + asked, ok := a.askChats()().(tea.BatchMsg) + if !ok || len(asked) == 0 { + t.Fatalf("the listing did not travel as a batch: %T", asked) + } + old := asked[0]() taker.on = func(string) { src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) } drain(t, a, a.takeRemote(src.row)) if !a.machineRead.rows[0].Mine { diff --git a/internal/tui3/continue_2070_test.go b/internal/tui3/continue_2070_test.go new file mode 100644 index 0000000000..3460de083e --- /dev/null +++ b/internal/tui3/continue_2070_test.go @@ -0,0 +1,617 @@ +package tui3 + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + machine "github.com/Agent-Field/codeaf/internal/preflight" + "github.com/Agent-Field/codeaf/internal/session" +) + +type busyProbeOp struct{} + +func (busyProbeOp) Verb() string { return "want" } +func (busyProbeOp) Args() []string { return []string{"want"} } + +func TestHoldStoreWriterHelperProcess(t *testing.T) { + storeDir := os.Getenv("CODEAF_TEST_HOLD_STORE_DIR") + if storeDir == "" { + return + } + release, err := cellstore.HoldStoreWriter(storeDir, "codeaf chat") + if err != nil { + os.Exit(2) + } + _, _ = os.Stdout.WriteString("LOCKED\n") + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + _ = release() + os.Exit(0) +} + +func startForeignStoreWriter(t *testing.T, storeDir string) func() { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(exe, "-test.run=^TestHoldStoreWriterHelperProcess$") //codeaf:plumbing spawns this test binary to hold the engine store writer lock in another process + cmd.Env = append(os.Environ(), "CODEAF_TEST_HOLD_STORE_DIR="+storeDir) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + line, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil || strings.TrimSpace(line) != "LOCKED" { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("helper failed to hold store %s: line=%q err=%v", storeDir, line, err) + } + var stopped bool + stop := func() { + if stopped { + return + } + stopped = true + _, _ = stdin.Write([]byte("release\n")) + _ = stdin.Close() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = cmd.Process.Kill() + <-done + } + } + t.Cleanup(stop) + return stop +} + +type scriptedRemoteSource struct { + rows []chatlist.Row +} + +func (s *scriptedRemoteSource) Rows(context.Context) ([]chatlist.Row, error) { + out := make([]chatlist.Row, len(s.rows)) + copy(out, s.rows) + return out, nil +} + +func stepKeyNoDrain(t *testing.T, a *app, k string) tea.Cmd { + t.Helper() + _, cmd := a.Update(key(k)) + return cmd +} + +func collectBatchMsgs(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + if msg == nil { + return nil + } + if batch, ok := msg.(tea.BatchMsg); ok { + var out []tea.Msg + for _, sub := range batch { + out = append(out, collectBatchMsgs(sub)...) + } + return out + } + return []tea.Msg{msg} +} + +func countHomeRowsForID(a *app, id string) int { + n := 0 + for _, line := range a.home.lines { + switch { + case line.kind == homeMachineRow && line.remote != nil && (line.remote.Cell == id || line.remote.Parent == id): + n++ + case line.kind == homeSession && homeSessionID(line.row) == id: + n++ + } + } + return n +} + +// homeRowWordsForID is everything Home's row for one chat says, its note and +// its description, without the layout that wraps them on the screen. +func homeRowWordsForID(a *app, id string) string { + var words []string + for _, line := range a.home.lines { + if line.cell == nil { + continue + } + mine := line.kind == homeSession && homeSessionID(line.row) == id + theirs := line.kind == homeMachineRow && line.remote != nil && line.remote.Cell == id + if mine || theirs { + words = append(words, line.cell.note, line.cell.noteShort, line.cell.sub) + } + } + return strings.Join(words, "\n") +} + +// Contract 1: In one window, continuing three different chats last written on +// another computer, one after another, each shows `continuing here…` and opens +// that chat with its full history. +func TestContinueThreeRemoteChatsInOneWindowShowsContinuingAndFullHistory(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + initial := lab.session("project", "0000000000000000", "Local start", workspace, time.Now().Add(-time.Hour)) + + type remoteChat struct { + id string + title string + device string + history []session.DisplayEntry + file string + } + remotes := []remoteChat{ + { + id: "0000000000000101", + title: "Alpha migration", + device: "tower", + history: []session.DisplayEntry{ + {Role: "user", Text: "alpha user question"}, + {Role: "assistant", Text: "alpha assistant answer", Answer: true}, + }, + }, + { + id: "0000000000000102", + title: "Beta indexing", + device: "laptop", + history: []session.DisplayEntry{ + {Role: "user", Text: "beta first turn"}, + {Role: "assistant", Text: "beta full response", Answer: true}, + }, + }, + { + id: "0000000000000103", + title: "Gamma release", + device: "studio", + history: []session.DisplayEntry{ + {Role: "user", Text: "gamma ship checklist"}, + {Role: "assistant", Text: "gamma complete verification", Answer: true}, + }, + }, + } + + src := &scriptedRemoteSource{} + byID := map[string]*remoteChat{} + for i := range remotes { + rc := &remotes[i] + byID[rc.id] = rc + src.rows = append(src.rows, chatlist.Row{ + Cell: rc.id, + Title: rc.title, + Device: rc.device, + DeviceID: "dev_" + rc.device, + Status: chatlist.Off, + DurableAgo: time.Duration(i+1) * time.Minute, + }) + } + + a := lab.app(initial) + a.width, a.height = 160, 48 + byFile := map[string][]session.DisplayEntry{} + a.open = func(where, transcript string) (Conversation, error) { + return Conversation{ + Agent: &switchAgent{fakeAgent: &fakeAgent{model: "m", past: byFile[transcript]}}, + SessionFile: transcript, + Workspace: where, + Resumed: true, + }, nil + } + + taker := &fakeTaker{} + taker.on = func(cellID string) { + rc := byID[cellID] + if rc == nil { + t.Fatalf("unexpected cell %q", cellID) + } + rc.file = lab.session("project", rc.id, rc.title, workspace, time.Now()) + byFile[rc.file] = rc.history + taker.taken = Taken{ + Fence: uint64(len(taker.cells)), + Transcript: rc.file, + Resume: machine.Resume{From: rc.device, Now: workspace}, + } + for i := range src.rows { + if src.rows[i].Cell == cellID { + src.rows[i].Status = chatlist.Here + src.rows[i].Mine = true + } + } + } + a.machines = src + a.taker = taker + + for i := range remotes { + rc := &remotes[i] + drain(t, a, a.openHome()) + a.machinesAsking = false + drain(t, a, a.askMachines()) + + standOn(t, a, rc.id) + _ = stepKeyNoDrain(t, a, "1") + takeCmd := stepKeyNoDrain(t, a, "enter") + + if a.homeTaking != rc.id { + t.Fatalf("%s: homeTaking = %q immediately after confirm, want %q", rc.id, a.homeTaking, rc.id) + } + if text := homeText(a); !strings.Contains(text, continuing) { + t.Fatalf("%s: Home did not show %q while take ran:\n%s", rc.id, continuing, text) + } + + drain(t, a, takeCmd) + + if a.homeTaking != "" { + t.Fatalf("%s: homeTaking = %q after chat opened, want empty", rc.id, a.homeTaking) + } + if a.at(pageHome) { + t.Fatalf("%s: stayed on Home instead of opening arrived chat:\n%s", rc.id, homeText(a)) + } + if a.file != rc.file { + t.Fatalf("%s: opened %q, want %q", rc.id, a.file, rc.file) + } + chatLines, _, _ := a.chatFrameLines(a.width, a.height) + rendered := plain(strings.Join(chatLines, "\n")) + for _, entry := range rc.history { + if !strings.Contains(rendered, entry.Text) { + t.Fatalf("%s: opened chat missing history line %q:\n%s", rc.id, entry.Text, rendered) + } + } + } +} + +// Contract 1: homeTaking is cleared on EVERY ending of a take (homeOpenLater, +// bringForward, failure, cancel), a press while a take is already running says +// so in one line on the card instead of doing nothing, and a take's failure is +// shown where the person is looking (the card on Home or notes in the chat). +func TestHomeTakingClearedOnEveryEndingAndBusyPressExplainsOnCard(t *testing.T) { + t.Run("homeOpenLater and bringForward and cancel", func(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + first := lab.session("project", "0000000000000201", "First", workspace, time.Now().Add(-2*time.Minute)) + second := lab.session("project", "0000000000000202", "Second", workspace, time.Now().Add(-time.Minute)) + + src := &scriptedRemoteSource{ + rows: []chatlist.Row{ + {Cell: "0000000000000202", Title: "Second", Device: "desk", Status: chatlist.Off, DurableAgo: time.Minute}, + {Cell: "0000000000000201", Title: "First", Device: "desk", Status: chatlist.Off, DurableAgo: 2 * time.Minute}, + }, + } + a := lab.app(first) + a.width, a.height = 160, 48 + a.machines = src + taker := &fakeTaker{taken: Taken{Fence: 1, Transcript: second, Resume: machine.Resume{From: "desk", Now: workspace}}} + a.taker = taker + + // 1. Success through homeOpenLater parks `first` in a.behind and opens `second`. + drain(t, a, a.openHome()) + drain(t, a, a.askMachines()) + standOn(t, a, "0000000000000202") + confirm(t, a) + if a.homeTaking != "" || a.file != second { + t.Fatalf("after homeOpenLater: homeTaking=%q file=%q", a.homeTaking, a.file) + } + + // 2. Success through bringForward (`first` is held in a.behind and AlreadyHere is true). + taker.taken = Taken{Fence: 2, AlreadyHere: true, Transcript: first, Resume: machine.Resume{Now: workspace}} + drain(t, a, a.openHome()) + a.machinesAsking = false + drain(t, a, a.askMachines()) + standOn(t, a, "0000000000000201") + confirm(t, a) + if a.homeTaking != "" || a.file != first { + t.Fatalf("after bringForward: homeTaking=%q file=%q", a.homeTaking, a.file) + } + + // 3. While a take is already running, pressing continue on a card says so on the card, + // and cancelling clears homeTaking and ignores the late message. + src.rows = []chatlist.Row{ + {Cell: "0000000000000203", Title: "Third", Device: "desk", Status: chatlist.Off, DurableAgo: time.Minute}, + {Cell: "0000000000000204", Title: "Fourth", Device: "desk", Status: chatlist.Off, DurableAgo: 2 * time.Minute}, + } + drain(t, a, a.openHome()) + a.machinesAsking = false + drain(t, a, a.askMachines()) + + standOn(t, a, "0000000000000203") + _ = stepKeyNoDrain(t, a, "1") + inFlight := stepKeyNoDrain(t, a, "enter") + if a.homeTaking != "0000000000000203" { + t.Fatalf("expected in-flight take, got %q", a.homeTaking) + } + + // Press enter on another row while the first take is still running. + standOn(t, a, "0000000000000204") + card := strings.Join(cardRows(a), "\n") + if !strings.Contains(card, continueAlreadyRunning) { + t.Fatalf("busy press did not explain on the card:\n%s\nfull Home:\n%s", card, homeText(a)) + } + + // First esc closes the card; second esc cancels the in-flight take. + drive(t, a, key("esc")) + drive(t, a, key("esc")) + if a.homeTaking != "" { + t.Fatalf("esc did not clear homeTaking: %q", a.homeTaking) + } + // Late arrival from cancelled command is ignored. + for _, msg := range collectBatchMsgs(inFlight) { + _, _ = a.Update(msg) + } + if a.homeTaking != "" { + t.Fatalf("late cancelled message dirtied homeTaking: %q", a.homeTaking) + } + + // 4. Cancel during Phase 2 (conversationOpening) also clears homeTaking. + thirdFile := lab.session("project", "0000000000000203", "Third", workspace, time.Now()) + taker.taken = Taken{Fence: 3, Transcript: thirdFile, Resume: machine.Resume{From: "desk", Now: workspace}} + standOn(t, a, "0000000000000203") + _ = stepKeyNoDrain(t, a, "1") + phase1 := stepKeyNoDrain(t, a, "enter") + takenMsg := phase1().(homeTakenMsg) + _ = a.tookTakeover(takenMsg) + if !a.conversationOpening { + t.Fatal("expected Phase 2 conversationOpening") + } + a.cancelConversationOpening() + if a.homeTaking != "" { + t.Fatalf("cancelConversationOpening left homeTaking=%q", a.homeTaking) + } + }) + + t.Run("failure shown on card on Home and in notes in chat", func(t *testing.T) { + taker := &fakeTaker{err: errors.New("disk write failed")} + a := continueHome(t, taker, continueRows, 160) + standOn(t, a, "c-off") + confirm(t, a) + if a.homeTaking != "" { + t.Fatalf("failed take left homeTaking=%q", a.homeTaking) + } + // The card itself must remain open with the failure reason and retry. + card := strings.Join(cardRows(a), "\n") + if !strings.Contains(card, continueFails) || !strings.Contains(card, continueRetry) { + t.Fatalf("failure not shown on card with retry:\n%s", card) + } + + // Failure while looking at the chat lands in chat notes and clears homeTaking. + a.closeHome() + cmd := a.takeRemote(chatlist.Row{Cell: "c-off", Title: "Port the picker", Device: "host-a", Status: chatlist.Off}) + drain(t, a, cmd) + if a.homeTaking != "" { + t.Fatalf("chat-page failed take left homeTaking=%q", a.homeTaking) + } + if notes := strings.Join(noteTexts(a), "\n"); !strings.Contains(notes, continueFails) { + t.Fatalf("chat-page failed take missing from chat notes: %q", notes) + } + }) +} + +// Contract 2: When the engine store's writer is busy, continuing says within a +// few seconds that the store is busy and which program holds it (pid and +// command), offers retry, and never shows the generic "could not be put in this +// folder" sentence. +func TestBusyStoreNamedWithHolderAndOffersRetry(t *testing.T) { + storeDir := filepath.Join(t.TempDir(), "v3", "stores", "shared") + stopWriter := startForeignStoreWriter(t, storeDir) + defer stopWriter() + + transport := cellstore.Spawn{ + Binary: "/bin/echo", + Run: func(context.Context, string, []string, ...string) ([]byte, error) { + return nil, errors.New("furrow: exit status 1: store writer is still busy after waiting 3 seconds; retry the operation") + }, + } + _, storeErr := transport.Do(context.Background(), cellstore.Target{Tree: t.TempDir(), DataDir: storeDir}, busyProbeOp{}) + if !errors.Is(storeErr, cellstore.ErrStoreBusy) { + t.Fatalf("transport.Call err = %v, want errors.Is(_, cellstore.ErrStoreBusy)", storeErr) + } + + lab := newHomeLab(t) + workspace := lab.workspace("project") + initial := lab.session("project", "0000000000000300", "Start", workspace, time.Now().Add(-time.Hour)) + arrived := lab.session("project", "c-off", "Port the picker", workspace, time.Now()) + + a := lab.app(initial) + a.width, a.height = 160, 48 + a.machines = continueRows + taker := &fakeTaker{err: fmt.Errorf("handoff: %w", storeErr)} + a.taker = taker + + start := time.Now() + drain(t, a, a.openHome()) + drain(t, a, a.askMachines()) + standOn(t, a, "c-off") + confirm(t, a) + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("busy store refusal took %s, want within a few seconds", elapsed) + } + + card := strings.Join(strings.Fields(strings.Join(cardRows(a), " ")), " ") + screen := homeText(a) + if strings.Contains(screen, "could not be put in this folder") { + t.Fatalf("generic folder error shown for busy store:\n%s", screen) + } + exeBase := filepath.Base(os.Args[0]) + for _, want := range []string{ + "could not continue this chat here because the engine store is busy (held by " + exeBase + ", pid ", + continueRetry, + } { + if !strings.Contains(card, want) { + t.Fatalf("busy store card missing %q:\n%s", want, card) + } + } + + // Release the foreign store lock and choose retry on the card. + stopWriter() + taker.err = nil + taker.taken = Taken{Fence: 1, Transcript: arrived, Resume: machine.Resume{From: "host-a", Now: workspace}} + + confirm(t, a) + if a.homeTaking != "" || a.at(pageHome) || a.file != arrived { + t.Fatalf("retry after releasing busy store did not open chat: homeTaking=%q atHome=%v file=%q", a.homeTaking, a.at(pageHome), a.file) + } +} + +// Contract 3: Under `--host`, `continue here` is not offered and a chat the +// remote host already holds never shows as held elsewhere or duplicated. +func TestHostedSurfaceNeverOffersContinueOrShowsHeldElsewhere(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + bChat := lab.session("project", heldCell, "Host B Chat", workspace, time.Now()) + + a := lab.app(bChat) + a.host = "host-b" + a.world = func() (session.World, bool) { return session.ReadWorld(a.placesRoot()), true } + a.width, a.height = 160, 48 + taker := &fakeTaker{} + src := &flipping{status: chatlist.Running} + a.machines = src + a.taker = taker + // Even if stale machine rows exist, hosted Home ignores them consistently. + a.machineRead.rows = []chatlist.Row{ + {Cell: heldCell, Title: "Host B Chat", Device: "host-b", Status: chatlist.Running, DurableAgo: time.Minute}, + {Cell: "other-remote", Title: "Other Remote", Device: "laptop", Status: chatlist.Off, DurableAgo: 2 * time.Minute}, + } + + drain(t, a, a.openHome()) + if cmd := a.pollMachines(a.now()); cmd != nil { + t.Fatalf("hosted pollMachines returned non-nil command") + } + if cmd := a.askMachines(); cmd != nil { + drain(t, a, cmd) + } + if _, elsewhere := a.heldElsewhere(); elsewhere { + t.Fatal("hosted chat reported as heldElsewhere") + } + if a.leftOff.row != nil || a.devicesWanted() || a.addMachineWanted() { + t.Fatal("hosted Home offered local device/resume bands") + } + + screen := homeText(a) + if strings.Contains(screen, "held on host-b") || strings.Contains(screen, chatlist.OfferContinue) || strings.Contains(screen, "Other Remote") { + t.Fatalf("hosted Home showed held-elsewhere or continue offer:\n%s", screen) + } + if got := countHomeRowsForID(a, heldCell); got != 1 { + t.Fatalf("hosted Home showed %d rows for %s, want 1:\n%s", got, heldCell, screen) + } +} + +// Contract 4: A chat known both locally and through sync appears once on Home, +// rows are keyed by chat id rather than transcript path, and branch copies of +// one chat collapse into the main row with a count and loss explanation. +func TestOneChatOneRowKeyedByIDAndCollapsedBranchCopies(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + now := time.Now() + + // Same chat ID stored at two different transcript paths (original and .moving copy). + const sharedID = "0000000000000401" + orig := lab.session("project", sharedID, "Shared Architecture", workspace, now.Add(-10*time.Minute)) + moving := lab.session(filepath.Join("project", ".moving"), sharedID, "Shared Architecture", workspace, now.Add(-2*time.Minute)) + _ = orig + + src := &scriptedRemoteSource{ + rows: []chatlist.Row{ + { + Cell: sharedID, + Title: "Shared Architecture", + Device: "tower", + Status: chatlist.Running, + Head: "turn-5", + DurableAgo: time.Minute, + }, + { + Cell: "branch-redundant", + Title: "Shared Architecture", + Device: "laptop", + Parent: sharedID, + Status: chatlist.Branch, + Head: "turn-5", + OrphanTurns: 2, + DurableAgo: 3 * time.Minute, + }, + }, + } + + a := lab.app(moving) + a.width, a.height = 180, 48 + a.machines = src + a.taker = &fakeTaker{} + + drain(t, a, a.openHome()) + drain(t, a, a.askMachines()) + + if got := countHomeRowsForID(a, sharedID); got != 1 { + t.Fatalf("rows for %s when held elsewhere = %d, want 1:\n%s", sharedID, got, homeText(a)) + } + if got := countHomeRowsForID(a, "branch-redundant"); got != 0 { + t.Fatalf("branch copy rendered as separate row (%d):\n%s", got, homeText(a)) + } + screen := homeText(a) + flat := strings.Join(strings.Fields(screen), " ") + for _, want := range []string{ + "1 copy", + "unsent turns set aside", + "discarding loses nothing", + } { + if !strings.Contains(flat, want) { + t.Fatalf("Home missing collapsed copy explanation %q:\n%s", want, screen) + } + } + + // When held locally (Status == Here) and a second non-redundant branch copy exists, + // there is still exactly one row for sharedID, now saying discarding archives turns not in this chat. + src.rows[0].Status = chatlist.Here + src.rows[0].Mine = true + src.rows = append(src.rows, chatlist.Row{ + Cell: "branch-extra", + Title: "Shared Architecture", + Device: "desk", + Parent: sharedID, + Status: chatlist.Branch, + Head: "turn-diverged", + OrphanTurns: 3, + DurableAgo: 4 * time.Minute, + }) + a.machinesAsking = false + drain(t, a, a.askMachines()) + + if got := countHomeRowsForID(a, sharedID); got != 1 { + t.Fatalf("rows for %s when held here = %d, want 1:\n%s", sharedID, got, homeText(a)) + } + // The explanation is read from the row itself: on the screen it wraps + // wherever its column ends, and the next panel's words fill the gap. + screen = homeText(a) + said := homeRowWordsForID(a, sharedID) + for _, want := range []string{ + "2 copies", + "unsent turns set aside", + "discarding archives", + "3 turns not in this chat", + } { + if !strings.Contains(said, want) { + t.Fatalf("Home missing non-redundant copy explanation %q:\n%s", want, screen) + } + } +} diff --git a/internal/tui3/continue_card.go b/internal/tui3/continue_card.go index e6b18d822f..6fd533a1c1 100644 --- a/internal/tui3/continue_card.go +++ b/internal/tui3/continue_card.go @@ -28,6 +28,18 @@ func (a *app) continueCardFor(row chatlist.Row, fromWindow bool) *continueCard { if line.row.Origin != "" { card.project = filepath.Base(line.row.Origin) + session.MovedCopyWord } + return card + } + for _, project := range a.home.world.Projects { + for _, s := range project.Sessions { + if homeSessionID(s) == row.Cell { + card.project = a.hostedPath(tildePath(s.Workspace, a.tilde)) + if s.Origin != "" { + card.project = filepath.Base(s.Origin) + session.MovedCopyWord + } + return card + } + } } return card } diff --git a/internal/tui3/continue_cleanup_test.go b/internal/tui3/continue_cleanup_test.go new file mode 100644 index 0000000000..610c491259 --- /dev/null +++ b/internal/tui3/continue_cleanup_test.go @@ -0,0 +1,77 @@ +package tui3 + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/remote" +) + +// The arrival opens a view on the same host session as the other window. +// Closing that view must not close the shared engine or release its ownership. +func TestAnAbandonedArrivalLeavesAnotherAttachmentRunning(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + for _, alreadyHere := range []bool{false, true} { + t.Run(map[bool]string{false: "arrived", true: "already here"}[alreadyHere], func(t *testing.T) { + far := &servedAgent{fakeAgent: &fakeAgent{model: "m"}} + engineAgent := &arrivalHostAgent{servedAgent: far, closed: make(chan struct{})} + sess := remote.NewSession(&remote.Engine{Agent: engineAgent, ReleaseOnLeave: func(remote.WrappedAgent) bool { return true }}, true) + t.Cleanup(func() { _ = sess.Close() }) + attach := func() *remote.Client { + surface, engine := remote.Pipe() + go func() { + _ = remote.ServeAttach(engine, engine, remote.AttachOptions{Open: func(remote.Hello) (*remote.Session, error) { return sess, nil }}) + _ = engine.Close() + }() + client, err := remote.Dial(surface, "loopback", remote.Hello{Version: remote.Version}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return client + } + other, unused := attach(), attach() + f := &intentTaker{} + releaseUnusedTake(f, homeTakenMsg{taken: Taken{Fence: 42, AlreadyHere: alreadyHere}}, unused.Agent()) + if sess.Ended() || len(f.released) != 0 { + t.Fatal("cleanup closed or released another window's conversation") + } + if err := other.Take(); err != nil { + t.Fatal(err) + } + if _, err := other.Agent().Submit(context.Background(), "still working"); err != nil { + t.Fatalf("other attachment stopped working: %v", err) + } + far.finish() + if alreadyHere && sess.Attached() != 2 { + t.Fatal("already-here cleanup was not skipped") + } + if !alreadyHere { + if sess.Attached() != 1 { + t.Fatal("unused view was not detached") + } + if err := other.Agent().DetachAndWait(); err != nil { + t.Fatal(err) + } + // The final stream may still be draining when detach answers. + // Its engine close is the last-window release acknowledgment. + <-engineAgent.closed + if !sess.Ended() { + t.Fatal("last-window departure did not release the conversation") + } + } + }) + } +} + +type arrivalHostAgent struct { + *servedAgent + closed chan struct{} +} + +func (a *arrivalHostAgent) Close() error { + err := a.servedAgent.Close() + close(a.closed) + return err +} diff --git a/internal/tui3/continue_conflict_test.go b/internal/tui3/continue_conflict_test.go new file mode 100644 index 0000000000..e6ff2f1d97 --- /dev/null +++ b/internal/tui3/continue_conflict_test.go @@ -0,0 +1,132 @@ +package tui3 + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestALostRaceKeepsItsRetryCardAndDraft(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + src.row.Fence = 3 + taker := &fakeTaker{err: directory.ErrLeaseHeld} + a, _ := heldLab(t, src, taker) + a.closeHome() + a.input.setText(" keep " + pasteToken(1, 2) + " these words") + a.pastes = []pasteChip{{n: 1, text: "whole\npaste"}} + a.chips = []chip{{path: "/tmp/attachment", file: true}} + want := a.mainComposer() + drive(t, a, key("enter")) + if _, ok := a.homeAsking(); !ok { + t.Fatalf("send did not raise a card: %s notes=%v", homeText(a), noteTexts(a)) + } + taker.on = func(string) { + src.change(func(r *chatlist.Row) { r.Device, r.DeviceID, r.Fence = "desktop", "dev-desktop", 8 }) + } + confirm(t, a) + q, ok := a.homeAsking() + if !ok || q.question.Options[0].Label != continueRetry || q.question.Options[1].Label != continueStay { + t.Fatalf("missing retry card: %+v", q) + } + if !strings.Contains(homeText(a), lostRaceWord) || q.question.Subject.Name != src.row.Title { + t.Fatal(homeText(a)) + } + if !reflect.DeepEqual(a.mainComposer(), want) { + t.Fatal("draft changed") + } +} + +func TestARefreshedHoldAsksAgainInsteadOfBlamingAnotherDevice(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + row := leftOffRow("c-off", "tower", chatlist.Running, time.Minute) + row.Fence = 3 + src := &continuationListing{row: row} + taker := &conflictTaker{fakeTaker: fakeTaker{err: directory.ErrLeaseHeld}} + a := continueHome(t, taker, src, 200) + standOn(t, a, row.Cell) + src.change(func(r *chatlist.Row) { r.Fence = 4 }) + confirm(t, a) + q, ok := a.homeAsking() + if strings.Contains(homeText(a), lostRaceWord) { + t.Fatalf("blamed another device for the same holder reopening:\n%s", homeText(a)) + } + if !ok { + t.Fatalf("no refreshed card:\n%s", homeText(a)) + } + if a.home.msg != "tower picked this chat up again; press enter to continue it here" { + t.Fatal(a.home.msg) + } + if len(taker.seen) != 1 { + t.Fatal(taker.seen) + } + taker.err = nil + confirm(t, a) + if len(taker.seen) != 2 || taker.seen[1].Device != row.DeviceID || taker.seen[1].Fence != 4 { + t.Fatalf("new consent not captured: %+v card=%+v", taker.seen, q) + } +} + +// conflictTaker records the consent the person actually confirmed on each card. +type conflictTaker struct { + fakeTaker + seen []directory.LeaseExpectation +} + +func (f *conflictTaker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (Taken, error) { + f.seen = append(f.seen, *seen) + return f.fakeTaker.Take(ctx, id) +} + +func TestALostRaceFromASilentReturnKeepsItsCardAndDraft(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: chatlist.Row{Cell: heldCell, Title: "returning chat", Status: chatlist.Off, Mine: true, DeviceID: "dev-here", Fence: 2, Head: "saved"}} + taker := &conflictTaker{fakeTaker: fakeTaker{err: directory.ErrLeaseHeld}} + a, old := heldLab(t, src, taker) + a.closeHome() + root := session.DirOf(a.file) + if err := os.MkdirAll(filepath.Join(root, ".cell"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".cell", "adopted.json"), []byte(`{"V":1,"head":"saved","turns_size":0}`), 0600); err != nil { + t.Fatal(err) + } + taker.taken = Taken{Fence: 5, Head: "saved", Transcript: a.file} + taker.on = func(string) { + src.change(func(r *chatlist.Row) { + r.Mine, r.Status, r.DeviceID, r.Device, r.Fence = false, chatlist.Running, "dev-away", "tower", 4 + }) + } + a.input.setText("keep " + pasteToken(1, 2) + " these words") + a.pastes = []pasteChip{{n: 1, text: "whole\npaste"}} + want := a.mainComposer() + drive(t, a, key("enter")) + q, ok := a.homeAsking() + if !a.at(pageHome) || !ok || q.question.Options[0].Label != continueRetry { + t.Fatalf("silent refusal has no card: %s", homeText(a)) + } + if len(old.sent) != 0 || !reflect.DeepEqual(a.mainComposer(), want) { + t.Fatal("refusal spent the draft") + } + taker.err = nil + taker.on = func(string) { src.change(func(r *chatlist.Row) { r.Mine, r.Status, r.Fence = true, chatlist.Here, 5 }) } + confirm(t, a) + if len(taker.seen) != 2 || taker.seen[1].Device != "dev-away" || taker.seen[1].Fence != 4 { + t.Fatal(taker.seen) + } + arrived, ok := a.agent.(*switchAgent) + if !ok || len(arrived.sent) != 0 || !reflect.DeepEqual(a.mainComposer(), want) { + t.Fatal("retry did not retain second-enter policy") + } +} diff --git a/internal/tui3/continue_fix_round_test.go b/internal/tui3/continue_fix_round_test.go new file mode 100644 index 0000000000..9ec2c42870 --- /dev/null +++ b/internal/tui3/continue_fix_round_test.go @@ -0,0 +1,156 @@ +package tui3 + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "testing/synctest" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +type cancelledIntentTaker struct{ *intentTaker } + +func (f cancelledIntentTaker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (Taken, error) { + if id != "x" { + return f.intentTaker.TakeSeen(ctx, id, seen) + } + f.started <- id + <-ctx.Done() + return Taken{}, ctx.Err() +} + +func TestAnAbandonedAutomaticTakeBoundsTheWaitingChoice(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + a, f, rows := intentRig(t) + keepSurfaceWorkersInTest(t, a) + a.taker = cancelledIntentTaker{f} + cmd := beginAutomatic(t, a) + defer a.homeTakeCancel() + result := make(chan []tea.Msg, 1) + go func() { result <- collectBatchMsgs(cmd) }() + <-f.started + // Time before abandonment does not shorten the person's waiting choice. + time.Sleep(3 * time.Minute) + standOn(t, a, rows[1].Cell) + confirm(t, a) + time.Sleep(2*time.Minute - time.Nanosecond) + select { + case <-result: + t.Fatal("automatic take cancelled before two minutes after abandonment") + default: + } + time.Sleep(time.Nanosecond) + synctest.Wait() + select { + case messages := <-result: + for _, msg := range messages { + drive(t, a, msg) + } + default: + t.Fatal("abandoned automatic take did not cancel after two minutes") + } + if a.file != f.files["y"] || a.homeTaking != "" || len(f.released) != 0 { + t.Fatal("waiting choice did not open after the cancelled take settled") + } + }) +} + +func TestASecondEscapeNavigatesWhileAnAutomaticTakeSettles(t *testing.T) { + a, f, _ := intentRig(t) + keepSurfaceWorkersInTest(t, a) + file := a.file + cmd := beginAutomatic(t, a) + drive(t, a, key("esc")) + if !a.at(pageHome) { + t.Fatal("first Escape left Home before withdrawing automatic navigation") + } + drive(t, a, key("esc")) + if a.at(pageHome) { + t.Fatal("second Escape was swallowed by the abandoned automatic take") + } + close(f.proceed) + drain(t, a, cmd) + if a.file != file || !reflect.DeepEqual(f.released, []string{"x"}) { + t.Fatal("automatic arrival opened after Escape") + } +} + +func TestInspectingAnotherCardLeavesTheAutomaticArrivalAlone(t *testing.T) { + for _, answer := range []string{"leave it there", "escape"} { + t.Run(answer, func(t *testing.T) { + a, f, rows := intentRig(t) + keepSurfaceWorkersInTest(t, a) + cmd := beginAutomatic(t, a) + standOn(t, a, rows[1].Cell) + if answer == "escape" { + drive(t, a, key("esc")) + } else { + drive(t, a, key("2"), key("enter")) + } + close(f.proceed) + drain(t, a, cmd) + if a.file != f.files["x"] || len(f.seen) != 1 || len(f.released) != 0 { + t.Fatal("inspecting and declining a card withdrew the automatic arrival") + } + }) + } +} + +func TestAlreadyHereRecoveryRetriesOnlyOnceAndKeepsTheDraft(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: chatlist.Row{Cell: heldCell, Title: "returning chat", Status: chatlist.Here, Mine: true, DeviceID: "dev-here", Fence: 3, Head: "saved"}} + f := &conflictTaker{fakeTaker: fakeTaker{err: directory.ErrLeaseHeld}} + a, _ := heldLab(t, src, f) + keepSurfaceWorkersInTest(t, a) + root := session.DirOf(a.file) + if err := os.MkdirAll(filepath.Join(root, ".cell"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".cell", "adopted.json"), []byte(`{"V":1,"head":"saved","turns_size":0}`), 0600); err != nil { + t.Fatal(err) + } + a.input.setText("keep " + pasteToken(1, 2) + " these words") + a.pastes = []pasteChip{{n: 1, text: "whole\npaste"}} + a.chips = []chip{{path: "/tmp/kept", file: true}} + want := a.mainComposer() + row := src.row + row.Fence = 2 + cmd := a.takeRemote(row) + // Deliver only a bounded number of answers, so the old retry loop fails + // the assertion rather than hanging the test's event-draining fixture. + for step := 0; cmd != nil && step < 8; step++ { + var next []tea.Cmd + for _, msg := range collectBatchMsgs(cmd) { + switch msg.(type) { + case homeTakenMsg, continueConflictMsg: + _, follow := a.Update(msg) + next = append(next, follow) + } + } + cmd = tea.Batch(next...) + } + q, ok := a.homeAsking() + if len(f.seen) != 2 || !ok || q.question.Reason != continueFails || q.question.Options[0].Label != continueRetry || !reflect.DeepEqual(a.mainComposer(), want) { + t.Fatalf("already-here disagreement: attempts=%d card=%+v draft=%+v", len(f.seen), q, a.mainComposer()) + } + for _, seen := range f.seen { + if seen.Device != "dev-here" || seen.Fence != 2 { + t.Fatal("silent recovery changed the original consent", f.seen) + } + } + // An explicit retry starts its own bounded recovery attempt. + f.err = errors.New("stop on the explicit retry") + confirm(t, a) + if len(f.seen) != 3 { + t.Fatal("failure card cannot be retried explicitly") + } +} diff --git a/internal/tui3/continue_intent_test.go b/internal/tui3/continue_intent_test.go new file mode 100644 index 0000000000..4a1528ce26 --- /dev/null +++ b/internal/tui3/continue_intent_test.go @@ -0,0 +1,435 @@ +package tui3 + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +type intentTaker struct { + started chan string + proceed chan struct{} + released []string + seen []directory.LeaseExpectation + files map[string]string + fail bool +} + +func (f *intentTaker) SafeToContinue(context.Context, string, string, bool) (bool, error) { + return true, nil +} +func (f *intentTaker) Take(ctx context.Context, id string) (Taken, error) { + return f.TakeSeen(ctx, id, nil) +} +func (f *intentTaker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (Taken, error) { + f.seen = append(f.seen, *seen) + f.started <- id + if id == "x" { + <-f.proceed + if f.fail { + return Taken{}, errors.New("failed x") + } + } + return Taken{Fence: 42, Transcript: f.files[id]}, nil +} +func (f *intentTaker) ReleaseUnused(ctx context.Context, id string, fence uint64) error { + if ctx.Err() != nil { + panic("cleanup inherited a cancelled context") + } + if _, ok := ctx.Deadline(); !ok || fence != 42 { + panic("unbounded or incorrect cleanup") + } + f.released = append(f.released, id) + return nil +} +func intentRig(t *testing.T) (*app, *intentTaker, []chatlist.Row) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + lab := newHomeLab(t) + ws := lab.workspace("p") + mine := lab.session("p", "local", "local", ws, time.Now()) + f := &intentTaker{started: make(chan string, 4), proceed: make(chan struct{}), files: map[string]string{}} + for _, id := range []string{"x", "y"} { + f.files[id] = lab.session("p", id, id, ws, time.Now()) + } + a := lab.app(mine) + a.width, a.height = 200, 70 + drain(t, a, a.openHome()) + rows := []chatlist.Row{ + {Cell: "x", Title: "automatic chat", Device: "tower", DeviceID: "dev-x", Fence: 7, Status: chatlist.Idle, Released: true, ReleasedAgo: time.Minute}, + {Cell: "y", Title: "chosen chat", Device: "laptop", DeviceID: "dev-y", Fence: 9, Status: chatlist.Off}, + } + a.taker = f + a.machineRead.rows = rows + a.rebuildMachines() + return a, f, rows +} +func beginAutomatic(t *testing.T, a *app) tea.Cmd { + t.Helper() + a.autoContinue = true + check := a.continueAfterQuit(nil) + if check == nil { + t.Fatal("no startup safety check") + } + cmds := a.continuationReady(check().(continuationChecked)) + if cmds == nil { + t.Fatal("no automatic take") + } + return cmds +} +func TestAPersonsPickIsTheChatThatOpens(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "failure"}[failed], func(t *testing.T) { + a, f, rows := intentRig(t) + f.fail = failed + result := make(chan []tea.Msg, 1) + cmd := beginAutomatic(t, a) + go func() { result <- collectBatchMsgs(cmd) }() + if id := <-f.started; id != "x" { + t.Fatal(id) + } + // Confirm the captured row, even if a later listing has a different ownership. + standOn(t, a, rows[1].Cell) + confirm(t, a) + if a.home.msg != "continuing chosen chat next; automatic chat is still on its way here" { + t.Fatalf("waiting choice: %q", a.home.msg) + } + select { + case id := <-f.started: + t.Fatalf("concurrent take %s", id) + default: + } + a.machineRead.rows[1].Fence = 99 + // A committed acquisition may answer after its request was cancelled. + // Cleanup must use its own bounded context rather than inherit this one. + a.homeTakeCancel() + close(f.proceed) + for _, msg := range <-result { + _, cmd := a.Update(msg) + drain(t, a, cmd) + } + if a.file != f.files["y"] { + t.Fatalf("opened %q, want y", a.file) + } + if !reflect.DeepEqual(f.seen, []directory.LeaseExpectation{{Device: "dev-x", Fence: 7}, {Device: "dev-y", Fence: 9}}) { + t.Fatal(f.seen) + } + want := []string{"x"} + if failed { + want = nil + } + if !reflect.DeepEqual(f.released, want) { + t.Fatalf("released %v", f.released) + } + if strings.Contains(homeText(a), "failed x") { + t.Fatal("abandoned failure raised a card") + } + }) + } +} +func TestPickingTheArrivingChatJoinsItsMove(t *testing.T) { + a, f, rows := intentRig(t) + cmd := beginAutomatic(t, a) + a.offerContinue(rows[0], chatlist.OfferFor(rows[0])) + confirm(t, a) + if strings.Contains(a.home.msg, continueAlreadyRunning) { + t.Fatal(a.home.msg) + } + close(f.proceed) + drain(t, a, cmd) + if a.file != f.files["x"] || len(f.seen) != 1 || len(f.released) != 0 { + t.Fatalf("join file=%s takes=%v releases=%v", a.file, f.seen, f.released) + } +} + +func TestAnExplicitMoveKeepsTheBusyAnswer(t *testing.T) { + a, f, rows := intentRig(t) + cmd := a.takeRemote(rows[0]) + standOn(t, a, rows[1].Cell) + confirm(t, a) + q, ok := a.homeAsking() + if !ok || q.question.Reason != continueAlreadyRunning || a.home.msg != continueAlreadyRunning { + t.Fatalf("explicit move lost its busy card: %s", homeText(a)) + } + close(f.proceed) + drain(t, a, cmd) + if a.file != f.files["x"] || len(f.seen) != 1 || len(f.released) != 0 { + t.Fatalf("explicit move changed: %s %+v %v", a.file, f.seen, f.released) + } +} + +func TestABareStartWithoutInputStillContinuesTheLatestRelease(t *testing.T) { + a, f, _ := intentRig(t) + close(f.proceed) + drain(t, a, beginAutomatic(t, a)) + if a.file != f.files["x"] || len(f.seen) != 1 { + t.Fatal("unattended startup changed") + } +} +func TestALateAutomaticMoveDoesNotOpenOverAChosenChat(t *testing.T) { + a, f, _ := intentRig(t) + cmd := beginAutomatic(t, a) + line := homeLine{kind: homeSession, row: session.SessionRow{ID: "local", Transcript: a.file}} + drain(t, a, a.homeOpenLine(line)) + chosen := a.file + close(f.proceed) + drain(t, a, cmd) + if a.file != chosen || !reflect.DeepEqual(f.released, []string{"x"}) { + t.Fatalf("late move file=%s released=%v", a.file, f.released) + } +} +func TestEscapeDropsAWaitingPick(t *testing.T) { + a, f, rows := intentRig(t) + cmd := beginAutomatic(t, a) + a.offerContinue(rows[1], chatlist.OfferFor(rows[1])) + confirm(t, a) + drive(t, a, key("esc")) + chosen := a.file + close(f.proceed) + drain(t, a, cmd) + if len(f.seen) != 1 || a.file != chosen || !reflect.DeepEqual(f.released, []string{"x"}) { + t.Fatalf("cancel file=%s takes=%v released=%v", a.file, f.seen, f.released) + } +} + +func TestAResumeChordKeepsItsChoiceBehindAnAutomaticMove(t *testing.T) { + a, f, rows := intentRig(t) + cmd := beginAutomatic(t, a) + a.leftOff.row = &rows[1] + drive(t, a, key(resumeKey)) + if a.home.msg != "continuing chosen chat next; automatic chat is still on its way here" { + t.Fatal(a.home.msg) + } + close(f.proceed) + drain(t, a, cmd) + if a.file != f.files["y"] || len(f.seen) != 2 || f.seen[1].Fence != 9 { + t.Fatalf("chord choice lost: file=%s consent=%+v", a.file, f.seen) + } +} + +func TestALateAutomaticDoorDoesNotReplaceAPick(t *testing.T) { + for _, choice := range []string{"local", "remote", "new"} { + t.Run(choice, func(t *testing.T) { + a, f, rows := intentRig(t) + opened, proceed := make(chan struct{}), make(chan struct{}) + original := a.open + a.open = func(where, file string) (Conversation, error) { + if file == f.files["x"] { + close(opened) + <-proceed + } + return original(where, file) + } + close(f.proceed) + answers := collectBatchMsgs(beginAutomatic(t, a)) + var door tea.Cmd + for _, msg := range answers { + if got, ok := msg.(homeTakenMsg); ok { + door = a.tookTakeover(got) + } + } + <-opened + var newDoor tea.Cmd + switch choice { + case "local": + drain(t, a, a.homeOpenLine(homeLine{kind: homeSession, row: session.SessionRow{ID: "local", Transcript: a.file}})) + case "remote": + standOn(t, a, rows[1].Cell) + confirm(t, a) + case "new": + newDoor = a.homeStart("") + drive(t, a, key("z")) + } + chosen := a.file + close(proceed) + drain(t, a, door) + if newDoor != nil { + drain(t, a, newDoor) + if a.file == chosen { + t.Fatal("new chat was not opened") + } + chosen = a.file + if a.input.String() != "z" { + t.Fatal("new chat follow-up was lost behind the automatic arrival") + } + } + if choice == "remote" { + chosen = f.files["y"] + } + if a.file != chosen || !reflect.DeepEqual(f.released, []string{"x"}) { + t.Fatalf("late door: choice=%s file=%s releases=%v", choice, a.file, f.released) + } + }) + } +} + +func TestMovingTheCursorDoesNotWithdrawAnAutomaticArrival(t *testing.T) { + a, f, _ := intentRig(t) + cmd := beginAutomatic(t, a) + drive(t, a, key("down")) + drain(t, a, a.openHome()) + close(f.proceed) + drain(t, a, cmd) + if a.file != f.files["x"] || len(f.released) != 0 { + t.Fatalf("navigation withdrew arrival: %s %v", a.file, f.released) + } +} + +func TestALaterLocalChoiceDropsAQueuedContinue(t *testing.T) { + a, f, rows := intentRig(t) + chosen := a.file + cmd := beginAutomatic(t, a) + result := make(chan []tea.Msg, 1) + go func() { result <- collectBatchMsgs(cmd) }() + <-f.started + standOn(t, a, rows[1].Cell) + confirm(t, a) + drain(t, a, a.homeOpenLine(homeLine{kind: homeSession, row: session.SessionRow{ID: "local", Transcript: chosen}})) + close(f.proceed) + for _, msg := range <-result { + drive(t, a, msg) + } + if a.file != chosen || len(f.seen) != 1 || !reflect.DeepEqual(f.released, []string{"x"}) { + t.Fatalf("latest choice lost: %s %+v %v", a.file, f.seen, f.released) + } +} + +func TestInputBeforeTheStartupSafetyAnswerCancelsTheMove(t *testing.T) { + a, f, _ := intentRig(t) + file := a.file + a.autoContinue = true + check := a.continueAfterQuit(nil) + drive(t, a, key("down")) + if cmd := a.continuationReady(check().(continuationChecked)); cmd != nil { + t.Fatal("input did not cancel the safety answer") + } + if len(f.seen) != 0 || a.file != file { + t.Fatal("cancelled startup moved a chat") + } +} + +func TestCancellingAJoinedAutomaticTakeStillLetsItGo(t *testing.T) { + a, f, rows := intentRig(t) + file := a.file + cmd := beginAutomatic(t, a) + a.offerContinue(rows[0], chatlist.OfferFor(rows[0])) + confirm(t, a) + drive(t, a, key("esc")) + close(f.proceed) + drain(t, a, cmd) + if a.file != file || len(f.seen) != 1 || !reflect.DeepEqual(f.released, []string{"x"}) { + t.Fatalf("cancelled join stayed held: %s %+v %v", a.file, f.seen, f.released) + } +} + +func TestPickingTheArrivingChatAfterCancellingAWaitingPickJoins(t *testing.T) { + a, f, rows := intentRig(t) + cmd := beginAutomatic(t, a) + standOn(t, a, rows[1].Cell) + confirm(t, a) + drive(t, a, key("esc")) + a.offerContinue(rows[0], chatlist.OfferFor(rows[0])) + confirm(t, a) + close(f.proceed) + drain(t, a, cmd) + if a.file != f.files["x"] || len(f.seen) != 1 || len(f.released) != 0 { + t.Fatalf("join did not adopt its arrival: %s %+v %v", a.file, f.seen, f.released) + } +} + +type checkedIntentTaker struct { + *intentTaker + checking chan struct{} + checked chan struct{} + checkErr error +} + +func (f *checkedIntentTaker) SafeToContinue(_ context.Context, id, _ string, _ bool) (bool, error) { + if id == "y" { + close(f.checking) + <-f.checked + return true, f.checkErr + } + return true, nil +} + +func TestALateSafetyAnswerCannotUndoANewerPick(t *testing.T) { + for _, action := range []string{"local", "escape"} { + t.Run(action, func(t *testing.T) { + a, f, rows := intentRig(t) + file := a.file + checked := &checkedIntentTaker{intentTaker: f, checking: make(chan struct{}), checked: make(chan struct{})} + a.taker = checked + arriving := make(chan []tea.Msg, 1) + cmd := beginAutomatic(t, a) + go func() { arriving <- collectBatchMsgs(cmd) }() + <-f.started + a.leftOff.row = &rows[1] + check := stepKeyNoDrain(t, a, resumeKey) + answer := make(chan []tea.Msg, 1) + go func() { answer <- collectBatchMsgs(check) }() + <-checked.checking + close(f.proceed) + for _, msg := range <-arriving { + drive(t, a, msg) + } + if a.file != file || !reflect.DeepEqual(f.released, []string{"x"}) { + t.Fatal("automatic arrival opened over a checking choice") + } + if action == "local" { + drain(t, a, a.homeOpenLine(homeLine{kind: homeSession, row: session.SessionRow{ID: "local", Transcript: file}})) + } else { + drive(t, a, key("esc")) + } + close(checked.checked) + for _, msg := range <-answer { + drive(t, a, msg) + } + if a.file != file || len(f.seen) != 1 { + t.Fatal("late safety answer opened an abandoned choice") + } + }) + } +} + +func TestAFailedSafetyCheckDoesNotCancelAnAutomaticAcquisition(t *testing.T) { + a, f, rows := intentRig(t) + file := a.file + checked := &checkedIntentTaker{intentTaker: f, checking: make(chan struct{}), checked: make(chan struct{}), checkErr: errors.New("safety read failed")} + a.taker = checked + arriving := make(chan []tea.Msg, 1) + cmd := beginAutomatic(t, a) + go func() { arriving <- collectBatchMsgs(cmd) }() + <-f.started + a.leftOff.row = &rows[1] + check := stepKeyNoDrain(t, a, resumeKey) + answer := make(chan []tea.Msg, 1) + go func() { answer <- collectBatchMsgs(check) }() + <-checked.checking + close(checked.checked) + for _, msg := range <-answer { + drive(t, a, msg) + } + if a.homeTaking != "x" { + t.Fatal("a safety error discarded the acquisition still running") + } + if _, ok := a.homeAsking(); !ok { + t.Fatal("failed safety check has no retry card") + } + close(f.proceed) + for _, msg := range <-arriving { + drive(t, a, msg) + } + if a.file != file || !reflect.DeepEqual(f.released, []string{"x"}) || len(f.seen) != 1 { + t.Fatal("failed safety check let the automatic arrival open") + } +} diff --git a/internal/tui3/conversation_open.go b/internal/tui3/conversation_open.go index dcf2d2de09..f8ef73990d 100644 --- a/internal/tui3/conversation_open.go +++ b/internal/tui3/conversation_open.go @@ -118,12 +118,22 @@ func (a *app) conversationLater(ask func() (Conversation, error), say func(strin } a.conversationRequest++ request, page, ctx := a.conversationRequest, a.page, a.ctx + var automatic *homeTakenMsg + if a.homeTakeOpening && a.homeTakeResult != nil && a.homeTakeResult.automatic { + result := *a.homeTakeResult + automatic = &result + } + taker := a.taker a.conversationOpening = true say(conversationOpeningWord) a.touch() return a.offLoop(func() func(bool) tea.Cmd { conv, err := ask() if ctx.Err() != nil { + if automatic != nil { + releaseUnusedTake(taker, *automatic, conv.Agent) + return nil + } if conv.Agent != nil { leaveAgent(conv.Agent) } @@ -132,11 +142,24 @@ func (a *app) conversationLater(ask func() (Conversation, error), say func(strin return func(here bool) tea.Cmd { preview := a.conversationPreview current := a.conversationOpening && request == a.conversationRequest + if automatic != nil && (automatic.seq != a.homeTakeSeq || a.homeTakeAbandoned || !current || !here || !a.at(page)) { + if automatic.seq == a.homeTakeSeq { + a.homeTakeAbandoned = true + } + if current { + a.conversationOpening = false + a.conversationPreview = nil + } + return a.settleAutomaticTake(*automatic, conv.Agent) + } if current { a.conversationOpening = false a.conversationPreview = nil } if !current || !here || !a.at(page) { + if current { + a.finishHomeTake(true) + } if conv.Agent != nil { leaveOffFrame(conv.Agent) } @@ -173,10 +196,8 @@ func (a *app) conversationLater(ask func() (Conversation, error), say func(strin } func (a *app) cancelConversationOpening() { - if a.homeTakeDraft != nil { - a.restoreTakeDraft(*a.homeTakeDraft) - a.homeTaking, a.homeTakeDraft, a.homeTakenSaid = "", nil, nil - } + a.abandonAutomaticTake() + a.finishHomeTake(true) hadDraft := a.conversationPreview != nil && (!a.conversationPreview.view.input.empty() || len(a.conversationPreview.view.pasted) > 0) if a.conversationPreview == nil { defer a.tabSettleTeam() @@ -216,6 +237,7 @@ func (a *app) startDoor(workspace string) (func() (Conversation, error), bool) { } func (a *app) renewLater(say func(string), after func() tea.Cmd) tea.Cmd { + a.abandonAutomaticTake() if a.shared { cmd, ok := a.renewRefusing(say) if ok && after != nil { @@ -328,12 +350,10 @@ func (a *app) homeOpenLater(line homeLine, takeover bool) tea.Cmd { if a.homeTakenSaid != nil { said, a.homeTakenSaid = a.homeTakenSaid(), nil } + a.finishHomeTake(false) return tea.Batch(opened, a.homeLandOnTask(line), said) }, func(err error) tea.Cmd { - if a.homeTakeDraft != nil { - a.restoreTakeDraft(*a.homeTakeDraft) - a.homeTaking, a.homeTakeDraft, a.homeTakenSaid = "", nil, nil - } + a.finishHomeTake(true) if errors.Is(err, session.ErrSessionLocked) || err.Error() == sessionBusyWord { if takeover { return a.homeTakeoverEnter(line) diff --git a/internal/tui3/conversationrow.go b/internal/tui3/conversationrow.go index 0159d7722f..4c8b91508f 100644 --- a/internal/tui3/conversationrow.go +++ b/internal/tui3/conversationrow.go @@ -22,6 +22,7 @@ import ( // locked" shortcut here, because this place never read the flock: the open // reports it instead, which is what the resume picker has always done. func (a *app) openConversationRow(row session.SessionRow) tea.Cmd { + a.abandonAutomaticTake() a.teamViewSet("") switch { case a.holding(row.Transcript): diff --git a/internal/tui3/devices.go b/internal/tui3/devices.go index bb6f813fc2..a8830488d2 100644 --- a/internal/tui3/devices.go +++ b/internal/tui3/devices.go @@ -4,9 +4,9 @@ package tui3 // // `/devices` lists the fleet: `● laptop Linux` for a device with a watch // socket open now, `○ desktop Mac seen 3h ago` for one without. `r` revokes the -// device under the cursor, at once and without a second question: the row is -// the choice, a revoked device is refused by the relay from that moment, and -// it can only come back as a new request that this fleet approves again. +// device under the cursor after an explicit confirmation. The question keeps +// that row selected, and every key except y cancels without another action. +// A removed device must pair again before it can return. // // `n` NAMES THIS DEVICE. The name starts as the host name and is filled in, so // enter keeps it and any typing edits it; the other devices show the new name @@ -29,23 +29,26 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) const ( - devicesNameKey = "n rename" - devicesGoneKey = "r remove" - devicesMoveKey = "↑↓ choose" - devicesCloseKey = "esc close" - devicesNameKeys = "enter save · ctrl+u clear · esc cancel" - devicesNameAsk = "name this device: " - devicesNameOwn = "a device renames itself — choose this device's row, or open /devices on the one you mean." - devicesLoading = "looking up your devices…" - devicesNone = "no other device yet — /pair adds one" - devicesRevoked = "%s was removed — it can no longer reach your chats." - devicesSelf = "this device" - devicesOwnRow = "this device cannot remove itself — choose another row." - idTail = 4 + devicesNameKey = "n rename" + devicesGoneKey = "r remove" + devicesMoveKey = "↑↓ choose" + devicesCloseKey = "esc close" + devicesNameKeys = "enter save · ctrl+u clear · esc cancel" + devicesNameAsk = "name this device: " + devicesNameOwn = "a device renames itself — choose this device's row, or open /devices on the one you mean." + devicesLoading = "looking up your devices…" + devicesRemoveKeys = "y removes · any other key keeps it" + devicesRemoveAsk = "remove %s? it must pair again to come back · " + devicesRemoveKeys + devicesRevoked = "%s was removed — it can no longer reach your chats." + devicesSelf = "this device" + devicesOwnRow = "this device cannot remove itself — choose another row." ) // deviceCard is the list's state. Online comes from the feed when the card opens. @@ -53,6 +56,7 @@ type deviceCard struct { // presence reads who is online and whether the feed can say, at each draw, // so a socket that comes up after the card opened still lights its dots. presence func() (map[string]bool, bool) + service string rowsOf []DeviceRow loaded bool cursor int @@ -62,6 +66,8 @@ type deviceCard struct { readErr error // naming is the name being typed, and nil when no name is being typed. naming *string + // removing freezes the chosen device across asynchronous roster refreshes. + removing *DeviceRow // read is the newest directory version the rows were read at, so a change another computer // makes (a device revoked, renamed or added) is read again once and not on every signal. read uint64 @@ -85,16 +91,41 @@ type revokedMsg struct { err error } +// openDevicesCard is the device list currently on screen, whether opened as +// /devices or hosted on the /settings sheet. +func (a *app) openDevicesCard() *deviceCard { + if a.pair.open { + if card, ok := a.pair.card.(*deviceCard); ok { + return card + } + } + if a.at(pageSettings) && a.sheet.dev != nil { + return a.sheet.dev + } + return nil +} + +// ownsDeviceCard reports whether c is still held by /devices or by the open +// /settings sheet, so a late answer from a closed view is dropped. +func (a *app) ownsDeviceCard(c *deviceCard) bool { + return c != nil && (a.pair.card == panelCard(c) || (a.at(pageSettings) && a.sheet.dev == c)) +} + func (m devicesMsg) land(a *app) tea.Cmd { - if a.pair.card != panelCard(m.card) { + if !a.ownsDeviceCard(m.card) { return nil } + wasAdd := m.card.loaded && m.card.cursor == len(m.card.rowsOf) + if a.at(pageSettings) && a.sheet.dev == m.card { + a.sheet.syncDeviceCursor() + } if m.err != nil { if !m.card.loaded { m.card.line = m.err.Error() } else { m.card.readErr = m.err } + a.syncSheetDeviceCard(m.card, wasAdd) return nil } m.card.readErr = nil @@ -105,6 +136,10 @@ func (m devicesMsg) land(a *app) tea.Cmd { picked, had := m.card.pick() m.card.rowsOf, m.card.loaded = orderDevices(m.rows), true m.card.keepCursorOn(picked.ID, had) + if wasAdd { + m.card.cursor = len(m.card.rowsOf) + } + a.syncSheetDeviceCard(m.card, wasAdd) return nil } @@ -128,27 +163,33 @@ func (c *deviceCard) livePresence() (map[string]bool, bool) { } func (m renamedMsg) land(a *app) tea.Cmd { - if a.pair.card != panelCard(m.card) { + if !a.ownsDeviceCard(m.card) { return nil } + wasAdd := a.at(pageSettings) && a.sheet.dev == m.card && a.sheet.onAddDeviceRow() if m.err != nil { m.card.line = m.err.Error() + a.syncSheetDeviceCard(m.card, wasAdd) return nil } m.card.naming, m.card.line = nil, m.said + a.syncSheetDeviceCard(m.card, wasAdd) return a.reloadDevices(m.card) } func (m revokedMsg) land(a *app) tea.Cmd { - if a.pair.card != panelCard(m.card) { + if !a.ownsDeviceCard(m.card) { return nil } + wasAdd := a.at(pageSettings) && a.sheet.dev == m.card && a.sheet.onAddDeviceRow() if m.err != nil { m.card.line = m.err.Error() + a.syncSheetDeviceCard(m.card, wasAdd) return nil } m.card.mark(m.row.ID) m.card.line = fmt.Sprintf(devicesRevoked, m.row.Name) + a.syncSheetDeviceCard(m.card, wasAdd) return a.forgetDevice(m.row.ID) } @@ -194,7 +235,7 @@ func (d DeviceRow) before(o DeviceRow) bool { func (c *deviceCard) shownName(d DeviceRow) string { for _, o := range c.rowsOf { if o.ID != d.ID && o.Name == d.Name { - return d.Name + " #" + d.ID[max(len(d.ID)-idTail, 0):] + return devname.DistinctName(d.Name, d.ID) } } return d.Name @@ -209,6 +250,9 @@ func (c *deviceCard) mark(id string) { } func (c *deviceCard) hint() string { + if c.removing != nil { + return devicesRemoveKeys + } if c.naming != nil { return devicesNameKeys } @@ -220,7 +264,10 @@ func (c *deviceCard) hint() string { // no other row to walk to. func (c *deviceCard) keys() string { var out []string - if len(c.rowsOf) > 1 { + if c.loaded && c.cursor == len(c.rowsOf) { + return strings.Join([]string{devicesMoveKey, devicesAddKey, devicesCloseKey}, " · ") + } + if len(c.rowsOf) > 0 { out = append(out, devicesMoveKey) } out = append(out, devicesNameKey) @@ -292,8 +339,8 @@ func (a *app) reloadDevices(c *deviceCard) tea.Cmd { // refreshOnChange reads the list again when the feed has announced a directory version the open // card has not read, so what another computer did to a device shows here without closing the card. func (a *app) refreshOnChange() tea.Cmd { - card, open := a.pair.card.(*deviceCard) - if !open || !a.pair.open || a.dirFeed == nil || a.approvals == nil { + card := a.openDevicesCard() + if card == nil || a.dirFeed == nil || a.approvals == nil { return nil } version := a.dirFeed.State().Version @@ -317,12 +364,17 @@ func (c *deviceCard) rows(width int, now time.Time, pal palette) []string { return dressed(wrap(c.waiting(), max(width, 4)), pal.ink) } var out []string + if c.service != "" { + out = append(out, dressed(wrap(syncwords.ServiceLine(c.service), max(width, 4)), pal.dim)...) + } for i, d := range c.rowsOf { out = append(out, c.row(i, d, width, now, pal)) } - if len(c.rowsOf) <= 1 { - out = append(out, dressed(wrap(devicesNone, max(width, 4)), pal.dim)...) + add := " " + addDeviceWord + if c.cursor == len(c.rowsOf) { + add = pal.glyph(tokens.GPointer) + " " + addDeviceWord } + out = append(out, pal.ink(fit(add, width))) if c.naming != nil { out = append(out, pal.ink(fit(devicesNameAsk+*c.naming+"▏", width))) } @@ -348,9 +400,11 @@ func (c *deviceCard) waiting() string { return devicesLoading } -// row is one device: its dot, name, system and, when it is off, when it was -// last seen. The cursor row is ink; the others are dim. -func (c *deviceCard) row(i int, d DeviceRow, width int, now time.Time, pal palette) string { +// rowText is one device's words without the card's own cursor gutter: its dot, +// name, system and, when it is off, when it was last seen. The card frames it +// with `› `, and the /settings Devices tab hands the same string to the sheet's +// row painter so neither list can drift from the other. +func (c *deviceCard) rowText(d DeviceRow, now time.Time, pal palette) string { online, _ := c.livePresence() d.Online = online[d.ID] && !d.Revoked nameW, wordW := c.columns() @@ -358,8 +412,15 @@ func (c *deviceCard) row(i int, d DeviceRow, width int, now time.Time, pal palet for _, tail := range d.tails(now) { text += " " + tail } + return text +} + +// row is one device: its dot, name, system and, when it is off, when it was +// last seen. The cursor row is ink; the others are dim. +func (c *deviceCard) row(i int, d DeviceRow, width int, now time.Time, pal palette) string { + text := c.rowText(d, now, pal) if i == c.cursor { - return pal.ink(fit("› "+text, width)) + return pal.ink(fit(pal.glyph(tokens.GPointer)+" "+text, width)) } return pal.dim(fit(" "+text, width)) } @@ -374,10 +435,12 @@ func (c *deviceCard) columns() (nameW, wordW int) { return nameW, wordW } -// dot is `●` for a device online now, `○` otherwise. +// dot is the filled circle for a device online now, and the resting circle +// otherwise, both resolved through the palette's glyph door so ASCII and Nerd +// Font terminals render their own repertoire. func (d DeviceRow) dot(pal palette) string { if !d.Revoked && (d.Online || d.Self) { - return "●" + return pal.glyph(tokens.GStepDone) } return pal.glyph(tokens.GQueued) } @@ -410,12 +473,24 @@ func seenTail(at, now time.Time) string { } func (c *deviceCard) key(a *app, name string) (tea.Cmd, bool) { + if c.removing != nil { + row := *c.removing + c.removing, c.line = nil, "" + c.keepCursorOn(row.ID, true) + if name == "y" { + return a.removeDevice(c, row), false + } + return nil, false + } move, isMove := cursorMoves[name] switch { case name == "esc": return nil, true case isMove: - c.cursor = min(max(c.cursor+move, 0), max(len(c.rowsOf)-1, 0)) + c.cursor = min(max(c.cursor+move, 0), len(c.rowsOf)) + case name == "enter" && c.loaded && c.cursor == len(c.rowsOf): + a.pair.close() + return a.runPair(""), false case name == "n": c.startNaming() case name == "r": @@ -426,8 +501,8 @@ func (c *deviceCard) key(a *app, name string) (tea.Cmd, bool) { var cursorMoves = map[string]int{"up": -1, "k": -1, "down": 1, "j": 1} -// revoke ends the picked device's membership, off the loop. Its own row, a -// revoked row and an empty list do nothing. +// revoke asks about the picked device before any membership changes. Its own +// row, a revoked row and an empty list cannot open that question. func (a *app) revoke(c *deviceCard) tea.Cmd { row, ok := c.pick() if ok && row.Self { @@ -436,6 +511,13 @@ func (a *app) revoke(c *deviceCard) tea.Cmd { if !ok || row.Self || row.Revoked { return nil } + c.removing = &row + c.line = fmt.Sprintf(devicesRemoveAsk, c.shownName(row)) + return nil +} + +// removeDevice writes only the row the person confirmed, even if a refresh moved it. +func (a *app) removeDevice(c *deviceCard, row DeviceRow) tea.Cmd { door := a.approvals return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), approveTimeout) @@ -453,7 +535,7 @@ func (a *app) openDevices() tea.Cmd { a.note(pairUnavailableWord) return nil } - card := &deviceCard{presence: a.presence} + card := a.newDeviceCard() a.showCard(card) door := a.approvals return tea.Batch(a.joinWatchIfAbsent(), func() tea.Msg { @@ -472,3 +554,16 @@ func (a *app) joinWatchIfAbsent() tea.Cmd { } return a.joinWatch() } + +// newDeviceCard binds both device lists to the same service and presence source. +func (a *app) newDeviceCard() *deviceCard { + address := a.syncService + if door, ok := a.approvals.(interface{ SyncService() string }); ok { + address = door.SyncService() + } + service := syncsetup.Relay{URL: address, Off: strings.EqualFold(address, "off")} + if !service.On() { + address = "" + } + return &deviceCard{presence: a.presence, service: address} +} diff --git a/internal/tui3/devices_rename_test.go b/internal/tui3/devices_rename_test.go index 58292aa5c4..a96d46249d 100644 --- a/internal/tui3/devices_rename_test.go +++ b/internal/tui3/devices_rename_test.go @@ -91,15 +91,17 @@ func TestOnlyThisDevicesRowCanBeNamed(t *testing.T) { func TestRenameFieldGolden(t *testing.T) { golden := map[int][]string{ 80: { - "› ● this mac Mac this device", + "▸ ● this mac Mac this device", " ○ desk Mac seen 3h ago", " ○ tower Linux offline", + " Add another device", "name this device: this mac▏", }, 40: { - "› ● this mac Mac this device", + "▸ ● this mac Mac this device", " ○ desk Mac seen 3h ago", " ○ tower Linux offline", + " Add another device", "name this device: this mac▏", }, } diff --git a/internal/tui3/displaced_offline_send_test.go b/internal/tui3/displaced_offline_send_test.go index b8b9be73e8..41e551a53e 100644 --- a/internal/tui3/displaced_offline_send_test.go +++ b/internal/tui3/displaced_offline_send_test.go @@ -38,7 +38,39 @@ func TestDisplacedOfflineWindowCannotBorrowAnEmptyListing(t *testing.T) { if len(agent.sent) != 0 || a.input.String() != "keep these words here" { t.Fatalf("offline copy sent: %v, draft=%q", agent.sent, a.input.String()) } - if !strings.Contains(strings.Join(noteTexts(a), "\n"), chatlist.Unreachable) { + if !strings.Contains(strings.Join(noteTexts(a), "\n"), "your message is still in the box, press enter to send it") { t.Fatal("the unsent draft has no explanation") } } + +type localEmptyAfterHoldSource struct{ *continuationListing } + +func (localEmptyAfterHoldSource) RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) { + return nil, 0, true, nil +} +func (localEmptyAfterHoldSource) SendLease(string) (string, uint64, error) { + return "", 0, os.ErrNotExist +} + +// A window with sync wired that previously recorded a fence must never treat an +// empty local reading as permission to send after losing its hold. +func TestDisplacedWindowWithLocalEmptyReadingRefusesSend(t *testing.T) { + src := localEmptyAfterHoldSource{&continuationListing{ + row: chatlist.Row{Cell: heldCell, Mine: true, Status: chatlist.Here, Fence: 5}, + }} + a, agent := heldLab(t, src, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) + a.closeHome() + if got := a.machineRead.sendHolds[heldCell].fence; got != 5 { + t.Fatalf("sendHolds[%s].fence = %d, want 5", heldCell, got) + } + a.agent = displacedSendAgent{agent} + a.input.setText("do not fork this chat") + drive(t, a, key("enter")) + if len(agent.sent) != 0 || a.input.String() != "do not fork this chat" { + t.Fatalf("displaced window sent: sent=%v, draft=%q", agent.sent, a.input.String()) + } + if !strings.Contains(strings.Join(noteTexts(a), "\n"), chatlist.Superseded("another device")) { + t.Fatalf("expected superseded notice %q, got %v", chatlist.Superseded("another device"), noteTexts(a)) + } +} diff --git a/internal/tui3/home.go b/internal/tui3/home.go index acf7fcb938..891edb8591 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -1235,9 +1235,13 @@ func (a *app) dropHome() { func (a *app) newHomeView(world session.World, known bool) homeView { draft := a.openingHomeDraft a.openingHomeDraft = composerState{} + notice := a.homeAccountNotice + if a.machineRead.computerRemoved { + notice = "" + } return homeView{ box: draft.box, chips: draft.chips, carrying: len(draft.chips) > 0, - msg: a.homeAccountNotice, + msg: notice, tabs: a.tabList, holding: a.holding, closedTabs: func() []chatTab { return a.closedTabs }, @@ -2528,6 +2532,9 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd { if a.cancelTakeover() { return nil } + if a.cancelHomeTake() { + return nil + } // THE @ LIST IS INNER TO THE BOX, and so it goes first. It is drawn // under home's box and belongs to the half-typed token in it // (homeat.go), so esc over an open list means "not that one" and must @@ -3094,6 +3101,19 @@ func (a *app) homeEnter() tea.Cmd { // cursor" is two answers to whether a project somewhere else may be opened, and // the phone tier had the older one. func (a *app) homeOpenLine(line homeLine) tea.Cmd { + if !a.homeTakeOpening { + a.continueChecking = nil + } + if !a.homeTakeOpening && a.homeTakeAutomatic && (!a.homeTakeAbandoned || a.homeTakeResult == nil) && homeSessionID(line.row) == a.homeTaking { + // A local row can still be the old copy of the arriving chat. Joining the + // move avoids opening that copy before the replacement has settled. + a.homeTakeAutomatic, a.homeTakeAbandoned, a.homeTakePending = false, false, nil + a.home.say(continuing, "") + return nil + } + if !a.homeTakeOpening { + a.abandonAutomaticTake() + } if line.row.DeletionPending { a.conversationDeleteOpen(line.row.Transcript, homeName(line.row)) return nil @@ -3109,7 +3129,8 @@ func (a *app) homeOpenLine(line homeLine) tea.Cmd { if line.row.Transcript == a.file { a.rememberOpen(a.frontTabKey()) a.closeHome() - return a.homeLandOnTask(line) + said := a.spendHomeTakeFor(line) + return tea.Batch(a.homeLandOnTask(line), said) } // THE ORDER OF THESE CHECKS IS THE FEATURE. Identity comes first, because a // transcript THIS PROCESS holds answers [session.InUse] true about itself — @@ -3130,7 +3151,8 @@ func (a *app) homeOpenLine(line homeLine) tea.Cmd { // surface narrating a door it just walked through. cmd, _ := a.bringForward(line.row.Transcript) a.closeHome() - return tea.Batch(cmd, a.homeLandOnTask(line)) + said := a.spendHomeTakeFor(line) + return tea.Batch(cmd, a.homeLandOnTask(line), said) case !a.canOpen(): h.say(resumeUnavailableWord, "") return nil @@ -3428,6 +3450,7 @@ func (a *app) homeStartInProject(project string) tea.Cmd { // opens. The shared legacy connection still swaps in place and keeps its // existing transition semantics. func (a *app) homeStartWithProject(text, place string) tea.Cmd { + a.abandonAutomaticTake() a.teamViewSet("") // THE SHELL REFUSAL COMES BEFORE THE ROAD IS CHOSEN, so a line home will not // run is refused the same way whichever road would have opened it. diff --git a/internal/tui3/homeband_addmachine.go b/internal/tui3/homeband_addmachine.go index 0355be6568..e56bf61764 100644 --- a/internal/tui3/homeband_addmachine.go +++ b/internal/tui3/homeband_addmachine.go @@ -29,6 +29,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/pair" "github.com/charmbracelet/x/ansi" ) @@ -46,13 +47,18 @@ const ( // fleetAskTimeout bounds one ask, so a silent directory is an unknown fleet. fleetAskTimeout = 5 * time.Second - addMachineHeading = "+ Add another device" - addMachinePitch = "Pick up your work anywhere, exactly where you left it." - addMachineStepRun = "1. On the new device, install and run: codeaf pair" - addMachineStepPut = "2. It shows a link. Paste it here:" - addMachineField = "> " - addMachineShow = addMachineKey + " how" - addMachineHide = addMachineKey + " hide" + // addDeviceWord is the one person-facing name for adding a computer, shared + // between the home card and the /settings Devices tab so the two never drift. + addDeviceWord = "Add another device" + addMachineHeading = "+ " + addDeviceWord + addMachinePitch = "Pick up your work anywhere, exactly where you left it." + addMachineStepRun = "1. On the new device, install and run: codeaf pair" + addMachineStepPut = "2. It shows a link. Paste it here:" + addMachineField = "> " + addMachinePlaceholder = "Paste pairing link" + addMachineLinkError = "Enter a pairing link" + addMachineShow = addMachineKey + " how" + addMachineHide = addMachineKey + " hide" bandOrderAddMachine = 20 // a one-machine fleet is told it can have two ) @@ -88,8 +94,11 @@ type addMachine struct { // wanted says the card is on: a door that can approve, and a fleet known to be one. func (a *app) addMachineWanted() bool { + if a.hosted() { + return false + } m := a.addMachine - return a.approvals != nil && m.known && m.size < fleetEnough + return a.approvals != nil && (a.machineRead.computerRemoved || m.known && m.size < fleetEnough) } // fleetMsg is one answer from the [Fleet]. @@ -102,7 +111,7 @@ type fleetMsg struct { // card has no more job and nothing is asked again. func (a *app) askFleet() tea.Cmd { m := &a.addMachine - if a.fleet == nil || m.asking || (m.known && m.size >= fleetEnough) { + if a.hosted() || a.fleet == nil || m.asking || (m.known && m.size >= fleetEnough) { return nil } m.asking = true @@ -157,7 +166,18 @@ func (a *app) pasteLink(text string) (tea.Cmd, bool) { func (m *addMachine) grew() { m.size++ } func drawAddMachineBand(a *app, ctx bandContext) []string { + if a.pair.open { + rows := a.pair.rows(ctx.width, ctx.now, ctx.pal) + rows = append(rows, ctx.pal.dim(a.pair.hint())) + return rows + } + if !a.addMachineWanted() { + // Account news must remain visible even when this connection cannot + // offer the pairing card and its keys. + if a.machineRead.computerRemoved && a.homeAccountNotice != "" { + return dressed(wrap(a.homeAccountNotice, ctx.width), ctx.pal.dim) + } return nil } m := a.addMachine @@ -166,6 +186,9 @@ func drawAddMachineBand(a *app, ctx bandContext) []string { heading = ctx.pal.underline(heading) } rows := []string{heading} + if a.homeAccountNotice != "" { + rows = append(rows, dressed(wrap(a.homeAccountNotice, ctx.width), ctx.pal.dim)...) + } for _, line := range wrap(addMachinePitch, ctx.width) { rows = append(rows, ctx.pal.dim(fit(line, ctx.width))) } @@ -187,7 +210,11 @@ func drawAddMachineBand(a *app, ctx bandContext) []string { // addMachineHow is the opened card: two steps, and the field the link goes in. func (a *app) addMachineHow(ctx bandContext) []string { var rows []string - for _, line := range []string{addMachineStepRun, addMachineStepPut} { + command := pair.Command("", "") + if door, ok := a.pairing.(interface{ PairCommand() string }); ok { + command = door.PairCommand() + } + for _, line := range []string{strings.TrimSuffix(addMachineStepRun, pair.Command("", "")) + command, addMachineStepPut} { text := ctx.pal.muted(line) text = strings.ReplaceAll(text, "codeaf pair", ctx.pal.bold(ctx.pal.data("codeaf pair"))) for _, wrapped := range wrap(text, ctx.width) { @@ -241,7 +268,7 @@ func (a *app) addMachineLine(width int, pal palette) (string, int) { b := &a.addMachine.box column := ansi.StringWidth(addMachineField) + ansi.StringWidth(string(b.value[start:b.cursor])) if b.empty() { - shown = "Paste pairing link" + shown = addMachinePlaceholder } ink := pal.ink if b.empty() { @@ -273,7 +300,7 @@ func (a *app) addMachineInputKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case "enter": link := strings.TrimSpace(b.String()) if !isPairLink(link) { - m.error = "Enter a pairing link" + m.error = addMachineLinkError return nil, true } m.open, m.focused = false, false diff --git a/internal/tui3/homeband_devices.go b/internal/tui3/homeband_devices.go index ac1f166cfd..86a377ca39 100644 --- a/internal/tui3/homeband_devices.go +++ b/internal/tui3/homeband_devices.go @@ -21,6 +21,7 @@ package tui3 import ( "context" + "slices" "strings" tea "charm.land/bubbletea/v2" @@ -54,6 +55,8 @@ func init() { // deviceRoster is the row's whole state: the devices last read, and whether a // read is out. type deviceRoster struct { + // Version invalidates cached provenance when a computer is renamed. + version uint64 devices []chatlist.Device asking bool bringPending bool @@ -67,6 +70,9 @@ type rosterMsg struct { // askRoster lists the devices, off the update loop. One ask is out at a time. func (a *app) askRoster() tea.Cmd { + if a.hosted() { + return nil + } src, ok := a.fleet.(Roster) if !ok || a.devRow.asking { return nil @@ -84,6 +90,9 @@ func (a *app) askRoster() tea.Cmd { func (a *app) tookRoster(msg rosterMsg) tea.Cmd { a.devRow.asking = false if msg.err == nil { + if !slices.Equal(a.devRow.devices, msg.devices) { + a.devRow.version++ + } a.devRow.devices = msg.devices a.considerResume() a.touch() @@ -107,6 +116,9 @@ func (a *app) presence() (online map[string]bool, known bool) { // devicesWanted says the row is drawn: a roster of more than one device and a // feed that can say who is online. func (a *app) devicesWanted() bool { + if a.hosted() { + return false + } _, known := a.presence() return known && len(a.devRow.devices) > 1 } @@ -127,7 +139,7 @@ func drawDevicesBand(a *app, ctx bandContext) []string { online, _ := a.presence() marks := make([]string, 0, len(a.devRow.devices)) for _, d := range a.devRow.devices { - marks = append(marks, paintPresence(ctx.pal, d.Mark(online[d.ID]), online[d.ID] || d.Self)) + marks = append(marks, paintPresence(ctx.pal, d.Mark(online[d.ID], DeviceRow{Self: d.Self, Online: online[d.ID]}.dot(ctx.pal)), online[d.ID] || d.Self)) } rows := []string{fit(strings.Join(marks, deviceGap), ctx.width)} if len(a.devicePicks()) == 0 { @@ -170,6 +182,9 @@ func (a *app) newestBringable(device string) (chatlist.Row, bool) { // bringWork is the chord: nothing to bring says so, one device goes straight to // the takeover card, and several ask which. func (a *app) bringWork() tea.Cmd { + if a.hosted() { + return nil + } picks := a.devicePicks() switch len(picks) { case 0: diff --git a/internal/tui3/homeband_resume.go b/internal/tui3/homeband_resume.go index 2c38baba4d..886cc916e1 100644 --- a/internal/tui3/homeband_resume.go +++ b/internal/tui3/homeband_resume.go @@ -33,6 +33,7 @@ package tui3 import ( "context" + "errors" "fmt" "slices" "time" @@ -40,6 +41,8 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" ) const ( @@ -65,6 +68,7 @@ func init() { // card stands; answered holds the devices the person has taken up or put off. type resumeOffer struct { row *chatlist.Row + reason string answered map[string]bool } @@ -102,7 +106,7 @@ func (s resumeState) pick() *chatlist.Row { // offered says a row is a chat to rescue. func (s resumeState) offered(row chatlist.Row) bool { live := row.Status == chatlist.Running || row.Status == chatlist.Off || (row.Status == chatlist.Idle && !row.Mine) - return live && !row.Mine && (s.self == "" || row.DeviceID != s.self) && row.Device != "" && !s.answered[row.Device] && row.DurableAgo <= resumeRecent && s.away(row) + return live && !row.Mine && !row.HolderRemoved && (s.self == "" || row.DeviceID != s.self) && row.Device != "" && !s.answered[row.Device] && row.DurableAgo <= resumeRecent && s.away(row) } // away says the chat's device has let go of it or is gone: its lease lapsed, @@ -120,8 +124,23 @@ func (s resumeState) away(row chatlist.Row) bool { // has gone. No listing yet, or no taker, offers nothing. func (a *app) considerResume() { r := &a.leftOff + unavailable := a.machineRead.down || a.dirFeed != nil && !a.dirFeed.State().Up + if unavailable && (r.row != nil || r.reason != "") && !a.machineRead.computerRemoved { + address := a.syncService + if address == "" { + address = syncwords.HostedURL + } + r.reason = syncwords.Unreachable(address) + if a.machineRead.err != nil { + r.reason = syncwords.Explain(a.machineRead.err, address).Error() + } + r.row = nil + a.touch() + return + } + r.reason = "" var row *chatlist.Row - if a.taker != nil && !a.machineRead.computerRemoved { + if !a.hosted() && a.taker != nil && !a.machineRead.computerRemoved { row = a.resumeState().pick() } if !sameOffer(r.row, row) { @@ -157,7 +176,7 @@ func sameOffer(x, y *chatlist.Row) bool { // resumeKeyHandler claims the card's two chords where the card stands. func (a *app) resumeKeyHandler(key string) (tea.Cmd, bool) { - if a.machineRead.computerRemoved { + if a.hosted() || a.machineRead.computerRemoved { return nil, false } row := a.leftOff.row @@ -168,6 +187,15 @@ func (a *app) resumeKeyHandler(key string) (tea.Cmd, bool) { case resumeKey: a.leftOff.answer() a.touch() + if a.homeTaking != "" && !a.homeTakeAutomatic { + a.raiseMachineAsk(*row, a.continueBusyShown(*row)) + a.home.say(continueAlreadyRunning, "") + return nil, true + } + if a.homeTakeAutomatic && row.Cell == a.homeTaking && (!a.homeTakeAbandoned || a.homeTakeResult == nil) { + return a.takeRemote(*row), true + } + a.abandonAutomaticTake() if safe, ok := a.taker.(continuationSafety); ok { return a.continueIfSafe(*row, safe), true } @@ -186,6 +214,9 @@ func drawResumeBand(a *app, ctx bandContext) []string { } row := a.leftOff.row if row == nil { + if a.leftOff.reason != "" { + return []string{ctx.pal.dim(fit(a.leftOff.reason, ctx.width))} + } return nil } head := ctx.pal.ink(fit(fmt.Sprintf(resumeAskFormat, row.Device), ctx.width)) @@ -203,9 +234,14 @@ type continuationSafety interface { // lease expectation so a later holder can never be displaced by this gesture. func (a *app) continueIfSafe(row chatlist.Row, safe continuationSafety) tea.Cmd { ctx, file := a.ctx, a.file + choice := a.nextContinueAskID() + a.continueChecking = &continuationChecked{row: row, generation: choice} + if a.homeTakeAutomatic && a.homeTaking != "" { + a.home.say(fmt.Sprintf("continuing %s next; %s is still on its way here", row.Title, a.homeTakeRow.Title), "") + } return func() tea.Msg { clean, err := safe.SafeToContinue(ctx, row.Cell, file, false) - return continuationChecked{row: row, clean: clean, err: err} + return continuationChecked{row: row, clean: clean, generation: choice, err: err} } } @@ -255,7 +291,13 @@ func (a *app) continueAfterQuit(err error) tea.Cmd { } func (a *app) continuationReady(msg continuationChecked) tea.Cmd { - if a.machineRead.computerRemoved { + if !msg.automatic && msg.generation != 0 { + if a.continueChecking == nil || a.continueChecking.generation != msg.generation { + return nil + } + a.continueChecking = nil + } + if a.hosted() || a.machineRead.computerRemoved { return nil } if msg.automatic && msg.generation != a.continueGeneration { @@ -276,7 +318,18 @@ func (a *app) continuationReady(msg continuationChecked) tea.Cmd { opened = a.openHome() } if msg.err != nil { - return tea.Batch(opened, a.tookTakeover(homeTakenMsg{row: msg.row, err: msg.err})) + if !a.at(pageHome) { + opened = a.openHome() + } + a.abandonAutomaticTake() + failed := homeTakenMsg{row: msg.row, err: msg.err} + if errors.Is(msg.err, directory.ErrLeaseHeld) { + return tea.Batch(opened, a.lostRace(failed)) + } + failure := a.continueFailure(failed) + a.note(failure) + a.showTakeFailure(failed, failure) + return opened } if !msg.clean { if msg.automatic { @@ -284,5 +337,5 @@ func (a *app) continuationReady(msg continuationChecked) tea.Cmd { } return a.raiseContinue(msg.row, continueYesAt) } - return tea.Batch(opened, a.takeRemote(msg.row)) + return tea.Batch(opened, a.takeRemoteState(homeTakenMsg{row: msg.row, automatic: msg.automatic})) } diff --git a/internal/tui3/homeband_resume_test.go b/internal/tui3/homeband_resume_test.go index 80aa550090..d176327652 100644 --- a/internal/tui3/homeband_resume_test.go +++ b/internal/tui3/homeband_resume_test.go @@ -13,6 +13,10 @@ func leftOffRow(cell, device string, status chatlist.Status, ago time.Duration) return chatlist.Row{Cell: cell, Title: cell, Device: device, DeviceID: "dev_" + device, Status: status, DurableAgo: ago} } +// removedRow is a row whose holder was removed from the person's devices: its +// lease reads as released, and it is never offered (#2072). +func removedRow(r chatlist.Row) chatlist.Row { r.HolderRemoved = true; return r } + // TestResumePicksTheNewestRecentChatOnAnOfflineDevice proves D6 for released and offline chats. func TestResumePicksTheNewestRecentChatOnAnOfflineDevice(t *testing.T) { cases := []struct { @@ -22,12 +26,13 @@ func TestResumePicksTheNewestRecentChatOnAnOfflineDevice(t *testing.T) { }{ {"off without a feed", resumeState{rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Off, time.Hour)}}, "a"}, {"running without a feed is busy", resumeState{rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Running, time.Minute)}}, ""}, - {"running on an offline device", resumeState{feedUp: true, rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Running, time.Minute)}}, "a"}, + {"held on an offline device", resumeState{feedUp: true, rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Running, time.Minute)}}, "a"}, {"online and busy", resumeState{feedUp: true, online: []string{"dev_tower"}, rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Running, time.Minute)}}, ""}, {"online with a lapsed lease", resumeState{feedUp: true, online: []string{"dev_tower"}, rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Off, time.Minute)}}, "a"}, {"online and let go", resumeState{feedUp: true, online: []string{"dev_tower"}, rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Idle, time.Minute)}}, "a"}, {"too old", resumeState{rows: []chatlist.Row{leftOffRow("a", "tower", chatlist.Off, 3*resumeRecent)}}, ""}, {"this device", resumeState{rows: []chatlist.Row{leftOffRow("a", "mac", chatlist.Here, time.Minute)}}, ""}, + {"a removed device", resumeState{rows: []chatlist.Row{removedRow(leftOffRow("a", "tower", chatlist.Idle, time.Minute))}}, ""}, {"newest wins", resumeState{rows: []chatlist.Row{ leftOffRow("old", "desk", chatlist.Off, 5*time.Hour), leftOffRow("new", "tower", chatlist.Off, time.Hour)}}, "new"}, } diff --git a/internal/tui3/homeband_standing.go b/internal/tui3/homeband_standing.go index 88e983d6ac..b89019a4be 100644 --- a/internal/tui3/homeband_standing.go +++ b/internal/tui3/homeband_standing.go @@ -20,6 +20,13 @@ func (a *app) standingStrip(width, room int) []placeRow { return nil } ctx := bandContext{width: inner, pal: a.pal, now: a.now()} + if a.pair.open { + // The modal card must remain visible even when other standing bands fill + // the small terminal. Its keys belong to the same visible surface. + lines := a.pair.draw(inner, max(1, room-3), a.now(), a.pal) + lines = append(lines, a.pal.dim(a.pair.hint())) + return standingRows(lines) + } var lines []string var targets []addMachineTarget for _, band := range standingBands() { @@ -29,7 +36,7 @@ func (a *app) standingStrip(width, room int) []placeRow { } limit := room / 2 // An opened form needs its input on screen even in a short terminal. - if band.name == "addmachine" && a.addMachine.open { + if band.name == "addmachine" && (a.addMachine.open || a.pair.open) { limit = room } if len(lines)+len(card)+2 > limit { @@ -37,7 +44,7 @@ func (a *app) standingStrip(width, room int) []placeRow { } for i := range card { target := addMachineNone - if band.name == "addmachine" { + if band.name == "addmachine" && !a.pair.open { target = addMachineToggle if a.addMachine.open && i == len(card)-2 { target = addMachineInput diff --git a/internal/tui3/homecell_keepnote_test.go b/internal/tui3/homecell_keepnote_test.go index 333aed86e4..432a71c1d1 100644 --- a/internal/tui3/homecell_keepnote_test.go +++ b/internal/tui3/homecell_keepnote_test.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/x/ansi" ) -// A chat on another machine keeps `running on ` at every width and +// A chat on another machine keeps `held on ` at every width and // title length: the title is cut with an ellipsis before the note goes. func TestMachineRowKeepsNoteBesideLongTitle(t *testing.T) { pal := newTestPalette() @@ -17,15 +17,15 @@ func TestMachineRowKeepsNoteBesideLongTitle(t *testing.T) { title := strings.Repeat("a", n) t.Run(fmt.Sprintf("w%d_t%d", width, n), func(t *testing.T) { cell := &homeCell{kind: cellRow, panel: panelSessions, title: title, - note: "running on host-a", keepNote: true, right: "now"} + note: "held on host-a", keepNote: true, right: "now"} row := plain(homeCellBody(cell, width, pal, false)) - if !strings.Contains(row, "running on host-a") || !strings.HasSuffix(row, "now") { + if !strings.Contains(row, "held on host-a") || !strings.HasSuffix(row, "now") { t.Fatalf("note or age lost: %q", row) } if got := ansi.StringWidth(row); got > width { t.Fatalf("row is %d cells in %d: %q", got, width, row) } - if whole := n <= width-len("running on host-a now")-4; whole != strings.HasPrefix(row, title) { + if whole := n <= width-len("held on host-a now")-4; whole != strings.HasPrefix(row, title) { t.Fatalf("title whole=%v, row %q", whole, row) } }) diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 1f8ab1856b..0238437b21 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -327,6 +327,7 @@ type homeGridInput struct { // ([homeGridPanel.growsARow]), and paying that on a narrow terminal for a // column that is not on the screen costs a row a person could have read. desc bool + far bool } // gridInput gathers it. EVERY FIELD IS A CACHE HOME ALREADY HOLDS, and the one @@ -348,7 +349,7 @@ func (h *homeView) gridInput() homeGridInput { open, closed := h.conversationRows() return homeGridInput{openChats: open, closedChats: closed, rows: reading.rows, ledger: reading.ledger, calls: calls, callsOlder: older, opened: h.opened, openedOn: h.openedOn, - desc: homeDescOn(h.cols), world: world, autos: h.autos, active: h.active, + desc: homeDescOn(h.cols), far: h.far, world: world, autos: h.autos, active: h.active, errands: h.switchExchanges(), bucket: h.bucket, launch: h.launch, tilde: h.tilde, last: h.last, repos: h.repos, others: h.others, spend: h.spend, seen: h.seen, now: h.world.Read} } diff --git a/internal/tui3/homelive_test.go b/internal/tui3/homelive_test.go index 12181c12ac..0adf8edbd9 100644 --- a/internal/tui3/homelive_test.go +++ b/internal/tui3/homelive_test.go @@ -58,13 +58,13 @@ func TestHomeLiveSource(t *testing.T) { live := &liveDirectory{dir: here, self: "dev_here", key: key} a := machinesHome(t, live, 200) - if text := homeText(a); !strings.Contains(text, "Port the picker") || !strings.Contains(text, "running on host-a") { + if text := homeText(a); !strings.Contains(text, "Port the picker") || !strings.Contains(text, "held on host-a") { t.Fatalf("a chat running elsewhere is not listed as such:\n%s", text) } clock.Advance(directory.LeaseTTL + 1) drain(t, a, a.askMachines()) - if text := homeText(a); !strings.Contains(text, "host-a offline") || strings.Contains(text, "running on host-a") { + if text := homeText(a); !strings.Contains(text, "host-a offline") || strings.Contains(text, "held on host-a") { t.Fatalf("the chat went quiet and home did not follow:\n%s", text) } @@ -73,8 +73,10 @@ func TestHomeLiveSource(t *testing.T) { t.Fatal(err) } drain(t, a, a.askMachines()) - if text := homeText(a); !strings.Contains(text, "2 turns from host-a") { - t.Fatalf("a branch that appeared is not listed:\n%s", text) + // A branch of the chat folds into the chat's own row (#2070): the row keeps + // its state and counts the copy rather than standing a second row beside it. + if text := homeText(a); !strings.Contains(text, "host-a offline · 1 copy") || strings.Count(text, "Port the picker") != 1 { + t.Fatalf("a branch that appeared is not counted on its chat's row:\n%s", text) } live.down = true diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index eba7fc4d39..94ac86042f 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -3,12 +3,16 @@ package tui3 import ( "context" "errors" + "fmt" + "log" "path/filepath" "strings" "time" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" machine "github.com/Agent-Field/codeaf/internal/preflight" @@ -84,14 +88,17 @@ type BranchActions struct { // The words of the two cards. The sentences that are contract copy come from // [chatlist]; these are the questions around them. const ( - continueAsk = "Continue this chat here?" - continueStay = "leave it there" - lostRaceWord = chatlist.LostRace - continuing = "continuing here…" - continueFails = "could not continue this chat here" + continueAsk = "Continue this chat here?" + continueStay = "leave it there" + continueRetry = "retry" + continueAlreadyRunning = "a chat is already continuing here; wait for it to open" + lostRaceWord = chatlist.LostRace + continuing = "continuing here…" + continueFails = "could not continue this chat here" + continueStoreBusy = continueFails + " because the engine store is busy" // continueUnreachable names what did not happen, not only why, because // the box still holds words the person meant to send in the moved chat. - continueUnreachable = continueFails + " because other machines are unreachable; your words are still in the box" + continueUnreachable = continueFails + " because " + chatlist.Unreachable + "; your words are still in the box" branchAsk = "What should happen to these turns?" branchLeave = "leave them" branchFails = "could not change those turns" @@ -161,8 +168,11 @@ var remoteOffers = map[chatlist.OfferKind]func(*app, chatlist.Row, chatlist.Offe // homeMachineEnter is enter on a chat from another machine. func (a *app) homeMachineEnter(line homeLine) tea.Cmd { + if a.hosted() || line.remote == nil { + return nil + } if a.machineRead.computerRemoved { - a.home.say(chatlist.Removed, "") + a.home.say(chatlist.RemovedChat, "") return nil } row := *line.remote @@ -178,6 +188,11 @@ func (a *app) homeMachineEnter(line homeLine) tea.Cmd { a.home.say(a.machineRead.unavailableWord(), "") return nil } + if a.homeTaking != "" && !a.homeTakeAutomatic && (offer.Kind == chatlist.Open || offer.Kind == chatlist.ContinueHere) && a.taker != nil { + a.raiseMachineAsk(row, a.continueBusyShown(row)) + a.home.say(continueAlreadyRunning, "") + return nil + } return run(a, row, offer) } @@ -195,6 +210,82 @@ const ( continueStayAt = 1 ) +func (a *app) nextContinueAskID() uint64 { + a.continueAskSeq++ + return a.continueAskSeq +} + +// finishHomeTake clears every in-flight continuation marker on this window. +// When restoreDraft is true, any unsent words captured before opening are put back. +func (a *app) finishHomeTake(restoreDraft bool) { + // A different door may finish while the automatic mutation is still settling. + // Its bookkeeping cannot discard the operation whose receipt needs releasing. + if a.homeTakeOriginAutomatic && a.homeTakeAbandoned { + return + } + a.stopAutomaticTakeTimer() + if a.homeTakeCancel != nil { + a.homeTakeCancel() + a.homeTakeCancel = nil + } + if restoreDraft && a.homeTakeDraft != nil { + a.restoreTakeDraft(*a.homeTakeDraft) + } + a.homeTaking, a.homeTakeDraft, a.homeTakenSaid = "", nil, nil + a.homeTakeAutomatic, a.homeTakeOriginAutomatic, a.homeTakeAbandoned = false, false, false + a.homeTakeResult = nil +} + +// cancelHomeTake withdraws the choice. An automatic acquisition still settles +// before its unused ownership is released, including after an explicit join. +func (a *app) cancelHomeTake() bool { + checking := a.continueChecking != nil + if checking { + a.abandonAutomaticTake() + } + if a.homeTaking == "" { + if checking { + a.home.say("", "") + a.touch() + } + return checking + } + if a.homeTakeOriginAutomatic || a.homeTakeAbandoned { + if a.homeTakeAbandoned && a.homeTakePending == nil && !checking { + return false + } + a.abandonAutomaticTake() + a.homeTakeAbandoned = true + a.homeTakePending = nil + a.home.say("", "") + a.touch() + return true + } + a.homeTakeSeq++ + a.finishHomeTake(true) + if a.at(pageHome) && a.home.msg == continuing { + a.home.say("", "") + } + a.touch() + return true +} + +func (a *app) spendHomeTakeFor(line homeLine) tea.Cmd { + if a.homeTakeAbandoned { + return nil + } + if a.homeTakenSaid != nil { + said := a.homeTakenSaid + a.homeTakenSaid = nil + a.finishHomeTake(false) + return said() + } + if a.homeTaking != "" && (homeSessionID(line.row) == a.homeTaking || filepath.Base(session.DirOf(line.row.Transcript)) == a.homeTaking) { + a.finishHomeTake(false) + } + return nil +} + // offerContinue raises the takeover screen for a person who chose to continue // (enter on the row, the left-off chord, the device chord): the cursor starts // on `continue here`, so enter completes the move they just asked for. @@ -209,8 +300,12 @@ func (a *app) askContinue(row chatlist.Row) tea.Cmd { } func (a *app) raiseContinue(row chatlist.Row, at int) tea.Cmd { + a.continueChecking = nil + if a.hosted() { + return nil + } if a.machineRead.computerRemoved { - a.home.say(chatlist.Removed, "") + a.home.say(chatlist.RemovedChat, "") return nil } if a.sayCellsUnavailable() { @@ -219,6 +314,11 @@ func (a *app) raiseContinue(row chatlist.Row, at int) tea.Cmd { if a.taker == nil { return a.sayOffer(row, chatlist.Offer{Line: chatlist.StatusLine(row)}) } + if a.homeTaking != "" && !a.homeTakeAutomatic { + a.raiseMachineAsk(row, a.continueBusyShown(row)) + a.home.say(continueAlreadyRunning, "") + return nil + } a.raiseMachineAsk(row, a.continueShown(row, at)) return nil } @@ -231,6 +331,7 @@ func (a *app) continueShown(row chatlist.Row, at int) questionShown { return questionShown{ continuation: card, question: session.Question{ + ID: a.nextContinueAskID(), Kind: homeContinueKind, Ask: session.AskConfirmation, Form: session.FormCard, @@ -253,11 +354,110 @@ func (a *app) continueShown(row chatlist.Row, at int) questionShown { } return nil } + if a.homeTaking != "" && !a.homeTakeAutomatic { + a.raiseMachineAsk(row, a.continueBusyShown(row)) + a.home.say(continueAlreadyRunning, "") + return nil + } return a.takeRemote(row) }, } } +// continueBusyShown tells the person on the card itself that another take is +// already in progress, rather than closing the card and doing nothing. +func (a *app) continueBusyShown(row chatlist.Row) questionShown { + row = a.presentRow(row) + card := a.continueCardFor(row, false) + return questionShown{ + continuation: card, + question: session.Question{ + ID: a.nextContinueAskID(), + Kind: homeContinueKind, + Ask: session.AskConfirmation, + Form: session.FormCard, + Asker: session.Asker{Kind: session.AskerSurface}, + Head: continueAsk, + Reason: continueAlreadyRunning, + Subject: session.SubjectRef{Name: row.Title}, + Options: []session.AnswerOption{ + {Key: "1", Label: chatlist.OfferContinue}, + {Key: "2", Label: continueStay, Safe: true}, + }, + Stakes: session.StakesReversible, + Asked: a.now(), + }, + pick: continueYesAt, + local: func(answer session.Answer) tea.Cmd { + if answer.FirstKey() != "1" { + if card.returnFile != "" && a.file == card.returnFile { + a.closeHome() + } + return nil + } + if a.homeTaking != "" && !a.homeTakeAutomatic { + a.raiseMachineAsk(row, a.continueBusyShown(row)) + a.home.say(continueAlreadyRunning, "") + return nil + } + return a.takeRemote(row) + }, + } +} + +// continueRetryShown keeps a failed take's reason on the card where the person +// is looking and offers retry without losing captured workspace roots or drafts. +func (a *app) continueRetryShown(msg homeTakenMsg, failure string) questionShown { + row := a.presentRow(msg.row) + card := a.continueCardFor(row, msg.fromFile != "") + if msg.draft != nil { + card.draft = *msg.draft + } + if msg.fromFile != "" { + card.returnFile = msg.fromFile + } + return questionShown{ + continuation: card, + question: session.Question{ + ID: a.nextContinueAskID(), + Kind: homeContinueKind, + Ask: session.AskConfirmation, + Form: session.FormCard, + Asker: session.Asker{Kind: session.AskerSurface}, + Head: continueAsk, + Reason: failure, + Subject: session.SubjectRef{Name: row.Title}, + Options: []session.AnswerOption{ + {Key: "1", Label: continueRetry}, + {Key: "2", Label: continueStay, Safe: true}, + }, + Stakes: session.StakesReversible, + Asked: a.now(), + }, + pick: continueYesAt, + local: func(answer session.Answer) tea.Cmd { + if answer.FirstKey() != "1" { + if card.returnFile != "" && a.file == card.returnFile { + a.closeHome() + } + return nil + } + if a.homeTaking != "" && !a.homeTakeAutomatic { + a.raiseMachineAsk(row, a.continueBusyShown(row)) + a.home.say(continueAlreadyRunning, "") + return nil + } + a.machineRead.down = false + retry := msg + retry.err = nil + retry.automatic = false + retry.taken = Taken{} + retry.recoveredHere = false + return a.takeRemoteState(retry) + }, + } +} + // presentRow is the row as the device's presence reads now: a chat whose holder // is not online is one that stopped, so the card does not say it runs there. func (a *app) presentRow(row chatlist.Row) chatlist.Row { @@ -271,13 +471,17 @@ func (a *app) presentRow(row chatlist.Row) chatlist.Row { // homeTakenMsg is the takeover's answer, coming back off the update loop. type homeTakenMsg struct { - row chatlist.Row - taken Taken - err error - roots map[string]string - draft *composerState - send func() tea.Cmd - fromFile string + automatic bool + seq uint64 + row chatlist.Row + taken Taken + err error + roots map[string]string + draft *composerState + send func() tea.Cmd + fromFile string + // A fresh listing can disagree with the taker once without asking again. + recoveredHere bool } // takeRemote asks for the chat off the update loop because a move waits on the network. @@ -290,17 +494,54 @@ func (a *app) takeRemoteWithRoots(row chatlist.Row, roots map[string]string) tea } func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { + if !msg.automatic { + a.continueChecking = nil + } + if a.hosted() || a.taker == nil { + a.restoreTakeDraft(msg) + return nil + } if a.machineRead.computerRemoved { a.restoreTakeDraft(msg) - a.home.say(chatlist.Removed, "") + a.home.say(chatlist.RemovedChat, "") + return nil + } + if a.homeTaking != "" && a.homeTakeAutomatic { + if a.homeTaking == msg.row.Cell && (!a.homeTakeAbandoned || a.homeTakeResult == nil) { + // Joining adopts the existing move, so later choices use the explicit busy rule. + a.stopAutomaticTakeTimer() + a.homeTakeAutomatic, a.homeTakeAbandoned, a.homeTakePending = false, false, nil + a.home.say(continuing, "") + return nil + } + a.abandonAutomaticTake() + a.homeTakePending = &msg + a.home.say(fmt.Sprintf("continuing %s next; %s is still on its way here", msg.row.Title, a.homeTakeRow.Title), "") return nil } if a.homeTaking != "" { + a.restoreTakeDraft(msg) + if a.at(pageHome) { + a.raiseMachineAsk(msg.row, a.continueBusyShown(msg.row)) + a.home.say(continueAlreadyRunning, "") + } else { + a.note(continueAlreadyRunning) + } return nil } + a.homeTakeSeq++ + msg.seq = a.homeTakeSeq a.homeTaking = msg.row.Cell + a.homeTakeAutomatic, a.homeTakeOriginAutomatic, a.homeTakeAbandoned = msg.automatic, msg.automatic, false + a.homeTakeRow = msg.row a.home.say(continuing, "") - taker, ctx := a.taker, a.ctx + baseCtx := a.ctx + if baseCtx == nil { + baseCtx = context.Background() + } + ctx, cancel := context.WithCancel(baseCtx) + a.homeTakeCancel = cancel + taker := a.taker if a.file != "" && filepath.Base(session.DirOf(a.file)) == msg.row.Cell { msg.fromFile = a.file // The displaced session may retire during the move, so its unsent words travel with the answer. @@ -322,10 +563,112 @@ func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { } } +// abandonAutomaticTake withdraws navigation without interrupting a mutation that +// may already have acquired ownership. Its answer must settle before another take. +func (a *app) abandonAutomaticTake() { + a.continueChecking = nil + if !a.homeTakeOriginAutomatic { + return + } + if !a.homeTakeAbandoned && a.homeTakeResult == nil && a.homeTakeCancel != nil { + // Cancellation follows the old Escape path only after the abandoned + // request has had its bounded chance to finish and report its receipt. + a.homeTakeAbandonTimer = time.AfterFunc(abandonedTakeWithin, a.homeTakeCancel) + } + a.homeTakeAbandoned = true + if a.homeTakePending != nil { + a.homeTakePending = nil + a.home.say("", "") + } + if a.homeTakeResult != nil && a.conversationOpening { + a.conversationOpening = false + a.conversationRequest++ + } +} + +const ( + unusedTakeWithin = 10 * time.Second + abandonedTakeWithin = 2 * time.Minute +) + +func (a *app) stopAutomaticTakeTimer() { + if a.homeTakeAbandonTimer != nil { + a.homeTakeAbandonTimer.Stop() + a.homeTakeAbandonTimer = nil + } +} + +type homeTakeSettledMsg struct{ seq uint64 } + +// settleAutomaticTake leaves an unopened view through the last-window release +// path. Cleanup must outlive cancellation of the originating UI. +func (a *app) settleAutomaticTake(msg homeTakenMsg, agent Agent) tea.Cmd { + taker := a.taker + return func() tea.Msg { + releaseUnusedTake(taker, msg, agent) + return homeTakeSettledMsg{seq: msg.seq} + } +} + +func releaseUnusedTake(taker Taker, msg homeTakenMsg, agent Agent) { + if msg.taken.AlreadyHere { + return + } + if agent != nil { + if err := leaveAgentError(agent); err != nil { + log.Printf("continue %s unused view leave: %v", msg.row.Cell, err) + } + // A host owns release once a view exists. Another window may use the + // same session, even before its writer replaces the take's receipt. + if _, hosted := agent.(detachable); hosted { + return + } + } + if msg.err == nil { + if release, ok := taker.(interface { + ReleaseUnused(context.Context, string, uint64) error + }); ok { + ctx, cancel := context.WithTimeout(context.Background(), unusedTakeWithin) + defer cancel() + if err := release.ReleaseUnused(ctx, msg.row.Cell, msg.taken.Fence); err != nil { + log.Printf("continue %s unused cleanup: %v", msg.row.Cell, err) + } + } + } +} + +func (a *app) automaticTakeSettled(msg homeTakeSettledMsg) tea.Cmd { + if msg.seq != a.homeTakeSeq { + return nil + } + pending := a.homeTakePending + a.homeTakePending = nil + a.homeTakeAutomatic, a.homeTakeOriginAutomatic = false, false + a.finishHomeTake(false) + if pending != nil { + return a.takeRemoteState(*pending) + } + if a.continueChecking != nil && a.at(pageHome) { + a.home.say(continuing, "") + } + return nil +} + // tookTakeover restores unsent words on failure and opens the arrived chat on success. func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { + if msg.seq != 0 && msg.seq != a.homeTakeSeq { + return nil + } + a.stopAutomaticTakeTimer() + if msg.automatic && a.homeTakeAbandoned { + return a.settleAutomaticTake(msg, nil) + } + if a.homeTakeCancel != nil { + a.homeTakeCancel() + a.homeTakeCancel = nil + } if msg.err != nil { - a.homeTaking = "" + a.finishHomeTake(false) } var missing *session.WorkspaceBindingsRequired if errors.As(msg.err, &missing) { @@ -337,20 +680,22 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { if msg.err != nil { failure = a.failedTake(msg) a.restoreTakeDraft(msg) - a.note(failure) + if !errors.Is(msg.err, directory.ErrLeaseHeld) { + a.note(failure) + } } switch { case errors.Is(msg.err, directory.ErrLeaseHeld): - return a.lostRace(msg.row) + return a.lostRace(msg) case errors.Is(msg.err, directory.ErrUnreachable): // Home is where the failed move lands, so it carries the whole sentence: // the reason alone does not say the chat stayed and the words were kept. a.machineRead.down = true - a.home.say(failure, "") + a.showTakeFailure(msg, failure) case errors.Is(msg.err, directory.ErrConditionalTake): - a.home.say(directory.ErrConditionalTake.Error(), "") + a.showTakeFailure(msg, directory.ErrConditionalTake.Error()) case msg.err != nil: - a.home.say(failure, "") + a.showTakeFailure(msg, failure) default: return a.openTaken(msg) } @@ -358,6 +703,27 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { return nil } +// showTakeFailure keeps the failure visible where the person is looking: on a +// retry card beside the row when Home is open, and in the conversation notes +// when looking at the chat. +func (a *app) showTakeFailure(msg homeTakenMsg, failure string) { + a.home.say(failure, "") + if a.at(pageHome) && a.taker != nil && !a.machineRead.computerRemoved { + a.raiseMachineAsk(msg.row, a.continueRetryShown(msg, failure)) + for i, line := range a.home.lines { + remote := line.remote != nil && line.remote.Cell == msg.row.Cell + local := line.kind == homeSession && homeSessionID(line.row) == msg.row.Cell + if remote || local { + a.home.cursor = i + if local { + a.home.armed = strings.TrimSpace(line.row.Transcript) + } + break + } + } + } +} + // chooseTakeRoots keeps the original holder and unsent words while collecting local folders. func (a *app) chooseTakeRoots(msg homeTakenMsg, missing []session.WorkspaceRootBinding) tea.Cmd { if len(missing) == 0 { @@ -367,7 +733,10 @@ func (a *app) chooseTakeRoots(msg homeTakenMsg, missing []session.WorkspaceRootB a.folder.forWorkspace = true a.folder.held = nil a.folder.chooseTitle = "local folder for " + missing[0].Label - a.folder.cancelChoose = func() { a.restoreTakeDraft(msg) } + a.folder.cancelChoose = func() { + a.finishHomeTake(false) + a.restoreTakeDraft(msg) + } a.folder.chooseFolder = func(path string) tea.Cmd { roots := make(map[string]string, len(msg.roots)+1) for uri, folder := range msg.roots { @@ -380,20 +749,92 @@ func (a *app) chooseTakeRoots(msg homeTakenMsg, missing []session.WorkspaceRootB return cmd } -// lostRace is another device winning the takeover. The row goes back to -// `running on ` at once, from the listing this window already holds, -// and the directory is asked again for the winner's own name. -func (a *app) lostRace(row chatlist.Row) tea.Cmd { - rows := append([]chatlist.Row(nil), a.machineRead.rows...) - for i := range rows { - if rows[i].Cell == row.Cell { - rows[i].Status = chatlist.Running +// lostRace retains the failed card and its draft while checking who actually +// holds the chat. A changed ownership number alone does not mean a new device. +func (a *app) lostRace(msg homeTakenMsg) tea.Cmd { + var opened tea.Cmd + if !a.at(pageHome) { + opened = a.openHome() + } + msg.send = nil + a.showTakeFailure(msg, continueFails) + src, seq := a.machines, a.homeTakeSeq + file := "" + if line, ok := a.homeLineOf(msg.row.Cell); ok { + file = line.row.Transcript + } + if a.file != "" && filepath.Base(session.DirOf(a.file)) == msg.row.Cell { + file = a.file + } + var cardID uint64 + if q, ok := a.homeAsking(); ok { + cardID = q.question.ID + } + read := func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) + defer cancel() + var rows []chatlist.Row + var err error + if src != nil { + rows, _, err = chatlist.RowsForSend(ctx, src) } + here := false + for _, row := range rows { + if row.Cell == msg.row.Cell && row.Status == chatlist.Here && row.Mine && file != "" { + here, _ = cellstore.KnowsHead(cell.Cell{ID: row.Cell, Root: session.DirOf(file)}, row.Head) + } + } + return continueConflictMsg{take: msg, rows: rows, err: err, seq: seq, cardID: cardID, here: here} } - a.machineRead.rows = rows - a.rebuildMachines() - a.home.say(lostRaceWord, "") - return a.askMachines() + return tea.Batch(opened, read) +} + +type continueConflictMsg struct { + take homeTakenMsg + rows []chatlist.Row + err error + seq, cardID uint64 + here bool +} + +func (a *app) continueConflictRead(msg continueConflictMsg) tea.Cmd { + if msg.seq != a.homeTakeSeq || a.homeTaking != "" { + return nil + } + if msg.cardID != 0 { + q, ok := a.homeAsking() + if !ok || q.question.ID != msg.cardID { + return nil + } + } + failed := msg.take + failure := continueFails + if msg.err == nil { + for _, row := range msg.rows { + if row.Cell != failed.row.Cell { + continue + } + if msg.here && !failed.recoveredHere { + // Keep the old consent: only the taker's complete-local-head recovery may + // bypass it. If ownership moves again, that consent must still be refused. + failed.err = nil + failed.recoveredHere = true + return a.takeRemoteState(failed) + } + sameHolder := row.DeviceID != "" && row.DeviceID == failed.row.DeviceID || row.DeviceID == "" && failed.row.DeviceID == "" && row.Device == failed.row.Device + if sameHolder && row.Fence != failed.row.Fence && row.Device != "" { + failure = fmt.Sprintf("%s picked this chat up again; press enter to continue it here", nonemptyDevice(row.Device)) + } else if !sameHolder && !row.Mine { + failure = lostRaceWord + } + failed.row = row + break + } + a.machineRead.rows = msg.rows + a.rebuildMachines() + } + a.showTakeFailure(failed, failure) + return nil } // rebuildMachines redraws home from the reading it holds. The compact inbox @@ -418,6 +859,9 @@ func (a *app) rebuildMachines() { // the one they are arriving in. A shared window opens in place, and the ride // is spent here rather than at the landing. func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { + a.homeTakeResult = &msg + a.homeTakeOpening = true + defer func() { a.homeTakeOpening = false }() if a.presentRow(msg.row).Mine { msg.taken.Resume.From = "" } @@ -428,7 +872,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { if previous.failed || (previous.fence != 0 && previous.fence != msg.taken.Fence) { msg.taken.AlreadyHere = false } - a.machineRead.sendHolds[msg.row.Cell] = sendHold{fence: msg.taken.Fence} + a.machineRead.sendHolds[msg.row.Cell] = sendHold{fence: msg.taken.Fence, awaitingWriter: !msg.taken.AlreadyHere} rows := append([]chatlist.Row(nil), a.machineRead.rows...) for i := range rows { if rows[i].Cell == msg.row.Cell { @@ -440,6 +884,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { a.machineRead.rows = rows a.machinesRetaken = a.machinesAsking a.considerResume() + a.home.others = a.othersNow() a.refreshHome() kept := takenSaid(msg.taken) moved := movedSaid(msg.taken) @@ -448,11 +893,16 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } line, ok := a.takenLine(msg) if !ok { - a.homeTaking = "" + a.finishHomeTake(false) a.restoreTakeDraft(msg) a.home.say(joinSaid(moved, kept), "") return a.askMachines() } + if a.conversationOpening { + a.conversationOpening = false + a.conversationPreview = nil + a.conversationRequest++ + } a.homeTakeDraft = &msg a.homeTakenSaid = func() tea.Cmd { // A local session of this same chat was retired before replacement. @@ -467,13 +917,16 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { a.forget(key) } } - a.homeTaking, a.homeTakeDraft = "", nil + a.finishHomeTake(false) a.note(joinSaid(moved, kept)) if msg.draft != nil { if msg.taken.AlreadyHere { a.moveTakeDraftCopies(*msg.draft) } a.putComposer(*msg.draft) + if msg.send == nil { + a.note("your message is still in the box; press enter to send it") + } } else if !msg.taken.AlreadyHere { // A take from Home restores the arrived chat's saved words. The // conversation being left keeps its own composer in its own draft. @@ -500,22 +953,26 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } switch { case cmd == nil && a.at(pageHome): - a.homeTaking, a.homeTakeDraft = "", nil - a.restoreTakeDraft(msg) + a.finishHomeTake(true) // THE OPEN REFUSED and home still stands: the sentence has no // conversation to arrive in, so home itself says the takeover // happened, the way it says every row's refusal. a.home.say(joinSaid(moved, kept), "") - a.homeTakenSaid = nil return a.askMachines() case !a.at(pageHome): // THE OPEN LANDED IN PLACE — a shared window walks in synchronously — // so the ride is spent here rather than at a landing that already // happened. - said := a.homeTakenSaid - a.homeTakenSaid = nil - return tea.Batch(cmd, said(), a.askMachines()) + var saidCmd tea.Cmd + if a.homeTakenSaid != nil { + said := a.homeTakenSaid + a.homeTakenSaid = nil + saidCmd = said() + } + a.finishHomeTake(false) + return tea.Batch(cmd, saidCmd, a.askMachines()) } + a.finishHomeTake(true) return tea.Batch(cmd, a.askMachines()) } @@ -604,7 +1061,7 @@ func takenSaid(t Taken) string { // homeLineOf is the conversation line of a chat this machine lists. func (a *app) homeLineOf(cell string) (homeLine, bool) { for _, line := range a.home.lines { - if line.kind == homeSession && line.row.ID == cell { + if line.kind == homeSession && homeSessionID(line.row) == cell { return line, true } } @@ -638,6 +1095,9 @@ func (a *app) branchVerbs() []branchVerb { // offerBranch raises the branch card, or says the row's sentence when nothing // can be done with it from here. func (a *app) offerBranch(row chatlist.Row, _ chatlist.Offer) tea.Cmd { + if a.hosted() { + return nil + } verbs := a.branchVerbs() if len(verbs) == 0 { a.home.say(chatlist.BranchLine(row, false), "") @@ -657,6 +1117,7 @@ func (a *app) branchShown(row chatlist.Row, verbs []branchVerb) questionShown { options = append(options, session.AnswerOption{Key: string(rune('1' + len(verbs))), Label: branchLeave, Safe: true}) return questionShown{ question: session.Question{ + ID: a.nextContinueAskID(), Kind: homeBranchKind, Ask: session.AskConfirmation, Form: session.FormCard, @@ -686,14 +1147,32 @@ type homeBranchMsg struct{ err error } // runBranchVerb runs one verb off the update loop, for takeRemote's reason. func (a *app) runBranchVerb(v branchVerb, row chatlist.Row) tea.Cmd { - ctx := a.ctx + if a.hosted() { + return nil + } if v.label == "open separately" { + if a.homeTaking != "" { + a.raiseMachineAsk(row, a.continueBusyShown(row)) + a.home.say(continueAlreadyRunning, "") + return nil + } + a.homeTakeSeq++ + seq := a.homeTakeSeq + a.homeTaking = row.Cell + a.home.say(continuing, "") + baseCtx := a.ctx + if baseCtx == nil { + baseCtx = context.Background() + } + ctx, cancel := context.WithCancel(baseCtx) + a.homeTakeCancel = cancel open := a.branches.Open return func() tea.Msg { taken, err := open(ctx, row.Cell) - return homeTakenMsg{row: row, taken: taken, err: err} + return homeTakenMsg{seq: seq, row: row, taken: taken, err: err} } } + ctx := a.ctx return func() tea.Msg { return homeBranchMsg{err: v.do(ctx, row.Cell)} } } @@ -708,6 +1187,9 @@ func (a *app) tookBranch(msg homeBranchMsg) tea.Cmd { // othersNow is the held reading with presence as the feed says it now. func (a *app) othersNow() machineReading { + if a.hosted() { + return machineReading{} + } reading := a.machineRead reading.away = a.resumeState().away return reading diff --git a/internal/tui3/homepanel_continue_test.go b/internal/tui3/homepanel_continue_test.go index 3a07825319..addd279f1a 100644 --- a/internal/tui3/homepanel_continue_test.go +++ b/internal/tui3/homepanel_continue_test.go @@ -100,13 +100,13 @@ func TestTakeoverScreenCopy(t *testing.T) { } } -// A chat running on another machine is continued from the same screen: it says +// A chat held on another machine is continued from the same screen: it says // where the chat runs now, and confirming takes it. func TestTakeoverScreenOffersARunningChat(t *testing.T) { taker := &fakeTaker{} a := continueHome(t, taker, continueRows, 200) standOn(t, a, "c-run") - if text := homeText(a); !strings.Contains(text, "running on host-a; last saved turn 2m ago") { + if text := homeText(a); !strings.Contains(text, "held on host-a; last saved turn 2m ago") { t.Fatalf("the screen does not name where the chat runs:\n%s", text) } confirm(t, a) @@ -189,7 +189,7 @@ func TestTakeoverScreenOpensTheChatAndSaysWhatWasKept(t *testing.T) { } // winnerTakes is a Source that lists the chat as off until it is asked again, -// and then as running on the device that won it. +// and then as held on the device that won it. type winnerTakes struct{ asked int } func (w *winnerTakes) Rows(context.Context) ([]chatlist.Row, error) { @@ -212,8 +212,18 @@ func TestTakeoverScreenLostRace(t *testing.T) { if !strings.Contains(text, "another device continued this chat first") { t.Fatalf("R25 visible refusal changed:\n%s", text) } - if !strings.Contains(text, "Port the picker running on desk") || !strings.Contains(text, lostRaceWord) { - t.Fatalf("the row did not come back as running on the winner, with one line saying so:\n%s", text) + q, card := a.homeAsking() + if !card || q.question.Options[0].Label != continueRetry || q.question.Options[1].Label != continueStay { + t.Fatalf("the refusal did not stay on its retry card:\n%s", text) + } + found := false + for _, row := range a.machineRead.rows { + if row.Cell == "c-off" && row.Device == "desk" && row.Status == chatlist.Running { + found = true + } + } + if !found { + t.Fatalf("the refreshed row lost its holder: %+v", a.machineRead.rows) } if len(taker.cells) != 1 { t.Fatalf("the takeover ran %d times", len(taker.cells)) @@ -353,7 +363,7 @@ func TestC7HomeExplainsAnOldSyncService(t *testing.T) { a := continueHome(t, &fakeTaker{err: directory.ErrConditionalTake}, continueRows, 220) standOn(t, a, "c-off") confirm(t, a) - if !strings.Contains(homeText(a), "the sync service must be updated before chats can move") { + if !strings.Contains(homeText(a), "waits for a sync service update") { t.Fatalf("old service refusal missing:\n%s", homeText(a)) } } @@ -483,7 +493,7 @@ func TestTakeoverCardOfAnOfflineDeviceDoesNotSayRunning(t *testing.T) { a.dirFeed = feed a.rebuildMachines() standOn(t, a, "c-run") - if text := homeText(a); !strings.Contains(text, "running on build-box; last saved turn 15s ago") { + if text := homeText(a); !strings.Contains(text, "held on build-box; last saved turn 15s ago") { t.Fatalf("an online device's card lost running on:\n%s", text) } feed.state.Online = nil diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 41c5e3b4cc..364491e286 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -7,6 +7,7 @@ import ( "path/filepath" "slices" "strings" + "time" tea "charm.land/bubbletea/v2" @@ -45,8 +46,14 @@ func (a *app) heldElsewhere() (chatlist.Row, bool) { // tray identify what was waiting, so an answer cannot send words the person // changed or move a message into a different conversation. type ownershipReadMsg struct { - file, draft string - chips []chip + file, draft string + host string + chips []chip + pastes []pasteChip + // A local startup timeout is not a failed Home directory listing. + writerWaitFailed bool + // Displacement has already been explained by the notice that ended this wait. + writerDisplaced bool reading homeMachinesMsg resume func() tea.Cmd first *composerState @@ -55,23 +62,34 @@ type ownershipReadMsg struct { proof bool local bool // notHeld prevents a saved receipt from reviving a displaced writer offline. - notHeld bool + notHeld bool + writerStopped bool } type sendHold struct { - fence uint64 - blocked bool + // awaitingWriter belongs only to this window's successful move, never to a listing receipt. + awaitingWriter bool + // A listing or notice wakes every send waiting for this window's writer. + writerStop chan struct{} + fence uint64 + blocked bool // seen marks a block that only news of another holder set: a listing row // or the displaced notice. A fresh reading made for this send that finds // the chat held here again lifts it, because that reading is newer than // the news; a block a take decided, or a failed take, never lifts that way. seen bool - // failed marks a hold whose take did not finish, so its session is a - // copy this window may not keep using even if a later take finds the - // chat already here. + // failed prevents reusing a stopped writer or an unfinished take, even + // when a later reading finds a complete chat held by this computer. failed bool } +func (h *sendHold) stopWriterWait() { + if h.writerStop != nil { + close(h.writerStop) + h.writerStop = nil + } +} + type sendLeaseSource interface { SendLease(string) (string, uint64, error) } @@ -93,9 +111,16 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { src, file, draft, agent := a.machines, a.file, a.input.String(), a.agent // A read that only ran out of time does not show the connection is down, // so the next Enter asks again rather than replaying the cached failure. - offline := a.machineRead.down && a.machineRead.unavailableWord() != chatlist.ReadTimedOut - blocked := a.machineRead.sendHolds[filepath.Base(session.DirOf(file))].blocked + offline := a.machineRead.down && !readTimedOut(a.machineRead.err) + hold := a.machineRead.sendHolds[filepath.Base(session.DirOf(file))] + blocked := hold.blocked + if hold.awaitingWriter && !blocked && hold.writerStop == nil { + hold.writerStop = make(chan struct{}) + a.machineRead.sendHolds[filepath.Base(session.DirOf(file))] = hold + } + host := a.relayHost() chips := slices.Clone(a.chips) + pastes := slices.Clone(a.pastes) var first *composerState if a.conversationPreview != nil && !a.conversationOpening { a.conversationPreview.ownershipPending = true @@ -103,21 +128,42 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { first = &composer } return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) + readWithin := sendOwnershipTimeout + ctx, cancel := context.WithTimeout(context.Background(), readWithin) defer cancel() var rows []chatlist.Row var version uint64 var local bool var err error + writerStopped := false + writerWaitFailed := false + writerDisplaced := false holding, notHeld := false, false if writer, ok := agent.(renewalReader); ok { _, _, holding = writer.RenewalStatus() + if hold.awaitingWriter && !blocked && !holding { + // A new writer's installation is separate from taking ownership. Waiting + // grants no authority; its live status and receipt must still agree below. + cancel() + readWithin = writerStartTimeout + ctx, cancel = context.WithTimeout(context.Background(), readWithin) + defer cancel() + holding, err = awaitSendWriter(ctx, writer, hold.writerStop) + writerDisplaced = errors.Is(err, errSendWriterDisplaced) + writerWaitFailed = err != nil && !writerDisplaced + } notHeld = !holding + if notHeld { + _, period, _ := writer.RenewalStatus() + writerStopped = period > 0 + } } if blocked { holding, notHeld = false, true } - if scoped, ok := src.(interface { + if err != nil { + // An expired startup wait keeps the original submission unsent. + } else if scoped, ok := src.(interface { RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) }); ok { rows, version, local, err = scoped.RowsForChatSend(ctx, file, offline, holding) @@ -126,7 +172,7 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { } else { rows, version, err = chatlist.RowsForSend(ctx, src) } - msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, local: local, notHeld: notHeld, reading: homeMachinesMsg{rows: rows, version: version, err: err}} + msg := ownershipReadMsg{file: file, draft: draft, chips: chips, resume: resume, first: first, local: local, notHeld: notHeld, reading: homeMachinesMsg{rows: rows, version: version, err: err, readWithin: readWithin}, host: host, pastes: pastes, writerWaitFailed: writerWaitFailed, writerStopped: writerStopped, writerDisplaced: writerDisplaced} id := filepath.Base(session.DirOf(file)) if local, ok := src.(sendLeaseSource); ok { msg.proof = true @@ -181,7 +227,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { next := a.liveComposer() a.putComposer(*msg.first) var reading tea.Cmd - if !msg.local || msg.reading.err != nil { + if !msg.writerWaitFailed && !msg.writerDisplaced && (!msg.local || msg.reading.err != nil) { reading = a.recordMachines(msg.reading) } cmd := a.resumeOwnedSend(msg) @@ -196,12 +242,12 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { } return tea.Batch(reading, cmd) } - if a.input.String() != msg.draft || !slices.Equal(a.chips, msg.chips) { + if a.input.String() != msg.draft || !slices.Equal(a.chips, msg.chips) || !slices.Equal(a.pastes, msg.pastes) { a.note("draft changed before sending; press enter to send your current words") return nil } var reading tea.Cmd - if !msg.local || msg.reading.err != nil { + if !msg.writerWaitFailed && !msg.writerDisplaced && (!msg.local || msg.reading.err != nil) { reading = a.recordMachines(msg.reading) } return tea.Batch(reading, a.resumeOwnedSend(msg)) @@ -210,6 +256,9 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { // resumeOwnedSend spends only the submitted gesture after its fresh ownership // reading. A free chat can resume silently only from the same saved turn. func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { + if msg.writerDisplaced { + return nil + } id := filepath.Base(session.DirOf(msg.file)) remote := id if msg.leaseCell != "" { @@ -223,7 +272,11 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if a.offlineSendHeld(id, remote, msg) { return msg.resume() } - a.note(chatlist.Removed) + a.note(chatlist.RemovedChat) + return nil + } + if msg.writerWaitFailed { + a.note(messageKept(msg.host)) return nil } if msg.reading.err != nil { @@ -235,7 +288,7 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if a.offlineSendHeld(id, remote, msg) { return msg.resume() } - a.note(a.machineRead.unavailableWord()) + a.note(messageKept(msg.host)) return nil } for _, row := range msg.reading.rows { @@ -247,7 +300,7 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if msg.proof { fence = msg.fence } - if row.Status == chatlist.Here && row.Fence == fence && (!held.blocked || held.seen && row.Mine && !held.failed) { + if row.Status == chatlist.Here && row.Fence == fence && !(msg.notHeld && (held.awaitingWriter || msg.writerStopped) && !msg.local) && (!held.blocked || held.seen && row.Mine && !held.failed) { if held.blocked { held.blocked, held.seen = false, false a.machineRead.sendHolds[id] = held @@ -264,7 +317,7 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { a.note(chatlist.Superseded(nonemptyDevice(row.Device))) return nil } - a.machineRead.sendHolds[id] = sendHold{fence: fence, blocked: true} + a.machineRead.sendHolds[id] = sendHold{fence: fence, blocked: true, failed: msg.writerStopped} cmd := a.openHome() return tea.Batch(cmd, a.askContinue(row)) } @@ -313,6 +366,7 @@ func (a *app) rememberSendHolds(msg homeMachinesMsg) { held, known := a.machineRead.sendHolds[row.Cell] if heldElsewhere(row) && (!known || row.Fence >= held.fence) { held.blocked, held.seen = true, !known || held.seen || !held.blocked + held.stopWriterWait() a.machineRead.sendHolds[row.Cell] = held } if row.Status != chatlist.Here || held.blocked || held.failed { @@ -321,7 +375,7 @@ func (a *app) rememberSendHolds(msg homeMachinesMsg) { // The directory can show another local window's fence. Only the // publishing receipt lets this window follow its own drive's new hold. if !known || (msg.holdCell == row.Cell && msg.holdFence != 0 && msg.holdFence == row.Fence) { - a.machineRead.sendHolds[row.Cell] = sendHold{fence: row.Fence} + a.machineRead.sendHolds[row.Cell] = sendHold{fence: row.Fence, awaitingWriter: held.awaitingWriter, writerStop: held.writerStop} } } } @@ -341,6 +395,7 @@ func (a *app) blockDisplacedSend(text string) { id := filepath.Base(session.DirOf(a.file)) held, known := a.machineRead.sendHolds[id] held.blocked, held.seen = true, !known || held.seen || !held.blocked + held.stopWriterWait() a.machineRead.sendHolds[id] = held } @@ -359,6 +414,12 @@ func (a *app) failedTake(msg homeTakenMsg) string { a.machineRead.sendHolds[current] = returned } } + return a.continueFailure(msg) +} + +// continueFailure describes an error without retiring local authority. A failed +// safety reading has not started a take and must not cancel one already running. +func (a *app) continueFailure(msg homeTakenMsg) string { switch { case errors.Is(msg.err, directory.ErrLeaseHeld): return lostRaceWord @@ -368,7 +429,15 @@ func (a *app) failedTake(msg homeTakenMsg) string { return directory.ErrConditionalTake.Error() case errors.Is(msg.err, directory.ErrRevoked): a.removedComputer() - return chatlist.Removed + return chatlist.RemovedChat + case errors.Is(msg.err, cellstore.ErrStoreBusy): + var busy *cellstore.StoreBusyError + if errors.As(msg.err, &busy) { + if who := busy.Holder.Describe(); who != "" { + return continueFails + " because the engine store is busy (held by " + who + ")" + } + } + return continueStoreBusy case strings.Contains(msg.err.Error(), "rewind cancelled"), strings.Contains(msg.err.Error(), "changed during rewind"): return continueFails + " because the folder changed during the move; your words are still in the box" default: @@ -376,3 +445,54 @@ func (a *app) failedTake(msg homeTakenMsg) string { return continueFails + " because the chat could not be put in this folder; your words are still in the box. Try continue here again." } } + +// awaitSendWriter waits only for an uninstalled writer. Displacement ends the +// wait even when the writer's renewal facts are indistinguishable from startup. +func awaitSendWriter(ctx context.Context, writer renewalReader, displaced <-chan struct{}) (bool, error) { + var poll *time.Ticker + defer func() { + if poll != nil { + poll.Stop() + } + }() + for { + select { + case <-displaced: + return false, errSendWriterDisplaced + default: + } + if err := ctx.Err(); err != nil { + return false, err + } + var changed <-chan struct{} + if source, ok := writer.(interface{ WriterChanges() <-chan struct{} }); ok { + changed = source.WriterChanges() + } else if source, ok := writer.(interface{ RenewalChanges() <-chan struct{} }); ok { + changed = source.RenewalChanges() + } + _, period, holding := writer.RenewalStatus() + if holding || period > 0 { + return holding, nil + } + if changed == nil { + if poll == nil { + poll = time.NewTicker(100 * time.Millisecond) + } + select { + case <-displaced: + return false, errSendWriterDisplaced + case <-ctx.Done(): + return false, ctx.Err() + case <-poll.C: + } + } else { + select { + case <-displaced: + return false, errSendWriterDisplaced + case <-ctx.Done(): + return false, ctx.Err() + case <-changed: + } + } + } +} diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 438ac5e8c4..1d846da95d 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -3,6 +3,7 @@ package tui3 import ( "context" "errors" + "fmt" "path/filepath" "sort" "time" @@ -34,6 +35,9 @@ import ( // last ask failed. A failed ask keeps the rows and explains whether the reading // reached its deadline or the connection could not be made. type machineReading struct { + host string + checking bool + readWithin time.Duration err error sendHolds map[string]sendHold computerRemoved bool @@ -57,15 +61,42 @@ type machineReading struct { // homeMachinesMsg is one listing, coming BACK from the source. type homeMachinesMsg struct { - rows []chatlist.Row - version uint64 - err error - holdCell string - holdFence uint64 + readWithin time.Duration + rows []chatlist.Row + version uint64 + err error + holdCell string + holdFence uint64 } // machinesAskTimeout bounds one ask without treating silence as a network failure. -const machinesAskTimeout = 5 * time.Second +const ( + machinesAskTimeout = 25 * time.Second + machinesSlowAfter = 5 * time.Second + sendOwnershipTimeout = 5 * time.Second + writerStartTimeout = 30 * time.Second +) + +// This signal stays inside the send path; the existing displacement notice +// already tells the person why the message cannot leave this window. +var errSendWriterDisplaced = errors.New("send writer displaced") + +type homeMachinesSlowMsg struct{ seq uint64 } + +func (a *app) relayHost() string { + if source, ok := a.machines.(interface{ RelayHost() string }); ok { + return source.RelayHost() + } + return "" +} + +func (a *app) machinesStillChecking(msg homeMachinesSlowMsg) { + if !a.machinesAsking || msg.seq != a.machinesAskSeq { + return + } + a.machineRead.checking = true + a.rebuildMachines() +} // askMachines lists the other machines, off the update loop for the reason a // repository's status is asked (homeband_repo.go): a keystroke may not wait on @@ -76,28 +107,33 @@ func (a *app) askMachines() tea.Cmd { // askChats is the listing half of [app.askMachines]. func (a *app) askChats() tea.Cmd { - if a.machines == nil || a.machinesAsking { + if a.hosted() || a.machines == nil || a.machinesAsking { return nil } a.machinesAsking = true + a.machinesAskSeq++ + seq := a.machinesAskSeq + a.machineRead.host = a.relayHost() src, file := a.machines, a.file - return func() tea.Msg { + read := func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) defer cancel() rows, version, err := chatlist.RowsAt(ctx, src) - msg := homeMachinesMsg{rows: rows, version: version, err: err} + msg := homeMachinesMsg{rows: rows, version: version, err: err, readWithin: machinesAskTimeout} if local, ok := src.(sendLeaseSource); ok && file != "" { msg.holdCell = filepath.Base(session.DirOf(file)) _, msg.holdFence, _ = local.SendLease(msg.holdCell) } return msg } + return tea.Batch(read, surfaceTick(machinesSlowAfter, func(time.Time) tea.Msg { return homeMachinesSlowMsg{seq: seq} })) } // tookMachines files the answer. An error keeps the last rows and only marks // them stale. func (a *app) tookMachines(msg homeMachinesMsg) tea.Cmd { a.machinesAsking = false + a.machineRead.checking = false if a.machinesRetaken { a.machinesRetaken = false return a.askMachines() @@ -123,6 +159,7 @@ func (a *app) recordMachines(msg homeMachinesMsg) tea.Cmd { before, wasElsewhere := a.heldElsewhere() a.fileMachines(msg) a.machineRead.err = msg.err + a.machineRead.readWithin = msg.readWithin if row, elsewhere := a.heldElsewhere(); msg.err == nil && elsewhere && (!wasElsewhere || before.DeviceID != row.DeviceID || before.Device != row.Device) { a.note(chatlist.Superseded(nonemptyDevice(row.Device))) } @@ -140,51 +177,130 @@ func (a *app) recordMachines(msg homeMachinesMsg) tea.Cmd { // into merges the other machines' chats into the panel's own lines, newest // first, cuts to the panel's cap, and puts the unreachable sentence at the foot. +// Each chat id appears at most once: a chat held elsewhere replaces its stale +// local row with the continuation door, while a chat held or released here +// keeps its single local row (carrying any collapsed branch copies). func (m machineReading) into(in *homeGridInput, own []homeLine) homePanelRows { - lines := append(own, m.lines(in, own)...) + if in != nil && in.far { + return homePanelCut(in, panelSessions, own) + } + localIDs := make(map[string]bool, len(own)) + localTitle := make(map[string]string, len(own)) + for _, line := range own { + if id := homeSessionID(line.row); id != "" { + localIDs[id] = true + if line.cell != nil && line.cell.title != "" { + localTitle[id] = line.cell.title + } + } + } + collapsed, byLocal := chatlist.CollapseCopies(m.rows, localIDs) + replacedByRemote := map[string]bool{} + var remoteLines []homeLine + for _, row := range collapsed { + if m.away != nil && m.away(row) { + row = row.Quiet() + } + if localIDs[row.Cell] { + if heldElsewhere(row) { + if (row.Title == "" || row.Title == "untitled") && localTitle[row.Cell] != "" { + row.Title = localTitle[row.Cell] + } + replacedByRemote[row.Cell] = true + remoteLines = append(remoteLines, machineLineIn(row, m.at, in, m.merge, m.separate)) + } else if len(row.Copies) > 0 { + if byLocal == nil { + byLocal = make(map[string][]chatlist.BranchCopy) + } + byLocal[row.Cell] = append(byLocal[row.Cell], row.Copies...) + } + continue + } + if drawnAsRemote(row, false) { + remoteLines = append(remoteLines, machineLineIn(row, m.at, in, m.merge, m.separate)) + } + } + lines := make([]homeLine, 0, len(own)+len(remoteLines)) + for _, line := range own { + id := homeSessionID(line.row) + if id != "" && replacedByRemote[id] { + continue + } + if copies := byLocal[id]; len(copies) > 0 && line.cell != nil { + decorateCellWithCopies(line.cell, copies, in != nil && in.desc) + } + lines = append(lines, line) + } + lines = append(lines, remoteLines...) sort.SliceStable(lines, func(i, j int) bool { return lines[i].stamp().After(lines[j].stamp()) }) rows := homePanelCut(in, panelSessions, lines) - if m.down { + if m.down || m.checking && m.host != "" { rows.lines = append(rows.lines, homeLine{kind: homeMachineRow, cell: &homeCell{ - kind: cellWhisper, panel: panelSessions, title: m.unavailableWord()}}) + kind: cellWhisper, panel: panelSessions, title: m.waitWord()}}) } return rows } -// unavailableWord preserves the distinction between a deadline and a failed connection. -func (m machineReading) unavailableWord() string { +// readTimedOut distinguishes an expired request from a failed connection. +func readTimedOut(err error) bool { var timeout interface{ Timeout() bool } - if errors.Is(m.err, context.DeadlineExceeded) || errors.As(m.err, &timeout) && timeout.Timeout() { + return errors.Is(err, context.DeadlineExceeded) || errors.As(err, &timeout) && timeout.Timeout() +} + +func (m machineReading) waitWord() string { + if m.down { + return m.unavailableWord() + } + if m.checking && m.host != "" { + return fmt.Sprintf("waiting for %s; still checking", m.host) + } + return "" +} + +func (m machineReading) unavailableWord() string { + if m.host != "" { + if readTimedOut(m.err) { + within := m.readWithin + if within == 0 { + within = machinesAskTimeout + } + return fmt.Sprintf("%s did not answer in %s; still trying", m.host, within) + } + return fmt.Sprintf("%s is unreachable; still trying", m.host) + } + if readTimedOut(m.err) { return chatlist.ReadTimedOut } return chatlist.Unreachable } -// lines are the rows to draw: none of the kind that only this machine can hold, -// and none for a chat this machine already lists unless another machine holds it -// live. That one is drawn WITH its door (`running on desktop`, enter takes it), -// because the local row of a chat taken elsewhere is a stale window, and the way -// back to it is the same take the cold path runs. +// lines are the rows to draw from other machines when merged with own. func (m machineReading) lines(in *homeGridInput, own []homeLine) []homeLine { + if in != nil && in.far { + return nil + } local := map[string]bool{} for _, line := range own { - local[line.row.ID] = true + if id := homeSessionID(line.row); id != "" { + local[id] = true + } } + collapsed, _ := chatlist.CollapseCopies(m.rows, local) var out []homeLine - for _, row := range m.rows { + for _, row := range collapsed { if m.away != nil && m.away(row) { row = row.Quiet() } if drawnAsRemote(row, local[row.Cell]) { - out = append(out, machineLine(row, m.at, in.now, m.merge, m.separate)) + out = append(out, machineLineIn(row, m.at, in, m.merge, m.separate)) } } return out } // drawnAsRemote says a row from the directory is a row of the panel. A chat -// held here is never drawn twice; a chat listed here is drawn again only while -// another machine holds it, live or gone quiet. +// held here is never drawn twice; a chat listed here replaces its local row +// only while another machine holds it, live or gone quiet. func drawnAsRemote(row chatlist.Row, listedHere bool) bool { if row.Status == chatlist.Here { return false @@ -200,10 +316,51 @@ func heldElsewhere(row chatlist.Row) bool { return row.Status == chatlist.Running || (row.Status == chatlist.Off && !row.Mine) } +func decorateCellWithCopies(cell *homeCell, copies []chatlist.BranchCopy, desc bool) { + full := chatlist.CopiesLine(copies) + short := chatlist.CopiesShort(copies) + if full == "" { + return + } + if cell.note != "" { + baseShort := cell.noteShort + if baseShort == "" { + baseShort = cell.note + } + cell.note = cell.note + " · " + full + // THE CHAT'S OWN STATE OUTRANKS THE STORY OF ITS COPIES. Where the whole + // sentence does not fit, the narrow spelling keeps the state and only counts + // the copies, so a row never loses `host-a offline` to the sentence about + // what a copy is; the painter drops a narrow note that is still too wide. + cell.noteShort = baseShort + " · " + chatlist.CopiesCount(copies) + } else { + cell.note = full + cell.noteShort = short + } + cell.keepNote = true + if desc { + cell.sub = rowClauses(cell.sub, full) + } +} + // machineLine is one chat on another machine as a row of the sessions panel. // The turn's time is fixed once, from the moment of the read; the age drawn is // then counted to now, so it grows with the clock however long ago the ask was. func machineLine(row chatlist.Row, readAt, now time.Time, merge bool, separate ...bool) homeLine { + return machineLineWithDesc(row, readAt, now, false, merge, separate...) +} + +func machineLineIn(row chatlist.Row, readAt time.Time, in *homeGridInput, merge bool, separate ...bool) homeLine { + var now time.Time + var desc bool + if in != nil { + now = in.now + desc = in.desc + } + return machineLineWithDesc(row, readAt, now, desc, merge, separate...) +} + +func machineLineWithDesc(row chatlist.Row, readAt, now time.Time, desc, merge bool, separate ...bool) homeLine { // THE DRAW CLOCK IS THE WORLD'S READING AND THE READ TIME IS THE ASK'S, two // samples of one wall clock a few milliseconds apart. A draw that seems to // come before the read would make a turn look younger than the directory said @@ -219,11 +376,15 @@ func machineLine(row chatlist.Row, readAt, now time.Time, merge bool, separate . // spelling for frames that cannot hold the whole. note, short = chatlist.BranchLine(row, merge, separate...), chatlist.BranchShort(row) } - return homeLine{kind: homeMachineRow, since: at, remote: &row, cell: &homeCell{ + cell := &homeCell{ kind: cellRow, panel: panelSessions, title: row.Title, note: note, noteShort: short, keepNote: true, right: sinceAt(at, now), key: "machine:" + row.Cell, - }} + } + if len(row.Copies) > 0 { + decorateCellWithCopies(cell, row.Copies, desc) + } + return homeLine{kind: homeMachineRow, since: at, remote: &row, cell: cell} } // stamp is when a line's chat last moved, for ordering the merged panel. @@ -244,3 +405,14 @@ func (a *app) machineRowTexts(line homeLine, at, width int, pal palette) []strin } return texts } + +// messageKept is the one line a send that could not leave says. It lives +// beside the other relay-named words, so the sentence a person reads after an +// unanswered check is worded in one place rather than inside the send path. +func messageKept(host string) string { + const kept = "your message is still in the box, press enter to send it" + if host == "" { + return kept + } + return host + " has not answered; " + kept +} diff --git a/internal/tui3/homepanel_machines_test.go b/internal/tui3/homepanel_machines_test.go index c9435f0dcc..d0d841044e 100644 --- a/internal/tui3/homepanel_machines_test.go +++ b/internal/tui3/homepanel_machines_test.go @@ -53,7 +53,7 @@ func TestHomeRowsFromOtherMachines(t *testing.T) { a := machinesHome(t, otherMachines, 200) text := homeText(a) for _, want := range []string{ - "Nightly index rebuild", "running on host-a", + "Nightly index rebuild", "held on host-a", "Port the picker", "host-a offline", "Fix the flaky test", "2 turns from laptop: merge / discard", "Notes on the migration", @@ -105,7 +105,7 @@ func TestUnreachableKeepsLastListing(t *testing.T) { if !strings.Contains(text, chatlist.Unreachable) { t.Fatalf("no unreachable line:\n%s", text) } - if !strings.Contains(text, "Nightly index rebuild") || !strings.Contains(text, "running on host-a") { + if !strings.Contains(text, "Nightly index rebuild") || !strings.Contains(text, "held on host-a") { t.Fatalf("the last listing was dropped:\n%s", text) } } @@ -115,13 +115,13 @@ func TestUnreachableKeepsLastListing(t *testing.T) { func TestHomeMachineRowsGolden(t *testing.T) { golden := map[int][]string{ 80: { - " Nightly index rebuild running on host-a 2m", + " Nightly index rebuild held on host-a 2m", " Port the picker host-a offline 3h", " Fix the flaky test 2 turns from laptop: merge / discard 5h", " Notes on the migration 1d", }, 40: { - " Nightly index... running on host-a 2m", + " Nightly index re... held on host-a 2m", " Port the picker host-a offline 3h", " Fix the flaky ... 2 turns · laptop 5h", " Notes on the migration 1d", @@ -228,7 +228,7 @@ func TestARowOnAnOfflineDeviceDoesNotSayRunning(t *testing.T) { feed := &fakeFeed{state: dirwatch.State{Up: true, Online: []string{"dev_host-a"}}} a.dirFeed = feed a.rebuildMachines() - if got := linesWith(a, "running on host-a"); len(got) != 1 { + if got := linesWith(a, "held on host-a"); len(got) != 1 { t.Fatalf("an online device's chat should say running:\n%s", homeText(a)) } feed.state.Online = nil diff --git a/internal/tui3/homepanel_sessions.go b/internal/tui3/homepanel_sessions.go index 888c2d814b..911fd9ef7a 100644 --- a/internal/tui3/homepanel_sessions.go +++ b/internal/tui3/homepanel_sessions.go @@ -3,6 +3,9 @@ package tui3 import ( "path/filepath" "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/session" ) // Home previews recent conversations; the Sessions tab holds their full trees. @@ -10,38 +13,103 @@ const homeSessionsLimit = 15 type sessionsPanel struct{ homePanelBase } +// homeSessionID extracts a conversation's stable chat id, recovering it from a +// structured session or cell folder when the row has not populated ID yet. +func homeSessionID(row session.SessionRow) string { + if id := strings.TrimSpace(row.ID); id != "" { + return id + } + if dir, ok := session.FolderOf(row.Transcript); ok { + if id := strings.TrimSpace(filepath.Base(dir)); id != "" && id != "." && id != string(filepath.Separator) { + return id + } + } + return "" +} + +// homeSessionKey keys Home's conversation rows by chat id rather than by +// transcript path, falling back to the cleaned file path only for bare test +// transcripts that live outside a session folder and carry no id. +func homeSessionKey(row session.SessionRow) string { + if id := homeSessionID(row); id != "" { + return "id:" + id + } + return "file:" + filepath.Clean(row.Transcript) +} + +func preferHomeSessionRow(candidate, existing switcherRow) bool { + if candidate.here != existing.here { + return candidate.here + } + if candidate.session.Archived != existing.session.Archived { + return !candidate.session.Archived + } + if candidate.session.Open != existing.session.Open { + return candidate.session.Open + } + if !candidate.session.At.Equal(existing.session.At) { + return candidate.session.At.After(existing.session.At) + } + candMoving := strings.Contains(filepath.ToSlash(candidate.session.Transcript), "/.moving/") + existMoving := strings.Contains(filepath.ToSlash(existing.session.Transcript), "/.moving/") + if candMoving != existMoving { + return candMoving + } + return candidate.session.Transcript < existing.session.Transcript +} + func (sessionsPanel) rows(in *homeGridInput) homePanelRows { - byFile := make(map[string]switcherRow) + byID := make(map[string]switcherRow) + put := func(row switcherRow) { + if row.session.ID == "" { + row.session.ID = homeSessionID(row.session) + } + key := homeSessionKey(row.session) + if prev, exists := byID[key]; exists && !preferHomeSessionRow(row, prev) { + return + } + byID[key] = row + } for _, row := range in.rows { if row.kind == switcherConversation { - byFile[filepath.Clean(row.session.Transcript)] = row + put(row) } } // Closed conversations remain part of history and retain their dim styling. for _, project := range in.world.Projects { for _, row := range project.Sessions { if row.Archived { - byFile[filepath.Clean(row.Transcript)] = switcherRow{kind: switcherConversation, + put(switcherRow{kind: switcherConversation, session: row, project: project.Name, title: homeName(row), - place: switcherWhere(row, project)} + place: switcherWhere(row, project)}) } } } // The tab keeper has fresher titles and ownership than the disk snapshot. for _, row := range in.closedChats { + if row.session.ID == "" { + row.session.ID = homeSessionID(row.session) + } row.session.Archived = true - byFile[filepath.Clean(row.session.Transcript)] = row + key := homeSessionKey(row.session) + if prev, exists := byID[key]; exists && (prev.here || prev.session.Open) { + continue + } + byID[key] = row } for _, row := range in.openChats { - byFile[filepath.Clean(row.session.Transcript)] = row + if row.session.ID == "" { + row.session.ID = homeSessionID(row.session) + } + byID[homeSessionKey(row.session)] = row } - rows := make([]switcherRow, 0, len(byFile)) - for _, row := range byFile { + rows := make([]switcherRow, 0, len(byID)) + for _, row := range byID { rows = append(rows, row) } sort.Slice(rows, func(i, j int) bool { if rows[i].session.At.Equal(rows[j].session.At) { - return rows[i].session.Transcript < rows[j].session.Transcript + return homeSessionKey(rows[i].session) < homeSessionKey(rows[j].session) } return rows[i].session.At.After(rows[j].session.At) }) @@ -54,7 +122,7 @@ func (sessionsPanel) rows(in *homeGridInput) homePanelRows { cell = recentOwnCell(row, in) } cell.panel, cell.chatKey, cell.closed = panelSessions, row.chatKey, row.session.Archived - cell.key = "sessions:" + row.session.Transcript + cell.key = "sessions:" + homeSessionKey(row.session) if row.needs && !needsLandingsSpeakFor(row.session, needsCallTitlesOn(in, row.session.ID)) { cell.mark = cellMarkNeeds } diff --git a/internal/tui3/homepanel_takedoor_test.go b/internal/tui3/homepanel_takedoor_test.go index 75ff81b6fc..a462daca2f 100644 --- a/internal/tui3/homepanel_takedoor_test.go +++ b/internal/tui3/homepanel_takedoor_test.go @@ -59,10 +59,10 @@ func rowsOf(a *app, cell string) (local, remote int) { func TestListedHereAndHeldElsewhereDrawsTheDoor(t *testing.T) { src := &flipping{status: chatlist.Running} a, _ := heldLab(t, src, &fakeTaker{}) - if local, remote := rowsOf(a, heldCell); local != 1 || remote != 1 { - t.Fatalf("local=%d remote=%d rows, want one of each:\n%s", local, remote, homeText(a)) + if local, remote := rowsOf(a, heldCell); local != 0 || remote != 1 { + t.Fatalf("local=%d remote=%d rows, want one single row with the door:\n%s", local, remote, homeText(a)) } - if !strings.Contains(homeText(a), "running on host-a") { + if !strings.Contains(homeText(a), "held on host-a") { t.Fatalf("the held chat does not say where it runs:\n%s", homeText(a)) } } diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 495d103b0a..748372b911 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -193,6 +193,8 @@ func homeFate(word, rest string) string { // /project IS THE PIN AND /folder IS NOT, since 2026-09-22 // (projectcmd.go says what the two used to share). return fateTargetFolder + case "pair", "devices": + return fateAnswers case "settings", "spend", "history", "home", "wall", "teams": return fatePlace case "resume": @@ -207,7 +209,7 @@ func homeFate(word, rest string) string { return fateBehind case "files", "permissions", "connect", "harness", "subharness", "skill", "autonomy", "copy", "select", "rewind", "compact", "export", "drafts", "manual", "folder", "processes", - "setup", "pair", "devices", "atlas": + "setup", "atlas": // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a // turn of a conversation now (manualcmd.go), and a turn needs one. As // an answer it printed the pages into the conversation BEHIND home, diff --git a/internal/tui3/hometabs.go b/internal/tui3/hometabs.go index 179e81cb2a..c33083573f 100644 --- a/internal/tui3/hometabs.go +++ b/internal/tui3/hometabs.go @@ -3,6 +3,8 @@ package tui3 import ( "path/filepath" "sort" + + "github.com/Agent-Field/codeaf/internal/session" ) // homeClosedLimit keeps recently closed conversations within easy reach without @@ -17,13 +19,22 @@ func (h *homeView) conversationRows() (open, closed []switcherRow) { return nil, nil } byFile := map[string]switcherRow{} + byID := map[string]switcherRow{} var archived []switcherRow for _, project := range h.world.Projects { for _, row := range project.Sessions { + if row.ID == "" { + row.ID = homeSessionID(row) + } read := switcherRow{kind: switcherConversation, session: row, project: project.Name, title: homeName(row), age: sinceAt(row.At, h.world.Read), at: switcherSortAt(row), gone: h.gone[switcherWhere(row, project)], place: switcherWhere(row, project)} byFile[filepath.Clean(row.Transcript)] = read + if row.ID != "" { + if prev, exists := byID[row.ID]; !exists || preferHomeSessionRow(read, prev) { + byID[row.ID] = read + } + } if row.Archived { archived = append(archived, read) } @@ -31,10 +42,18 @@ func (h *homeView) conversationRows() (open, closed []switcherRow) { } seen := map[string]bool{} fromTab := func(tab chatTab, isOpen bool) switcherRow { - row := byFile[filepath.Clean(tab.file)] + row, ok := byFile[filepath.Clean(tab.file)] + if !ok { + if id := homeSessionID(session.SessionRow{Transcript: tab.file}); id != "" { + row = byID[id] + } + } row.kind, row.title, row.here = switcherConversation, tab.word, isOpen && tab.here row.chatKey = tab.key row.session.Transcript = tab.file + if row.session.ID == "" { + row.session.ID = homeSessionID(row.session) + } owned := tab.held || tab.here || h.holding != nil && h.holding(tab.file) row.held = (row.session.Open || row.session.Live) && !owned row.door = row.held && !h.far && row.session.Dir != "" @@ -52,8 +71,10 @@ func (h *homeView) conversationRows() (open, closed []switcherRow) { if tab.work || tab.start || tab.file == "" { continue } + built := fromTab(tab, true) + seen[homeSessionKey(built.session)] = true seen[filepath.Clean(tab.file)] = true - open = append(open, fromTab(tab, true)) + open = append(open, built) } if h.closedTabs != nil { tabs := h.closedTabs() @@ -62,22 +83,27 @@ func (h *homeView) conversationRows() (open, closed []switcherRow) { if tab.work || tab.start || tab.file == "" { continue } + built := fromTab(tab, false) + key := homeSessionKey(built.session) file := filepath.Clean(tab.file) - if seen[file] { + if seen[key] || seen[file] { continue } + seen[key] = true seen[file] = true - closed = append(closed, fromTab(tab, false)) + closed = append(closed, built) } } // The close stack gives this window's exact close order. Persisted archived // conversations fill any remaining slots after a restart, newest activity first. sort.SliceStable(archived, func(i, j int) bool { return archived[i].at.After(archived[j].at) }) for _, row := range archived { + key := homeSessionKey(row.session) file := filepath.Clean(row.session.Transcript) - if seen[file] || len(closed) >= homeClosedLimit { + if seen[key] || seen[file] || len(closed) >= homeClosedLimit { continue } + seen[key] = true seen[file] = true closed = append(closed, row) } diff --git a/internal/tui3/hometip.go b/internal/tui3/hometip.go index a1ce424f3e..b2b2519df1 100644 --- a/internal/tui3/hometip.go +++ b/internal/tui3/hometip.go @@ -83,10 +83,15 @@ func (a *app) tipLine(tip string, width int, pal palette) (string, hudSpan) { // columns in [app.targetFolderSpan] — the same span the rule used to write — // and clears them where the path does not fit. func (a *app) homeFootLine(width int, pal palette) string { + return a.homeFootWithWarning(width, pal, a.homeCreditWarningFor(width)) +} + +// homeFootWithWarning also serves a footer below a message row that already +// carries the warning, so the account news is drawn only once. +func (a *app) homeFootWithWarning(width int, pal palette, warning string) string { a.targetFolderSpan = hudSpan{} // THE LOW-CREDIT LINE, when it applies, stands just left of the project and // is fitted before either the keys or the path (credits.go, #1439). - warning := a.homeCreditWarningFor(width) room := width - 2 if warning != "" { room -= ansi.StringWidth(warning) + 1 diff --git a/internal/tui3/hostlink.go b/internal/tui3/hostlink.go index 94da0c8acd..700011cf93 100644 --- a/internal/tui3/hostlink.go +++ b/internal/tui3/hostlink.go @@ -53,6 +53,10 @@ import ( // session, every test, and every headless frame. Nothing half-works there: no // segment is drawn, no notice is looked for, and no question is asked about. type LinkSeam struct { + // SendRefusal reads known connection state before the composer spends a draft. + // Nil is a local connection with no remote-send gate. + SendRefusal func() string + // EngineBuild reads the latest welcome already held by the client. It // changes after redial onto a replacement engine and never touches the wire. EngineBuild func() string @@ -203,7 +207,7 @@ func (a *app) linkSegment() string { if note := a.linkNote(); note != "" { return note } - if !a.hosted() || a.linkLatency <= 0 { + if !a.hosted() || !a.linkLatencyFresh() { return "" } return a.host + " · " + latencyWord(a.linkLatency) @@ -231,6 +235,24 @@ func (a *app) linkNoting() bool { return a.linkNote() != "" } // traffic. const hostPingEvery = 5 * time.Second +// hostPingMargin tolerates a normal reply without keeping an unanswered reading. +const hostPingMargin = time.Second + +func (a *app) linkLatencyFresh() bool { + return a.linkLatency > 0 && !a.linkLatencyAt.IsZero() && a.now().Sub(a.linkLatencyAt) < hostPingEvery+hostPingMargin +} + +type linkLatencyExpiredMsg struct{ at time.Time } + +// linkLatencyExpiry repaints an idle bar even while its next ping is still waiting. +func (a *app) linkLatencyExpiry() tea.Cmd { + if !a.linkLatencyFresh() { + return nil + } + at := a.linkLatencyAt + return surfaceTick(hostPingEvery+hostPingMargin, func(time.Time) tea.Msg { return linkLatencyExpiredMsg{at: at} }) +} + // latencyHandful is the EWMA's effective window. A quarter of each new sample // moves the reading, so a brief spike is visible without making the status row // twitch on every answer. @@ -276,11 +298,12 @@ func (a *app) linkPingBack(msg linkPingMsg) { if msg.err != nil || msg.elapsed <= 0 { return } - if a.linkLatency <= 0 { + if !a.linkLatencyFresh() { a.linkLatency = msg.elapsed } else { a.linkLatency = (a.linkLatency*time.Duration(latencyHandful-1) + msg.elapsed) / latencyHandful } + a.linkLatencyAt = a.now() a.touch() } diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 74f492e4b5..d1ff056033 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -340,6 +340,12 @@ func (e *editor) down() { // two typed overlays are read before the editor, because while a list is up the // four keys that move and commit it are the list's. func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { + // The removal question consumes its answer whole, including cancellation chords. + if c := a.openDevicesCard(); c != nil && c.removing != nil { + cmd, _ := c.key(a, msg.String()) + a.touch() + return cmd + } // LEAVING IS NEVER MODAL, and these two rungs are read above the door, so // each of them lets the door's key past: a page that is on its way and a // page that is up both took it, and on an engine that never answered the @@ -473,10 +479,9 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return tea.Batch(cmd, a.takeRoomPump()) } - // AND THE ONE KEY THE PROPOSAL STILL OWNS, which is not an answer: ctrl+e - // unfolds the assignment in the transcript (task.go). It is below the block - // because the block is where the proposal is answered and a key it draws - // must reach it first. + // The proposal's own keys disclose its brief or choose its computer without + // answering it (task.go). The question block comes first because a key it + // draws must reach the place where the proposal is answered. if cmd, taken := a.taskKey(msg); taken { return cmd } @@ -1488,6 +1493,13 @@ func (a *app) enterCheckedLine(ownershipRead bool) tea.Cmd { if cmd, took := a.takePairLink(); took { return cmd } + // A known link refusal is checked before any draft, attachment or recall is spent. + if a.link.SendRefusal != nil && !strings.HasPrefix(strings.TrimSpace(a.input.String()), "/") && (!a.input.empty() || len(a.chips) > 0) { + if sentence := a.link.SendRefusal(); sentence != "" { + a.note(sentence) + return nil + } + } if a.startingChat() { return a.startChatEnter() } diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 949c1db8e8..a6d372b04c 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1083,6 +1083,9 @@ func (a *app) holding(file string) bool { // conversation in the keeper is not something to open. It is something to look // at again. func (a *app) bringForward(file string) (cmd tea.Cmd, owned bool) { + if !a.homeTakeOpening { + a.abandonAutomaticTake() + } if a.startingChat() { back := a.parkChatStart() defer func() { cmd = tea.Batch(back, cmd) }() @@ -1279,6 +1282,7 @@ func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { return cmd, more } a.conversationClosing = true + a.retainSavingDurability([]Agent{leaving}) desk := a.outsideDesk return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted, func() { desk.Say(name + ": " + closeSaveFailedWord) @@ -1416,36 +1420,54 @@ func (a *app) leavingWork() func() { errands = append(errands, errandClose{agent: ex.agent, working: ex.working}) } } + departures := a.retainSavingDurability(agents) + errandsDone := make([][]func(error), len(errands)) + for i, ex := range errands { + errandsDone[i] = a.retainSavingDurability([]Agent{ex.agent}) + } a.exchanges, a.home.exchanges = nil, nil waitForRelease, report := a.waitForRelease, a.saveFailure return func() { waitRelease := waitForRelease != nil && waitForRelease() var wg sync.WaitGroup - for _, agent := range agents { + for i, agent := range agents { wg.Add(1) go func(agent Agent) { + complete := func(err error) { + if len(departures) > i { + departures[i](err) + } + } defer wg.Done() if waitRelease { if hosted, ok := agent.(interface{ DetachAndWait() error }); ok { - if err := hosted.DetachAndWait(); err != nil && report != nil { + err := hosted.DetachAndWait() + complete(err) + if err != nil && report != nil { report(err) } return } } - if err := leaveAgentError(agent); err != nil && report != nil { + err := leaveAgentError(agent) + complete(err) + if err != nil && report != nil { report(err) } }(agent) } - for _, ex := range errands { + for i, ex := range errands { wg.Add(1) go func() { defer wg.Done() if ex.working { ex.agent.InterruptFor(session.StopByLeaving) } - if err := ex.agent.Close(); err != nil && report != nil { + err := ex.agent.Close() + if len(errandsDone[i]) > 0 { + errandsDone[i][0](err) + } + if err != nil && report != nil { report(err) } }() @@ -1748,3 +1770,31 @@ func (a *app) keptQuiet(held *kept, now time.Time) bool { // not a surprise. An undated sidecar is not evidence of age, so it waits. return !side.since.IsZero() && now.Sub(side.since) >= keptIdleGrace } + +// retainSavingDurability captures only thread-safe receipt readers, before the +// update loop forgets the conversations whose closing work remains pending. +func (a *app) retainSavingDurability(agents []Agent) []func(error) { + if a.savingCompatibility != nil { + for _, agent := range agents { + if receipts, ok := agent.(interface{ SavingReceipts() bool }); ok { + a.savingCompatibility(receipts.SavingReceipts) + } + } + } + if a.savingDurability == nil { + return nil + } + completed := make([]func(error), 0, len(agents)) + for _, agent := range agents { + // A successful detach is this chat's completed departure, even when + // its work stays hosted and no local close receipt is expected. + var departed atomic.Bool + local := func() bool { return false } + if receipt, ok := agent.(interface{ LocalDurable() bool }); ok { + local = receipt.LocalDurable + } + a.savingDurability(func() bool { return departed.Load() || local() }) + completed = append(completed, func(err error) { departed.Store(err == nil) }) + } + return completed +} diff --git a/internal/tui3/l4_pair_test.go b/internal/tui3/l4_pair_test.go new file mode 100644 index 0000000000..0037d4bcc8 --- /dev/null +++ b/internal/tui3/l4_pair_test.go @@ -0,0 +1,69 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" +) + +type l4PairDoor struct{ Pairing } + +func (l4PairDoor) PairCommand() string { return pair.Command("http://127.0.0.1:8901", "") } + +// L4-H1a: Home instructions render the selected service through the shared command builder. +func TestL4HomePairCommand(t *testing.T) { + a := &app{pairing: l4PairDoor{}} + lines := a.addMachineHow(bandContext{width: 160}) + if !strings.Contains(strings.Join(lines, "\n"), "codeaf pair --via http://127.0.0.1:8901") { + t.Fatal(lines) + } +} + +// L4-H3d: an outage already said by either producer suppresses the second sentence. +func TestL4OneSyncOutageNotice(t *testing.T) { + a := &app{file: "chat"} + line := "the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back" + if !a.acceptSyncNotice(line) || a.acceptSyncNotice(line) { + t.Fatal("outage repeated") + } + b := &app{file: "chat", syncOutages: map[string]bool{"chat": true}} + if b.acceptSyncNotice(line) { + t.Fatal("the renewal outage was said again") + } +} + +// L4-H3b: an old healthy renewal reading cannot recover a newer upload outage. +func TestL4SyncRecoveryRequiresNewSuccess(t *testing.T) { + source := &renewalAgent{fakeAgent: &fakeAgent{model: "m"}, held: true} + a := newTestApp(source) + a.file = "chat" + a.syncService = "http://127.0.0.1:8901" + now := time.Now() + a.clock = func() time.Time { return now } + a.note("the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back") + a.takeRenewalNotice() + if !a.syncOutages[a.file] { + t.Fatal("an unchanged healthy renewal reading recovered a newer outage") + } + source.since = now.Add(-11 * time.Second) + a.takeRenewalNotice() + source.since = time.Time{} + a.takeRenewalNotice() + if a.syncOutages[a.file] { + t.Fatal("a completed successful renewal did not recover the outage") + } + a.note("the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back") + a.takeRenewalNotice() + if !a.syncOutages[a.file] { + t.Fatal("evidence from the previous outage recovered the next one") + } + source.since = now + a.takeRenewalNotice() + source.since = time.Time{} + a.takeRenewalNotice() + if a.syncOutages[a.file] { + t.Fatal("a brief renewal failure and success did not recover the engine outage") + } +} diff --git a/internal/tui3/l4b2_devices_test.go b/internal/tui3/l4b2_devices_test.go new file mode 100644 index 0000000000..131569f777 --- /dev/null +++ b/internal/tui3/l4b2_devices_test.go @@ -0,0 +1,156 @@ +package tui3 + +import ( + "context" + "fmt" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "strings" + "testing" + "time" +) + +// L4-H7a, L4-H7b, L4-H7c: Removal requires one explicit answer and consumes every cancellation key. +func TestL4B2RemovalQuestion(t *testing.T) { + for _, key := range []string{"y", "esc", "n", "down", "r", "enter", "ctrl+c"} { + t.Run(key, func(t *testing.T) { + door := &fakeApprovals{} + a, _ := approveApp(t, door) + c := &deviceCard{loaded: true, cursor: 1, rowsOf: []DeviceRow{{ID: "dev_1111", Name: "desk-b"}, {ID: "dev_2222", Name: "desk-b"}}} + a.pair.open, a.pair.card = true, c + cmd, close := c.key(a, "r") + if cmd != nil || close || door.did() != "" { + t.Fatal("r changed the device") + } + text := plain(strings.Join(c.rows(160, time.Now(), a.pal), "\n")) + if !strings.Contains(text, "remove desk-b #2222? it must pair again to come back · y removes · any other key keeps it") { + t.Fatalf("question: %s", text) + } + cmd, close = c.key(a, key) + if close || c.cursor != 1 { + t.Fatal("answer left the selected row") + } + if key == "y" { + if cmd == nil { + t.Fatal("yes did not remove") + } + m := cmd().(revokedMsg) + m.land(a) + if door.did() != "revoke:dev_2222" || c.line != fmt.Sprintf(devicesRevoked, "desk-b") { + t.Fatalf("result: %s %s", door.did(), c.line) + } + } else if cmd != nil || door.did() != "" || c.line != "" { + t.Fatal("cancellation did more than keep the device") + } + }) + } +} + +// L4-H8a: Approval platforms and Home presence use the terminal's selected repertoire. +func TestL4B2DeviceGlyphs(t *testing.T) { + for _, tier := range []tokens.GlyphSet{tokens.Plain, tokens.NerdFont, tokens.ASCII} { + a := newTestApp(&fakeAgent{model: "m"}) + a.pal.icons = tier + req := tower(time.Unix(1_000_000, 0)) + for _, platform := range []string{"darwin", "linux", "windows", "ios", "android", "other"} { + req.Platform = platform + c := &approveCard{req: &req} + text := plain(strings.Join(c.rows(160, req.RequestedAt, a.pal), "\n")) + if strings.Contains(text, "$") { + t.Fatalf("platform drawn as money: %s", text) + } + if !strings.Contains(text, a.pal.glyph(platformOf(platform).icon)+" tower") { + t.Fatalf("wrong tier %v: %s", tier, text) + } + } + a.devRow.devices = []chatlist.Device{{ID: "self", Name: "This computer", Self: true}, {ID: "peer", Name: "desk"}} + a.dirFeed = &fakeFeed{state: dirwatch.State{Up: true, Online: []string{"peer"}}} + text := plain(strings.Join(drawDevicesBand(a, bandContext{width: 160, pal: a.pal}), "\n")) + if !strings.Contains(text, tier.Glyph(tokens.GStepDone)+" desk") { + t.Fatalf("presence tier: %s", text) + } + } +} + +// L4-M9b, L4-M10b, L4-M10c: Both lists share service and row words, including this device and the Add row. +func TestL4B2DeviceListsAgree(t *testing.T) { + now := time.Unix(1_000_000, 0) + door := &fakeApprovals{devices: []DeviceRow{{ID: "self", Name: "laptop", Platform: "darwin", Self: true}, {ID: "other", Name: "desk", Platform: "linux", LastSeen: now.Add(-time.Hour)}}} + a, r := approveApp(t, door) + a.syncService = "http://127.0.0.1:8901" + a.clock = func() time.Time { return now } + r.run(a.openDevices()) + r.until("devices", func() bool { c, ok := a.pair.card.(*deviceCard); return ok && c.loaded }) + c := a.pair.card.(*deviceCard) + listFrame := plain(frame(a)) + text := plain(strings.Join(c.rows(160, now, a.pal), "\n")) + if !strings.Contains(text, "sync service: 127.0.0.1:8901 (your own)") || !strings.Contains(text, addDeviceWord) { + t.Fatal(text) + } + a.pair.close() + a.openSettings() + walkToDevicesTab(t, a, r) + r.until("settings devices", func() bool { return a.sheet.dev != nil && a.sheet.dev.loaded }) + for _, row := range c.rowsOf { + words := plain(c.rowText(row, now, a.pal)) + if !strings.Contains(listFrame, words) || !strings.Contains(plain(frame(a)), words) { + t.Fatalf("device row differs between the actual screens: %q\n%s", words, plain(frame(a))) + } + } + if !strings.Contains(plain(frame(a)), "sync service: 127.0.0.1:8901 (your own)") { + t.Fatal("settings omitted the service") + } +} + +// L4-M10b: A solo device can be renamed, then the Add row starts the same pairing offer. +func TestL4B2SoloDeviceAddRow(t *testing.T) { + a, r := approveApp(t, &fakeApprovals{devices: []DeviceRow{{ID: "self", Name: "here", Self: true}}}) + r.run(a.openDevices()) + r.until("solo device", func() bool { c, ok := a.pair.card.(*deviceCard); return ok && c.loaded }) + c := a.pair.card.(*deviceCard) + r.press("n") + if c.naming == nil { + t.Fatal("this device cannot be named") + } + r.press("esc", "down") + if _, ok := c.pick(); ok || !strings.Contains(c.hint(), "enter add") { + t.Fatalf("add row not selected: %s", c.hint()) + } + a.pairing = &fakePairing{offer: func(ctx context.Context, ui pair.OfferUI) (string, error) { <-ctx.Done(); return "", ctx.Err() }} + r.press("enter") + if a.pair.run == nil || a.pair.card != nil { + t.Fatal("Add did not open /pair") + } +} + +// L4-H7a, L4-H7b, L4-H7c: Settings consumes the confirmation key before navigation, search or quitting. +func TestL4B2SettingsRemovalKeys(t *testing.T) { + for _, answer := range []string{"esc", "right", "down", "n", "ctrl+c", "y"} { + t.Run(answer, func(t *testing.T) { + door := devicesFake() + a, r := openSettingsDevices(t, door, time.Now()) + r.press("down") + c := a.sheet.dev + row, ok := c.pick() + if !ok || row.Self { + t.Fatal("no removable row") + } + cursor, tab := a.sheet.cursor, a.sheet.tab + r.press("r") + if c.removing == nil || door.did() != "" { + t.Fatal("r did not ask") + } + r.press(answer) + if answer == "y" { + r.until("removal", func() bool { return strings.Contains(c.line, "was removed") }) + if door.did() != "revoke:"+row.ID { + t.Fatal(door.did()) + } + } else if door.did() != "" || !a.at(pageSettings) || a.sheet.cursor != cursor || a.sheet.tab != tab || !a.sheet.query.empty() || a.quitting { + t.Fatal("cancellation performed another action") + } + }) + } +} diff --git a/internal/tui3/l4b2_home_test.go b/internal/tui3/l4b2_home_test.go new file mode 100644 index 0000000000..c097ac1fc5 --- /dev/null +++ b/internal/tui3/l4b2_home_test.go @@ -0,0 +1,183 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// L4-M4a: A link entered on Home replaces the add-device steps and returns there after either decision. +func TestL4B2HomeApprovalStaysHome(t *testing.T) { + for _, answer := range []string{"a", "d", "esc"} { + t.Run(answer, func(t *testing.T) { + door := &fakeApprovals{pending: tower(time.Now())} + a, r := approveApp(t, door) + a.openHome() + a.addMachine = addMachine{size: 1, known: true, open: true, focused: true} + a.addMachine.box.setText(testLink) + cmd, took := a.addMachineInputKey(key("enter")) + if !took { + t.Fatal("link field refused Enter") + } + r.run(cmd) + r.until("approval", func() bool { c, ok := a.pair.card.(*approveCard); return ok && c.req != nil }) + if !a.at(pageHome) { + t.Fatal("approval left Home") + } + text := homeText(a) + if !strings.Contains(text, "Check number 4821") || strings.Contains(text, addMachineStepPut) { + t.Fatalf("approval not in place: %s", text) + } + r.press(answer) + r.until("decision", func() bool { return !a.pair.open }) + if !a.at(pageHome) { + t.Fatal("decision left Home") + } + if answer != "a" && !strings.Contains(homeText(a), addDeviceWord) { + t.Fatal("normal card did not return") + } + }) + } +} + +// L4-M5a: The offer shows both directions, preserves --via and approves a pasted link through the usual door. +func TestL4B2PairOfferAcceptsLink(t *testing.T) { + a, r := approveApp(t, &fakeApprovals{pending: tower(time.Now())}) + code, err := pair.NewCode(time.Now()) + if err != nil { + t.Fatal(err) + } + a.pair = pairPanel{open: true, offering: true, code: code, lines: pair.ChatCardLines(code, "http://127.0.0.1:8901")} + text := plain(strings.Join(a.pair.rows(220, time.Now(), a.pal), "\n")) + if !strings.Contains(text, "paste the link the new device shows, or on it run codeaf pair "+code.Shown()+" --via http://127.0.0.1:8901") || !strings.Contains(text, "Paste pairing link") { + t.Fatal(text) + } + r.run(a.paste(testLink)) + r.press("enter") + r.until("approval", func() bool { c, ok := a.pair.card.(*approveCard); return ok && c.req != nil }) + if a.input.String() != "" { + t.Fatal("link reached chat draft") + } +} + +// L4-M6a, L4-M6b, L4-M6c: Removal restores the Home card and gets its own line before pairing instructions. +func TestL4B2RemovedHomeAndPair(t *testing.T) { + a, _ := approveApp(t, &fakeApprovals{}) + a.openHome() + a.addMachine = addMachine{size: 3, known: true} + a.removedComputer() + text := homeText(a) + want := "removed by another of your devices — pair again to sync" + if !a.addMachineWanted() || !strings.Contains(text, want) || !strings.Contains(text, addDeviceWord) { + t.Fatal(text) + } + if line, ok := a.placeMsgLine(200); ok && strings.Contains(line, "removed") { + t.Fatalf("status on hint row: %s", line) + } + a.pairing = &fakePairing{hasChats: true} + a.runPair("") + text = plain(strings.Join(a.pair.rows(180, a.now(), a.pal), "\n")) + if !a.pair.rejoining || strings.Index(text, want) < 0 || strings.Index(text, want) > strings.Index(text, "on a device still") || !strings.Contains(text, "Code from another device") { + t.Fatal(text) + } + +} + +// L4-M13a: Account cards opened from Home never call the conversation-opening door. +func TestL4B2HomeCommandsDoNotStartChats(t *testing.T) { + for _, command := range []string{"/devices", "/pair", "/settings"} { + t.Run(command, func(t *testing.T) { + a, r := approveApp(t, &fakeApprovals{devices: []DeviceRow{{ID: "self", Self: true, Name: "here"}}}) + opened := 0 + a.fresh = func() (Agent, string, error) { + opened++ + return &fakeAgent{model: "m"}, "/private/transcript.jsonl", nil + } + a.pairing = &fakePairing{offer: func(ctx context.Context, ui pair.OfferUI) (string, error) { <-ctx.Done(); return "", ctx.Err() }} + a.openHome() + a.home.box.setText(command) + r.run(a.homeSlash(command)) + if opened != 0 { + t.Fatal("account command started a conversation") + } + if command != "/settings" && !a.at(pageHome) { + t.Fatal("card left Home") + } + }) + } +} + +// L4-M13b: A fresh conversation note names its project without leaking a transcript path. +func TestL4B2NewConversationNote(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.workspace = "/private/proj-a" + a.finishRenew(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/private/hidden/transcript.jsonl", Workspace: a.workspace}, true, true) + text := updateNotes(a) + if !strings.Contains(text, "new conversation · proj-a") || strings.Contains(text, "transcript.jsonl") || strings.Contains(text, "new session") { + t.Fatal(text) + } +} + +// L4-M6c: A removed computer can join through a code from a current device and still asks before replacement. +func TestL4B2RemovedPairUsesCurrentDeviceCode(t *testing.T) { + a, r, joins := replaceApp(t, true) + a.removedComputer() + r.run(a.runPair("")) + r.run(a.paste("71-869-571")) + r.press("enter") + r.until("confirmation", func() bool { return a.pair.ask != nil }) + if *joins != 0 { + t.Fatal("rejoined before confirmation") + } + r.press("y") + r.until("paired again", func() bool { return a.pair.done }) + if *joins != 1 { + t.Fatal("did not use the replacement join") + } +} + +// L4-M17c: Home omits the strip whenever the feed cannot vouch for fresh presence. +func TestL4B2UnknownPresenceHidesStrip(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.devRow.devices = []chatlist.Device{{ID: "self", Self: true, Name: "This computer"}, {ID: "peer", Name: "laptop-a"}} + feed := &fakeFeed{state: dirwatch.State{Up: true, Online: []string{"peer"}}} + a.dirFeed = feed + ctx := bandContext{width: 160, pal: a.pal} + if len(drawDevicesBand(a, ctx)) == 0 { + t.Fatal("fresh presence missing") + } + feed.state.Up = false + if len(drawDevicesBand(a, ctx)) != 0 { + t.Fatal("unknown presence drawn") + } + feed.state.Up = true + if len(drawDevicesBand(a, ctx)) == 0 { + t.Fatal("new answer did not restore strip") + } +} + +// L4-M6b: Status news on Home occupies its own dim row and never replaces or joins the key hints. +func TestL4B2HomeNewsHasOwnLine(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.width, a.height = 120, 36 + a.openHome() + a.home.say("desk-b was declined.", "") + text := homeText(a) + found := false + for _, line := range strings.Split(text, "\n") { + if strings.Contains(line, "desk-b was declined.") { + found = true + if strings.Contains(line, "enter ") || strings.Contains(line, "tab ") || strings.Contains(line, "→ options") { + t.Fatalf("news joined key hints: %s", line) + } + } + } + if !found { + t.Fatal("news disappeared") + } +} diff --git a/internal/tui3/l4b2_latency_test.go b/internal/tui3/l4b2_latency_test.go new file mode 100644 index 0000000000..a54dc3abe8 --- /dev/null +++ b/internal/tui3/l4b2_latency_test.go @@ -0,0 +1,27 @@ +package tui3 + +import ( + "strings" + "testing" + "time" +) + +// L4-M11b: A measured latency expires on the surface clock, even if the next ping never finishes. +func TestL4B2LatencyExpires(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.host = "desk-b" + now := time.Unix(1_000_000, 0) + a.clock = func() time.Time { return now } + a.linkPingBack(linkPingMsg{elapsed: time.Millisecond}) + if !strings.Contains(a.linkSegment(), "1ms") { + t.Fatal("answer missing") + } + now = now.Add(hostPingEvery + time.Second + time.Nanosecond) + if strings.Contains(a.linkSegment(), "ms") { + t.Fatalf("stale latency: %s", a.linkSegment()) + } + a.linkPingBack(linkPingMsg{elapsed: time.Millisecond}) + if !strings.Contains(a.linkSegment(), "1ms") { + t.Fatal("new answer did not restore latency") + } +} diff --git a/internal/tui3/l4b2_send_test.go b/internal/tui3/l4b2_send_test.go new file mode 100644 index 0000000000..406fe2fdfe --- /dev/null +++ b/internal/tui3/l4b2_send_test.go @@ -0,0 +1,26 @@ +package tui3 + +import ( + "strings" + "testing" +) + +// L4-H10a, L4-H10b: A refused Enter leaves the complete draft in the message box and starts no turn. +func TestL4B2RefusedSendKeepsDraft(t *testing.T) { + for _, sentence := range []string{ + "not sent — still reconnecting to localhost; enter sends it once the link is back", + "not sent — the connection to desk-b is gone; your words are still in the box. Run the same command to pick the conversation back up", + } { + agent := &fakeAgent{model: "m"} + a := newTestApp(agent) + a.link.SendRefusal = func() string { return sentence } + a.input.setText(" my words\nwith another line ") + a.enterLine() + if a.input.String() != " my words\nwith another line " || len(agent.sent) != 0 || a.turn != 0 { + t.Fatalf("draft spent: %q sent=%v", a.input.String(), agent.sent) + } + if !strings.Contains(updateNotes(a), sentence) { + t.Fatalf("missing refusal: %s", updateNotes(a)) + } + } +} diff --git a/internal/tui3/l4fix2_notices_test.go b/internal/tui3/l4fix2_notices_test.go new file mode 100644 index 0000000000..b204ed23e3 --- /dev/null +++ b/internal/tui3/l4fix2_notices_test.go @@ -0,0 +1,30 @@ +package tui3 + +import ( + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" + "strings" + "testing" +) + +// L4-B13, L4-B16: An older engine's wording selects the same recovery door and +// outage ledger, rather than displaying duplicate or terminal-only guidance. +func TestL4Fix2LegacyNotices(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.syncService = "https://sync.example" + a.apply(session.Event{Kind: session.EventNotice, Text: "this chat is not syncing because other machines are unreachable; it keeps working here"}) + a.note(syncwords.Outage(a.syncService)) + notes := strings.Join(noteTexts(a), "\n") + if strings.Count(notes, "this chat keeps working here") != 1 || strings.Contains(notes, "other machines") { + t.Fatal(notes) + } + a.outsideDesk.Say("this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back") + a.saidNotices() + notes = strings.Join(noteTexts(a), "\n") + if !strings.Contains(notes, "/pair brings it back") || strings.Contains(notes, "run `codeaf pair`") { + t.Fatal(notes) + } + if a.homeAccountNotice != removedPairNotice { + t.Fatal(a.homeAccountNotice) + } +} diff --git a/internal/tui3/l4fix3_credits_test.go b/internal/tui3/l4fix3_credits_test.go new file mode 100644 index 0000000000..abcdb8ce61 --- /dev/null +++ b/internal/tui3/l4fix3_credits_test.go @@ -0,0 +1,39 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/credits" +) + +// L4-M6b, fix round 3: A Home message keeps the whole account warning once, +// while its footer remains a separate row of key hints and the project. +func TestL4Fix3HomeMessageCreditWarningOnce(t *testing.T) { + a := placeApp(t) + a.width = 200 + a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{}, nil } + seedLowCredits(t, a) + a.refreshCreditWarnings() + a.pageMsg = "open in another window" + text := placeFrameText(a) + if strings.Count(text, lowCreditsWarning) != 1 { + t.Fatalf("warning must appear once: %s", text) + } + message, keys := false, false + for _, line := range strings.Split(text, "\n") { + if strings.Contains(line, a.pageMsg) { + message = strings.Contains(line, lowCreditsWarning) + } + if strings.Contains(line, "/ commands") { + keys = true + if strings.Contains(line, lowCreditsWarning) || strings.Contains(line, a.pageMsg) { + t.Fatalf("account news joined the key hints: %q", line) + } + } + } + if !message || !keys { + t.Fatalf("message or key row missing: %s", text) + } +} diff --git a/internal/tui3/l4fix_devices_test.go b/internal/tui3/l4fix_devices_test.go new file mode 100644 index 0000000000..fbdaeacbc5 --- /dev/null +++ b/internal/tui3/l4fix_devices_test.go @@ -0,0 +1,39 @@ +package tui3 + +import ( + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "strings" + "testing" + "time" +) + +// L4-R9: Neither device card calls the explicit off setting a service. +func TestL4FixDeviceCardsSyncOff(t *testing.T) { + a, r := approveApp(t, &fakeApprovals{devices: []DeviceRow{{Name: "laptop", Self: true, Platform: "linux"}}}) + a.syncService = "off" + r.run(a.openDevices()) + r.until("devices", func() bool { c, ok := a.pair.card.(*deviceCard); return ok && c.loaded }) + if text := plain(frame(a)); strings.Contains(text, "sync service:") { + t.Fatal(text) + } + a.pair.close() + a.openSettings() + walkToDevicesTab(t, a, r) + r.until("settings devices", func() bool { return a.sheet.dev != nil && a.sheet.dev.loaded }) + if text := plain(frame(a)); strings.Contains(text, "sync service:") { + t.Fatal(text) + } +} + +// L4-R10: The Add selection uses the same cursor slot in every repertoire. +func TestL4FixAddDeviceCursor(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + c := &deviceCard{loaded: true} + for _, tier := range []tokens.GlyphSet{tokens.ASCII, tokens.Plain, tokens.NerdFont} { + a.pal.icons = tier + text := plain(strings.Join(c.rows(120, time.Time{}, a.pal), "\n")) + if !strings.HasPrefix(text, a.pal.glyph(tokens.GPointer)+" ") { + t.Fatalf("tier=%v: %s", tier, text) + } + } +} diff --git a/internal/tui3/l4fix_quit_test.go b/internal/tui3/l4fix_quit_test.go new file mode 100644 index 0000000000..c9cda573e9 --- /dev/null +++ b/internal/tui3/l4fix_quit_test.go @@ -0,0 +1,51 @@ +package tui3 + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/syncwords" + "sync" + "sync/atomic" + "testing" +) + +type l4QuitAgent struct { + *hostedAgent + idle bool + saved atomic.Bool +} + +func (a *l4QuitAgent) LocalDurable() bool { return a.saved.Load() } +func (a *l4QuitAgent) Detach() error { + if a.idle { + a.saved.Store(true) + return syncwords.UploadFailure(context.DeadlineExceeded) + } + return a.hostedAgent.Detach() +} + +// L4-R5: A running chat's successful detach cannot poison an idle chat's pending upload on the same quit. +func TestL4FixTwoChatQuit(t *testing.T) { + running := &l4QuitAgent{hostedAgent: &hostedAgent{fakeAgent: &fakeAgent{model: "m"}}} + idle := &l4QuitAgent{hostedAgent: &hostedAgent{fakeAgent: &fakeAgent{model: "m"}}, idle: true} + a := newTestApp(running) + a.file = t.TempDir() + "/running/transcript.jsonl" + a.stirs = make(chan behindStirMsg, stirDepth) + stowAgent(t, a, idle, t.TempDir()+"/idle/transcript.jsonl") + var checks []func() bool + a.savingDurability = func(check func() bool) { checks = append(checks, check) } + var mu sync.Mutex + var failed error + a.saveFailure = func(err error) { mu.Lock(); defer mu.Unlock(); failed = err } + a.leaveEverything() + if len(checks) != 2 || !syncwords.UploadPending(failed) { + t.Fatalf("checks=%d err=%v", len(checks), failed) + } + for i, check := range checks { + if !check() { + t.Errorf("chat %d is treated as an unacknowledged local save", i) + } + } + if running.detaches != 1 || running.closes != 0 { + t.Fatalf("running chat was ended: %+v", running) + } +} diff --git a/internal/tui3/l4fix_sync_words_test.go b/internal/tui3/l4fix_sync_words_test.go new file mode 100644 index 0000000000..7049f6961b --- /dev/null +++ b/internal/tui3/l4fix_sync_words_test.go @@ -0,0 +1,77 @@ +package tui3 + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" + "strings" + "testing" + "time" +) + +// L4-G1: Chat removal points to the chat's working pairing door; Home uses its account line. +func TestL4FixRemovedWordsByPlace(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.outsideDesk.Say(chatlist.Removed) + a.saidNotices() + notes := strings.Join(noteTexts(a), "\n") + if !strings.Contains(notes, "/pair brings it back") || strings.Contains(notes, "run `codeaf pair`") { + t.Fatal(notes) + } + if a.homeAccountNotice != "removed by another of your devices — pair again to sync" { + t.Fatal(a.homeAccountNotice) + } +} + +// L4-G2: Home, continuation and timeout identify the service; chat outage producers share one notice. +func TestL4FixSyncOutageWords(t *testing.T) { + for _, tc := range []struct { + err error + want string + }{ + {directory.ErrUnreachable, "the sync service is not answering"}, + {context.DeadlineExceeded, "the sync service did not answer in time"}, + } { + got := (machineReading{err: tc.err}).unavailableWord() + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + } + a := newTestApp(&fakeAgent{model: "m"}) + a.syncService = "http://127.0.0.1:8901" + a.note(chatlist.SyncPaused) + a.apply(session.Event{Kind: session.EventNotice, Text: syncwords.Outage(a.syncService)}) + notes := strings.Join(noteTexts(a), "\n") + if strings.Count(notes, "this chat keeps working here") != 1 || strings.Contains(notes, "other machines") { + t.Fatal(notes) + } + if !strings.Contains(continueUnreachable, "because the sync service is not answering; your words are still in the box") { + t.Fatal(continueUnreachable) + } +} + +// L4-G5: Losing service freshness explains a withdrawn resume card in the same Home band. +func TestL4FixResumeCardExplainsOutage(t *testing.T) { + a, feed := devicesRig() + a.syncService = "http://127.0.0.1:8901" + a.taker = &fakeTaker{} + a.tookMachines(homeMachinesMsg{rows: []chatlist.Row{leftOffRow("a", "desk-b", chatlist.Running, time.Minute)}}) + if leftOffFrame(a) == "" { + t.Fatal("no initial card") + } + feed.state = dirwatch.State{Up: false} + a.tookWatch(dirWatchMsg{from: feed}) + got := leftOffFrame(a) + if !strings.Contains(got, "nothing is answering at http://127.0.0.1:8901") { + t.Fatalf("card vanished without reason: %q", got) + } + // Fresh evidence restores the offer, rather than preserving an obsolete failure. + feed.state = dirwatch.State{Up: true} + a.tookWatch(dirWatchMsg{from: feed}) + if got := leftOffFrame(a); !strings.Contains(got, "Continue desk-b's latest chat here?") { + t.Fatal(got) + } +} diff --git a/internal/tui3/machinepoll.go b/internal/tui3/machinepoll.go index c9136220ea..33c6bd3765 100644 --- a/internal/tui3/machinepoll.go +++ b/internal/tui3/machinepoll.go @@ -98,7 +98,7 @@ func (a *app) attended(now time.Time) bool { // pollMachines asks the directory if, and only if, the schedule allows: home is // showing, somebody is at the window, and the wait is over. func (a *app) pollMachines(now time.Time) tea.Cmd { - wanted := (a.at(pageHome) || a.watchesConversation()) && a.attended(now) + wanted := !a.hosted() && (a.at(pageHome) || a.watchesConversation()) && a.attended(now) followed := a.tendWatch(wanted) if !wanted || !a.machinePoll.ready(now) { return followed @@ -126,6 +126,6 @@ func (a *app) fileMachines(msg homeMachinesMsg) { changed := msg.err == nil && !chatlist.Same(a.machineRead.rows, msg.rows) a.machinePoll.landed(now, changed, a.socketUp() && msg.err == nil) if msg.err == nil { - a.machineRead = machineReading{rows: msg.rows, at: now, version: msg.version, sendHolds: a.machineRead.sendHolds, computerRemoved: a.machineRead.computerRemoved} + a.machineRead = machineReading{rows: msg.rows, at: now, version: msg.version, sendHolds: a.machineRead.sendHolds, computerRemoved: a.machineRead.computerRemoved, host: a.machineRead.host, checking: a.machineRead.checking} } } diff --git a/internal/tui3/machinewatch.go b/internal/tui3/machinewatch.go index f97dacd2f2..1ebafaa8e4 100644 --- a/internal/tui3/machinewatch.go +++ b/internal/tui3/machinewatch.go @@ -133,10 +133,10 @@ func (a *app) probeWatch() { } } -// devicesCardOpen says /devices is on screen, which wants the feed on any page. +// devicesCardOpen says the device list is on screen — as /devices or on the +// /settings sheet — which wants the feed on any page. func (a *app) devicesCardOpen() bool { - _, open := a.pair.card.(*deviceCard) - return a.pair.open && open + return a.openDevicesCard() != nil } // letGoOffHome leaves the feed once the card that joined it away from home has diff --git a/internal/tui3/manual_devices_test.go b/internal/tui3/manual_devices_test.go index 4fc99c590a..381297d80d 100644 --- a/internal/tui3/manual_devices_test.go +++ b/internal/tui3/manual_devices_test.go @@ -2,6 +2,7 @@ package tui3 import ( "fmt" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" "regexp" "strings" "testing" @@ -22,14 +23,14 @@ func TestEverySentenceOfTheDeviceScreensIsOnItsPage(t *testing.T) { addMachineHeading, addMachinePitch, addMachineStepRun, addMachineStepPut, addMachineShow, approveAsk, fmt.Sprintf(approveCheckWord, "4821"), approveKeys, approveGone, fmt.Sprintf(approveDenied, "laptop"), fmt.Sprintf(joinedFleetWord, "laptop"), - strings.Join([]string{devicesMoveKey, devicesNameKey, devicesGoneKey, devicesCloseKey}, " · "), devicesNone, fmt.Sprintf(devicesRevoked, "desk"), "seen 3h ago", "offline", approveWorking, approveDeclining, approveSlow, + strings.Join([]string{devicesMoveKey, devicesNameKey, devicesGoneKey, devicesCloseKey}, " · "), fmt.Sprintf(devicesRemoveAsk, "desk"), fmt.Sprintf(devicesRevoked, "desk"), "seen 3h ago", "offline", approveWorking, approveDeclining, approveSlow, deviceBring, deviceNothing, devicePickAsk, devicePickLeav, - chatlist.Device{Name: "desk"}.Mark(false), chatlist.Removed, + chatlist.Device{Name: "desk"}.Mark(false, tokens.Plain.Glyph(tokens.GQueued)), chatlist.RemovedChat, }, "naming-a-device": {devicesNameKeys, devicesNameAsk + "atlas▏", devicesNameOwn}, "continuing-a-chat-on-another-computer": { chatlist.SyncPaused, chatlist.SyncRestored, - continueAsk, continueStay, continueFails, continueUnreachable, lostRaceWord, chatlist.Unreachable, + continueAsk, continueStay, continueRetry, continueAlreadyRunning, continuing, continueFails, continueStoreBusy, continueUnreachable, lostRaceWord, chatlist.Unreachable, fmt.Sprintf(resumeAskFormat, "tower"), resumeYes, resumeNo, chatlist.Moved("tower", 3200e6, true), chatlist.Moved("", 3200e6, false), chatlist.ArrivedHead("tower"), chatlist.OfferSetUp, chatlist.OfferNotNow, diff --git a/internal/tui3/mention.go b/internal/tui3/mention.go index 51e885b7ae..90274586a5 100644 --- a/internal/tui3/mention.go +++ b/internal/tui3/mention.go @@ -305,6 +305,17 @@ func (a *app) mentionChatsExcept(front string) []mentionChat { chat.open = false out = append(out, chat) } + if a.teamsShare && a.wall.loaded { + for _, t := range a.wall.teams { + for _, m := range t.Members { + if m.Key == "" || m.Key == front || seen[m.Key] { + continue + } + seen[m.Key] = true + out = append(out, mentionChat{key: m.Key, file: m.File, where: m.Where, title: m.Word, handle: m.Handle, slug: mentionSlug(m.Word), note: m.Word}) + } + } + } return out } @@ -795,7 +806,13 @@ func mentionTextRefs(flat string, teams []mentionTeam, chats []mentionChat) []ta } func (a *app) mentionChatPress(key string) tea.Cmd { - if key == "" || key == a.frontTabKey() { + if key == "" { + return nil + } + if cmd, remote := a.teamsRemoteFront(key); remote { + return cmd + } + if key == a.frontTabKey() { return nil } for _, tab := range a.tabList() { diff --git a/internal/tui3/models.go b/internal/tui3/models.go index 53a00da780..c39bc1a4d0 100644 --- a/internal/tui3/models.go +++ b/internal/tui3/models.go @@ -120,12 +120,10 @@ type Model struct { Direct bool `json:"-"` } -// modelCacheName is the file under the codeaf state root. It is v3's own list -// and deliberately NOT internal/catalog's cache: this one holds the two fields -// a picker draws, so reading it costs a kilobyte or two rather than the whole -// six-hundred-row catalog, and a schema change on either side cannot break the -// other. -var modelCacheName = []string{"v3", "models.json"} +// The default filename is derived once so reading the default picker cache +// keeps its existing path-only cost. Its smaller schema stays independent of +// the catalog's cache even though catalog owns both filenames. +var modelCacheName = []string{"v3", modelcatalog.PickerCacheName("", modelcatalog.DefaultBaseURL)} // ModelCachePath is ~/.codeaf/v3/models.json, moved wholesale by CODEAF_HOME // the way every other file codeaf writes is. @@ -134,11 +132,7 @@ func ModelCachePath() string { return home.Join(modelCacheName...) } // ModelCachePathFor returns the cache owned by one service-and-base pair. The // default pair keeps the legacy path so an ordinary upgrade stays warm. func ModelCachePathFor(source, base string) string { - key := modelcatalog.CacheKey(source, base) - if key == "" { - return ModelCachePath() - } - return home.Join("v3", "models-"+key+".json") + return home.Join("v3", modelcatalog.PickerCacheName(source, base)) } // modelCache is the file's shape. The list is wrapped in an object so a field diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 355bc7a2ae..4593088bdf 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -402,7 +402,7 @@ var notices = []notice{ // it. id: "home-by-two-spaces", slot: slotHint, armed: awayFromHome, - text: "space space takes you back to home", + text: "space space brings you back to home", retire: eventHomeGesture, }, { diff --git a/internal/tui3/notices.go b/internal/tui3/notices.go index 25162833c2..6894c3fcda 100644 --- a/internal/tui3/notices.go +++ b/internal/tui3/notices.go @@ -128,14 +128,14 @@ func (a *app) useNotices(desk *Notices) { // loop on the door again. func (a *app) saidNotices() { for _, line := range a.outsideDesk.take() { - if line.text == chatlist.Removed { + if chatlist.IsRemovedNotice(line.text) { a.removedComputer() } - if line.home { + if line.home && !chatlist.IsRemovedNotice(line.text) { a.homeAccountNotice = firstLine(line.text) } before := a.echoHome - a.echoHome = before || line.home + a.echoHome = before || (line.home && !chatlist.IsRemovedNotice(line.text)) a.note(line.text) a.echoHome = before } diff --git a/internal/tui3/notices_test.go b/internal/tui3/notices_test.go index a0c9921cde..8cfecdad98 100644 --- a/internal/tui3/notices_test.go +++ b/internal/tui3/notices_test.go @@ -19,13 +19,13 @@ func TestSayOnceSpeaksASentenceOncePerRun(t *testing.T) { // TestC3RemovedComputerIsToldOnHomeOnce proves C3 at the surface's notice // door, including a launch-time refusal queued before home starts listening. func TestC3RemovedComputerIsToldOnHomeOnce(t *testing.T) { - const line = "this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back" + const line = "this device was removed by another of your devices, so this chat stays here only — /pair brings it back" desk := NewNotices() desk.SayHomeOnce(line) a, _ := homeTabsFixture(t) a.useNotices(desk) a.route(noticesMsg{}) - if !strings.Contains(homeText(a), "this device was removed by another of your devices") { + if !strings.Contains(homeText(a), "removed by another of your devices — pair again to sync") { t.Fatalf("removal hidden on home:\n%s", homeText(a)) } desk.SayHomeOnce(line) diff --git a/internal/tui3/ownership_deadline_test.go b/internal/tui3/ownership_deadline_test.go index 1644ad1f97..f6dca4d5d5 100644 --- a/internal/tui3/ownership_deadline_test.go +++ b/internal/tui3/ownership_deadline_test.go @@ -13,6 +13,8 @@ import ( type deadlineChatListing struct{} +func (deadlineChatListing) RelayHost() string { return "sync.example" } + func (deadlineChatListing) Rows(context.Context) ([]chatlist.Row, error) { return nil, fmt.Errorf("%w: %w", directory.ErrUnreachable, context.DeadlineExceeded) } @@ -21,7 +23,7 @@ func (deadlineChatListing) Rows(context.Context) ([]chatlist.Row, error) { func TestHomeOwnershipDeadlineDoesNotClaimTheNetworkIsUnreachable(t *testing.T) { a := machinesHome(t, deadlineChatListing{}, 200) frame := homeText(a) - if !strings.Contains(frame, "other machines did not answer in time") || strings.Contains(frame, "other machines unreachable") { + if !strings.Contains(frame, "sync.example did not answer in 25s; still trying") || strings.Contains(frame, "the sync service is not answering") { t.Fatalf("ownership read deadline was misreported:\n%s", frame) } } @@ -35,7 +37,7 @@ func TestSendOwnershipDeadlineKeepsWordsAndNamesTheDeadline(t *testing.T) { a.machines = deadlineChatListing{} a.input.setText("keep these words") drive(t, a, key("enter")) - if got := plain(lastNote(t, a)); !strings.Contains(got, chatlist.ReadTimedOut) || strings.Contains(got, chatlist.Unreachable) { + if got := plain(lastNote(t, a)); !strings.Contains(got, "sync.example has not answered; your message is still in the box, press enter to send it") || strings.Contains(got, chatlist.Unreachable) { t.Fatalf("deadline = %q", got) } if len(agent.sent) != 0 || a.input.String() != "keep these words" { diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 8cfd865ada..9718d32a54 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1125,7 +1125,14 @@ func placeFrameWithBar(a *app, width, height int, // after the body would push the box down by three cells the moment the chord // was pressed. layer := a.composerRows(width, pal) + homeMessage := "" + if a.at(pageHome) && !a.composer.open { + homeMessage, _ = a.placeMsgLine(width) + } foot := 2 + len(tray) + draftHeight + len(strip) + len(layer) + if homeMessage != "" { + foot++ + } room := height - len(lines) - foot - spacingRuleClearance if room < 1 { room = 1 @@ -1360,7 +1367,10 @@ func placeFrameWithBar(a *app, width, height int, } fallthrough default: - if msg, ok := a.placeMsgLine(width); ok { + if homeMessage != "" { + add(a.creditPlaceMessage(width, homeMessage, pal), nil) + add(a.homeFootWithWarning(width, pal, ""), nil) + } else if msg, ok := a.placeMsgLine(width); ok { add(a.creditPlaceMessage(width, msg, pal), nil) } else if hasBox { // HOME'S KEYS ROW CARRIES THE PROJECT AT ITS RIGHT (hometip.go's @@ -2033,7 +2043,13 @@ func (a *app) placeMsgLine(width int) (string, bool) { // line, so its keys are read by the same grammar ([paintHint]) rather than // dimmed flat, and the door's clause keeps the colour every other key on // this surface wears. - line := a.pathLink(path, paintHint(hintFitBeside(a.placeHint(), msg, width-2), a.pal, a.pal.dim)) + line := "" + if a.at(pageHome) { + // News has its own dim row; the footer below remains a key legend. + line = a.pathLink(path, a.pal.dim(fit(msg, max(width-2, 1)))) + } else { + line = a.pathLink(path, paintHint(hintFitBeside(a.placeHint(), msg, width-2), a.pal, a.pal.dim)) + } // AND A LINE THAT NAMES SOMEWHERE TO GO CARRIES THE WAY THERE: the address // [app.tellHere] was handed with it, for as long as the line still says it. // It wraps the line whole, after the fitting, because a link occupies no diff --git a/internal/tui3/pair.go b/internal/tui3/pair.go index 5bc5b1a291..f5ad1515b4 100644 --- a/internal/tui3/pair.go +++ b/internal/tui3/pair.go @@ -55,15 +55,14 @@ const ( pairJoiningWord = "joining…" // pairSharingKeys, pairAskKeys and pairDoneKeys are the hint under the box. pairSharingKeys = "esc stop" + pairLinkKeys = "enter approve link" pairCodeKeys = "c copy code · esc stop" pairAskKeys = pair.AskChoice + " · esc is n" pairDoneKeys = "esc close" // replaceAskLine is the question a joining side with chats of its own reads // before anything is replaced — the same facts the manual states // (use-this-on-another-computer.md), and no default: esc is a no. - replaceAskLine = "replacing this computer's own chats with the other computer's " + - "cannot be undone: its chats are gone for good, and chats sealed under the " + - "old identity cannot be read afterwards. Replace them?" + replaceAskLine = "Pair with your other devices? This computer's existing chats stay here but stop syncing; shared memories and settings are cleared. Provider keys saved on this computer stay here." // replaceDeclinedLine is the word when the answer was no. replaceDeclinedLine = "nothing was replaced: this computer's chats stay." ) @@ -184,8 +183,14 @@ func (u pairUI) Ask(ctx context.Context, label, words string) bool { type pairPanel struct { open bool run *pairRun + // linkBox belongs to this offer, independently of the conversation draft. + linkBox editor + linkFocus bool + offering bool + rejoining bool // What the person sees, in the order it is drawn. + notice string burned bool code *pair.Code lines string @@ -244,6 +249,9 @@ func (p *pairPanel) rows(width int, now time.Time, pal palette) []string { out = append(out, dress(line)) } } + if p.notice != "" { + say(p.notice, pal.dim) + } if p.burned { say(pair.BurnLine, pal.accent) } @@ -251,6 +259,16 @@ func (p *pairPanel) rows(width int, now time.Time, pal palette) []string { say(strings.TrimRight(p.lines, "\n"), pal.ink) say(" "+pairCountdown(p.code, now), pal.dim) } + if (p.offering || p.rejoining) && p.ask == nil && !p.done { + text := p.linkBox.String() + if text == "" { + text = addMachinePlaceholder + if p.rejoining { + text = "Code from another device" + } + } + say(addMachineField+text, pal.dim) + } if p.waiting != "" { say(p.waiting, pal.dim) } @@ -310,7 +328,14 @@ func (p *pairPanel) hint() string { case p.done: return pairDoneKeys case p.code != nil: - return pairCodeKeys + if p.linkFocus { + return pairLinkKeys + " · " + pairSharingKeys + } + return pairCodeKeys + " · " + pairLinkKeys + case p.rejoining: + return "enter pair again · esc close" + case p.offering: + return pairLinkKeys + " · " + pairSharingKeys } return pairSharingKeys } @@ -347,11 +372,19 @@ func (a *app) runPair(typed string) tea.Cmd { } a.closeLists() a.dismissWelcome() + if typed == "" && a.machineRead.computerRemoved { + a.pair.close() + a.pair = pairPanel{open: true, rejoining: true, notice: removedPairNotice, waiting: "on a device still in your devices, open /pair; enter its code here to pair again"} + a.touch() + return nil + } if typed == "" { - return a.startPair("", func(run *pairRun) { + cmd := a.startPair("", func(run *pairRun) { label, err := door.Offer(run.ctx, pairUI{run}) run.send(pairEnded{line: sharedLine(label, err), ok: err == nil}) }) + a.pair.offering = true + return cmd } return a.startPair(pairJoiningWord, joinWork(door, typed, replace)) } @@ -388,6 +421,9 @@ func joinWork(door Pairing, typed string, replace bool) func(*pairRun) { func (a *app) startPair(first string, work func(*pairRun)) tea.Cmd { run := newPairRun(a.ctx) a.pair.start(run) + if a.machineRead.computerRemoved { + a.pair.notice = removedPairNotice + } a.pair.waiting = first a.touch() return tea.Batch(func() tea.Msg { work(run); return nil }, run.wait()) @@ -433,6 +469,9 @@ func (a *app) pairKey(msg tea.KeyPressMsg) tea.Cmd { if p.card != nil { return a.cardKey(msg) } + if (p.offering || p.rejoining) && p.ask == nil && !p.done && (p.linkFocus || msg.String() != "c" || p.rejoining) { + return a.pairLinkKey(msg) + } switch key := msg.String(); { case p.ask != nil && key == "y": p.answer(true) @@ -447,3 +486,44 @@ func (a *app) pairKey(msg tea.KeyPressMsg) tea.Cmd { a.touch() return nil } + +// pairLinkKey edits the pairing field without touching the chat's draft. +func (a *app) pairLinkKey(msg tea.KeyPressMsg) tea.Cmd { + p := &a.pair + p.linkFocus = true + b := &p.linkBox + defer a.touch() + if editorMotion(b, msg.String()) || editorWordKill(b, msg.String()) || editorUndo(b, msg.String()) || editorPick(b, msg.String()) { + return nil + } + switch msg.String() { + case "esc": + p.close() + case "enter": + link := strings.TrimSpace(b.String()) + if p.rejoining { + if link == "" { + p.result = "Enter a pairing code" + return nil + } + p.close() + return a.runPair(link + " --replace") + } + if !isPairLink(link) { + p.result = addMachineLinkError + return nil + } + p.close() + return a.openApprove(link) + case "backspace": + b.deleteBackward() + case "delete", "ctrl+d": + b.deleteForward() + case "tab": + default: + if msg.Text != "" { + b.insert(strings.ReplaceAll(msg.Text, "\n", " ")) + } + } + return nil +} diff --git a/internal/tui3/pairdrag_test.go b/internal/tui3/pairdrag_test.go index 88b5140b8f..b49d818e42 100644 --- a/internal/tui3/pairdrag_test.go +++ b/internal/tui3/pairdrag_test.go @@ -104,7 +104,7 @@ func TestPairReplaceAsksAndOnlyYesJoins(t *testing.T) { r.slash("/pair 71-869-571 --replace") r.until("the question", func() bool { return a.pair.ask != nil }) shown := strings.Join(strings.Fields(plain(frame(a))), " ") - for _, want := range []string{"Replace them?", "cannot be read afterwards", "gone for good"} { + for _, want := range []string{"Pair with your other devices?", "existing chats stay here", "shared memories and settings are cleared"} { if !strings.Contains(shown, want) { t.Fatalf("no replace confirmation (lacks %q):\n%s", want, shown) } diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index da113f2e93..3f64138921 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -579,6 +579,10 @@ func (placeHome) key(a *app, msg tea.KeyPressMsg) tea.Cmd { // while the cursor is on that exchange's row, so walking away can never leave // the arrows moving a pane nobody is looking at ([app.settleExchangeFocus]). func (placeHome) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { + if a.pair.open { + return a.pairKey(msg), true + } + if cmd, took := a.homeSheetKeyFirst(msg); took { return cmd, true } diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 6a23275f5b..e10b7a17bc 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -600,6 +600,7 @@ func (a *app) taskSheetOwnRows() []session.TaskIndexEntry { label = node.title } rows = append(rows, session.TaskIndexEntry{ + Computer: node.computer, Unreachable: node.unreachable, RanElsewhere: node.ranElsewhere, ID: strconv.FormatUint(node.id, 10), Parent: node.parent, Label: label, diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 939b2df80a..368e723810 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -5,6 +5,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/charmbracelet/x/ansi" ) @@ -43,7 +44,7 @@ func (placeSettings) open(a *app) tea.Cmd { // // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A // local launch asks for nothing. - return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults()) + return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices()) } // tick re-reads nothing and keeps the beat: see the note over [placeSettings.open]. @@ -125,6 +126,11 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { rows = append(rows, placeRow{text: sheetTabBar(width, displayTab, pal), hit: sheetHit{kind: sheetHitTabs}}) } rows = append(rows, placeRow{}) + if s.onDevices() && s.dev != nil && s.dev.service != "" { + for _, line := range wrap(syncwords.ServiceLine(s.dev.service), max(width-2, 4)) { + rows = append(rows, placeRow{text: " " + pal.dim(line)}) + } + } if s.choice != nil { rows = append(rows, s.choiceRows(width, room-len(rows), pal, a.hoveredSheetRow())...) for len(rows) < room { @@ -135,7 +141,7 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { if s.edit != nil { return settingsEditorBody(a, width, room, rows) } - if s.sel == nil && s.edit == nil && s.conn.entry == nil { + if s.sel == nil && s.edit == nil && s.conn.entry == nil && !s.devTyping() { filter, _, _ := draftBlock(&s.query, pal, width-2, 1, "Search all settings", "") for _, line := range filter { rows = append(rows, placeRow{text: " " + line}) @@ -370,6 +376,9 @@ func (placeSettings) note(a *app, width int) []string { return []string{" " + pal.dim(noteFit(a.sheet.sel.label, width-2))} } + if lines, ok := a.sheet.devicesNote(pal, width); ok { + return lines + } item, ok := settingsFocused(a) scope := a.sheet.footNote() if ok && (item.memoryDoor || item.advancedDoor) { @@ -397,6 +406,15 @@ func settingsFocused(a *app) (sheetItem, bool) { } func settingsDetails(a *app, width int) []string { + if a.sheet.onDevices() && !a.sheet.searching() { + frameW, _ := a.size() + lines := a.sheet.devicesDetails(frameW, width) + if len(lines) > 5 { + lines = lines[:5] + lines[4] = fit(lines[4]+glyphMore, max(1, width-4)) + } + return lines + } item, ok := settingsFocused(a) if !ok { return nil @@ -451,6 +469,8 @@ func (placeSettings) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { a.sheetSelectKey(msg) case s.conn.entry != nil: return a.connEntryKey(msg), true + case s.devTyping(): + cmd = s.dev.typed(a, msg) default: return nil, false } @@ -485,6 +505,16 @@ func (placeSettings) caretRow(a *app, width int, rows []placeRow) (int, int, boo } content := func(text string) string { return ansi.Strip(ansi.Cut(text, offset, offset+width)) } if entry == nil { + if a.sheet.devTyping() { + line, column := a.sheet.devNamingLine(width, a.pal) + want := ansi.Strip(line) + for j, row := range rows { + if content(row.text) == want { + return j, column + offset, true + } + } + return -1, 0, true + } if a.sheet.edit != nil { block, column, at := a.sheet.editBlock(width, a.pal) if at >= 0 && at < len(block) { diff --git a/internal/tui3/placeprose_test.go b/internal/tui3/placeprose_test.go index 1aebdc7703..c77c73ba53 100644 --- a/internal/tui3/placeprose_test.go +++ b/internal/tui3/placeprose_test.go @@ -139,7 +139,7 @@ func TestEveryEmptyPlaceDrawsItsHeadingAndOneWhisper(t *testing.T) { a.width, a.height = size[0], size[1] lines, _, _, _ := a.placeDraw(placeFor(lab.id), a.width, a.height) body := make([]string, 0, len(lines)) - for _, line := range lines[emptyPlaceHeadingRow(t, lab.id, lines) : len(lines)-placeFootRowsFor(lab.id, size[1])+1] { + for _, line := range lines[emptyPlaceHeadingRow(t, a.chords, lab.id, lines) : len(lines)-placeFootRowsFor(lab.id, size[1])+1] { body = append(body, strings.TrimRight(plain(line), " ")) } want := placeWhisperLines(lab.id, a.width, newPalette(tokens.NoColor, false)) @@ -159,11 +159,11 @@ func TestEveryEmptyPlaceDrawsItsHeadingAndOneWhisper(t *testing.T) { // Memory has a saved-memory/knowledge switch above its empty-state guidance. // Assert that navigation explicitly before applying the common heading rules. -func emptyPlaceHeadingRow(t *testing.T, id page, rows []string) int { +func emptyPlaceHeadingRow(t *testing.T, chords chordSpelling, id page, rows []string) int { t.Helper() top := placeHeadRows if id == pageMemory { - if len(rows) <= top+2 || strings.TrimSpace(plain(rows[top])) != "Saved memory Knowledge · alt+u" || strings.TrimSpace(plain(rows[top+1])) != "" { + if len(rows) <= top+2 || strings.TrimSpace(plain(rows[top])) != chords.say("Saved memory Knowledge · alt+u") || strings.TrimSpace(plain(rows[top+1])) != "" { t.Fatalf("Memory must show its switch and spacing before empty-state guidance: %q", rows) } top += 2 diff --git a/internal/tui3/relay_fix_round_test.go b/internal/tui3/relay_fix_round_test.go new file mode 100644 index 0000000000..a386c0f499 --- /dev/null +++ b/internal/tui3/relay_fix_round_test.go @@ -0,0 +1,115 @@ +package tui3 + +import ( + "context" + "path/filepath" + "reflect" + "strings" + "testing" + "testing/synctest" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestASendReadingPreservesOutstandingHomeProgress(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + for _, answer := range []error{nil, directory.ErrUnreachable, context.DeadlineExceeded} { + t.Run(map[error]string{nil: "success", directory.ErrUnreachable: "unreachable", context.DeadlineExceeded: "deadline"}[answer], func(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + keepSurfaceWorkersInTest(t, a) + drain(t, a, a.openHome()) + src := &slowRelayListing{entered: make(chan context.Context, 1), answer: make(chan error, 1)} + a.machines = src + commands := a.askChats()().(tea.BatchMsg) + result := make(chan tea.Msg, 1) + go func() { result <- commands[0]() }() + <-src.entered + drive(t, a, waitOut(commands[1])) + sent := false + drain(t, a, a.afterOwnershipRead(ownershipReadMsg{file: a.file, draft: a.input.String(), reading: homeMachinesMsg{readWithin: sendOwnershipTimeout}, resume: func() tea.Cmd { sent = true; return nil }})) + if !sent || !a.machinesAsking || !strings.Contains(homeText(a), "waiting for relay.example; still checking") { + t.Errorf("send reading dropped outstanding Home progress: %s", homeText(a)) + } + src.answer <- answer + drive(t, a, <-result) + if answer == nil { + if strings.Contains(homeText(a), "still checking") { + t.Fatal("completed listing retained progress") + } + } else { + want := "relay.example is unreachable; still trying" + if answer == context.DeadlineExceeded { + want = "relay.example did not answer in 25s; still trying" + } + if !strings.Contains(homeText(a), want) { + t.Fatal("listing failure lost its host: ", homeText(a)) + } + } + }) + } +} + +type zeroRenewalFactsWriter struct{ *startingSendAgent } + +func (*zeroRenewalFactsWriter) ReasoningLevels() map[string]string { return nil } + +func TestDisplacementEndsAWriterWaitWithZeroRenewalFacts(t *testing.T) { + for _, notice := range []bool{false, true} { + t.Run(map[bool]string{false: "listing", true: "notice"}[notice], func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + a, _, rows := intentRig(t) + keepSurfaceWorkersInTest(t, a) + a.closeHome() + writer := &zeroRenewalFactsWriter{&startingSendAgent{fakeAgent: &fakeAgent{model: "m"}, ready: make(chan struct{}), observed: make(chan struct{}, 1)}} + a.agent = writer + a.machines = chatlist.Static{} + id := filepath.Base(session.DirOf(a.file)) + a.machineRead.sendHolds = map[string]sendHold{id: {fence: 4, awaitingWriter: true}} + a.input.setText("keep " + pasteToken(1, 2) + " these words") + a.pastes = []pasteChip{{n: 1, text: "whole\npaste"}} + a.chips = []chip{{path: "/tmp/kept", file: true}} + want := a.mainComposer() + // A real viewer's (zero, 0, false) is omitted by FactsOf, so + // writer startup and displacement have identical renewal facts. + if session.FactsOf(writer).Renewal != nil { + t.Fatal("fixture does not match the drive's viewer facts") + } + cmd := stepKeyNoDrain(t, a, "enter") + result := make(chan []tea.Msg, 1) + go func() { result <- collectBatchMsgs(cmd) }() + <-writer.observed + if notice { + a.note(chatlist.Superseded("tower")) + } else { + row := rows[0] + row.Cell, row.Status, row.Released, row.Device, row.Fence = id, chatlist.Running, false, "tower", 5 + drain(t, a, a.recordMachines(homeMachinesMsg{rows: []chatlist.Row{row}})) + } + synctest.Wait() + select { + case messages := <-result: + for _, msg := range messages { + drive(t, a, msg) + } + default: + t.Error("displaced writer still waits for its startup timeout") + // The fake clock finishes the old timeout without wall time. + for _, msg := range <-result { + drive(t, a, msg) + } + } + if !reflect.DeepEqual(a.mainComposer(), want) || len(writer.sent) != 0 { + t.Fatal("displacement spent or changed the submitted message") + } + notes := strings.Join(noteTexts(a), "\n") + if !strings.Contains(notes, chatlist.Superseded("tower")) || strings.Contains(notes, "has not answered") { + t.Fatal("displacement blamed the relay: ", notes) + } + }) + }) + } +} diff --git a/internal/tui3/relay_manual_test.go b/internal/tui3/relay_manual_test.go new file mode 100644 index 0000000000..11d17d7b7b --- /dev/null +++ b/internal/tui3/relay_manual_test.go @@ -0,0 +1,28 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/manual" +) + +func TestRelayWaitAndChoiceWordsAreInTheChatManual(t *testing.T) { + page, ok := manual.Chat().Page("continuing-a-chat-on-another-computer") + if !ok { + t.Fatal("missing continue manual") + } + for _, words := range []string{ + "continuing next; is still on its way here", + " picked this chat up again; press enter to continue it here", + "waiting for ; still checking", + " did not answer in " + machinesAskTimeout.String() + "; still trying", + " is unreachable; still trying", + messageKept(""), + "your message is still in the box; press enter to send it", + } { + if !strings.Contains(page, words) { + t.Errorf("manual is missing %q", words) + } + } +} diff --git a/internal/tui3/relay_surface_test.go b/internal/tui3/relay_surface_test.go index 07f33ef7a0..103a8edf21 100644 --- a/internal/tui3/relay_surface_test.go +++ b/internal/tui3/relay_surface_test.go @@ -213,7 +213,7 @@ func TestHolderReportsRenewalOutageAndRecovery(t *testing.T) { for range 3 { a.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) } - outage := "this chat is not syncing because other machines are unreachable; it keeps working here" + outage := "the sync service is not answering; this chat keeps working here and catches up when it is back" if got := plain(frame(a)); strings.Count(got, outage) != 1 { t.Fatalf("outage notice count is wrong: %s", got) } diff --git a/internal/tui3/relay_wait_test.go b/internal/tui3/relay_wait_test.go new file mode 100644 index 0000000000..6b7b0ddc73 --- /dev/null +++ b/internal/tui3/relay_wait_test.go @@ -0,0 +1,182 @@ +package tui3 + +import ( + "context" + "errors" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +type slowRelayListing struct { + entered chan context.Context + answer chan error + rows []chatlist.Row +} + +func (s *slowRelayListing) RelayHost() string { return "relay.example" } +func (s *slowRelayListing) Rows(ctx context.Context) ([]chatlist.Row, error) { + s.entered <- ctx + select { + case err := <-s.answer: + return s.rows, err + case <-ctx.Done(): + return nil, ctx.Err() + } +} +func TestASlowRelayKeepsCheckingAndNamesTheRelay(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + a := newTestApp(&fakeAgent{model: "m"}) + drain(t, a, a.openHome()) + rows := []chatlist.Row{leftOffRow("kept", "tower", chatlist.Off, time.Minute)} + src := &slowRelayListing{entered: make(chan context.Context, 1), answer: make(chan error, 1), rows: rows} + a.machines = src + a.machineRead.rows = rows + batch, ok := a.askChats()().(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatal("listing has no progress threshold") + } + result := make(chan tea.Msg, 1) + go func() { result <- batch[0]() }() + ctx := <-src.entered + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) < 24*time.Second || time.Until(deadline) > 25*time.Second { + t.Fatal("listing bound is not 25s") + } + drive(t, a, waitOut(batch[1])) + if !strings.Contains(homeText(a), "waiting for relay.example; still checking") || a.machineRead.down { + t.Fatal(homeText(a)) + } + select { + case <-ctx.Done(): + t.Fatal("progress abandoned the listing") + default: + } + if !reflect.DeepEqual(a.machineRead.rows, rows) { + t.Fatal("slow read dropped rows") + } + src.answer <- nil + drive(t, a, <-result) + if strings.Contains(homeText(a), "still checking") { + t.Fatal("progress survived success") + } + for _, err := range []error{directory.ErrUnreachable, context.DeadlineExceeded} { + a.machinesAsking = false + commands := a.askChats()().(tea.BatchMsg) + go func() { result <- commands[0]() }() + <-src.entered + src.answer <- err + drive(t, a, <-result) + want := "relay.example is unreachable; still trying" + if errors.Is(err, context.DeadlineExceeded) { + want = "relay.example did not answer in 25s; still trying" + } + if !strings.Contains(homeText(a), want) || !a.machineRead.down || !reflect.DeepEqual(a.machineRead.rows, rows) { + t.Fatal(homeText(a)) + } + } +} + +type startingSendAgent struct { + *fakeAgent + ready chan struct{} + observed chan struct{} + held atomic.Bool +} + +func (s *startingSendAgent) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, 0, s.held.Load() +} +func (s *startingSendAgent) WriterChanges() <-chan struct{} { + select { + case s.observed <- struct{}{}: + default: + } + return s.ready +} + +type localWriterListing struct { + chatlist.Static + failure error +} + +func (s localWriterListing) RelayHost() string { return "relay.example" } +func (s localWriterListing) SendLease(string) (string, uint64, error) { return heldCell, 4, nil } +func (s localWriterListing) RowsForChatSend(_ context.Context, _ string, _ bool, holding bool) ([]chatlist.Row, uint64, bool, error) { + if !holding { + return nil, 0, false, directory.ErrUnreachable + } + return s.Static, 0, true, s.failure +} +func TestTheFirstMessageAfterAMoveWaitsForItsWriter(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, _ := heldLab(t, src, taker) + taker.taken = Taken{Fence: 4, Transcript: a.file} + taker.on = func(string) { + src.change(func(row *chatlist.Row) { row.Mine, row.Status, row.Fence = true, chatlist.Here, 4 }) + } + standOn(t, a, heldCell) + confirm(t, a) + writer := &startingSendAgent{fakeAgent: &fakeAgent{model: "m"}, ready: make(chan struct{}), observed: make(chan struct{}, 1)} + a.agent = writer + a.machines = localWriterListing{Static: chatlist.Static{src.row}} + a.input.setText(" keep this exact message ") + cmd := stepKeyNoDrain(t, a, "enter") + result := make(chan []tea.Msg, 1) + go func() { result <- collectBatchMsgs(cmd) }() + select { + case <-writer.observed: + case <-result: + t.Fatal("send returned before waiting for its writer") + } + if len(writer.sent) != 0 || a.input.String() != " keep this exact message " { + t.Fatal("send was spent before writer startup") + } + writer.held.Store(true) + close(writer.ready) + for _, msg := range <-result { + drive(t, a, msg) + } + if len(writer.sent) != 1 || writer.sent[0] != "keep this exact message" { + t.Fatalf("sent %v", writer.sent) + } + if strings.Contains(strings.Join(noteTexts(a), "\n"), chatlist.ReadTimedOut) { + t.Fatal("Home listing sentence appeared in chat") + } +} +func TestAnUnsentMessageSaysItWasKept(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + a.machines = hostedFailedListing{} + a.input.setText(" keep " + pasteToken(1, 2) + " these words") + a.pastes = []pasteChip{{n: 1, text: "whole\npaste"}} + a.chips = []chip{{path: "/tmp/kept", file: true}} + want := a.mainComposer() + drive(t, a, key("enter")) + if !reflect.DeepEqual(a.mainComposer(), want) || len(agent.sent) != 0 { + t.Fatal("unsent composer changed") + } + if got := plain(lastNote(t, a)); got != "relay.example has not answered; your message is still in the box, press enter to send it" { + t.Fatal(got) + } +} + +type hostedFailedListing struct{} + +func (hostedFailedListing) RelayHost() string { return "relay.example" } +func (hostedFailedListing) Rows(context.Context) ([]chatlist.Row, error) { + return nil, context.DeadlineExceeded +} diff --git a/internal/tui3/removed_computer.go b/internal/tui3/removed_computer.go index 9c86d909a4..df5e21ce02 100644 --- a/internal/tui3/removed_computer.go +++ b/internal/tui3/removed_computer.go @@ -1,13 +1,14 @@ package tui3 -import "github.com/Agent-Field/codeaf/internal/chatlist" +const removedPairNotice = "removed by another of your devices — pair again to sync" func (a *app) removedComputer() { a.machineRead.computerRemoved = true a.leftOff.row = nil a.continueGeneration++ a.dropHomeAsk() - a.homeAccountNotice = chatlist.Removed - a.home.say(chatlist.Removed, "") + a.homeAccountNotice = removedPairNotice + a.addMachine.known, a.addMachine.size = true, 1 + a.home.say("", "") a.touch() } diff --git a/internal/tui3/removed_continuation_test.go b/internal/tui3/removed_continuation_test.go index 92aa60534e..1e937828e4 100644 --- a/internal/tui3/removed_continuation_test.go +++ b/internal/tui3/removed_continuation_test.go @@ -70,7 +70,7 @@ func TestRemovedComputerOffersNoContinuation(t *testing.T) { if strings.Contains(text, "latest chat here?") || strings.Contains(text, "continue here") { t.Errorf("removed computer offered continuing: %s", text) } - if !strings.Contains(text, "this device was removed") { + if !strings.Contains(text, removedPairNotice) { t.Errorf("removal sentence missing: %s", text) } if _, claimed := a.resumeKeyHandler(resumeKey); claimed { diff --git a/internal/tui3/renewal_notice.go b/internal/tui3/renewal_notice.go index ee8cc32e14..36c5c77b5d 100644 --- a/internal/tui3/renewal_notice.go +++ b/internal/tui3/renewal_notice.go @@ -4,6 +4,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // renewalReader reports the first failed renewal, its period, and whether this @@ -21,22 +22,68 @@ func (a *app) takeRenewalNotice() { } since, period, held := source.RenewalStatus() if !held { - delete(a.syncOutages, a.file) + delete(a.renewalFailures, a.file) return } if since.IsZero() { - if a.syncOutages[a.file] { - delete(a.syncOutages, a.file) + if a.renewalFailures[a.file] { a.note(chatlist.SyncRestored) } return } + if a.renewalFailures == nil { + a.renewalFailures = map[string]bool{} + } + a.renewalFailures[a.file] = true if period <= 0 || a.now().Sub(since) <= period || a.syncOutages[a.file] { return } if a.syncOutages == nil { a.syncOutages = map[string]bool{} } + if a.syncService != "" { + a.note(syncwords.Outage(a.syncService)) + } else { + a.note(chatlist.SyncPaused) + } + // Accepting a new outage clears older evidence, so retain this observation + // only after the notice has established the current outage. + a.renewalFailures[a.file] = true +} + +// acceptSyncNotice shares the chat's existing outage ledger across producers. +func (a *app) acceptSyncNotice(text string) bool { + if text == syncwords.Recovered || text == chatlist.SyncRestored { + had := a.syncOutages[a.file] + delete(a.syncOutages, a.file) + delete(a.renewalFailures, a.file) + return had + } + outage := syncwords.IsOutage(text) + if !outage { + return true + } + if a.syncOutages[a.file] { + return false + } + if a.syncOutages == nil { + a.syncOutages = map[string]bool{} + } a.syncOutages[a.file] = true - a.note(chatlist.SyncPaused) + delete(a.renewalFailures, a.file) + return true +} + +// chatNotice chooses the recovery door and outage sentence for this surface. +func (a *app) chatNotice(text string) string { + if chatlist.IsRemovedNotice(text) { + return chatlist.RemovedChat + } + if text == chatlist.SyncPaused || text == syncwords.LegacyOutage { + if a.syncService != "" { + return syncwords.Outage(a.syncService) + } + return chatlist.SyncPaused + } + return text } diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 3dca49a100..3f5caae1cf 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -2112,6 +2112,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return a.slash(line), true } } + if msg.String() == taskRunHereKey && a.input.empty() && !a.guarding() && !a.stopping() && !a.recalling() && !a.roomIsGuest() && a.taskCanRunHere(a.roomRetryEntry()) { + return a.runTaskHere(a.roomRetryEntry()), true + } if a.room.orch != nil && !a.recalling() { if cmd, taken := a.orchKey(msg); taken { return cmd, true @@ -2248,6 +2251,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // next keystroke. func (a *app) roomHint() string { if a.room != nil && !a.roomIsGuest() && a.room.program == nil && !a.guarding() && !a.asking() && !a.stopping() { + if a.input.empty() && !a.recalling() && a.taskCanRunHere(a.roomRetryEntry()) { + return taskRunHereVerb + } entry := a.roomRetryEntry() if hint := a.taskRetryHint(entry); hint != "" { return hint @@ -3435,6 +3441,18 @@ func guestOwnerName(owner string) string { // roomStateWord is what the node is doing, in the engine's own vocabulary where // it has one (task.go's merge words). func (a *app) roomStateWord(node *taskNode) string { + word := a.roomLocalStateWord(node) + if words := a.taskComputerWords(node); words != "" { + if word == "" || strings.HasPrefix(words, word+" ") { + return words + } + return word + railSep + words + } + return word +} + +// The state reading stays the same when a separate placement fact is drawn. +func (a *app) roomLocalStateWord(node *taskNode) string { // A QUESTION SOMEBODY ELSE IS ANSWERING IS STILL THAT QUESTION. This line // used to read `awaiting review` for a card the settle policy had handed to // the model, which was a fourth word for one reading and said nothing about @@ -3469,8 +3487,8 @@ func (a *app) roomStateWord(node *taskNode) string { // IS at this moment (session's TaskNotice.Doing) — a header that said // "working" over a card waiting on the person would be the one line on the // page spent on the least specific thing on it. - if node.doing != "" { - return node.doing + if doing := taskDoingWords(node); doing != "" { + return doing } // AND SO DOES A NODE THAT IS NOT ITS OWN WORKER RIGHT NOW. A check // reading what the work left, and a round closing what the check found, diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d7d23d552d..836a6cc356 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" @@ -87,7 +88,7 @@ const ( ) var settingTabs = []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, - tabSafety, tabSpending, tabConnections, tabPrivacy} + tabSafety, tabSpending, tabConnections, tabDevices, tabPrivacy} var settingTabCategory = map[string]string{ tabSpending: config.CategorySpending, tabSafety: config.CategorySafety, tabTasks: config.CategoryTasks, tabTeams: config.CategoryTeams, @@ -320,6 +321,12 @@ var settingUI = map[string]settingMeta{ about: "the model a task's worker runs on when you have not asked for another. " + "Blank leaves it to the crew: your /crew pin, or the model picked for each task.", }, + // Placement sits beside the worker model because both describe the work + // handed out from this conversation rather than the chat's own calls. + config.KeyTaskRunsOn: { + tab: tabTasks, label: "Run tasks on", widget: widgetCycle, + about: taskComputerHint, + }, // THE CREW'S THREE SEATS ARE REGISTRY ROWS AND ARE NOT DRAWN AS THREE ROWS. // Each is empty for AUTO — codeaf routes that seat per task — and a model id // written there is a PIN, the same pin `/crew pin` writes (internal/config's @@ -593,6 +600,9 @@ var settingUI = map[string]settingMeta{ tab: tabDisplay, label: "turn work", widget: widgetCycle, about: "fold completed turn machinery into one worked chip, or keep it open.", }, + config.KeyTeamsShare: { + tab: tabGeneral, label: "share teams", widget: widgetCycle, + }, config.KeyIcons: { tab: tabDisplay, label: "step icons", about: "Rich icons normally; plain symbols when your terminal needs them.", @@ -854,7 +864,7 @@ func (s *sheet) finishCategory() { } // Lead with the controls a developer commonly changes, not registry order. if title == tabTasks { - priority := map[string]int{config.KeyTaskParallel: 0, config.KeyTaskModel: 1, config.KeyTaskAudit: 2, config.KeyTaskSettle: 3, config.KeyTaskRepairRounds: 4} + priority := map[string]int{config.KeyTaskParallel: 0, config.KeyTaskModel: 1, config.KeyTaskRunsOn: 2, config.KeyTaskAudit: 3, config.KeyTaskSettle: 4, config.KeyTaskRepairRounds: 5} rank := func(item sheetItem) int { if n, ok := priority[item.row.Key]; ok { return n @@ -924,6 +934,10 @@ type sheetItem struct { // reason: an item is an item, and only what draws it and what the cursor // does with it ask which kind this one is. read *railReading + // dev is set on the rows of the Devices tab, which reuse [deviceCard] and + // add one door to `/pair` rather than reading the settings registry + // (settings_devices.go). + dev *deviceSheetRow // hitAt and hitLen say where the search landed on this row's own label — // an offset and a length into the sheet's hit span, the bytes the query // matched, which the row draws carried in bold ([overlayLinesHit]). Both @@ -944,8 +958,10 @@ func (i sheetItem) restful() bool { return i.head == "" && i.read == nil } // sheet is the panel's whole state. The zero value is closed and costs the // frame nothing. type sheet struct { - advanced map[string]bool - tab int + taskComputers bool + taskComputerRows []session.TaskComputer + advanced map[string]bool + tab int // host is the machine the session runs on over --host, "" otherwise. host string // teamDefaultsWrite says the teams seam can change that machine's `teams.` @@ -991,7 +1007,14 @@ type sheet struct { // conn is what that tab remembers between builds (connectcaps.go). conn connTab - rows []config.Setting + // cellsUnavailable, approvals, dev and now serve the Devices tab, which + // hosts a [deviceCard] over the surface's own [Approvals] door and clock + // (settings_devices.go). + cellsUnavailable string + approvals Approvals + dev *deviceCard + now func() time.Time + rows []config.Setting // matchFields is the field list the search hands the fuzzy matcher one row // at a time, reused rather than rebuilt: a rebuild runs per keystroke over // every row, and a fresh slice per row is the one allocation it does not @@ -1285,6 +1308,8 @@ func (a *app) raiseSettings() { write = true } a.sheet = sheet{ + taskComputers: a.taskComputersAvailable(), + taskComputerRows: a.taskComputerRows, host: a.host, teamDefaultsWrite: write, registry: a.registry(), @@ -1296,17 +1321,20 @@ func (a *app) raiseSettings() { // conversation-wide reading taken on the way up and refreshed after every // write ([app.readAutonomy]), not a door asked here. Opening a page is on // the update loop, where a surface may not wait on a network. - autonomy: a.autonomyRules, - autonomyDoor: a.hasAutonomyDoor(), - conns: a.conns, - modelRows: a.modelConnectionRows, - liveModel: a.conversationModel, - sources: a.sources, - force: a.laneForceNow, - defaults: settingDefaults(), - sessionModel: a.model, - routing: a.routing, - today: a.todayReading(), + autonomy: a.autonomyRules, + autonomyDoor: a.hasAutonomyDoor(), + conns: a.conns, + cellsUnavailable: a.cellsUnavailable, + approvals: a.approvals, + now: a.now, + modelRows: a.modelConnectionRows, + liveModel: a.conversationModel, + sources: a.sources, + force: a.laneForceNow, + defaults: settingDefaults(), + sessionModel: a.model, + routing: a.routing, + today: a.todayReading(), } a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() @@ -1346,6 +1374,7 @@ func (a *app) closeSettings() { // dropSettings is [placeSettings]'s `close`. Nothing but the router calls it. func (a *app) dropSettings() { a.sheet = sheet{} + a.letGoOffHome() a.touch() } @@ -1472,6 +1501,10 @@ func (s *sheet) build() { return } if query == "" { + if s.onDevices() { + s.buildDevices() + return + } // THE SPENDING TAB LAYS ITSELF OUT, because three of its rows are not // registry rows at all and the order of the rest is a reading of how // often a person worries about each one rather than of registry order @@ -1558,7 +1591,7 @@ func (s *sheet) build() { if len(s.items) == start { s.items = append(s.items, sheetItem{head: title}) } - s.items = append(s.items, sheetItem{row: one.row, meta: one.meta, hitAt: one.hitAt, hitLen: one.hitLen}) + s.items = append(s.items, sheetItem{row: s.taskComputerSetting(one.row), meta: one.meta, hitAt: one.hitAt, hitLen: one.hitLen}) } // A ROLE IS FOUND BY ITS OWN NAME, or by the line that says what it does. // Somebody searching for "planner" is not searching for a registry key — @@ -1595,6 +1628,9 @@ func (s *sheet) build() { if title == tabConnections { s.buildConnections() } + if title == tabDevices { + best = max(best, s.appendDevicesSearch(terms)) + } if len(s.items) > start { groups = append(groups, resultGroup{start, len(s.items), tab, best}) } @@ -1629,6 +1665,9 @@ func (s *sheet) build() { // sheet rebuilds after every write, so the selected search row follows keys and // pins immediately while ordinary frame rendering stays read-only. func (s *sheet) metaFor(row config.Setting) (settingMeta, bool) { + if row.Key == config.KeyTaskRunsOn && !s.taskComputers { + return settingMeta{}, false + } meta, ok := settingMetaFor(row) if ok && row.Key == config.KeySearchProvider { meta.about = config.SearchProviderHintAt(s.profileDir) @@ -1655,8 +1694,8 @@ func (s *sheet) tabRows() []config.Setting { title := settingTabs[s.tab] mine := make([]config.Setting, 0, len(s.rows)) for _, row := range s.rows { - if meta, ok := settingMetaFor(row); ok && meta.tab == title { - mine = append(mine, row) + if meta, ok := s.metaFor(row); ok && meta.tab == title { + mine = append(mine, s.taskComputerSetting(row)) } } led := map[string]bool{} @@ -1744,7 +1783,7 @@ func (s *sheet) settingMatch(row config.Setting, meta settingMeta, tab string, t fields[4] = tab presentation := row.ChatPresentation() fields = append(fields, presentation.Aliases...) - fields = append(fields, settingValueWord(row)) + fields = append(fields, s.settingValueWord(row)) if old, ok := settingUI[row.Key]; ok { fields = append(fields, old.label, old.tab) } @@ -1815,6 +1854,7 @@ func (s *sheet) move(delta int) { at = next } s.cursor = at + s.syncDeviceCursor() } func abs(n int) int { @@ -2249,12 +2289,19 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { if bind := a.takeSpendRailBindCmd(); bind != nil { cmd = tea.Batch(cmd, bind) } + if devCmd := a.ensureSheetDevices(); devCmd != nil { + cmd = tea.Batch(cmd, devCmd) + } }() if !a.at(pageSettings) { return nil, false } s := &a.sheet defer a.touch() + if s.dev != nil && s.dev.removing != nil { + cmd, _ := s.dev.key(a, msg.String()) + return cmd, true + } // THE THREE BOXES THAT TAKE THE WHOLE KEYBOARD ARE READ BEFORE THE ROUTER AND // ARE NOT HERE. They are [placeSettings.owns], because a layer inside a place // that has claimed every key outranks even the six classes — and the router @@ -2329,11 +2376,37 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { s.move(sheetRows) case "home": s.cursor = s.clampCursor(0) + s.syncDeviceCursor() case "end": s.cursor = s.clampCursor(len(s.items) - 1) + s.syncDeviceCursor() case "enter": return a.activate(), true + case "r": + if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { + if s.dev != nil && s.dev.loaded { + s.syncDeviceCursor() + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + return a.revoke(s.dev), true + } + } + return nil, true + } + s.query.insert("r") + s.build() + case "n": + if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { + if s.dev != nil && s.dev.loaded { + s.syncDeviceCursor() + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + s.dev.startNaming() + } + } + return nil, true + } + s.query.insert("n") + s.build() case " ", "space": // A SPACE WHILE A SEARCH IS TYPED IS A WORD IN THE QUERY and not a // press on whatever row the cursor happens to share the screen with. @@ -2407,6 +2480,9 @@ func (s *sheet) tabBy(delta int) { // SERVICE, because coming back to a tab you left half-read and finding it // collapsed is the tab forgetting where you were. s.conn.armed, s.conn.entry = false, nil + if s.dev != nil { + s.dev.naming = nil + } s.build() } @@ -2431,6 +2507,30 @@ func (a *app) activate() tea.Cmd { s.build() return nil } + if item.dev != nil { + if item.dev.add { + a.closeSettings() + return a.runPair("") + } + if s.searching() { + wantID := item.dev.id + s.query.reset() + for at, title := range settingTabs { + if title == tabDevices { + s.tab = at + } + } + s.build() + for at, candidate := range s.items { + if candidate.dev != nil && !candidate.dev.add && candidate.dev.id == wantID { + s.cursor = at + break + } + } + s.syncDeviceCursor() + } + return nil + } if item.conn != nil { // Service actions run on their category so asynchronous replies and Escape // use the same account lifecycle as a service reached by browsing. @@ -2537,6 +2637,12 @@ func (a *app) activate() tea.Cmd { return nil } choice := &sheetChoice{item: item, current: item.row.Value()} + if item.row.Key == config.KeyTaskRunsOn { + choice.current = config.TaskRunsOnAt(s.profileDir) + if taskComputerName(s.taskComputerRows, choice.current) == config.TaskRunsHere { + choice.current = "" + } + } for i, value := range item.row.Choices { if value == choice.current { choice.cursor = i @@ -2544,6 +2650,9 @@ func (a *app) activate() tea.Cmd { } } s.choice = choice + if item.row.Key == config.KeyTaskRunsOn { + return a.readTaskComputers() + } case widgetLane: // ENTER ON THIS ROW OPENS THE MACHINES. It used to walk four words — @@ -2739,6 +2848,11 @@ func (a *app) applySetting(item sheetItem, raw string) bool { // banked rule through, and it rebuilds the policy from these same three rows. note := "" switch item.row.Key { + case config.KeyTaskRunsOn: + if a.task != nil { + a.task.computerPreference = config.TaskRunsOnAt(a.profileDir) + a.markCardStale(a.task) + } case config.KeyUpdateAuto: // THE LIVE SESSION SEES THE ROW IT JUST CHANGED. The grace re-reads // this flag, so turning it off in the panel must reach the running @@ -3132,8 +3246,11 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { if bind := a.takeSpendRailBindCmd(); bind != nil { cmd = tea.Batch(cmd, bind) } + if devCmd := a.ensureSheetDevices(); devCmd != nil { + cmd = tea.Batch(cmd, devCmd) + } }() - if a.sheet.conn.entry != nil { + if a.sheet.conn.entry != nil || a.sheet.devTyping() { // A BOX BEING TYPED INTO IS NOT A LIST. Every press is swallowed and none // of them acts — esc is the way out, which is the way out of every box on // this surface (connectpanel.go's [app.connectPanelPress] says it first). @@ -3192,6 +3309,9 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { a.sheet.tab = tab a.sheet.cursor, a.sheet.top, a.sheet.msg = 0, 0, "" a.sheet.conn.armed, a.sheet.conn.entry = false, nil + if a.sheet.dev != nil { + a.sheet.dev.naming = nil + } a.sheet.build() a.saySettingsHost() a.touch() @@ -3204,6 +3324,7 @@ func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { // that was passing through. if a.sheet.cursor != hit.index { a.sheet.cursor = hit.index + a.sheet.syncDeviceCursor() a.touch() return nil } @@ -3514,6 +3635,8 @@ func (s *sheet) listLines(width, room int, pal palette, hover int) ([]string, [] word := "nothing matches" if s.onConnections() { word = s.connEmptyWord() + } else if s.onDevices() && !s.searching() { + word = s.devEmptyWord() } put(placeLead+pal.dim(word), -1) return lines, owner @@ -3593,6 +3716,7 @@ func (s *sheet) cursorLastLine(owner []int, at, width int) int { entryOpen := item.conn != nil && item.conn.entry != nil entryOpen = entryOpen || (item.service != nil && s.conn.entry != nil && s.conn.entry.id == modelConnectionID(item.service.id)) + entryOpen = entryOpen || (item.dev != nil && s.devTyping()) if entryOpen { last := at for last+1 < len(owner) && owner[last+1] == s.cursor { @@ -3639,6 +3763,9 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo if item.conn != nil { return s.connRowLines(item.conn, selected, hovered, width, boxRows, pal) } + if item.dev != nil { + return s.devRowLines(item.dev, s.itemHit(item), selected, hovered, width, pal) + } if item.service != nil { head := overlayLines(item.service.name, item.service.value, selected, false, hovered, width, pal) entry := s.conn.entry @@ -3677,7 +3804,7 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo // The unit is the registry's ([config.Setting.Unit]), so it is stated once, // beside the default, rather than spelled again by every surface that draws // a number ([config.Setting.Reading]). - value := settingValueWord(item.row) + value := s.settingValueWord(item.row) if hosted, ok := s.hostedTeamReading(item); ok { value = hosted @@ -3862,6 +3989,9 @@ func (s *sheet) footNote() string { if s.onConnections() { return s.connFootNote() } + if s.onDevices() { + return "" + } // THE TEAMS TAB IS DEFAULTS, and says where the exceptions live: a team's // own overrides are on its card on the teams page (teamsheet.go). Over // --host it says whose defaults the teams there really read. @@ -3920,6 +4050,8 @@ func (s *sheet) keysLine() string { return s.connKeysLine() case s.onConnections(): return s.connKeysLine() + case s.onDevices() && !s.searching(): + return s.devicesKeysLine() default: // A PINNED ROLE HAS A KEY THE REST OF THE SHEET DOES NOT, so the legend // says so on the row it works on and nowhere else — a line that offered @@ -3936,6 +4068,10 @@ func (s *sheet) keysLine() string { switch { case item.memoryDoor, item.crewDoor: action = "open" + case item.dev != nil && item.dev.add: + action = "add" + case item.dev != nil: + action = "open" case item.advancedDoor: action = "expand" if s.advanced[settingTabs[s.tab]] { @@ -3952,16 +4088,16 @@ func (s *sheet) keysLine() string { } } -// sheetLayerOwnsKeys is whether one of the three layers inside this panel has -// taken the WHOLE keyboard ([placeSettings.owns]). The foot asks, because a -// hint line that named the router's `tab` while a layer had it would be naming -// a key nothing on screen answers (pages.go's [app.placeHintSaid]). +// sheetLayerOwnsKeys is whether one of the layers inside this panel has taken +// the WHOLE keyboard ([placeSettings.owns]). The foot asks, because a hint line +// that named the router's `tab` while a layer had it would be naming a key +// nothing on screen answers (pages.go's [app.placeHintSaid]). func (a *app) sheetLayerOwnsKeys() bool { if !a.at(pageSettings) { return false } s := &a.sheet - return s.edit != nil || s.sel != nil || s.choice != nil || s.conn.entry != nil + return s.edit != nil || s.sel != nil || s.choice != nil || s.conn.entry != nil || s.devTyping() } // hoveredSheetRow is the item the pointer is over, or -1. diff --git a/internal/tui3/settings_choice.go b/internal/tui3/settings_choice.go index 1bd126336a..92bd6145ea 100644 --- a/internal/tui3/settings_choice.go +++ b/internal/tui3/settings_choice.go @@ -2,6 +2,7 @@ package tui3 import ( tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" ) // sheetChoice keeps navigation separate from the persisted value. Opening or @@ -46,6 +47,10 @@ func (a *app) saveSheetChoice() tea.Cmd { } if a.applySetting(c.item, c.item.row.Choices[c.cursor]) { a.sheet.choice = nil + if c.item.row.Key == config.KeyTaskRunsOn { + a.touch() + return a.readTaskComputers() + } } a.touch() return nil @@ -80,6 +85,9 @@ func (s *sheet) choiceRows(width, room int, pal palette, hovered int) []placeRow for i := top; i < top+count; i++ { raw := c.item.row.Choices[i] label := settingChoiceWord(c.item.row.Key, raw, raw) + if c.item.row.Key == config.KeyTaskRunsOn { + label = taskComputerName(s.taskComputerRows, raw) + } current := "" if raw == c.current { current = " · current" diff --git a/internal/tui3/settings_devices.go b/internal/tui3/settings_devices.go new file mode 100644 index 0000000000..3541ec4ffc --- /dev/null +++ b/internal/tui3/settings_devices.go @@ -0,0 +1,392 @@ +package tui3 + +import ( + "sort" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/fuzzy" +) + +// ── THE DEVICES TAB ON THE SETTINGS SHEET ─────────────────────────────────── +// +// `/devices` is the fleet asked in a hurry; this is the same list at rest on the +// settings sheet, right after Connections, plus one door that starts `/pair`. +// +// ONE SOURCE OF TRUTH. The tab holds a [deviceCard] (devices.go) rather than a +// second device slice or a second set of strings: the same [Approvals] door, the +// same [orderDevices] sort, the same row formatting ([deviceCard.rowText]), the +// same `n` rename editor ([deviceCard.startNaming], [deviceCard.typed], +// [app.rename]), the same `r` removal ([app.revoke]), the same directory-version +// refresh ([app.refreshOnChange]), and the same unavailability sentences +// ([app.openDevices]). A sentence spelled in two places drifts. + +const ( + // tabDevices is the category title on the settings bar, placed between + // Connections and Privacy. + tabDevices = "Devices" + // devicesAddKey is the key legend clause for the row that starts pairing + // another computer. + devicesAddKey = "enter add" +) + +// deviceSheetRow hangs one Devices-tab entry off [sheetItem] so the sheet's own +// cursor walk, scroll window, hover and pointer hit-testing work unchanged. +type deviceSheetRow struct { + index int + id string + add bool + unavailable bool +} + +// onDevices reports whether the Devices category is the active tab. +func (s *sheet) onDevices() bool { + return s.tab >= 0 && s.tab < len(settingTabs) && settingTabs[s.tab] == tabDevices +} + +// devTyping reports whether the inline rename field on this device's row currently +// owns the keyboard. +func (s *sheet) devTyping() bool { + return s.onDevices() && s.dev != nil && s.dev.typing() +} + +// onAddDeviceRow reports whether the sheet cursor is resting on the add-device +// door row. +func (s *sheet) onAddDeviceRow() bool { + item, ok := s.current() + return ok && item.dev != nil && item.dev.add +} + +// devicesUnavailable answers why devices cannot work in this session, in the +// exact order and wording a typed `/devices` uses ([app.openDevices]), or "" +// when the door is ready to ask. +func (s *sheet) devicesUnavailable() string { + if s.cellsUnavailable != "" { + return s.cellsUnavailable + } + if s.approvals == nil { + return pairUnavailableWord + } + return "" +} + +// devNow reads the surface clock handed to the sheet so relative `seen … ago` +// timestamps stay deterministic under test clocks. +func (s *sheet) devNow() time.Time { + if s.now != nil { + return s.now() + } + return timeNow() +} + +// buildDevices populates the Devices tab when no search query is active. When +// devices cannot work here, or while the initial directory read is still in +// flight or failed, no rows are emitted — [sheet.listLines] draws the single +// explanatory sentence instead, never an empty list and never a working-looking +// add row. +func (s *sheet) buildDevices() { + if s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + s.cursor = 0 + return + } + for i, d := range s.dev.rowsOf { + s.items = append(s.items, sheetItem{ + dev: &deviceSheetRow{index: i, id: d.ID}, + }) + } + s.items = append(s.items, sheetItem{ + dev: &deviceSheetRow{add: true}, + }) + s.cursor = s.clampCursor(s.cursor) + s.syncDeviceCursor() +} + +// devEmptyWord is the single sentence drawn when the Devices tab has no rows to +// offer: the unavailability reason, the loading line, or the card's own read +// failure ([deviceCard.waiting]). +func (s *sheet) devEmptyWord() string { + if why := s.devicesUnavailable(); why != "" { + return why + } + if s.dev == nil { + return devicesLoading + } + return s.dev.waiting() +} + +// syncDeviceCursor keeps the hosted [deviceCard]'s cursor aligned with the +// sheet's cursor so `r`, `n` and re-reads act on the row the person selected. +// Standing on the add row parks the card's cursor past its slice so +// [deviceCard.pick] reports no device row under it. +func (s *sheet) syncDeviceCursor() { + if s.dev == nil { + return + } + item, ok := s.current() + if !ok || item.dev == nil || item.dev.add || item.dev.unavailable { + s.dev.cursor = len(s.dev.rowsOf) + return + } + s.dev.cursor = item.dev.index +} + +// ensureSheetDevices starts the initial directory read and joins the change +// watch the first time a visit lands on the Devices tab. Other categories never +// pay a network ask they did not look at. +func (a *app) ensureSheetDevices() tea.Cmd { + if !a.at(pageSettings) || !a.sheet.onDevices() { + return nil + } + if a.sheet.devicesUnavailable() != "" || a.sheet.dev != nil { + return nil + } + card := a.newDeviceCard() + a.sheet.dev = card + if !a.sheet.searching() { + a.sheet.build() + } + return tea.Batch(a.joinWatchIfAbsent(), a.reloadDevices(card)) +} + +// syncSheetDeviceCard rebuilds the sheet after an asynchronous device read, +// rename or removal lands on its hosted [deviceCard], keeping the cursor on the +// same computer (or on the add row) across re-sorts. +func (a *app) syncSheetDeviceCard(c *deviceCard, wasAdd bool) { + if !a.at(pageSettings) || a.sheet.dev != c { + return + } + s := &a.sheet + if s.searching() { + s.build() + a.touch() + return + } + picked, had := c.pick() + s.build() + switch { + case wasAdd: + for i, item := range s.items { + if item.dev != nil && item.dev.add { + s.cursor = i + break + } + } + case had: + for i, item := range s.items { + if item.dev != nil && !item.dev.add && item.dev.id == picked.ID { + s.cursor = i + break + } + } + } + s.syncDeviceCursor() + a.touch() +} + +// addDeviceSearchWords and unavailableDevicesSearchWords let global `/settings` +// search find the Devices tab by any of the words people ask with — `devices`, +// `remove`, `revoke`, `pair`, `add device` — even before the tab has been opened +// on this visit. +var addDeviceSearchWords = []string{ + addDeviceWord, + tabDevices, + "devices", + "remove", + "revoke", + "pair", + "rename", + "computer", +} + +var unavailableDevicesSearchWords = []string{ + tabDevices, + "devices", + "remove", + "revoke", + "pair", + addDeviceWord, + "rename", + "computer", +} + +// appendDevicesSearch scores the Devices tab against a standing search query and +// appends its heading and matching rows. When pairing cannot work here, a match +// surfaces the unavailability sentence rather than a working-looking add row. +func (s *sheet) appendDevicesSearch(terms []fuzzy.Term) int { + if len(terms) == 0 { + return 0 + } + if s.devicesUnavailable() != "" { + score, ok := fuzzy.ScoreFields(unavailableDevicesSearchWords, terms) + if !ok { + return 0 + } + s.items = append(s.items, + sheetItem{head: tabDevices}, + sheetItem{dev: &deviceSheetRow{unavailable: true}}, + ) + return score + } + type devHit struct { + item sheetItem + score int + } + var matched []devHit + best := 0 + if s.dev != nil && s.dev.loaded { + for i, d := range s.dev.rowsOf { + fields := []string{ + s.dev.shownName(d), + platformOf(d.Platform).word, + tabDevices, + "devices", + "remove", + "revoke", + "rename", + "computer", + } + score, ok := fuzzy.ScoreFields(fields, terms) + if !ok { + continue + } + best = max(best, score) + matched = append(matched, devHit{ + item: sheetItem{dev: &deviceSheetRow{index: i, id: d.ID}}, + score: score, + }) + } + } + if score, ok, hitAt, hitLen := s.matchHits(addDeviceSearchWords, terms); ok { + best = max(best, score) + matched = append(matched, devHit{ + item: sheetItem{dev: &deviceSheetRow{add: true}, hitAt: hitAt, hitLen: hitLen}, + score: score, + }) + } + if len(matched) == 0 { + return 0 + } + sort.SliceStable(matched, func(i, j int) bool { return matched[i].score > matched[j].score }) + s.items = append(s.items, sheetItem{head: tabDevices}) + for _, one := range matched { + s.items = append(s.items, one.item) + } + return best +} + +// devRowLines renders one row of the Devices tab through the sheet's standard +// [overlayLines] grammar while delegating every word and mark of a device row +// to [deviceCard.rowText]. When this device is being renamed, the card's own +// prompt hangs directly beneath its row. +func (s *sheet) devRowLines(row *deviceSheetRow, hit []int, selected, hovered bool, width int, pal palette) []string { + if row.unavailable { + return overlayLines(s.devicesUnavailable(), "", selected, false, hovered, width, pal) + } + if row.add { + return overlayLinesHit(addDeviceWord, "", hit, selected, false, hovered, width, pal) + } + if s.dev == nil || row.index < 0 || row.index >= len(s.dev.rowsOf) { + return nil + } + d := s.dev.rowsOf[row.index] + head := overlayLines(s.dev.rowText(d, s.devNow(), pal), "", selected, false, hovered, width, pal) + if d.Self && s.devTyping() { + line, _ := s.devNamingLine(width, pal) + return append(head, line) + } + return head +} + +// devNamingLine paints the inline rename prompt under this device's row and +// reports the terminal column immediately after the typed name so +// [placeSettings.caretRow] can park the hardware cursor there. +func (s *sheet) devNamingLine(width int, pal palette) (string, int) { + if s.dev == nil || s.dev.naming == nil { + return "", 0 + } + room := max(1, width-overlayIndent) + shown := fit(devicesNameAsk+*s.dev.naming, room) + return strings.Repeat(" ", overlayIndent) + pal.ink(shown), overlayIndent + ansi.StringWidth(shown) +} + +// devicesNote supplies the foot-rule caption for the Devices tab. The tab +// writes to the directory rather than `.codeaf/config.json`, so at rest it says +// nothing instead of claiming profile writes; after a rename, removal or refusal +// that fits the rule, it shows the card's own sentence on the foot line. A +// sentence wider than the rule wraps whole in [settingsDetails] above it rather +// than being cut in half or repeated twice on one frame. +func (s *sheet) devicesNote(pal palette, width int) ([]string, bool) { + if !s.onDevices() || s.searching() { + return nil, false + } + if s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + return nil, true + } + room := max(1, width-2) + if s.dev.line != "" && ansi.StringWidth(s.dev.line) <= room { + return []string{" " + pal.dim(noteFit(s.dev.line, room))}, true + } + if s.dev.line == "" && s.dev.readErr != nil && ansi.StringWidth(s.dev.readErr.Error()) <= room { + return []string{" " + pal.bad(noteFit(s.dev.readErr.Error(), room))}, true + } + return nil, true +} + +// devicesDetails returns any card message that did not fit on the single-line +// foot rule (or a concurrent refresh error standing alongside a completed +// removal, so neither fact erases the other). +func (s *sheet) devicesDetails(frameWidth, bodyWidth int) []string { + if !s.onDevices() || s.searching() || s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + return nil + } + ruleRoom := max(1, frameWidth-placeNoteRuleFrame) + wrapRoom := max(1, bodyWidth-4) + var lines []string + if s.dev.line != "" && ansi.StringWidth(s.dev.line) > ruleRoom { + lines = append(lines, wrap(s.dev.line, wrapRoom)...) + } + if s.dev.readErr != nil && (s.dev.line != "" || ansi.StringWidth(s.dev.readErr.Error()) > ruleRoom) { + lines = append(lines, wrap(s.dev.readErr.Error(), wrapRoom)...) + } + return lines +} + +// devicesKeysLine builds the key legend for the Devices tab from [deviceCard]'s +// own key constants, shrinking dynamically to the gestures that work on the row +// under the cursor: `r remove` only on a removable computer, `↑↓ choose` only +// when more than one row can be walked, and `enter add` on the add row. +func (s *sheet) devicesKeysLine() string { + if s.dev != nil && s.dev.removing != nil { + return s.dev.hint() + } + if s.devTyping() { + return s.dev.hint() + } + var out []string + restful := 0 + for _, item := range s.items { + if item.restful() { + restful++ + } + } + if restful > 1 { + out = append(out, devicesMoveKey) + } + if item, ok := s.current(); ok && item.dev != nil && !item.dev.unavailable { + if item.dev.add { + out = append(out, devicesAddKey) + } else { + out = append(out, devicesNameKey) + if s.dev != nil && item.dev.index >= 0 && item.dev.index < len(s.dev.rowsOf) { + if row := s.dev.rowsOf[item.dev.index]; !row.Self && !row.Revoked { + out = append(out, devicesGoneKey) + } + } + } + } + out = append(out, "←→ categories", devicesCloseKey) + return strings.Join(out, " · ") +} diff --git a/internal/tui3/settings_devices_test.go b/internal/tui3/settings_devices_test.go new file mode 100644 index 0000000000..18f16afcb1 --- /dev/null +++ b/internal/tui3/settings_devices_test.go @@ -0,0 +1,491 @@ +package tui3 + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/furrowbin" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "github.com/charmbracelet/x/ansi" +) + +func openSettingsDevices(t *testing.T, door Approvals, now time.Time) (*app, *pairRig) { + t.Helper() + a, r := approveApp(t, door) + a.clock = func() time.Time { return now } + a.openSettings() + walkToDevicesTab(t, a, r) + if door != nil && a.cellsUnavailable == "" { + r.until("the settings device list", func() bool { + return a.sheet.dev != nil && (a.sheet.dev.loaded || a.sheet.dev.line != "" || a.sheet.dev.readErr != nil) + }) + } + return a, r +} + +func walkToDevicesTab(t *testing.T, a *app, r *pairRig) { + t.Helper() + for guard := 0; guard < len(settingTabs) && !a.sheet.onDevices(); guard++ { + r.press("right") + } + if !a.sheet.onDevices() { + t.Fatalf("right arrows never reached %s; stopped on %s", tabDevices, settingTabs[a.sheet.tab]) + } +} + +// Validation contract item 1: `/settings` shows a `Devices` tab between +// `Connections` and `Privacy`, and moving across the bar with the existing tab +// keys (`←` and `→`) reaches it. +func TestSettingsDevicesTabSitsBetweenConnectionsAndPrivacyAndIsReachedByTabKeys(t *testing.T) { + at := -1 + for i, name := range settingTabs { + if name == tabDevices { + at = i + break + } + } + if at <= 0 || at+1 >= len(settingTabs) { + t.Fatalf("%s at index %d in %v, want between %s and %s", tabDevices, at, settingTabs, tabConnections, tabPrivacy) + } + if settingTabs[at-1] != tabConnections || settingTabs[at+1] != tabPrivacy { + t.Fatalf("neighbors around %s are %q and %q, want %q and %q", + tabDevices, settingTabs[at-1], settingTabs[at+1], tabConnections, tabPrivacy) + } + + a, r := approveApp(t, devicesFake()) + a.openSettings() + if settingTabs[a.sheet.tab] != tabGeneral { + t.Fatalf("settings opened on %s, want %s", settingTabs[a.sheet.tab], tabGeneral) + } + walkToDevicesTab(t, a, r) + if !strings.Contains(plain(frame(a)), tabDevices) { + t.Fatalf("frame does not draw %s on the bar:\n%s", tabDevices, plain(frame(a))) + } + r.press("right") + if settingTabs[a.sheet.tab] != tabPrivacy { + t.Fatalf("one step right of %s landed on %s, want %s", tabDevices, settingTabs[a.sheet.tab], tabPrivacy) + } + r.press("left") + if !a.sheet.onDevices() { + t.Fatalf("one step left of %s landed on %s, want %s", tabPrivacy, settingTabs[a.sheet.tab], tabDevices) + } +} + +type gatedDevicesApprovals struct { + *fakeApprovals + release chan struct{} +} + +func (g *gatedDevicesApprovals) Devices(ctx context.Context) ([]DeviceRow, error) { + select { + case <-g.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return g.fakeApprovals.Devices(ctx) +} + +// Validation contract item 2: the tab lists devices through `a.approvals.Devices` +// in `orderDevices` order, using the card's row words (`this device`, +// `seen … ago`, `removed`, online and offline dots from the palette's glyph +// door) and `devicesLoading` while loading, and re-reads when the directory +// version advances (`refreshOnChange`). +func TestSettingsDevicesListsDevicesInCardOrderAndRefreshesOnDirectoryChange(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := &fakeApprovals{devices: []DeviceRow{ + {ID: "dev_C", Name: "desk", Platform: "darwin", LastSeen: now.Add(-3 * time.Hour)}, + {ID: "dev_B", Name: "tower", Platform: "linux"}, + {ID: "dev_A", Name: "this mac", Platform: "darwin", Self: true}, + }} + gated := &gatedDevicesApprovals{fakeApprovals: door, release: make(chan struct{})} + + a, r := approveApp(t, gated) + a.clock = func() time.Time { return now } + a.dirFeed = &stillFeed{online: []string{"dev_B"}, version: 1} + a.openSettings() + walkToDevicesTab(t, a, r) + + loading := plain(frame(a)) + if !strings.Contains(loading, devicesLoading) { + t.Fatalf("while loading, tab lacked %q:\n%s", devicesLoading, loading) + } + if strings.Contains(loading, addDeviceWord) { + t.Fatalf("while loading, tab already drew %q:\n%s", addDeviceWord, loading) + } + + close(gated.release) + r.until("loaded rows", func() bool { return a.sheet.dev != nil && a.sheet.dev.loaded }) + + got := plain(frame(a)) + selfAt := strings.Index(got, "● this mac") + towerAt := strings.Index(got, "● tower") + deskAt := strings.Index(got, "○ desk") + addAt := strings.Index(got, addDeviceWord) + if selfAt < 0 || towerAt < 0 || deskAt < 0 || addAt < 0 { + t.Fatalf("missing expected rows:\n%s", got) + } + if !(selfAt < towerAt && towerAt < deskAt && deskAt < addAt) { + t.Fatalf("rows out of orderDevices order (self=%d tower=%d desk=%d add=%d):\n%s", + selfAt, towerAt, deskAt, addAt, got) + } + for _, want := range []string{devicesSelf, "seen 3h ago"} { + if !strings.Contains(got, want) { + t.Fatalf("screen lacked %q:\n%s", want, got) + } + } + + // Dots resolve through the palette's glyph door rather than literal marks. + asciiPal := newPalette(tokens.ANSI256, true) + asciiSelf := plain(strings.Join(a.sheet.devRowLines(a.sheet.items[0].dev, nil, false, false, 80, asciiPal), "\n")) + asciiAway := plain(strings.Join(a.sheet.devRowLines(a.sheet.items[2].dev, nil, false, false, 80, asciiPal), "\n")) + if !strings.Contains(asciiSelf, "# this mac") || !strings.Contains(asciiAway, "o desk") { + t.Fatalf("ASCII glyph repertoire not honored: self=%q away=%q", asciiSelf, asciiAway) + } + + // Directory change triggers refreshOnChange and updates the open tab. + door.mu.Lock() + door.devices[1].Revoked = true + door.mu.Unlock() + a.dirFeed = &stillFeed{online: []string{"dev_B"}, version: 2} + r.feed(dirWatchMsg{from: a.dirFeed}) + r.until("directory refresh", func() bool { + shown := plain(frame(a)) + return strings.Contains(shown, "○ tower") && strings.Contains(shown, "removed") + }) + if strings.Contains(plain(frame(a)), "● tower") { + t.Fatalf("revoked device kept online dot after refresh:\n%s", plain(frame(a))) + } +} + +// Validation contract item 3: the tab has an `Add another device` row sharing +// the single person-facing constant, and pressing `enter` on it leaves +// `/settings` and starts bare `/pair` (`runPair("")`). +func TestSettingsDevicesAddRowLeavesSettingsAndStartsBarePair(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := devicesFake() + var shown *pair.Code + pairing := &fakePairing{ + offer: func(ctx context.Context, ui pair.OfferUI) (string, error) { + code, err := pair.NewCode(now) + if err != nil { + return "", err + } + code = code.WithPlate("42") + shown = code + ui.Show(code, pair.ChatLines(code, "")) + <-ctx.Done() + return "", ctx.Err() + }, + } + + a, r := approveApp(t, door) + a.pairing = pairing + a.clock = func() time.Time { return now } + a.openSettings() + walkToDevicesTab(t, a, r) + r.until("loaded rows", func() bool { return a.sheet.dev != nil && a.sheet.dev.loaded }) + + for guard := 0; guard < len(a.sheet.items) && !a.sheet.onAddDeviceRow(); guard++ { + r.press("down") + } + if !a.sheet.onAddDeviceRow() { + t.Fatal("down arrows did not reach Add another device") + } + + r.press("enter") + if a.at(pageSettings) { + t.Fatal("enter on Add another device did not close /settings") + } + r.until("the pairing code", func() bool { return a.pair.open && a.pair.code != nil }) + if shown == nil || !strings.Contains(plain(frame(a)), "codeaf pair "+shown.Shown()) { + t.Fatalf("bare /pair screen did not open:\n%s", plain(frame(a))) + } +} + +// Validation contract item 4: `r` calls `Revoke(ctx, id)`, marks the row +// `removed`, and puts `devicesRevoked` with the name on the foot line; on this +// device's own row `r` shows `devicesOwnRow` and calls nothing; on the add row +// or an already-removed row `r` does nothing. +func TestSettingsDevicesRKeyConfirmsRemovalRefusesOwnRowAndIgnoresAddAndRemovedRows(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := &fakeApprovals{devices: []DeviceRow{ + {ID: "dev_A", Name: "this mac", Platform: "darwin", Self: true}, + {ID: "dev_B", Name: "tower", Platform: "linux"}, + }} + a, r := openSettingsDevices(t, door, now) + + // Own row: shows devicesOwnRow on the foot line and calls nothing. + r.press("r") + if door.did() != "" { + t.Fatalf("r on own row called door: %s", door.did()) + } + foot := plain(strings.Join((placeSettings{}).note(a, a.width), " ")) + if !strings.Contains(foot, devicesOwnRow) || !strings.Contains(plain(frame(a)), devicesOwnRow) { + t.Fatalf("r on own row did not show %q on foot line (foot=%q):\n%s", devicesOwnRow, foot, plain(frame(a))) + } + + // Add row: r does nothing. + r.press("down", "down") + if !a.sheet.onAddDeviceRow() { + t.Fatal("expected cursor on add row") + } + a.sheet.dev.line = "" + r.press("r") + if door.did() != "" || a.sheet.dev.line != "" { + t.Fatalf("r on add row acted: door=%q line=%q", door.did(), a.sheet.dev.line) + } + + // A removable row asks once; y removes it and puts devicesRevoked on the foot line. + r.press("up", "r", "y") + wantRevoked := fmt.Sprintf(devicesRevoked, "tower") + r.until("revoked outcome", func() bool { + return strings.Contains(plain(frame(a)), wantRevoked) + }) + if door.did() != "revoke:dev_B" { + t.Fatalf("door calls = %q, want revoke:dev_B", door.did()) + } + foot = plain(strings.Join((placeSettings{}).note(a, a.width), " ")) + if !strings.Contains(foot, wantRevoked) { + t.Fatalf("foot line = %q, want %q", foot, wantRevoked) + } + shown := plain(frame(a)) + if !strings.Contains(shown, "○ tower") || !strings.Contains(shown, "removed") { + t.Fatalf("row not marked removed:\n%s", shown) + } + + // Already-removed row: a second r does nothing. + r.press("r") + if door.did() != "revoke:dev_B" { + t.Fatalf("r on removed row called door again: %s", door.did()) + } +} + +// Validation contract item 5: `n` renames this device through `startNaming`, +// `typed` and `rename`; while typing, `esc` cancels typing and does NOT close +// `/settings`, and only a second `esc` with no typing in progress leaves +// `/settings`. +func TestSettingsDevicesNKeyRenamesThisDeviceAndEscCancelsTypingWithoutClosingSettings(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + door := devicesFake() + a, r := openSettingsDevices(t, door, now) + + r.press("n") + if !a.sheet.devTyping() { + t.Fatal("n on own row did not start naming") + } + lines, _, caretX, caretY, up := a.placeFrameNow(a.width, a.height) + if !up { + t.Fatal("settings place not up") + } + promptRow := -1 + for i, line := range lines { + if strings.Contains(plain(line), devicesNameAsk+"this mac") { + promptRow = i + break + } + } + if promptRow < 0 { + t.Fatalf("prefilled rename field missing:\n%s", plain(strings.Join(lines, "\n"))) + } + if caretY != promptRow || !strings.HasSuffix(ansi.Truncate(plain(lines[caretY]), caretX, ""), "this mac") { + t.Fatalf("caret at (%d,%d), want end of prompt row %d", caretX, caretY, promptRow) + } + + // Letters that are otherwise commands (`r`, `n`) type into the name, and esc + // cancels only the typing without closing /settings. + r.press("ctrl+u") + r.typeText("rnd") + if door.did() != "" { + t.Fatalf("typing into rename field called door: %s", door.did()) + } + r.press("esc") + if a.sheet.devTyping() { + t.Fatal("first esc did not cancel naming") + } + if !a.at(pageSettings) || !a.sheet.onDevices() { + t.Fatal("first esc closed /settings instead of only cancelling the rename") + } + if door.did() != "" { + t.Fatalf("cancelling rename wrote to door: %s", door.did()) + } + + // Saving a new name goes through Rename and refreshes the row. + r.press("n", "ctrl+u") + r.typeText("studio mac") + r.press("enter") + r.until("renamed row", func() bool { + return !a.sheet.devTyping() && strings.Contains(plain(frame(a)), "● studio mac") + }) + if door.did() != "rename:studio mac" { + t.Fatalf("door = %q, want rename:studio mac", door.did()) + } + + // With no typing in progress, esc leaves /settings. + r.press("esc") + if a.at(pageSettings) { + t.Fatal("esc at rest on Devices did not close /settings") + } +} + +// Validation contract item 6: the keys line uses `devicesNameKey`, +// `devicesGoneKey`, `devicesMoveKey` and `devicesNameKeys`, shrinking so +// `r remove` appears only on a removable row, `↑↓ choose` only when more than +// one row can be walked, and `enter add` on the add row. +func TestSettingsDevicesKeysLineShrinksToWhatWorksOnTheCurrentRow(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + + // Single selectable row (empty directory + Add another device): no ↑↓ choose, + // enter add present, no r remove. + single, _ := openSettingsDevices(t, &fakeApprovals{}, now) + keys := single.sheet.keysLine() + if strings.Contains(keys, devicesMoveKey) { + t.Fatalf("single-row Devices tab offered %q: %q", devicesMoveKey, keys) + } + if !strings.Contains(keys, devicesAddKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("single add row keys wrong: %q", keys) + } + + // Multi-row tab: self, removable, and already-removed rows (orderDevices + // sorts offline computers alphabetically, so "old" precedes "tower"). + door := &fakeApprovals{devices: []DeviceRow{ + {ID: "dev_A", Name: "this mac", Platform: "darwin", Self: true}, + {ID: "dev_B", Name: "tower", Platform: "linux"}, + {ID: "dev_C", Name: "old", Platform: "linux", Revoked: true}, + }} + a, r := openSettingsDevices(t, door, now) + + // Row 0 (Self): ↑↓ choose + n rename, no r remove. + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesNameKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("own row keys = %q", keys) + } + + // Typing a name: devicesNameKeys. + r.press("n") + if got := a.sheet.keysLine(); got != devicesNameKeys { + t.Fatalf("typing keys = %q, want %q", got, devicesNameKeys) + } + r.press("esc") + + // Row 1 ("old", already removed): ↑↓ choose + n rename, no r remove. + r.press("down") + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesNameKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("removed row keys = %q", keys) + } + + // Row 2 ("tower", removable): ↑↓ choose + n rename + r remove. + r.press("down") + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesNameKey) || !strings.Contains(keys, devicesGoneKey) { + t.Fatalf("removable row keys = %q", keys) + } + + // Row 3 (Add another device): ↑↓ choose + enter add, no r remove. + r.press("down") + keys = a.sheet.keysLine() + if !strings.Contains(keys, devicesMoveKey) || !strings.Contains(keys, devicesAddKey) || strings.Contains(keys, devicesGoneKey) { + t.Fatalf("add row keys = %q", keys) + } +} + +// Validation contract item 7: when devices cannot work here, the tab shows the +// same single sentence `/devices` would say — `cellsUnavailable` (including on +// platforms where `commandOnPlatform` hides `/devices`), `pairUnavailableWord` +// with a nil approvals door, or the card's own error wording on a failed list +// read — and never shows an empty list, `0 devices`, or a working-looking add +// row. +func TestSettingsDevicesUnavailableAndFailedReadSayWhyAndNothingElse(t *testing.T) { + now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + + t.Run("nil approvals door", func(t *testing.T) { + a, _ := openSettingsDevices(t, nil, now) + got := plain(frame(a)) + if !strings.Contains(got, pairUnavailableWord) { + t.Fatalf("nil door lacked %q:\n%s", pairUnavailableWord, got) + } + if strings.Contains(got, addDeviceWord) || strings.Contains(got, "0 devices") { + t.Fatalf("nil door drew forbidden list or add row:\n%s", got) + } + }) + + t.Run("cells unavailable reason", func(t *testing.T) { + reason := furrowbin.CellsUnavailable + a, r := approveApp(t, devicesFake()) + a.cellsUnavailable = reason + a.clock = func() time.Time { return now } + // Confirm a typed /devices says this exact sentence. + a.slash("/devices") + if !strings.Contains(updateNotes(a), reason) { + t.Fatalf("typed /devices did not say %q: %q", reason, updateNotes(a)) + } + a.openSettings() + walkToDevicesTab(t, a, r) + got := plain(frame(a)) + if !strings.Contains(got, reason) { + t.Fatalf("tab lacked cells-unavailable sentence %q:\n%s", reason, got) + } + if strings.Contains(got, addDeviceWord) || strings.Contains(got, "this mac") { + t.Fatalf("unavailable tab drew rows or add door:\n%s", got) + } + }) + + t.Run("failed list read", func(t *testing.T) { + readErr := errors.New("the device directory did not answer") + a, _ := openSettingsDevices(t, &fakeApprovals{err: readErr}, now) + got := plain(frame(a)) + if !strings.Contains(got, readErr.Error()) { + t.Fatalf("failed read lacked card error %q:\n%s", readErr.Error(), got) + } + if strings.Contains(got, addDeviceWord) { + t.Fatalf("failed read still offered %q:\n%s", addDeviceWord, got) + } + }) +} + +// Validation contract item 8: typing `devices`, `remove`, `revoke`, `pair` or +// `add device` into the `/settings` search finds the `Devices` tab. +func TestSettingsDevicesSearchFindsTheTabAcrossQueries(t *testing.T) { + queries := []struct { + query string + top bool + }{ + {"devices", true}, + {"remove", false}, + {"revoke", true}, + {"pair", true}, + {"add device", true}, + } + + for _, tc := range queries { + t.Run(tc.query, func(t *testing.T) { + a, r := approveApp(t, devicesFake()) + a.openSettings() + r.press("ctrl+u") + r.typeText(tc.query) + if tc.top && !a.sheet.onDevices() { + t.Fatalf("query %q landed on %s, want %s", tc.query, settingTabs[a.sheet.tab], tabDevices) + } + var hasHeading, hasRow bool + for _, item := range a.sheet.items { + if item.head == tabDevices { + hasHeading = true + } + if item.dev != nil { + hasRow = true + } + } + if !hasHeading || !hasRow { + t.Fatalf("query %q missing %s results (heading=%t row=%t)", tc.query, tabDevices, hasHeading, hasRow) + } + if shown := plain(frame(a)); !strings.Contains(shown, addDeviceWord) { + t.Fatalf("query %q did not draw %q on screen:\n%s", tc.query, addDeviceWord, shown) + } + }) + } +} diff --git a/internal/tui3/settings_live.go b/internal/tui3/settings_live.go index 595611ea9e..8f5feb55e7 100644 --- a/internal/tui3/settings_live.go +++ b/internal/tui3/settings_live.go @@ -10,6 +10,7 @@ func (a *app) refreshProfileUI() { a.mouse = config.MouseEnabledAt(a.profileDir) a.timestamps = config.TimestampsAt(a.profileDir) a.workMode = config.WorkAt(a.profileDir) + a.teamsShare = config.TeamsShareAt(a.profileDir) a.adoptIcons() a.hopQuick = config.QuickSwitchAt(a.profileDir) a.askWait = a.consentWait() diff --git a/internal/tui3/settings_organization_test.go b/internal/tui3/settings_organization_test.go index ae75a937f7..89d52e0e0c 100644 --- a/internal/tui3/settings_organization_test.go +++ b/internal/tui3/settings_organization_test.go @@ -13,7 +13,7 @@ func TestSettingsOrganizationCategoriesAndAdvancedSearch(t *testing.T) { if settingTabs[a.sheet.tab] != tabGeneral { t.Fatal("settings must open on General") } - want := "General,Models,Memory,Tasks,AI teams,Permissions,Spending,Connections,Privacy" + want := "General,Models,Memory,Tasks,AI teams,Permissions,Spending,Connections,Devices,Privacy" if strings.Join(settingTabs, ",") != want { t.Fatalf("categories: %v", settingTabs) } diff --git a/internal/tui3/settingspend_test.go b/internal/tui3/settingspend_test.go index 6ea6ca3725..6558a48c53 100644 --- a/internal/tui3/settingspend_test.go +++ b/internal/tui3/settingspend_test.go @@ -116,7 +116,7 @@ func TestSafetyAndTasksHoldWhatSpendingLetGoAndWorkspaceNamesNoMoney(t *testing. } } // AND THE BAR READS IN THE DESIGN'S ORDER. - want2 := []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, tabSafety, tabSpending, tabConnections, tabPrivacy} + want2 := []string{tabGeneral, tabProviders, tabMemory, tabTasks, tabTeams, tabSafety, tabSpending, tabConnections, tabDevices, tabPrivacy} if strings.Join(settingTabs, " ") != strings.Join(want2, " ") { t.Fatalf("the tab bar reads %v", settingTabs) } diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go index 29b7614103..aaef019ec0 100644 --- a/internal/tui3/sidecol.go +++ b/internal/tui3/sidecol.go @@ -452,7 +452,7 @@ func (a *app) sideTrafficCount(t team, kind int, handle string) (int, int) { continue } n++ - if e.ID > seen { + if e.Origin == "" && e.ID > seen { fresh++ } } diff --git a/internal/tui3/sidetraffic.go b/internal/tui3/sidetraffic.go index 247d44b53f..b44da243b9 100644 --- a/internal/tui3/sidetraffic.go +++ b/internal/tui3/sidetraffic.go @@ -47,6 +47,8 @@ type sideTrafficCacheKey struct { kind, rows, width, height, hotDoor, moved int ascii, held bool minute int64 + computers uint64 + trafficVersion int // wrap is the team's wrap-up words (teamwrapclock.go). wrap string } @@ -98,7 +100,9 @@ func (a *app) sideTrafficView(height int) ([]railLine, int) { team: t.ID, handle: handle, last: last, divider: a.side.divider[t.ID], hot: hot, focus: focus, front: a.frontTabKey(), kind: kind, rows: len(rows), width: width, height: height, hotDoor: hotDoor, moved: a.side.moved, ascii: a.pal.ascii, held: a.railHold, minute: a.now().Unix() / 60, - wrap: a.teamWrapWords(t, a.now()), + wrap: a.teamWrapWords(t, a.now()), + computers: a.devRow.version, + trafficVersion: a.traffic.version, } a.trafficMarkSeen(t) if c := &a.side.traffic; c.lines != nil && c.key == key { @@ -151,6 +155,10 @@ type sideSeg struct { // newer draws the `new` line once, above the first row that is not newer // than the divider, when rows above it were. func (s *sideSheet) newer(id string) { + // Mirror history has no position in the local unread stream. + if strings.Contains(id, "/") { + return + } if s.ruled { return } @@ -470,7 +478,7 @@ func sideChatter(th teamstore.Thread) bool { func sideLast(th teamstore.Thread) teamstore.Entry { last := th.Root for _, r := range th.Replies { - if r.ID > last.ID { + if trafficLater(r, last) { last = r } } @@ -489,7 +497,7 @@ func (s *sideSheet) threads(kind int, handle string) { continue } chatter = append(chatter, th.Root) - if th.Root.ID > newest.ID { + if trafficLater(th.Root, newest) { newest = th.Root } } @@ -499,7 +507,7 @@ func (s *sideSheet) threads(kind int, handle string) { continue } last := sideLast(th) - if !laid && newest.ID > last.ID { + if !laid && trafficLater(newest, last) { s.general(chatter, newest) laid = true } @@ -578,6 +586,7 @@ func (s *sideSheet) work(th teamstore.Thread, last teamstore.Entry) { } row.hint = s.jumpHint(e.From, "", age, extra) s.add(row, segs, right, age) + s.provenance(e) if !open { return } @@ -624,6 +633,7 @@ func (s *sideSheet) member(r trafficReply) { segs = append(segs, sideSeg{text: " ", door: -1}, sideSeg{text: words, paint: pal.muted, door: -1, flex: true}) row.hint = s.jumpHint(r.who, "", age, words) s.add(row, segs, nil, age) + s.provenance(e) } // sideThreadState is the one word a thread's answers leave it in: someone @@ -678,6 +688,7 @@ func (s *sideSheet) reply(e teamstore.Entry) { segs = append(segs, sideSeg{text: " ", door: -1}, sideSeg{text: words, paint: pal.muted, door: -1, flex: true}) row.hint = s.jumpHint(e.From, "", age, words) s.add(row, segs, nil, age) + s.provenance(e) } // general lays the chatter as one thread, `General`, and its entries under @@ -737,5 +748,14 @@ func (s *sideSheet) messages(handle string) { segs = append(segs, sideSeg{text: " ", door: -1}, sideSeg{text: words, paint: s.a.pal.muted, door: -1, flex: true}) row.hint = s.jumpHint(e.From, handle, age, words) s.add(row, segs, nil, age) + s.provenance(e) + } +} + +// A separate dim line names the writer without consuming the message's few +// columns. It carries no action, so history never offers an answer or reply. +func (s *sideSheet) provenance(e teamstore.Entry) { + if origin := s.a.trafficComputer(e); origin != "" { + s.lines = append(s.lines, railLine{text: s.a.pal.dim(fit(" "+origin, s.width)), entry: -1}) } } diff --git a/internal/tui3/spendmemwords_test.go b/internal/tui3/spendmemwords_test.go index 51fbf8b36c..7e17262f06 100644 --- a/internal/tui3/spendmemwords_test.go +++ b/internal/tui3/spendmemwords_test.go @@ -246,7 +246,7 @@ func TestEveryPlaceWhisperWrapsLikeHomesAndIsNeverCut(t *testing.T) { for i, line := range lines { rows[i] = strings.TrimRight(plain(line), " ") } - said := whisperRowsUnder(rows, emptyPlaceHeadingRow(t, lab.id, rows)) + said := whisperRowsUnder(rows, emptyPlaceHeadingRow(t, a.chords, lab.id, rows)) if len(said) == 0 || len(said) > 3 { t.Fatalf("the empty %s place at %d cells whispers on %d rows:\n%s", lab.id.word(), width, len(said), strings.Join(rows, "\n")) diff --git a/internal/tui3/statusnote.go b/internal/tui3/statusnote.go index d897c5eed6..28c1faf6d9 100644 --- a/internal/tui3/statusnote.go +++ b/internal/tui3/statusnote.go @@ -102,7 +102,7 @@ func (a *app) statusItems() []deckItem { // link the row says `devbox · 3ms`; /status has room to say what that // number is. While redialling, the existing sentence is already whole // and is kept verbatim instead. - if a.hosted() && item.label == deckSegWords[segLink] && a.linkLatency > 0 && a.linkNote() == "" { + if a.hosted() && item.label == deckSegWords[segLink] && a.linkLatencyFresh() && a.linkNote() == "" { item.value = "the round trip to " + a.host + " is about " + latencyWord(a.linkLatency) } // AND AN OWNED SESSION SAYS WHERE IT ACTUALLY IS, here and nowhere else. diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index 6e20866104..592781042b 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -591,6 +591,7 @@ func (a *app) takeOver() tea.Cmd { // This window is reopening the same chat, so retirement needs no new chat. a.dropParked() a.writeDraftsNow(a.leavingDraft()) + a.retainSavingDurability([]Agent{a.agent}) return closeConversationLater(a.agent, session.StopByTakeover, a.saveFailure, a.closingStarted, nil) } a.dropParked() diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 0af71b0ca2..1aaf055791 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -55,6 +55,9 @@ import ( // [app.task], the lane that answers it — so the answer and the row can never // disagree about what was decided. type taskCard struct { + computerPicked bool + computerPreference string + computer string id uint64 title, summary, brief, acceptance, where string // name is the two-or-three-word NAME cut from the title, sub the one-line @@ -150,6 +153,11 @@ func (c *taskCard) settled() bool { return c.verdict != "" } // elapsed clock counts on the frame tick instead of freezing at whatever the // last update happened to say. Everything else is the notice, kept. type taskNode struct { + computer string + // A later local replacement clears placement; an older silent node cannot. + computerKnown bool + unreachable, ranElsewhere bool + remote *session.TaskRemoteRecord // workTurn records the request that admitted this live work, independently of completion time. workTurn int workOwned bool @@ -905,6 +913,9 @@ func (a *app) taskEvent(ev session.Event) tea.Cmd { a.note(ev.Text) case session.EventTaskProposal: a.proposeTask(ev) + if a.proposalComputerName(a.task) != "" { + pilot = a.readTaskComputers() + } case session.EventTaskUpdate: pilot = a.taskUpdate(ev) mentions = a.refreshTasks() @@ -1290,22 +1301,24 @@ func (a *app) proposeTask(ev session.Event) { brief := strings.TrimSpace(notice.Brief) name := taskTitleOf(title, firstNonEmpty(summary, brief), notice.ID) card := &taskCard{ - id: notice.ID, - title: title, - summary: summary, - brief: brief, - acceptance: strings.TrimSpace(notice.Acceptance), - where: strings.TrimSpace(notice.Where), - name: name, - sub: taskSubtitleOf(name, firstNonEmpty(summary, brief)), - ident: identFor(notice.ID), - dependsOn: notice.DependsOn, - model: strings.TrimSpace(notice.Model), - program: strings.TrimSpace(notice.Program), - ceiling: strings.TrimSpace(notice.Ceiling), - elsewhere: strings.TrimSpace(notice.Elsewhere), - deadline: notice.Deadline, - born: a.now(), + id: notice.ID, + computer: strings.TrimSpace(notice.Computer), + computerPreference: config.TaskRunsOnAt(a.profileDir), + title: title, + summary: summary, + brief: brief, + acceptance: strings.TrimSpace(notice.Acceptance), + where: strings.TrimSpace(notice.Where), + name: name, + sub: taskSubtitleOf(name, firstNonEmpty(summary, brief)), + ident: identFor(notice.ID), + dependsOn: notice.DependsOn, + model: strings.TrimSpace(notice.Model), + program: strings.TrimSpace(notice.Program), + ceiling: strings.TrimSpace(notice.Ceiling), + elsewhere: strings.TrimSpace(notice.Elsewhere), + deadline: notice.Deadline, + born: a.now(), } a.task = card a.closeLive() @@ -1593,11 +1606,12 @@ const ( // something is being asked, which is what its readers actually want to know. func (a *app) awaitingTask() bool { return a.task != nil && !a.task.settled() } -// taskKey is the ONE key the proposal still owns, and it is not an answer. +// taskKey handles the proposal's disclosure and computer keys. Neither answers +// the shared question, whose own keys still decide whether the work starts. // -// The three answers and the clock went to the question block with every other -// decision on this surface (question.go), and what is left here is the key that -// opens the assignment in the transcript: `ctrl+e` is what this surface means +// The answers and clock belong to the shared question. These keys change what +// its assignment shows: `ctrl+d` cycles eligible computers before acceptance, +// while `ctrl+e` is what this surface means // "show me the rest of this" by, and the brief is the rest of a proposal. It // belongs to the lane rather than to the block because it acts on the BLOCK IN // THE TRANSCRIPT — the question above the box has nothing to unfold. @@ -1606,12 +1620,22 @@ func (a *app) awaitingTask() bool { return a.task != nil && !a.task.settled() } // is the rule input.go applies to the thinking block, and it is applied here for // the same reason: ctrl+e is end-of-line in every shell a person has used. func (a *app) taskKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { - if !a.awaitingTask() || msg.String() != "ctrl+e" { + if !a.awaitingTask() { return nil, false } if !a.input.empty() { return nil, false } + if msg.String() == taskComputerCycleKey { + if a.questionOffFrame() || !a.canCycleTaskComputer(a.task) { + return nil, false + } + a.cycleTaskComputer() + return nil, true + } + if msg.String() != "ctrl+e" { + return nil, false + } a.toggleCard() return nil, true } @@ -1644,6 +1668,12 @@ func (a *app) taskAnswered(id uint64, answer session.Answer) session.Answer { } card.verdict = taskVerdict(approve, redirect) card.answer = word + if approve && card.computerPicked && a.taskComputersAvailable() { + if answer.Blanks == nil { + answer.Blanks = map[string]string{} + } + answer.Blanks[session.TaskComputerBlank] = card.computer + } // A YES OPENS THE FORMING BLOCK. The task the card names does not exist // until its first update arrives, and the person who just said yes is owed // something moving in that pause rather than nothing (taskcommand.go's @@ -1921,6 +1951,10 @@ func (a *app) taskCardRows(card *taskCard, width int, sel bool) []string { } head := a.taskHead(card, width, sel) if card.settled() { + if words := a.taskComputerWords(a.tasks[card.id]); words != "" { + stem := a.blockStem() + return []string{head, a.pal.dim(stem + fit(words, max(1, width-ansi.StringWidth(stem)))), a.taskFoot(card, width)} + } return []string{head, a.taskFoot(card, width)} } stem := a.pal.ask(a.blockStem()) @@ -1961,6 +1995,9 @@ func (a *app) taskCardRows(card *taskCard, width int, sel bool) []string { if card.where != "" { out = append(out, stem+a.pal.dim(fit("where: "+card.where, room))) } + if computer := a.proposalComputerName(card); computer != "" { + out = append(out, stem+a.pal.dim(fit("runs on "+computer, room))) + } if point := a.taskBranchPoint(); point != "" && card.program == "" { // The branch point is the last of the facts about the work, and it is the // one thing on the card a person cannot find out afterwards without @@ -2197,6 +2234,9 @@ func (a *app) taskMetaWord(card *taskCard, width int) string { if card.model != "" { parts = append(parts, taskModelTag+card.model) } + if a.canCycleTaskComputer(card) { + parts = append(parts, taskComputerCycleHint) + } if !card.open && card.brief != "" { parts = append(parts, taskExpandHint) } @@ -4251,6 +4291,14 @@ func (a *app) railEntryRow(e railEntry, width int) string { const indent = " " glyph := a.railTreeGlyph(node) room := width - len(indent) - 2 + // Placement is a recorded fact; its complete words get cells before the + // title or clock. A rail too narrow for the phrase leaves it off whole. + placement := a.taskComputerWords(node) + if cells := ansi.StringWidth(placement); placement != "" && room >= cells+2 { + room -= cells + 1 + } else { + placement = "" + } // A program's badge stays on its row when the side column is compact. // The time gives up cells first, then the badge shortens, while the title // keeps enough room to name the work. @@ -4269,6 +4317,9 @@ func (a *app) railEntryRow(e railEntry, width int) string { } title, w := fitWidth(node.title, room) line := indent + glyph + " " + a.railTitle(node, title) + a.pal.programAfter(wears) + if placement != "" { + line += a.pal.dim(" " + placement) + } if age != "" { line += strings.Repeat(" ", max(room-w, 0)+1) + a.pal.dim(age) } @@ -4390,6 +4441,15 @@ func (a *app) railTitle(node *taskNode, title string) string { // handles back to work that is not on screen, and half of one of those is worth // nothing at all. func (a *app) railUnder(node *taskNode, width int) []string { + if words := a.taskComputerWords(node); words != "" { + rows := []string{a.pal.dim(fit(words, width))} + if node.state == session.TaskRunning { + if figures := a.railTelemetry(node, width); figures != "" { + rows = append(rows, a.pal.dim(figures)) + } + } + return rows + } paint, text := a.pal.dim, "" switch node.state { case session.TaskRunning: @@ -4760,10 +4820,13 @@ func planLiveRow(command string, parts []session.PlanCommandPart, width int, pal // is doing, in the only vocabulary it has — so a prefix would be the surface // explaining a plain English word with a second plain English word. func (a *app) railDoing(node *taskNode, width int) []string { - if node.doing == "" { + if words := a.taskComputerWords(node); words != "" { + return []string{a.pal.dim(fit(words, width))} + } + if taskDoingWords(node) == "" { return nil } - return []string{a.pal.dim(fit(node.doing, width))} + return []string{a.pal.dim(fit(taskDoingWords(node), width))} } // railProgramRow is the run's own plan row for a node whose run was handed to @@ -5291,6 +5354,7 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { resumed = a.resumeRetriedRoom(node) } node.state = notice.State + node.takeComputerNotice(notice) if len(notice.DependsOn) > 0 { node.dependsOn = notice.DependsOn } diff --git a/internal/tui3/task_stability_test.go b/internal/tui3/task_stability_test.go new file mode 100644 index 0000000000..594ad2ea83 --- /dev/null +++ b/internal/tui3/task_stability_test.go @@ -0,0 +1,69 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +type countedSurfaceComputers struct { + surfaceTaskComputers + lists int +} + +func (c *countedSurfaceComputers) List(context.Context) ([]session.TaskComputer, error) { + c.lists++ + return c.surfaceTaskComputers, nil +} + +// S1 forbids a task-computer directory call for an ordinary proposal. +func TestAPairedHomeWithDefaultsNeverListsTaskComputers(t *testing.T) { + a, _, _ := taskApp(t) + c := &countedSurfaceComputers{surfaceTaskComputers: surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}}} + a.taskComputers = c + _ = runCmd(a.Init()) + if c.lists != 0 { + t.Fatalf("opening a paired chat listed task computers %d times", c.lists) + } + drive(t, a, taskEventMsg{gen: a.taskGen, ev: proposal(a, 7, 0)}) + if c.lists != 0 { + t.Fatalf("an ordinary proposal listed task computers %d times", c.lists) + } +} + +// S3 keeps both the original card and its key bindings until explicit placement. +func TestTheProposalCardSaysNothingAboutComputersByDefault(t *testing.T) { + a, _, _ := taskApp(t) + a.proposeTask(proposal(a, 7, 0)) + original := strings.Join(a.taskCardRows(a.task, 120, false), "\n") + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}} + drive(t, a, taskMsg(a.readTaskComputers())) + if got := strings.Join(a.taskCardRows(a.task, 120, false), "\n"); got != original { + t.Fatalf("pairing changed the default proposal:\n%s", plain(got)) + } + if cmd, took := a.taskKey(key("ctrl+d")); cmd != nil || took { + t.Fatal("the default proposal claimed a computer key") + } +} + +// S10 makes the setting the default opt-in door, with no roster fetch on entry. +func TestTheSettingsRowIsTheOnlyNewThingAPairedPersonSeesByDefault(t *testing.T) { + a, _ := sheetApp(t) + c := &countedSurfaceComputers{surfaceTaskComputers: surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}}} + a.taskComputers = c + drive(t, a, key("ctrl+,")) + cursorTo(t, a, config.KeyTaskRunsOn) + if c.lists != 0 { + t.Fatalf("opening settings listed task computers %d times", c.lists) + } + if text := plain(frame(a)); !strings.Contains(text, "Run tasks on") || !strings.Contains(text, "this computer") { + t.Fatalf("missing default opt-in row:\n%s", text) + } + drive(t, a, key("enter")) + if c.lists != 1 || !strings.Contains(plain(frame(a)), "work computer") { + t.Fatalf("opening the row did not fetch its computers: calls=%d\n%s", c.lists, plain(frame(a))) + } +} diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 7644a11dfa..f4a38426a0 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "time" + "unicode" tea "charm.land/bubbletea/v2" @@ -29,6 +30,50 @@ type taskCommandAgent interface { StartTask(context.Context, string, bool) (uint64, string, string, error) } +type taskCommandOnAgent interface { + StartTaskOn(context.Context, string, bool, string, ...string) (uint64, string, string, error) +} + +// Only the computer name is unquoted. The rest is the person's brief, whose +// punctuation and spacing must reach the worker without shell interpretation. +func taskComputerArgument(arg string) (name, rest string, ok bool) { + arg = strings.TrimSpace(arg) + if arg == "" { + return "", "", false + } + quote := arg[0] + if quote != '"' && quote != '\'' { + at := strings.IndexFunc(arg, unicode.IsSpace) + if at < 0 { + return arg, "", true + } + return arg[:at], strings.TrimSpace(arg[at:]), true + } + var word strings.Builder + for i := 1; i < len(arg); i++ { + if arg[i] == quote { + tail := arg[i+1:] + if tail != "" && strings.TrimLeftFunc(tail, unicode.IsSpace) == tail { + return "", "", false + } + return word.String(), strings.TrimSpace(tail), word.Len() > 0 + } + if arg[i] == '\\' && quote == '"' && i+1 < len(arg) && (arg[i+1] == quote || arg[i+1] == '\\') { + i++ + } + word.WriteByte(arg[i]) + } + return "", "", false +} + +func (a *app) taskCommandUsage() string { + usage := "usage: /task · /task solo · /task --best · /task --cheap " + if _, ok := a.agent.(taskCommandOnAgent); ok && a.taskComputersAvailable() { + usage += " · /task --on " + } + return usage +} + // taskCrewEffortAgent is the session's door for a task with a one-task effort // word said: best or cheap (session's taskcrew.go). It is a second interface // rather than a wider first one, so a surface or a test double that has only @@ -136,15 +181,29 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { a.note("could not start the task · this session has no task door") return nil } - // THE WHOLE VOCABULARY IS TWO FORMS: a brief, or `solo` and a brief. A first - // word that is neither of those is simply the beginning of the brief, so the - // split is taken once here and the brief defaults to everything typed. - // HOW HARD TO TRY THIS ONE TASK comes first when it is said at all — - // `/task --best …`, `/task --cheap …` — and moves this task's crew and - // nothing after it (crew.go). A flag that is neither is part of the brief. - effort := "" - if flag, after, _ := strings.Cut(arg, " "); flag == "--best" || flag == "--cheap" { - effort, arg = strings.TrimPrefix(flag, "--"), strings.TrimSpace(after) + // Placement and effort are read only from the prefix, so the work's own + // words stay intact. Without the placement capability, --on remains part + // of the brief just as every unrecognized flag did before it existed. + effort, computer := "", "" + onDoor, canPlace := a.agent.(taskCommandOnAgent) + canPlace = canPlace && a.taskComputersAvailable() + for { + flag, after, _ := strings.Cut(arg, " ") + if effort == "" && (flag == "--best" || flag == "--cheap") { + effort, arg = strings.TrimPrefix(flag, "--"), strings.TrimSpace(after) + if canPlace { + continue + } + } else if canPlace && computer == "" && flag == "--on" { + var valid bool + computer, arg, valid = taskComputerArgument(after) + if !valid { + a.note(a.taskCommandUsage()) + return nil + } + continue + } + break } word, rest, _ := strings.Cut(arg, " ") rest = strings.TrimSpace(rest) @@ -172,9 +231,27 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { } } if brief == "" { - a.note("usage: /task · /task solo · /task --best · /task --cheap ") + a.note(a.taskCommandUsage()) return nil } + if computer != "" { + solo = solo || config.TaskStartAt(a.profileDir) == config.TaskStartSingle + file, ctx := a.file, a.ctx + conv := a.taskCopyDoorNotes(brief) + return a.offLoop(func() func(bool) tea.Cmd { + var id uint64 + var title, note string + var err error + if bound, ok := onDoor.(interface { + StartTaskOnIn(context.Context, string, bool, string, string, string) (uint64, string, string, error) + }); ok { + id, title, note, err = bound.StartTaskOnIn(ctx, brief, solo, computer, effort, file) + } else { + id, title, note, err = onDoor.StartTaskOn(ctx, brief, solo, computer, effort) + } + return foldTaskStarted(taskStartedMsg{kind: "single", id: strconv.FormatUint(id, 10), title: title, note: note, err: err, brief: brief, conv: conv}) + }) + } if effort != "" { door, ok := a.agent.(taskCrewEffortAgent) if !ok { diff --git a/internal/tui3/taskcomputer.go b/internal/tui3/taskcomputer.go new file mode 100644 index 0000000000..0a32e45b4d --- /dev/null +++ b/internal/tui3/taskcomputer.go @@ -0,0 +1,286 @@ +package tui3 + +import ( + "context" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +const taskComputerHint = "where a task goes when you do not name a computer" + +const taskRunHereKey = "h" +const taskRunHereVerb = taskRunHereKey + " run it here" +const taskRunHereSuffix = " — run it here" +const taskComputerCycleKey = "ctrl+d" +const taskComputerCycleHint = taskComputerCycleKey + " computer" + +type taskRunHereAgent interface { + RunTaskHere(context.Context, uint64) (string, error) +} + +// A connection has the Go methods even when its engine predates them. Its +// welcome must explicitly allow these controls before a window draws them. +func (a *app) taskComputersAvailable() bool { + if a.taskComputers == nil { + return false + } + if connected, ok := a.agent.(interface{ TaskComputersSupported() bool }); ok { + return connected.TaskComputersSupported() + } + return true +} + +// These are the same applicability facts RunTaskHere checks. The owning live +// notice supplies them; an interrupted local row supplies none of them. +func (a *app) taskCanRunHere(entry session.TaskIndexEntry) bool { + if !a.taskComputersAvailable() || a.taskSheet.awayOwner.on || a.taskHerePending == tasksKeyOf(entry) { + return false + } + if _, ok := a.agent.(taskRunHereAgent); !ok { + return false + } + node := a.taskSheetNodeFor(&entry) + if node == nil || node.remote == nil || node.remote.Abandoned { + return false + } + r := node.remote + return r.StartFailed || r.Unreachable && !r.Finished || node.state == session.TaskInterrupted +} + +func (a *app) runTaskHere(entry session.TaskIndexEntry) tea.Cmd { + if !a.taskCanRunHere(entry) { + return nil + } + node := a.taskSheetNodeFor(&entry) + door := a.agent.(taskRunHereAgent) + ctx, id, key, file := a.ctx, node.id, tasksKeyOf(entry), a.file + a.taskHerePending = key + a.touch() + return a.offLoop(func() func(bool) tea.Cmd { + var words string + var err error + if bound, ok := door.(interface { + RunTaskHereIn(context.Context, uint64, string) (string, error) + }); ok { + words, err = bound.RunTaskHereIn(ctx, id, file) + } else { + words, err = door.RunTaskHere(ctx, id) + } + return func(here bool) tea.Cmd { + if a.taskHerePending == key { + a.taskHerePending = tasksKey{} + } + if here { + if err != nil { + a.note(err.Error()) + } else if words != "" { + a.note(words) + } + a.touch() + } + return nil + } + }) +} + +// The roster is read for a placement choice, never while drawing. A failed +// refresh retains the last names rather than claiming a computer was removed. +func (a *app) readTaskComputers() tea.Cmd { + if !a.taskComputersAvailable() { + return nil + } + computers, ctx := a.taskComputers, a.ctx + return a.besideLine(func() func(bool) tea.Cmd { + rows, err := computers.List(ctx) + localName := computers.ThisComputer() + return func(here bool) tea.Cmd { + if !here || err != nil { + return nil + } + a.taskComputerRows = nil + a.taskComputerHere = localName + for _, row := range rows { + if row.CanTakeTasks { + a.taskComputerRows = append(a.taskComputerRows, row) + } + } + if a.sheet.registry != nil { + a.sheet.taskComputerRows = a.taskComputerRows + a.sheet.build() + if choice := a.sheet.choice; choice != nil && choice.item.row.Key == config.KeyTaskRunsOn { + choice.item.row = a.sheet.taskComputerSetting(choice.item.row) + choice.current = config.TaskRunsOnAt(a.sheet.profileDir) + choice.cursor = 0 + if taskComputerName(a.sheet.taskComputerRows, choice.current) == config.TaskRunsHere { + choice.current = "" + } + for i, value := range choice.item.row.Choices { + if value == choice.current { + choice.cursor = i + break + } + } + } + } + if a.task != nil { + a.markCardStale(a.task) + } + a.touch() + return nil + } + }) +} + +func (a *app) proposalComputerName(card *taskCard) string { + if card == nil || !a.taskComputersAvailable() || (card.computer == "" && card.computerPreference == "") { + return "" + } + named := strings.TrimSpace(card.computer) + if named == "" { + return taskComputerName(a.taskComputerRows, card.computerPreference) + } + if strings.EqualFold(named, "here") || strings.EqualFold(named, config.TaskRunsHere) || strings.EqualFold(named, a.taskComputerHere) { + return config.TaskRunsHere + } + for _, computer := range a.taskComputerRows { + if strings.EqualFold(named, computer.Name) { + return computer.Name + } + } + return named +} + +func (a *app) canCycleTaskComputer(card *taskCard) bool { + return card != nil && !card.forming && !card.settled() && a.proposalComputerName(card) != "" && len(a.taskComputerRows) > 0 +} + +// A pick belongs to this proposal, not to the standing preference. The answer +// carries it only when the person later accepts or corrects the proposal. +func (a *app) cycleTaskComputer() { + card := a.task + if !a.canCycleTaskComputer(card) { + return + } + choices := []string{config.TaskRunsHere} + for _, computer := range a.taskComputerRows { + choices = append(choices, computer.Name) + } + current, at := a.proposalComputerName(card), -1 + for i, choice := range choices { + if strings.EqualFold(choice, current) { + at = i + break + } + } + a.holdTask(card.id) + card.computer, card.computerPicked = choices[(at+1)%len(choices)], true + a.markCardStale(card) + a.touch() +} + +func taskComputerName(rows []session.TaskComputer, device string) string { + for _, row := range rows { + if row.Device == device { + return row.Name + } + } + return config.TaskRunsHere +} + +func (s *sheet) taskComputerSetting(row config.Setting) config.Setting { + if row.Key == config.KeyTaskRunsOn { + row.Choices = []string{""} + row.Hint = taskComputerHint + for _, computer := range s.taskComputerRows { + row.Choices = append(row.Choices, computer.Device) + } + } + return row +} + +func (s *sheet) settingValueWord(row config.Setting) string { + if row.Key == config.KeyTaskRunsOn { + return taskComputerName(s.taskComputerRows, config.TaskRunsOnAt(s.profileDir)) + } + return settingValueWord(row) +} + +// Placement is a separate fact from the shared state reading. A checkpoint +// saying running alone never supplies a computer's identity. +// Both local and watching pages apply the same placement history, so an empty +// later notice can clear a known remote attempt without erasing an older record. +func (node *taskNode) takeComputerNotice(notice *session.TaskNotice) { + node.computerKnown = node.computerKnown || node.computer != "" || notice.Computer != "" || notice.Remote != nil + node.computer, node.unreachable, node.ranElsewhere = notice.Computer, notice.Unreachable, notice.RanElsewhere + node.remote = notice.Remote +} + +func taskComputerWords(name string, unreachable, ran bool, state session.TaskState) string { + if name == "" { + return "" + } + if unreachable { + return session.TaskComputerStoppedAnswering(name) + } + if state == session.TaskInterrupted { + return "interrupted on " + name + } + if ran { + return session.TaskRanOn(name) + } + if state == session.TaskRunning || state == session.TaskQueued { + return session.TaskRunningOn(name) + } + return "" +} + +// Placement belongs to the record; a window without computer controls still +// says where the recorded work ran. +func (a *app) taskComputerWords(node *taskNode) string { + if node == nil { + return "" + } + if node.remote != nil && node.remote.Abandoned { + return "" + } + if node.remote != nil && node.remote.StartFailed && !node.remote.Abandoned { + return strings.TrimSuffix(firstNonEmpty(node.doing, node.report), taskRunHereSuffix) + } + return taskComputerWords(node.computer, node.unreachable, node.ranElsewhere, node.state) +} + +// The session carries placement on the phase lane too. It remains a placement fact, +// rather than becoming a second state word or being drawn twice in the room. +func taskDoingWords(node *taskNode) string { + if node.computer != "" { + switch node.doing { + case session.TaskRunningOn(node.computer), session.TaskRanOn(node.computer), session.TaskComputerStoppedAnswering(node.computer): + return "" + } + if node.remote != nil && node.remote.StartFailed { + return "" + } + } + return node.doing +} + +func (a *app) taskEntryComputerWords(entry session.TaskIndexEntry) string { + if node := a.taskSheetNodeFor(&entry); node != nil { + if words := a.taskComputerWords(node); words != "" || node.computerKnown || node.remote != nil && node.remote.Abandoned { + return words + } + } + return taskComputerWords(entry.Computer, entry.Unreachable, entry.RanElsewhere, session.TaskState(entry.Status)) +} + +// A saved remote default is itself the person's choice to consult the roster. +// An unchanged paired home must make no task-computer call at startup. +func (a *app) readPreferredTaskComputers() tea.Cmd { + if config.TaskRunsOnAt(a.profileDir) == "" { + return nil + } + return a.readTaskComputers() +} diff --git a/internal/tui3/taskcomputer_test.go b/internal/tui3/taskcomputer_test.go new file mode 100644 index 0000000000..af1986bc15 --- /dev/null +++ b/internal/tui3/taskcomputer_test.go @@ -0,0 +1,484 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +const surfaceComputerB = "dev_11111111111111111111111111111111" +const surfaceComputerC = "dev_22222222222222222222222222222222" +const surfaceComputerWindows = "dev_33333333333333333333333333333333" +const surfaceComputerRemoved = "dev_44444444444444444444444444444444" + +type surfaceTaskComputers []session.TaskComputer + +func (c surfaceTaskComputers) List(context.Context) ([]session.TaskComputer, error) { return c, nil } +func (surfaceTaskComputers) ThisComputer() string { return "home computer" } +func (surfaceTaskComputers) Engine(context.Context, string) (session.TaskRunEngine, error) { + return nil, nil +} + +type surfaceTaskComputerAgent struct { + *taskFake + computerAnswers []session.Answer + here []uint64 + computer, brief string + effort string + solo bool + starts, localStarts int + startErr error + startNote string +} + +type olderTaskComputerSurface struct{ *surfaceTaskComputerAgent } + +func (*olderTaskComputerSurface) TaskComputersSupported() bool { return false } + +// The wire compatibility law requires the welcome's missing capability to +// hide the new controls even though the client now has their Go methods. +func TestAnOlderEngineHasNoTaskComputerFlagOrRecoveryVerb(t *testing.T) { + a, f, _ := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{StartFailed: true}, "work computer is not taking tasks right now — run codeaf serve --take-tasks on it") + a.agent = &olderTaskComputerSurface{f} + if strings.Contains(a.taskCommandUsage(), "--on") { + t.Fatal("an older engine advertises --on") + } + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); strings.Contains(text, "run it here") { + t.Fatalf("an older engine advertises recovery:\n%s", text) + } + if cmd := a.taskCardKey("h"); cmd != nil || len(f.here) != 0 { + t.Fatal("an absent recovery control reached the engine") + } + a.taskSheet.detailOn = false + a.proposeTask(proposal(a, 8, 0)) + if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); strings.Contains(text, "ctrl+d computer") || strings.Contains(text, "runs on") { + t.Fatalf("an older engine advertises proposal placement:\n%s", text) + } + if cmd, took := a.taskKey(key("ctrl+d")); cmd != nil || took { + t.Fatal("an older engine takes the computer-cycle key") + } +} + +func (f *surfaceTaskComputerAgent) ResolveQuestion(answer session.Answer) error { + f.computerAnswers = append(f.computerAnswers, answer) + return f.taskFake.ResolveQuestion(answer) +} + +// DESIGN contracts 1 and 6 require both prefix orders to carry the same +// computer and one-task effort through the named placement operation. +func TestTaskOnCarriesEffortInEitherOrder(t *testing.T) { + for _, tc := range []struct{ arg, effort string }{ + {`--best --on "Abir's Mac" repair export`, "best"}, + {`--on "Abir's Mac" --best repair export`, "best"}, + {`--cheap --on "Abir's Mac" repair export`, "cheap"}, + {`--on "Abir's Mac" --cheap repair export`, "cheap"}, + } { + t.Run(tc.arg, func(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task "+tc.arg))) + if f.starts != 1 || f.localStarts != 0 || f.computer != "Abir's Mac" || f.effort != tc.effort || f.brief != "repair export" { + t.Fatalf("computer or effort was lost: %+v", f) + } + }) + } +} + +// DESIGN contract 1 requires a proposal's explicit choice to win over its +// default. Choosing a computer answers nothing until the person accepts. +func TestTheProposalCardCyclesWhereTheTaskRuns(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + f.pending = []uint64{7} + a.agent = f + a.taskComputers = surfaceTaskComputers{ + {Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}, + {Device: surfaceComputerC, Name: "Abir's Mac", CanTakeTasks: true}, + {Device: surfaceComputerWindows, Name: "Windows computer"}, + } + if err := config.WriteTaskRunsOn(a.profileDir, surfaceComputerB); err != nil { + t.Fatal(err) + } + drive(t, a, taskMsg(a.readTaskComputers())) + ev := proposal(a, 7, 0) + ev.Task.Model, ev.Task.ModelOptions = "test/one", []string{"test/one", "test/two"} + drive(t, a, streamEventMsg{gen: a.gen, ev: ev}) + taskText(a) + settleAsk(a) + text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")) + if !strings.Contains(text, "runs on work computer") || !strings.Contains(text, "ctrl+d computer") { + t.Fatalf("the proposal hides its choice or key:\n%s", text) + } + a.input.setText("keep my draft") + drive(t, a, key("ctrl+d")) + if a.input.String() != "keep my draft" || !strings.Contains(plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")), "runs on work computer") { + t.Fatal("the computer key changed a nonempty draft or its placement") + } + a.input.setText("") + drive(t, a, key("right")) + for _, want := range []string{"Abir's Mac", "this computer", "work computer", "Abir's Mac", "this computer"} { + drive(t, a, key("ctrl+d")) + if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); !strings.Contains(text, "runs on "+want) || strings.Contains(text, "Windows computer") { + t.Fatalf("the cycle did not pick %q:\n%s", want, text) + } + if len(f.computerAnswers) != 0 { + t.Fatal("cycling answered the proposal") + } + } + drive(t, a, key("1")) + if len(f.computerAnswers) != 1 { + t.Fatalf("proposal answers = %d", len(f.computerAnswers)) + } + answer := f.computerAnswers[0] + if answer.Blanks[session.TaskComputerBlank] != "this computer" || answer.Blanks[session.TaskModelBlank] != "test/two" { + t.Fatalf("the accepted choices were lost: %+v", answer) + } + if got := config.TaskRunsOnAt(a.profileDir); got != surfaceComputerB { + t.Fatalf("a one-task choice changed the standing default: %q", got) + } +} + +func (f *surfaceTaskComputerAgent) StartTaskEffort(ctx context.Context, brief string, solo bool, effort string) (uint64, string, string, error) { + f.effort = effort + return f.StartTask(ctx, brief, solo) +} + +// TestTaskComputerPlacementKeepsEveryExistingTaskForm covers SPEC-R4's +// compatibility requirement at the typed command boundary, including the +// workspace verbs that must be intercepted before any brief is started. +func TestTaskComputerPlacementKeepsEveryExistingTaskForm(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + for _, tc := range []struct { + arg, brief, effort string + solo, intercepted bool + }{ + {arg: "repair export", brief: "repair export"}, + {arg: "solo repair export", brief: "repair export", solo: true}, + {arg: "--best repair export", brief: "repair export", effort: "best"}, + {arg: "--cheap solo repair export", brief: "repair export", effort: "cheap", solo: true}, + {arg: "adaptive rate limiting", brief: "adaptive rate limiting"}, + {arg: "--unknown repair export", brief: "--unknown repair export"}, + {arg: "review", intercepted: true}, + {arg: "apply", intercepted: true}, + } { + t.Run(tc.arg, func(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task "+tc.arg))) + if tc.intercepted { + if f.starts != 0 || f.localStarts != 0 { + t.Fatal("a workspace verb was sent as a brief") + } + return + } + if f.starts != 0 || f.localStarts != 1 || f.brief != tc.brief || f.solo != tc.solo || f.effort != tc.effort { + t.Fatalf("an existing form changed: %+v", f) + } + }) + } +} + +func (f *surfaceTaskComputerAgent) StartTask(_ context.Context, brief string, solo bool) (uint64, string, string, error) { + f.localStarts++ + f.brief, f.solo = brief, solo + return 7, "Repair export", f.startNote, f.startErr +} + +func (f *surfaceTaskComputerAgent) StartTaskOn(_ context.Context, brief string, solo bool, computer string, effort ...string) (uint64, string, string, error) { + f.starts++ + f.brief, f.solo, f.computer = brief, solo, computer + if len(effort) > 0 { + f.effort = effort[0] + } + return 7, "Repair export", f.startNote, f.startErr +} + +// TestTaskOnNamesTheComputerTheTaskRunsOn maps to DESIGN contract 6 at the +// command boundary: the named computer reaches R3's existing placement door. +func TestTaskOnNamesTheComputerTheTaskRunsOn(t *testing.T) { + for _, tc := range []struct { + arg, computer, brief string + solo bool + }{ + {`--on work repair export`, "work", "repair export", false}, + {`--on "Abir's Mac" repair export`, "Abir's Mac", "repair export", false}, + {`--on here solo repair export`, "here", "repair export", true}, + } { + t.Run(tc.arg, func(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task "+tc.arg))) + if f.starts != 1 || f.localStarts != 0 || f.computer != tc.computer || f.brief != tc.brief || f.solo != tc.solo { + t.Fatalf("the command did not reach placement: %+v", f) + } + }) + } +} + +// TestTaskOnWithAnUnknownNameListsTheComputers maps to DESIGN contract 6: +// the placement door's correctable error reaches the person unchanged. +func TestTaskOnWithAnUnknownNameListsTheComputers(t *testing.T) { + const problem = `no paired computer is named "missing" — your computers are: Abir's Mac, work computer` + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base, startErr: errors.New(problem)} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task --on missing repair export"))) + if f.starts != 1 || !strings.Contains(lastNote(t, a), problem) { + t.Fatalf("the unknown computer's answer did not reach the person: %q", lastNote(t, a)) + } +} + +// TestTheProposalCardNamesItsExplicitOrDefaultComputer covers DESIGN 1.1's +// placement disclosure before approval, independent of the later run state. +func TestTheProposalCardNamesItsExplicitOrDefaultComputer(t *testing.T) { + for _, tc := range []struct{ computer, preference, shown string }{ + {"", "", ""}, + {"Abir's Mac", "", "Abir's Mac"}, + {"", surfaceComputerB, "work computer"}, + {"here", surfaceComputerB, "this computer"}, + {"Abir's Mac", surfaceComputerB, "Abir's Mac"}, + } { + t.Run(tc.computer+tc.preference, func(t *testing.T) { + a, _, _ := taskApp(t) + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}, {Device: surfaceComputerC, Name: "Abir's Mac", CanTakeTasks: true}} + if err := config.WriteTaskRunsOn(a.profileDir, tc.preference); err != nil { + t.Fatal(err) + } + drive(t, a, taskMsg(a.readTaskComputers())) + ev := proposal(a, 7, 0) + ev.Task.Computer = tc.computer + a.proposeTask(ev) + text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")) + if tc.shown == "" { + if strings.Contains(text, "runs on") || strings.Contains(text, "ctrl+d computer") { + t.Fatalf("a default proposal disclosed computers:\n%s", text) + } + } else if !strings.Contains(text, "runs on "+tc.shown) { + t.Fatalf("the proposal does not say where it will run:\n%s", text) + } + }) + } +} + +// TestNothingAboutComputersIsDrawnWhenNothingIsPaired covers the absence law +// for controls and local work. R9 keeps recorded placement visible regardless. +func TestNothingAboutComputersIsDrawnWhenNothingIsPaired(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent = f + a.proposeTask(proposal(a, 7, 0)) + if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); strings.Contains(text, "runs on") || strings.Contains(text, "computer") { + t.Fatalf("an unpaired proposal draws placement:\n%s", text) + } + if cmd, took := a.taskKey(key("ctrl+d")); cmd != nil || took { + t.Fatal("an unpaired proposal takes the computer key") + } + // A saved executor is a fact, and does not enable unavailable controls. + entry := session.TaskIndexEntry{ID: "9", Title: "Earlier export", Status: string(session.TaskDone), Computer: "work computer", RanElsewhere: true} + reading := readTasks(session.World{}, a.taskSheetMine(), session.LastDays(a.now(), 1), tasksSort{}, a.now(), a.now()) + line := tasksLine{kind: tasksLineTask, item: &tasksItem{entry: entry}} + if text := plain(reading.paint([]tasksLine{line}, 0, 160, a.pal, false)); !strings.Contains(text, "ran on work computer") { + t.Fatalf("a saved executor disappeared with the controls: %s", text) + } + a.task.verdict = "approved" + drive(t, a, key("ctrl+,")) + for i := range settingTabs { + a.sheet.tab = i + a.sheet.build() + for _, item := range a.sheet.items { + if item.row.Key == config.KeyTaskRunsOn { + t.Fatal("an unpaired conversation offers Run tasks on") + } + } + } + a.sheet.query.setText("Run tasks on") + a.sheet.build() + for _, item := range a.sheet.items { + if item.row.Key == config.KeyTaskRunsOn { + t.Fatal("search reveals the unavailable computer setting") + } + } + a.dropSettings() + if strings.Contains(a.taskCommandUsage(), "--on") { + t.Fatal("the old command advertises a computer flag") + } + drive(t, a, taskMsg(a.runTaskCommand("--on work repair export"))) + if f.starts != 0 || f.localStarts != 1 || f.brief != "--on work repair export" { + t.Fatalf("the unavailable flag changed the old brief grammar: %+v", f) + } +} + +func (f *surfaceTaskComputerAgent) RunTaskHere(_ context.Context, id uint64) (string, error) { + f.here = append(f.here, id) + return "running task 7 Repair export here", nil +} + +func computerRecoveryApp(t *testing.T, state session.TaskState, remote *session.TaskRemoteRecord, words string) (*app, *surfaceTaskComputerAgent, session.TaskIndexEntry) { + t.Helper() + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent = f + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}} + entry := session.TaskIndexEntry{ID: "7", SessionID: a.taskSheetSelfID(), Title: "Repair export", Status: string(state), Computer: "work computer", Unreachable: remote.Unreachable} + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, state, session.TaskNotice{Computer: entry.Computer, Unreachable: remote.Unreachable, Remote: remote, Doing: words, Report: words})}) + a.taskSheet.detail, a.taskSheet.detailOn = entry, true + return a, f, entry +} + +// TestAComputerThatStoppedAnsweringOffersRunItHere maps to DESIGN contracts 9 +// and 11: the missing computer is said without claiming it stopped, and one +// deliberate action reaches the session's local replacement operation. +func TestAComputerThatStoppedAnsweringOffersRunItHere(t *testing.T) { + const words = "work computer stopped answering — it may still be working" + for _, surface := range []string{"card", "page"} { + t.Run(surface, func(t *testing.T) { + remote := &session.TaskRemoteRecord{Unreachable: true} + a, f, entry := computerRecoveryApp(t, session.TaskRunning, remote, words) + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, words) || strings.Count(text, "h run it here") != 1 { + t.Fatalf("the card does not give one local choice:\n%s", text) + } + if surface == "card" { + drive(t, a, taskMsg(a.taskCardKey("h"))) + } else { + a.taskSheet.detailOn = false + a.room = a.newRoom(7, entry.Title) + if hint := a.roomHint(); !strings.Contains(hint, "h run it here") { + t.Fatalf("the page lost the local choice: %q", hint) + } + cmd, took := a.roomKey(key("h")) + if !took { + t.Fatal("the page did not take its local-choice key") + } + drive(t, a, taskMsg(cmd)) + } + if len(f.here) != 1 || f.here[0] != 7 { + t.Fatalf("the local choice reached the wrong task: %v", f.here) + } + }) + } +} + +// TestAComputerNotTakingTasksSaysToRunServeOnIt maps to DESIGN contract 8: +// the session's actual refusal is retained beside the local replacement verb. +func TestAComputerNotTakingTasksSaysToRunServeOnIt(t *testing.T) { + const words = "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" + a, f, _ := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{StartFailed: true}, words) + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, words) || strings.Count(text, "h run it here") != 1 { + t.Fatalf("the start failure does not explain its two choices:\n%s", text) + } + drive(t, a, taskMsg(a.taskCardKey("h"))) + if len(f.here) != 1 || f.here[0] != 7 { + t.Fatalf("the failed start did not reach RunTaskHere: %v", f.here) + } +} + +// TestAnInterruptedRemoteTaskOffersRunItHere covers DESIGN section 4's +// interrupted case without treating a local interrupted task as remote work. +func TestAnInterruptedRemoteTaskOffersRunItHere(t *testing.T) { + a, _, entry := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{}, "") + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, "interrupted on work computer") || !strings.Contains(text, "h run it here") { + t.Fatalf("the interrupted remote run has no local choice:\n%s", text) + } + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, session.TaskRunning, session.TaskNotice{})}) + rows, _, _, _ = a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); strings.Contains(text, "work computer") || strings.Contains(text, "run it here") { + t.Fatalf("the local replacement kept the remote placement:\n%s", text) + } +} + +// TestTheRunTasksOnRowListsPairedComputersAndDefaultsToThisComputer maps to +// DESIGN contract 7: names are shown, device identities are saved, and an +// unavailable preference falls back to this computer. +func TestTheRunTasksOnRowListsPairedComputersAndDefaultsToThisComputer(t *testing.T) { + a, _ := sheetApp(t) + a.taskComputers = surfaceTaskComputers{ + {Device: surfaceComputerB, Name: "Abir's Mac", CanTakeTasks: true}, + {Device: surfaceComputerC, Name: "work computer", CanTakeTasks: true}, + {Device: surfaceComputerWindows, Name: "Windows computer"}, + } + drive(t, a, key("ctrl+,")) + cursorTo(t, a, config.KeyTaskRunsOn) + if text := plain(frame(a)); !strings.Contains(text, "Run tasks on") || !strings.Contains(text, "this computer") || !strings.Contains(text, "where a task goes when you do not name a computer") { + t.Fatalf("the row does not explain its default:\n%s", text) + } + drive(t, a, key("enter")) + if a.sheet.choice == nil { + t.Fatal("Run tasks on did not offer computers") + } + text := plain(frame(a)) + for _, name := range []string{"this computer", "Abir's Mac", "work computer"} { + if !strings.Contains(text, name) { + t.Fatalf("the list does not offer %q:\n%s", name, text) + } + } + if strings.Contains(text, "Windows computer") || strings.Contains(text, surfaceComputerB) { + t.Fatalf("the list offers an unavailable computer or exposes its id:\n%s", text) + } + drive(t, a, key("home"), key("down"), key("enter")) + if got := config.TaskRunsOnAt(a.profileDir); got != surfaceComputerB { + t.Fatalf("the choice saved %q instead of the device id", got) + } + if text := plain(frame(a)); !strings.Contains(text, "Abir's Mac") || strings.Contains(text, surfaceComputerB) { + t.Fatalf("the saved row does not show its computer's name:\n%s", text) + } + chooseSheetValue(t, a, "") + if got := config.TaskRunsOnAt(a.profileDir); got != "" { + t.Fatalf("this computer did not restore the default: %q", got) + } + if err := config.WriteTaskRunsOn(a.profileDir, surfaceComputerRemoved); err != nil { + t.Fatal(err) + } + a.sheet.build() + if text := plain(frame(a)); !strings.Contains(text, "this computer") || strings.Contains(text, surfaceComputerRemoved) { + t.Fatalf("a removed computer did not fall back to this computer:\n%s", text) + } +} + +// TestARemoteTaskSaysRunningOnThenRanOn maps to DESIGN contract 2: placement +// follows the task across the rail, roster, room and saved result. +func TestARemoteTaskSaysRunningOnThenRanOn(t *testing.T) { + a, _, _ := taskApp(t) + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}} + remote := &session.TaskRemoteRecord{Executor: session.TaskExecutor{Device: surfaceComputerB, Name: "work computer"}} + entry := session.TaskIndexEntry{ID: "7", SessionID: a.taskSheetSelfID(), Title: "Repair export", Status: string(session.TaskRunning), Computer: "work computer"} + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, session.TaskRunning, session.TaskNotice{Computer: entry.Computer, Remote: remote, Doing: "running on work computer"})}) + node := a.tasks[7] + for surface, text := range map[string]string{ + "rail": plain(strings.Join(a.railUnder(node, 100), "\n")), + "Tasks": plain(tasksRow(tasksLine{item: &tasksItem{entry: entry, runs: true}}, 160, a.now(), tasksSort{}, a.pal, false)), + "compact Tasks": plain(tasksCardTail(tasksItem{entry: entry, runs: true}, a.now())), + "page": plain(strings.Join(a.taskCardBody(entry, 120), "\n")), + "room": plain(a.roomStateWord(node)), + } { + if strings.Count(text, "running on work computer") != 1 { + t.Errorf("%s does not say where the work runs:\n%s", surface, text) + } + } + entry.Status, entry.RanElsewhere = string(session.TaskDone), true + remote.Finished, remote.Settled = true, true + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, session.TaskDone, session.TaskNotice{Computer: entry.Computer, RanElsewhere: true, Remote: remote, Doing: "ran on work computer"})}) + for surface, text := range map[string]string{ + "saved card": plain(strings.Join(a.taskCardBody(entry, 120), "\n")), + "room": plain(a.roomStateWord(a.tasks[7])), + } { + if !strings.Contains(text, "ran on work computer") { + t.Errorf("%s lost the computer after finishing:\n%s", surface, text) + } + } + entry.Computer, entry.RanElsewhere = "", false + entry.ID, entry.SessionID = "8", "local conversation" + if text := plain(strings.Join(a.taskCardBody(entry, 120), "\n")); strings.Contains(text, "ran on") || strings.Contains(text, "this computer") { + t.Fatalf("local work drew placement words:\n%s", text) + } +} diff --git a/internal/tui3/taskdone.go b/internal/tui3/taskdone.go index a6658d49d0..7787c74d7e 100644 --- a/internal/tui3/taskdone.go +++ b/internal/tui3/taskdone.go @@ -22,6 +22,8 @@ import ( type taskDone struct { id uint64 ident taskIdent + // Placement survives the live node, just like the rest of this receipt. + placement string // program is the program the work was handed to, "" for codeaf's own. // Its ending is the conversation's to act on (session's program_outcome.go), // so its card says it ended and that the chat has the rest @@ -205,6 +207,7 @@ func (a *app) landedCard(node *taskNode) { landed = a.now() } card := &taskDone{ + placement: a.taskComputerWords(node), id: node.id, ident: node.ident, program: a.nodeProgram(node), @@ -472,6 +475,11 @@ func (a *app) doneRows(card *taskDone, width int, sel bool) []string { return nil } out := []string{a.doneHead(card, width, sel)} + if card.placement != "" { + for _, line := range wrap(card.placement, width-2) { + out = append(out, " "+a.pal.dim(line)) + } + } if card.attention != "" { for _, line := range wrap(card.attention, width-2) { out = append(out, " "+a.pal.warn(line)) diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index 3ab7c9c0c7..11ad189296 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -202,6 +202,7 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { node.label = firstNonEmpty(strings.TrimSpace(row.Title), strings.TrimSpace(row.Label)) node.title = taskTitleOf(node.label, "", id) node.state = session.TaskState(row.Status) + node.computer, node.unreachable, node.ranElsewhere = row.Computer, row.Unreachable, row.RanElsewhere if row.Live() { a.anchorFarLiveNode(node, row) } else { diff --git a/internal/tui3/taskowner.go b/internal/tui3/taskowner.go index 9cb4051399..b6fcc04894 100644 --- a/internal/tui3/taskowner.go +++ b/internal/tui3/taskowner.go @@ -455,6 +455,7 @@ func (a *app) tookGuestNotice(msg taskGuestNoticeMsg) tea.Cmd { } guest.told = true if guest.node != nil { + guest.node.takeComputerNotice(notice) guest.node.stopped, guest.node.ending = notice.Stopped, notice.Ending live := taskLiveLines(notice) guest.node.doing, guest.node.mending, guest.node.waiting = live.doing, live.mending, live.waiting @@ -1086,6 +1087,7 @@ func taskGuestNode(item tasksItem) *taskNode { title = strings.TrimSpace(item.entry.Label) } return &taskNode{ + computer: item.entry.Computer, unreachable: item.entry.Unreachable, ranElsewhere: item.entry.RanElsewhere, id: id, ident: identFor(id), title: title, diff --git a/internal/tui3/taskpane.go b/internal/tui3/taskpane.go index 86bed7d19d..7300a70762 100644 --- a/internal/tui3/taskpane.go +++ b/internal/tui3/taskpane.go @@ -341,6 +341,11 @@ func (a *app) taskPaneRecord(item tasksItem, width int) []taskPaneRow { // width (taskrecord.go's [app.taskCardAwayRows] states it). if item.away { var away []string + if placement := a.taskEntryComputerWords(entry); placement != "" { + for _, row := range wrap(placement, width) { + away = append(away, pal.dim(row)) + } + } for _, wrapped := range wrap(taskAwayCardWhere(item.window), width) { away = append(away, pal.ink(wrapped)) } @@ -349,6 +354,11 @@ func (a *app) taskPaneRecord(item tasksItem, width int) []taskPaneRow { } var facts []string + if placement := a.taskEntryComputerWords(entry); placement != "" { + for _, row := range wrap(placement, width) { + facts = append(facts, pal.dim(row)) + } + } if line := a.taskPaneFacts(entry, width); line != "" { facts = append(facts, fit(pal.dim(line), width)) } diff --git a/internal/tui3/taskplacement_r10_test.go b/internal/tui3/taskplacement_r10_test.go new file mode 100644 index 0000000000..8a0f8ccd0b --- /dev/null +++ b/internal/tui3/taskplacement_r10_test.go @@ -0,0 +1,110 @@ +package tui3 + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/charmbracelet/x/ansi" +) + +func r10RailFixture(t *testing.T, computer string) (*app, *taskNode) { + t.Helper() + a, _, _ := taskApp(t) + a.taskUpdate(update(7, "replace the whole content of notes.txt with the requested words", session.TaskRunning, session.TaskNotice{Computer: computer})) + node := a.tasks[7] + node.started, node.began = a.now().Add(-4*time.Minute), a.now().Add(-4*time.Minute) + return a, node +} + +// R10(b2) names the executor on the actual rail headline, giving the title's +// cells to placement first. A narrow rail may drop the whole phrase only. +func TestTheRailSaysWhichComputerATaskRunsOn(t *testing.T) { + a, node := r10RailFixture(t, "beta") + for width := 8; width <= 120; width++ { + line := plain(a.railEntryRow(railEntry{node: node, group: railRunning}, width)) + if ansi.StringWidth(line) > width { + t.Errorf("width %d spilled: %q", width, line) + } + if width >= 24 && !strings.Contains(line, "running on beta") { + t.Errorf("width %d spent placement cells on the title: %q", width, line) + } + if !strings.Contains(line, "running on beta") && strings.Contains(line, "running on") { + t.Errorf("width %d cut a placement word: %q", width, line) + } + } +} + +func r10HistoryFixture(t *testing.T, computer string, historical bool) (*app, tasksReading, tasksItem) { + t.Helper() + a, _, _ := taskApp(t) + owner := session.SessionRow{ID: "r10-conversation", Title: "export work"} + entry := session.TaskIndexEntry{ID: "7", SessionID: owner.ID, Title: "create hello.txt containing the word KIWI", Status: string(session.TaskDone), Computer: computer, RanElsewhere: computer != ""} + plan := session.PlanTaskRow{ID: "p-r10", Title: entry.Title, Status: "done", Steps: 2, Archived: true} + row := tasksMineRow{entry: entry, planTask: plan.ID} + if historical { + plan.HistoricalRun, plan.HistoricalSession = 7, owner.ID + row.planTask = "" + } + mine := tasksMine{row: owner, rows: []tasksMineRow{row}, plan: []session.PlanTaskRow{plan}} + reading := readTasks(session.World{}, mine, session.LastDays(a.now(), 1), tasksSort{}, a.now(), a.now()) + if len(reading.items) != 1 || reading.items[0].plan == nil { + t.Fatalf("not the actual history plan row: %+v", reading.items) + } + return a, reading, reading.items[0] +} + +// R10(b3) follows the same plan-over-record replacement /history uses, both +// while the store is held and after it becomes a retained run receipt. +func TestAFinishedHistoryRowSaysWhichComputerItRanOn(t *testing.T) { + for _, historical := range []bool{false, true} { + a, r, item := r10HistoryFixture(t, "beta", historical) + lines := []tasksLine{{kind: tasksLineTask, item: &item}, {kind: tasksLineTail, item: &item}} + for i := range lines { + text := plain(r.paint(lines, i, 160, a.pal, false)) + if !strings.Contains(text, "ran on beta") || !strings.Contains(text, "2 steps") { + t.Errorf("historical %v, line %d: %s", historical, i, text) + } + } + } +} + +func r10LocalRows(t *testing.T) map[string]string { + t.Helper() + a, node := r10RailFixture(t, "") + rows := map[string]string{} + for _, width := range []int{20, 40, 80, 160} { + rows[fmt.Sprintf("rail/%d", width)] = a.railEntryRow(railEntry{node: node, group: railRunning}, width) + } + for _, historical := range []bool{false, true} { + a, r, item := r10HistoryFixture(t, "", historical) + lines := []tasksLine{{kind: tasksLineTask, item: &item}, {kind: tasksLineTail, item: &item}} + for i := range lines { + rows[fmt.Sprintf("history/%v/%d", historical, i)] = r.paint(lines, i, 160, a.pal, false) + } + } + return rows +} + +// R10's stability rule pins the actual local rail and plan/history bytes from +// 4443a1750, before either remote-only renderer change. +func TestLocalRailAndHistoryRowsKeepTheirOriginalBytes(t *testing.T) { + raw, err := os.ReadFile("testdata/task_local_r10_4443a1750.json") + if err != nil { + t.Fatal(err) + } + var want map[string]string + if err = json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + got := r10LocalRows(t) + for surface, original := range want { + if got[surface] != original { + t.Errorf("%s changed: got %q, want %q", surface, got[surface], original) + } + } +} diff --git a/internal/tui3/taskplacement_rows_test.go b/internal/tui3/taskplacement_rows_test.go new file mode 100644 index 0000000000..f1fe4ad99a --- /dev/null +++ b/internal/tui3/taskplacement_rows_test.go @@ -0,0 +1,230 @@ +package tui3 + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func placementRowFixture(t *testing.T, state session.TaskState, computer string) (*app, session.TaskIndexEntry) { + t.Helper() + a := completionFoldApp(t) + entry := session.TaskIndexEntry{ID: "7", SessionID: a.taskSheetSelfID(), Title: "Create hello.txt", Label: "Create hello.txt", Status: string(state), Computer: computer} + notice := session.TaskNotice{Computer: computer} + if computer != "" { + notice.Remote = &session.TaskRemoteRecord{Executor: session.TaskExecutor{Name: computer}} + } + if state == session.TaskDone || state == session.TaskFailed { + entry.EndedAt = a.now() + entry.RanElsewhere, notice.RanElsewhere = computer != "", computer != "" + if notice.Remote != nil { + notice.Remote.Finished, notice.Remote.Settled = true, true + } + } + a.taskUpdate(update(7, entry.Title, state, notice)) + return a, entry +} + +func placementRowReadings(a *app, entry session.TaskIndexEntry) map[string]string { + item := &tasksItem{entry: entry, runs: true} + reading := tasksReading{now: a.now()} + rows := []tasksLine{{kind: tasksLineTask, item: item}, {kind: tasksLineTail, item: item}} + readings := map[string]string{ + "Background work": a.completionRows([]workCompletion{{task: 7, title: entry.Title, state: entry.Status}}, 200)[0].text, + "conversation rail": strings.Join(a.railUnder(a.tasks[7], 180), "\n"), + "conversation phase": strings.Join(a.railDoing(a.tasks[7], 180), "\n"), + "task room": a.roomStateWord(a.tasks[7]), + "Tasks": reading.paint(rows, 0, 200, a.pal, false), + "compact Tasks": reading.paint(rows, 1, 70, a.pal, false), + "task page": strings.Join(a.taskCardBody(entry, 180), "\n"), + } + var preview []string + for _, row := range a.taskPaneRecord(*item, 100) { + preview = append(preview, row.text) + } + readings["task preview"] = strings.Join(preview, "\n") + if entry.Status == string(session.TaskRunning) { + r, watched := watchedPlacementReading(a, entry) + lines := []tasksLine{{kind: tasksLineTask, item: &watched}, {kind: tasksLineTail, item: &watched}} + readings["Home watched Tasks"] = r.paint(lines, 0, 180, a.pal, false) + readings["Home watched compact Tasks"] = r.paint(lines, 1, 70, a.pal, false) + } + approved := &taskCard{id: 7, title: entry.Title, verdict: "approved"} + readings["accepted conversation card"] = strings.Join(a.taskCardRows(approved, 200, false), "\n") + if at := a.doneEntryFor(7); at >= 0 { + card := *a.entries[at].done + card.quiet = false + readings["conversation landing"] = strings.Join(a.doneRows(&card, 200, false), "\n") + card.open = true + readings["open conversation landing"] = strings.Join(a.doneRows(&card, 200, false), "\n") + } + return readings +} + +// R9.1 requires placement on the actual result door, including failed work and +// restored receipts, even when this window cannot choose task computers. +func TestTheBackgroundWorkRowSaysWhereARemoteTaskRan(t *testing.T) { + for _, state := range []session.TaskState{session.TaskDone, session.TaskFailed} { + for _, saved := range []bool{false, true} { + a, entry := placementRowFixture(t, state, "beta") + if saved { + delete(a.tasks, 7) + a.comp.tasks = []session.TaskIndexEntry{entry} + } + a.openWorkfold(1) + a.touch() + found := false + for _, row := range a.visible(a.bodyWidth()) { + if row.hit == hitWorkCompletion && row.completion.task == 7 { + found = true + if text := plain(row.text); strings.Count(text, "ran on beta") != 1 || !strings.Contains(text, "open result") { + t.Errorf("state %s, saved %v: %s", state, saved, text) + } + a.openWorkCompletion(row.completion) + if a.taskSheet.detail.Computer != "beta" || !a.taskSheet.detail.RanElsewhere { + t.Errorf("open result dropped the executor: %+v", a.taskSheet.detail) + } + } + } + if !found { + t.Fatalf("state %s, saved %v: no Background work result row", state, saved) + } + } + } +} + +// R9.1 makes the saved executor authoritative on each list, independently of +// whether this particular window has acquired the pairing capability. +func TestEveryTaskSurfaceKeepsTheRecordedComputerWithoutTheCapability(t *testing.T) { + for _, state := range []session.TaskState{session.TaskRunning, session.TaskDone} { + a, entry := placementRowFixture(t, state, "beta") + want := "running on beta" + if entry.RanElsewhere { + want = "ran on beta" + } + for surface, text := range placementRowReadings(a, entry) { + t.Run(string(state)+"/"+surface, func(t *testing.T) { + if strings.Count(plain(text), want) != 1 { + t.Fatalf("recorded placement missing or repeated: %s", plain(text)) + } + }) + } + t.Run(string(state)+"/saved task page", func(t *testing.T) { + delete(a.tasks, 7) + if text := plain(strings.Join(a.taskCardBody(entry, 180), "\n")); !strings.Contains(text, want) { + t.Fatal(text) + } + }) + } +} + +// R9.1 treats the index's executor as a fact even when a window still has an +// older live node from before the placement update arrived. +func TestASavedTaskComputerSurvivesAnOlderLiveNode(t *testing.T) { + a, entry := placementRowFixture(t, session.TaskDone, "") + a.file = filepath.Join(a.homeRoot, "conversation", "session.jsonl") + entry.SessionID = a.taskSheetSelfID() + entry.Computer, entry.RanElsewhere = "beta", true + a.comp.tasks = []session.TaskIndexEntry{entry} + found := false + for _, row := range a.taskSheetOwnRows() { + if row.ID == entry.ID { + found = true + entry = row + } + } + if !found { + t.Fatal("the saved task disappeared from the actual Tasks reading") + } + if text := plain(strings.Join(a.taskCardBody(entry, 180), "\n")); !strings.Contains(text, "ran on beta") { + t.Fatal(text) + } +} + +// R9.1 includes Home's merge with another window's live presence: presence +// does not carry placement and must preserve it from the saved record. +func TestHomeKeepsARemoteExecutorWhenAnotherWindowOwnsTheTask(t *testing.T) { + a, entry := placementRowFixture(t, session.TaskRunning, "beta") + r, item := watchedPlacementReading(a, entry) + if !item.away { + t.Fatalf("expected the watched task, got %+v", item) + } + lines := []tasksLine{{kind: tasksLineTask, item: &item}, {kind: tasksLineTail, item: &item}} + for _, i := range []int{0, 1} { + if text := plain(r.paint(lines, i, 180, a.pal, false)); !strings.Contains(text, "running on beta") { + t.Errorf("watched row %d: %s", i, text) + } + } + var preview []string + for _, row := range a.taskPaneRecord(item, 100) { + preview = append(preview, plain(row.text)) + } + if text := strings.Join(preview, "\n"); !strings.Contains(text, "running on beta") { + t.Errorf("watched preview: %s", text) + } + if text := a.roomStateWord(taskGuestNode(item)); !strings.Contains(text, "running on beta") { + t.Errorf("watched task room: %s", text) + } +} + +// R9.1 also applies after a watcher receives the owner's finished notice; +// phase text alone cannot retain the executor on a settled task page. +func TestAWatchingTaskPageKeepsTheOwnersExecutorAfterFinishing(t *testing.T) { + a, door := guestLab(t) + door.watching() + enterAway(t, a) + for _, state := range []session.TaskState{session.TaskRunning, session.TaskDone} { + finished := state == session.TaskDone + ownerSays(t, a, session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, State: state, Computer: "beta", RanElsewhere: finished, + Remote: &session.TaskRemoteRecord{Executor: session.TaskExecutor{Name: "beta"}, Finished: finished}}}) + want := "running on beta" + if finished { + want = "ran on beta" + } + if text := a.roomStateWord(a.roomNode()); !strings.Contains(text, want) { + t.Errorf("%s: %s", state, text) + } + if a.tasks[7].computer != "" { + t.Fatal("the owner's executor changed this conversation's local task") + } + } +} + +func watchedPlacementReading(a *app, entry session.TaskIndexEntry) (tasksReading, tasksItem) { + entry.SessionID = "watched conversation" + owner := session.SessionRow{ID: entry.SessionID} + owner.Tasks.Rows = []session.TaskIndexEntry{entry} + world := session.World{Projects: []session.Project{{Sessions: []session.SessionRow{owner}}}} + mine := tasksMine{away: []session.ElsewhereTask{{SessionID: entry.SessionID, Session: "another window", Task: session.PresenceTask{ID: entry.ID, Title: entry.Title, State: entry.Status}}}} + r := readTasks(world, mine, session.LastDays(a.now(), 1), tasksSort{}, a.now(), a.now()) + return r, r.items[0] +} + +// R9's stability rule compares the same local rows with dev 9be6706b3's +// renderers, captured in the fixture with their original bytes intact. +func TestALocalTaskRowIsUnchangedInEveryList(t *testing.T) { + raw, err := os.ReadFile("testdata/task_local_dev_9be6706b3.json") + if err != nil { + t.Fatal(err) + } + var want map[string]map[string]string + if err := json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + for state, surfaces := range want { + a, entry := placementRowFixture(t, session.TaskState(state), "") + got := placementRowReadings(a, entry) + if len(got) != len(surfaces) { + t.Fatalf("%s: dev fixture covers %d surfaces, current row has %d", state, len(surfaces), len(got)) + } + for surface, original := range surfaces { + if got[surface] != original { + t.Errorf("%s/%s changed dev's local row:\n got %q\nwant %q", state, surface, got[surface], original) + } + } + } +} diff --git a/internal/tui3/taskrecord.go b/internal/tui3/taskrecord.go index c807f9b400..35bfeb9b64 100644 --- a/internal/tui3/taskrecord.go +++ b/internal/tui3/taskrecord.go @@ -185,6 +185,7 @@ const ( taskCardHitMention taskCardHitStronger taskCardHitOutput + taskCardHitRunHere ) // back reports whether a row of the card is the way back to the list. @@ -436,6 +437,8 @@ func (a *app) taskCardKey(key string) tea.Cmd { return cmd } switch key { + case taskRunHereKey: + return a.runTaskHere(entry) case "o": return a.openTaskResultOutput(entry) case "r": @@ -515,6 +518,9 @@ func (a *app) taskCardPress(x, y int) tea.Cmd { if hits[y] == taskCardHitOutput { return a.openTaskResultOutput(a.taskSheet.detail) } + if hits[y] == taskCardHitRunHere { + return a.runTaskHere(a.taskSheet.detail) + } if hits[y] == taskCardHitStronger { return a.redoTaskStronger(a.taskSheet.detail) } @@ -585,6 +591,10 @@ func (a *app) taskCardFrame(width, height int) ([]string, []taskCardHit, int, in head := len(lines) room := height - head - taskCardFoot + runHere := a.taskCanRunHere(entry) && width-2 >= len(taskRunHereVerb) + if runHere { + room-- + } stronger := a.taskCanRedoStronger(entry) && width-2 >= len(taskStrongerWord) if stronger { room-- @@ -622,6 +632,9 @@ func (a *app) taskCardFrame(width, height int) ([]string, []taskCardHit, int, in for i := 0; i < drawn; i++ { add(" "+body[a.taskSheet.detailTop+i], taskCardHitNone) } + if runHere { + add(" "+pal.underline(taskRunHereVerb), taskCardHitRunHere) + } if stronger { add(" "+pal.underline(taskStrongerWord), taskCardHitStronger) } @@ -998,6 +1011,14 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { if line := a.taskCardWhenLine(entry); line != "" { bands = append(bands, []string{pal.ink(fit(line, width))}) } + placement := a.taskEntryComputerWords(entry) + if placement != "" { + var rows []string + for _, line := range wrap(placement, width) { + rows = append(rows, pal.dim(line)) + } + bands = append(bands, rows) + } // THE RECOVERY BAND STANDS WHERE THE OUTCOME WOULD, because it is what this // card was opened to say: work another window is running has no outcome and // never will here, and the sentence a person came for is where the work is @@ -1016,7 +1037,7 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { bands = append(bands, result) } // Identical result and landing prose needs one paragraph; both original fields remain intact. - if outcome := strings.TrimSpace(entry.Outcome); outcome != "" && outcome != displayedResult { + if outcome := strings.TrimSpace(entry.Outcome); outcome != "" && outcome != displayedResult && (placement == "" || strings.TrimSuffix(outcome, taskRunHereSuffix) != placement) { var said []string for _, wrapped := range wrap(outcome, width) { said = append(said, pal.ink(wrapped)) diff --git a/internal/tui3/taskretry.go b/internal/tui3/taskretry.go index b1299d927d..afcf9f3b00 100644 --- a/internal/tui3/taskretry.go +++ b/internal/tui3/taskretry.go @@ -90,6 +90,12 @@ func (a *app) taskRetried(msg taskRetriedMsg) { // The live graph overrides a saved landing while preserving its transcript and identity. func (a *app) currentTaskEntry(entry session.TaskIndexEntry) session.TaskIndexEntry { node := a.taskSheetNodeFor(&entry) + // An older node may predate the executor in the saved row. Only a named + // placement or an explicit local replacement can supersede that fact. + if node != nil && (node.computerKnown || node.computer != "" || node.remote != nil && node.remote.Abandoned) { + entry.Status, entry.Ending = string(node.state), node.ending + entry.Computer, entry.Unreachable, entry.RanElsewhere = node.computer, node.unreachable, node.ranElsewhere + } if node != nil && node.crew != nil { entry.Crew = node.crew } diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 4fd31d6119..073756bc48 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -32,6 +32,7 @@ package tui3 import ( "sort" + "strconv" "strings" "time" @@ -276,6 +277,21 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by // the same set of rows the page does — a run's row left in the name set // would hide its own plan row. var replaced map[tasksKey]bool + // The plan replaces the roster row, not its recorded executor. Keep only + // named placements; ordinary local readings need no extra map. + var placements map[tasksKey]session.TaskIndexEntry + for i := range mine.rows { + row := &mine.rows[i] + if row.entry.Computer != "" { + if placements == nil { + placements = make(map[tasksKey]session.TaskIndexEntry) + } + placements[tasksKeyOf(row.entry)] = row.entry + if row.planTask != "" { + placements[tasksKey{session: row.entry.SessionID, id: row.planTask}] = row.entry + } + } + } mine.rows, replaced = planStoreDraws(mine.rows, mine.plan) // order keeps the pass stable: a map alone would re-order the page on every // frame it was rebuilt, and the sections below are drawn in the order the @@ -343,6 +359,14 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by continue } item := planItem(task, mine.row.ID, kin) + if placements != nil { + key := tasksKeyOf(item.entry) + if task.HistoricalRun != 0 { + key = tasksKey{session: task.HistoricalSession, id: strconv.FormatUint(task.HistoricalRun, 10)} + } + saved := placements[key] + item.entry.Computer, item.entry.Unreachable, item.entry.RanElsewhere = saved.Computer, saved.Unreachable, saved.RanElsewhere + } item.row = tasksRowFor(world, mine, item.entry) put(tasksKeyOf(item.entry), item) } @@ -364,7 +388,11 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by // there is one: presence carries neither, and a row that dropped the // program would open a page with no badge over a program's work // ([taskGuestNode]). - entry.Parent, entry.Program = get(key).entry.Parent, get(key).entry.Program + saved := get(key).entry + entry.Parent, entry.Program = saved.Parent, saved.Program + // Presence knows which window owns the work, but the saved record is + // still the authority for its executor. + entry.Computer, entry.Unreachable, entry.RanElsewhere = saved.Computer, saved.Unreachable, saved.RanElsewhere put(key, tasksItem{ entry: entry, row: tasksRowFor(world, mine, entry), runs: true, away: true, window: task.Session, here: mine.here[strings.TrimSpace(task.SessionID)], @@ -2028,6 +2056,9 @@ func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palett state, second = planStateField(item), planSpendField(item) } label := tasksLabel(item.entry) + if words := taskComputerWords(item.entry.Computer, item.entry.Unreachable, item.entry.RanElsewhere, session.TaskState(item.entry.Status)); words != "" { + label += rowSep + words + } if item.plan != nil && item.plan.Total > 0 { label += " " + planProgress(*item.plan, width, pal) } @@ -2157,10 +2188,17 @@ func tasksEntryAt(entry session.TaskIndexEntry, now time.Time) time.Time { // GRADES it was the state word again in different clothes. func tasksMiddle(item tasksItem) string { entry := item.entry + placement := taskComputerWords(entry.Computer, entry.Unreachable, entry.RanElsewhere, session.TaskState(entry.Status)) if activity := strings.TrimSpace(entry.Activity); activity != "" { + if placement != "" { + return placement + rowSep + activity + } return activity } var parts []string + if placement != "" { + parts = append(parts, placement) + } // A PLAN ROW CARRIES WHAT THE STORE KNOWS: its steps and its money // (taskplan.go), the two figures a phone card otherwise has no room for. if item.plan != nil { diff --git a/internal/tui3/taskstatus.go b/internal/tui3/taskstatus.go index 902707c088..2f76711316 100644 --- a/internal/tui3/taskstatus.go +++ b/internal/tui3/taskstatus.go @@ -27,7 +27,7 @@ func (a *app) taskStatus(node *taskNode) session.TaskStatus { Ending: node.ending, Life: node.phase, Kind: node.kind, - Phase: node.doing, + Phase: taskDoingWords(node), Gap: node.mending, Hold: node.waiting, Waits: a.taskWaitTitles(node), diff --git a/internal/tui3/teamjump.go b/internal/tui3/teamjump.go index e792bca872..db34e5575b 100644 --- a/internal/tui3/teamjump.go +++ b/internal/tui3/teamjump.go @@ -53,9 +53,10 @@ type trafficJumpTo struct { } type trafficLanding struct { - entry int - older bool - until time.Time + entry int + older bool + elsewhere bool + until time.Time } // trafficLandedMsg is the lift expiring: one repaint. @@ -74,6 +75,12 @@ func (a *app) trafficJump(key, id string) tea.Cmd { // An overview link carries its originating team through an asynchronous open. // Numbers are scoped to that team even when the member belongs to several. func (a *app) trafficJumpFromTeam(teamID, key, id string) tea.Cmd { + if key == "" { + key = a.frontTabKey() + } + if cmd, remote := a.teamsRemoteFront(key); remote { + return cmd + } var cmd tea.Cmd if key != "" && key != a.frontTabKey() { cmd = a.trafficGo(key) @@ -119,6 +126,7 @@ func (a *app) trafficLand() tea.Cmd { if at < 0 { a.offset, a.stick = 0, true land.older = true + land.elsewhere = strings.Contains(j.id, "/") land.until = a.now().Add(3 * trafficLandFor) } else { a.revealTrafficEntry(at) @@ -370,6 +378,9 @@ func (a *app) trafficLandRows(rows []row, width int) []row { func (a *app) trafficJumpWords() string { l := a.traffic.landing if l.older && a.now().Before(l.until) { + if l.elsewhere { + return "that message is on the other computer's history" + } return trafficOlderWords } return "" diff --git a/internal/tui3/teamlink.go b/internal/tui3/teamlink.go index be343cbbfb..52e0f28f54 100644 --- a/internal/tui3/teamlink.go +++ b/internal/tui3/teamlink.go @@ -276,7 +276,13 @@ func (a *app) teamLinkPress(link taskLink) tea.Cmd { return a.openTeamFromLink(t) } m, ok := t.Member(link.member) - if !ok || m.Key == a.frontTabKey() { + if !ok { + return nil + } + if cmd, remote := a.teamsRemoteFront(m.Key); remote { + return cmd + } + if m.Key == a.frontTabKey() { return nil } for _, tab := range a.tabList() { diff --git a/internal/tui3/teamrail.go b/internal/tui3/teamrail.go index 74ba528e5d..3ae01db920 100644 --- a/internal/tui3/teamrail.go +++ b/internal/tui3/teamrail.go @@ -204,7 +204,12 @@ func (a *app) trafficMarkSeen(t team) { if a.traffic.seen == nil { a.traffic.seen = map[string]string{} } - a.traffic.seen[t.ID] = rows[len(rows)-1].ID + // Read-only history must not hide the next local unread message. + for _, e := range rows { + if e.Origin == "" && e.ID > a.traffic.seen[t.ID] { + a.traffic.seen[t.ID] = e.ID + } + } } // trafficRulingText is a ruling's words without the lead the store writes @@ -220,3 +225,34 @@ func trafficRulingText(text string) string { } return rest } + +// Provenance is read from the already-held device roster, never from disk or +// the relay during paint. History controls can still navigate to a chat. +func (a *app) trafficComputer(e teamstore.Entry) string { + if e.Origin == "" { + return "" + } + device, _, _ := strings.Cut(e.Origin, "/") + return "from " + a.teamComputerName(device) +} + +// Traffic history has independent writers. Their ids identify lines, while +// event time and a stable identity tie-break choose the display order. +func trafficLater(a, b teamstore.Entry) bool { + if a.ID == "" { + return false + } + if b.ID == "" { + return true + } + if a.Origin == "" && b.Origin == "" { + return a.ID > b.ID + } + if !a.At.Equal(b.At) { + return a.At.After(b.At) + } + if a.Origin != b.Origin { + return a.Origin > b.Origin + } + return a.ID > b.ID +} diff --git a/internal/tui3/teamrailpointer.go b/internal/tui3/teamrailpointer.go index 6dede5cbb5..33b475e8ff 100644 --- a/internal/tui3/teamrailpointer.go +++ b/internal/tui3/teamrailpointer.go @@ -24,7 +24,22 @@ func (a *app) trafficToggle(key string) { // trafficGo switches to member key: its tab when the strip has one, and // otherwise what the team kept of it, which is enough to open it again. func (a *app) trafficGo(key string) tea.Cmd { - if key == "" || key == a.frontTabKey() { + if key == "" { + return nil + } + // Ownership precedes the shortcut for a tab already in front or behind. + // A local copy can still be held on the other computer now. + member := teamMember{Key: key} + for _, t := range a.wall.teams { + if m, ok := t.Member(key); ok { + member = m + break + } + } + if cmd, remote := a.teamsRemoteGo(member); remote { + return cmd + } + if key == a.frontTabKey() { return nil } for _, tab := range a.tabList() { @@ -47,6 +62,11 @@ func (a *app) trafficGo(key string) tea.Cmd { } } } + if ok { + if cmd, remote := a.teamsRemoteGo(m); remote { + return cmd + } + } if ok && m.File != "" { return a.tabGo(chatTab{key: m.Key, file: m.File, where: m.Where, word: m.Word, full: m.Word}) } diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go index 1b35947975..8b18d0080b 100644 --- a/internal/tui3/teams.go +++ b/internal/tui3/teams.go @@ -391,6 +391,16 @@ func (a *app) teamAdopt(teams []team) { teams[i].Made = teams[i].Made.Round(0) } a.wall.teams = teams + // An already-open @ list holds its own ranked catalog, so adopting a + // changed file must refresh that reading as well as the dropdown. + if a.teamsShare && a.comp.open { + a.fillMentions() + a.comp.rank() + } + if a.teamsShare && a.home.comp.open { + a.fillHomeMentions() + a.home.comp.rank() + } if a.wall.activeID != "" && teamIndex(teams, a.wall.activeID) < 0 { a.wall.activeID = "" } diff --git a/internal/tui3/teams_refresh_test.go b/internal/tui3/teams_refresh_test.go new file mode 100644 index 0000000000..abd68cbda7 --- /dev/null +++ b/internal/tui3/teams_refresh_test.go @@ -0,0 +1,152 @@ +package tui3 + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/chatlist" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +func TestMirrorRefreshActsOnEveryPendingLocalStartAndStopExactlyOnce(t *testing.T) { + a, id, _, _ := trafficApp(t) + enableSurfaceTeamSharing(t, a) + a.start = nil + trafficReadNow(t, a) + first := a.traffic.cursor[id] + var ids []string + for i := 0; i < trafficPage+7; i++ { + kind := teamstore.KindStart + if i%2 == 1 { + kind = teamstore.KindStop + } + line, err := teamstore.AppendTrafficID(a.profileDir, id, teamstore.Entry{Kind: kind, From: teamstore.FromManager, To: "boss", Member: a.frontTabKey(), Text: fmt.Sprint(i)}) + if err != nil { + t.Fatal(err) + } + ids = append(ids, line) + } + if err := teamstore.WriteTrafficMirror(a.profileDir, id, []teamstore.Entry{{ID: "elsewhere/1", At: time.Now(), Text: "mirror changed"}}); err != nil { + t.Fatal(err) + } + trafficReadNow(t, a) + if a.traffic.cursor[id] == ids[len(ids)-1] || a.traffic.cursor[id] == first { + t.Fatal("refresh skipped or failed local page", a.traffic.cursor[id]) + } + trafficReadNow(t, a) + for _, line := range ids { + if !a.traffic.done[id+"/"+line] { + t.Fatalf("pending action lost: %s", line) + } + } + count := len(a.traffic.done) + trafficReadNow(t, a) + if len(a.traffic.done) != count || count != len(ids) { + t.Fatal("actions repeated", count, len(a.traffic.done)) + } +} + +func TestSameChatGesturesCheckCachedRemoteOwnershipBeforeReturning(t *testing.T) { + celltest.Available(t) + for _, gesture := range []string{"traffic", "mention", "team-link"} { + t.Run(gesture, func(t *testing.T) { + a := continueHome(t, &fakeTaker{}, continueRows, 140) + enableSurfaceTeamSharing(t, a) + row := continueRows[0] + m := teamMember{Key: a.frontTabKey(), File: a.file, Handle: "worker", Word: row.Title, SyncID: "cell:" + row.Cell} + a.wall.loaded = true + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{m}}} + a.leavePlace() + switch gesture { + case "traffic": + a.trafficJumpFromTeam("alpha", m.Key, "000000000001") + case "mention": + a.mentionChatPress(m.Key) + case "team-link": + a.teamLinkPress(taskLink{team: "alpha", member: m.Key}) + } + if !a.at(pageHome) { + t.Fatal("front-chat shortcut bypassed ownership") + } + ask, ok := a.homeAsking() + if !ok || ask.question.Head != continueAsk { + t.Fatal("no continuation question", ask, ok) + } + frame, _, _ := a.frame() + if !strings.Contains(plain(frame), continueAsk) { + t.Fatal("the continuation question is not visible") + } + }) + } +} + +func TestRemoteMemberCompletionIsDeduplicatedAndItsLinkContinuesHere(t *testing.T) { + celltest.Available(t) + a := continueHome(t, &fakeTaker{}, continueRows, 140) + enableSurfaceTeamSharing(t, a) + row := continueRows[0] + m := teamMember{Key: teamstore.RemoteKey(row.Cell), Handle: "worker", Word: "remote worker", SyncID: "cell:" + row.Cell} + a.wall.loaded = true + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{m}}, {ID: "beta", Name: "beta", Members: []teamMember{m}}} + a.comp.recents = []mentionChat{{key: m.Key, title: m.Word, handle: m.Handle}} + a.comp.open, a.comp.query = true, "chat:worker" + a.fillMentions() + a.comp.rank() + if len(a.comp.chatHits) != 1 || a.comp.chatHits[0].key != m.Key { + t.Fatal("remote completion absent or duplicated", a.comp.chatHits) + } + chosen := a.comp.chatHits[0] + a.input.setText("@chat:worker") + a.completeChat(chosen) + a.mentionChatPress(chosen.key) + ask, ok := a.homeAsking() + if !ok || ask.question.Head != continueAsk { + t.Fatal("chosen remote link missed continuation") + } +} + +func TestHostedTeamMemberNeverUsesTheLaptopsContinuation(t *testing.T) { + celltest.Available(t) + a := continueHome(t, &fakeTaker{}, continueRows, 140) + enableSurfaceTeamSharing(t, a) + a.host = "engine.example" + row := continueRows[0] + a.teamsDisk.door = TeamsSeam{Load: func([]float64) ([]teamstore.Team, string, bool) { return nil, "", true }} + // Even a colliding laptop row belongs to a different continuation context. + a.wall.loaded = true + m := teamMember{Key: teamstore.RemoteKey(row.Cell), Word: "engine member"} + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{m}}} + a.trafficGo(m.Key) + if _, ok := a.homeAsking(); ok { + t.Fatal("hosted member entered laptop continuation") + } + if !strings.Contains(a.tp.msg, "on the other computer") { + t.Fatal(a.tp.msg) + } +} + +func TestUnknownComputerNamesNeverExposeDeviceIDs(t *testing.T) { + a := teamsPlaceLab(t) + enableSurfaceTeamSharing(t, a) + id := "dev_1234567890abcdef" + a.machineRead.rows = []chatlist.Row{{DeviceID: id, Device: "12345678"}} + a.devRow.devices = []chatlist.Device{{ID: id, Name: "12345678"}} + if got := a.teamComputerName(id); got != "the other computer" { + t.Fatal(got) + } + a.teamsRemoteGo(teamMember{Key: teamstore.OtherComputerKey("device:" + id + ":chat"), Handle: "worker"}) + if strings.Contains(a.tp.msg, id) || !strings.Contains(a.tp.msg, "the other computer") { + t.Fatal(a.tp.msg) + } +} + +func TestMissingMirrorReferenceNamesTheOtherComputersHistory(t *testing.T) { + a, _, _, _ := trafficApp(t) + a.trafficJump("", "elsewhere/000000000001") + if got := a.trafficJumpWords(); got != "that message is on the other computer's history" { + t.Fatal(got) + } +} diff --git a/internal/tui3/teamsacts.go b/internal/tui3/teamsacts.go index 077affa2de..758188f9b6 100644 --- a/internal/tui3/teamsacts.go +++ b/internal/tui3/teamsacts.go @@ -178,7 +178,8 @@ func (a *app) teamsMemberGo(id, key string) tea.Cmd { if key == "" { return nil } - if !a.trafficHeld(key) && a.tp.previews[key].missing { + _, remote := teamstore.RemoteCell(key) + if !remote && !teamstore.OnOtherComputer(key) && a.teamsElsewhere(teamMember{Key: key}) == "" && !a.trafficHeld(key) && a.tp.previews[key].missing { a.tp.msg = "Conversation unavailable" a.touch() return nil @@ -195,6 +196,9 @@ func (a *app) teamsMemberGo(id, key string) tea.Cmd { // teamsResumeBehind opens one member's conversation behind the one in front, // off the loop, and holds it as a tab of its own. func (a *app) teamsResumeBehind(m teamMember) tea.Cmd { + if cmd, remote := a.teamsRemoteGo(m, func() tea.Cmd { return a.teamsResumeBehind(m) }); remote { + return cmd + } name := m.Word if m.Handle != "" { name = "@" + m.Handle diff --git a/internal/tui3/teamsall.go b/internal/tui3/teamsall.go index 66fcf292f1..842d6b0e1e 100644 --- a/internal/tui3/teamsall.go +++ b/internal/tui3/teamsall.go @@ -248,13 +248,21 @@ func (a *app) teamsOverviewCard(d *teamsDraw, t team, width, x, y int, compact b if preview.missing { words = "Conversation unavailable" } + elsewhere := a.teamsElsewhere(m) + if elsewhere != "" { + words = "Chat is on " + elsewhere + } parts := wrap(words, inner) for i := 0; i < 2; i++ { line := "" if i < len(parts) { line = parts[i] } - add(a.pal.ink(line)) + if elsewhere != "" { + add(a.pal.dim(line)) + } else { + add(a.pal.ink(line)) + } } } } else if !t.Closed() { diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 94500769b1..d3d8026255 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -60,6 +60,9 @@ type TeamsSeam struct { // ask from the same cursor is answered with nothing from a stat. It may // block. Traffic func(team, after string, limit int) ([]teamstore.Entry, error) + // DisplayTraffic is optional for older remote engines. Its cursor is local + // even when a mirror change replaces the entire time-ordered display tail. + DisplayTraffic func(team, after, version string, limit int) ([]teamstore.Entry, string, string, bool, error) // ── DELEGATION (DESIGN.md section 8) ── // @@ -166,10 +169,17 @@ func localTeams(dir string, watch *teamstore.Watch) TeamsSeam { if since != "" && since == stamp { return nil, stamp, true, nil } - f, err := teamstore.LoadHued(dir, reserved) + var f *teamstore.File + var err error + if config.TeamsShareAt(dir) { + f, err = teamstore.Snapshot(dir) + } else { + f, err = teamstore.LoadHued(dir, reserved) + } if err != nil { return nil, stamp, false, err } + f.Colour(reserved) return f.Teams, stamp, false, nil }, Update: func(change func(*teamstore.File) error) ([]teamstore.Team, string, error) { @@ -183,6 +193,9 @@ func localTeams(dir string, watch *teamstore.Watch) TeamsSeam { entries, _, err := watch.Traffic(dir, team, after, limit) return entries, err }, + DisplayTraffic: func(team, after, version string, limit int) ([]teamstore.Entry, string, string, bool, error) { + return watch.History(dir, team, after, version, limit) + }, Defaults: func() (teamstore.Defaults, error) { return teamstore.DefaultsAt(dir), nil }, ApplyDefault: func(key, raw string) (teamstore.Defaults, error) { if err := config.ApplyTeamDefault(dir, key, raw); err != nil { @@ -242,6 +255,8 @@ func teamsAcceptClosingLocal(dir, id string) (bool, error) { // teamsDisk is the window's side of the seam: the door, the queue of edits // not yet written, and the read asked for when nothing was held. type teamsDisk struct { + // The file refresh also runs when no team has a held manager. + refreshing bool // door is [Options.Teams]; its zero value is the local seam. door TeamsSeam // watch is the local seam's stat-before-read memory of the logs. diff --git a/internal/tui3/teamsoverview.go b/internal/tui3/teamsoverview.go index 1a135d8f93..dda479690f 100644 --- a/internal/tui3/teamsoverview.go +++ b/internal/tui3/teamsoverview.go @@ -42,6 +42,10 @@ type teamsPreview struct { } func teamsReadPreview(file string, previous teamsPreview) teamsPreview { + // An absent local path is a remote member, not a missing local transcript. + if file == "" { + return teamsPreview{} + } f, err := os.Open(file) if err != nil { return teamsPreview{missing: errors.Is(err, os.ErrNotExist), unavailable: true} @@ -106,7 +110,16 @@ func (a *app) teamsTakeInteractions(id string, entries []teamstore.Entry) bool { for _, e := range byID { list = append(list, e) } - sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + mirrored := false + for _, entry := range list { + mirrored = mirrored || entry.Origin != "" + } + sort.Slice(list, func(i, j int) bool { + if mirrored && !list[i].At.Equal(list[j].At) { + return list[i].At.Before(list[j].At) + } + return list[i].ID < list[j].ID + }) if len(list) > trafficKeep { list = list[len(list)-trafficKeep:] } @@ -494,6 +507,9 @@ func (a *app) teamsInteractionTable(d *teamsDraw, t team, width, y int) []string } messageWidth := max(inner-labelWidth-metaWidth-4, 1) preview := strings.Join(strings.Fields(e.Text), " ") + if origin := a.trafficComputer(e); origin != "" { + preview = a.pal.dim(origin+": ") + preview + } line := teamsPad(mark, 2) + teamsPad(participants, labelWidth) + " " + teamsPad(preview, messageWidth) if metaWidth > 0 { line += " " + a.pal.dim(teamsPad(strings.TrimSpace(meta), metaWidth)) diff --git a/internal/tui3/teamspage.go b/internal/tui3/teamspage.go index d22416aa67..c782e756e4 100644 --- a/internal/tui3/teamspage.go +++ b/internal/tui3/teamspage.go @@ -660,8 +660,12 @@ func (a *app) teamsRead(withWorld bool) tea.Cmd { } else if selected.ID != "" { interactionIDs = append(interactionIDs, selected.ID) } + elsewhere := map[string]bool{} + for _, m := range members { + elsewhere[m.Key] = a.teamsElsewhere(m) != "" + } previous := a.tp.previews - worldDoor, hosted, rowsDoor := a.world, a.hosted(), a.teamsDisk.rows + worldDoor, hosted, rowsDoor, sharing := a.world, a.hosted(), a.teamsDisk.rows, a.teamsShare if rowsDoor == nil { rowsDoor = session.ReadRows } @@ -683,14 +687,24 @@ func (a *app) teamsRead(withWorld bool) tea.Cmd { side(func() { got.previews = map[string]teamsPreview{} for _, m := range members { - got.previews[m.Key] = teamsReadPreview(m.File, previous[m.Key]) + if elsewhere[m.Key] { + got.previews[m.Key] = teamsPreview{} + } else { + got.previews[m.Key] = teamsReadPreview(m.File, previous[m.Key]) + } } }) } traffic := make([][]teamstore.Entry, len(interactionIDs)) if seam.Traffic != nil { for i, id := range interactionIDs { - side(func() { traffic[i], _ = seam.Traffic(id, "", trafficKeep) }) + side(func() { + if seam.DisplayTraffic != nil && sharing { + traffic[i], _, _, _, _ = seam.DisplayTraffic(id, "", "", trafficKeep) + } else { + traffic[i], _ = seam.Traffic(id, "", trafficKeep) + } + }) } } type spendGot struct { @@ -839,6 +853,20 @@ func (a *app) teamsFold(got teamsGot) tea.Cmd { changed = a.teamsTakeInteractions(got.interactionTeam, got.interactions) || changed } for id, entries := range got.interactionSets { + // This is a complete history tail. Retire old mirror rows while + // retaining local rows a concurrent live read may have just found. + kept := a.traffic.rows[id][:0:0] + for _, e := range a.traffic.rows[id] { + if e.Origin == "" { + kept = append(kept, e) + } + } + if len(kept) != len(a.traffic.rows[id]) { + changed = true + } + if a.traffic.rows != nil { + a.traffic.rows[id] = kept + } changed = a.teamsTakeInteractions(id, entries) || changed } if got.packetsErr == nil && !got.packetsSame && got.packetsStamp != "" { @@ -885,6 +913,16 @@ func (a *app) teamsFold(got teamsGot) tea.Cmd { return a.teamsRead(world) } } + for _, entries := range got.interactionSets { + for _, e := range entries { + if e.Origin != "" { + id, _, _ := strings.Cut(e.Origin, "/") + if a.teamsShare && !a.teamComputerKnown(id) { + return a.askRoster() + } + } + } + } return nil } diff --git a/internal/tui3/teamspreview.go b/internal/tui3/teamspreview.go index 66f08ed8d5..9b3f77c0f0 100644 --- a/internal/tui3/teamspreview.go +++ b/internal/tui3/teamspreview.go @@ -144,6 +144,9 @@ func (a *app) teamsConversationPreview(key string, width, budget int) []string { if budget <= 0 { return nil } + if where := a.teamsElsewhere(teamMember{Key: key}); where != "" { + return []string{a.pal.dim("Chat is on " + where)} + } messages := a.teamsManagerMessages(key) if len(messages) == 0 { word := "Updates appear here" diff --git a/internal/tui3/teamsrelay.go b/internal/tui3/teamsrelay.go new file mode 100644 index 0000000000..64c7e1c1a8 --- /dev/null +++ b/internal/tui3/teamsrelay.go @@ -0,0 +1,260 @@ +package tui3 + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// teamMemberCell uses the same directory identity as Home for both a remote +// row and a local copy whose last holder is now another computer. +func (a *app) teamMemberCell(m teamMember) string { + if id, ok := teamstore.RemoteCell(m.Key); ok { + return id + } + if id, ok := strings.CutPrefix(m.SyncID, "cell:"); ok { + return id + } + for _, t := range a.wall.teams { + if member, ok := t.Member(m.Key); ok { + if id, ok := strings.CutPrefix(member.SyncID, "cell:"); ok { + return id + } + } + } + file := m.File + if file == "" { + file = m.Key + } + return filepath.Base(session.DirOf(file)) +} + +func (a *app) teamComputerName(id string) string { + if a.hosted() { + return "the other computer" + } + for _, d := range a.devRow.devices { + if d.ID == id && teamComputerNamed(id, d.Name) { + return d.Name + } + } + for _, r := range a.machineRead.rows { + if r.DeviceID == id && teamComputerNamed(id, r.Device) { + return r.Device + } + } + return "the other computer" +} + +// Home's directory and roster may substitute shortened ids for absent names. +// Team words keep those identity fallbacks out of notices and provenance. +func teamComputerNamed(id, name string) bool { + return strings.TrimSpace(name) != "" && !strings.HasPrefix(name, "dev_") && + !strings.HasPrefix(id, name) && !strings.HasPrefix(strings.TrimPrefix(id, "dev_"), name) +} + +func (a *app) teamsElsewhere(m teamMember) string { + if a.hosted() { + _, remote := teamstore.RemoteCell(m.Key) + if remote || teamstore.OnOtherComputer(m.Key) { + return "the other computer" + } + return "" + } + if teamstore.OnOtherComputer(m.Key) { + id := strings.TrimPrefix(m.Key, "other-computer:device:") + id, _, _ = strings.Cut(id, ":") + return a.teamComputerName(id) + } + if !a.teamsShare { + if _, remote := teamstore.RemoteCell(m.Key); remote { + return "the other computer" + } + return "" + } + id := a.teamMemberCell(m) + for _, row := range a.machineRead.rows { + if row.Cell == id && heldElsewhere(row) { + return a.teamComputerName(row.DeviceID) + } + } + if _, remote := teamstore.RemoteCell(m.Key); remote { + return "the other computer" + } + return "" +} + +// teamsContinue enters Home through its router before raising Home's own +// question. Arming and selecting the row keeps that question drawn and usable. +func (a *app) teamsContinue(row chatlist.Row) tea.Cmd { + cmd := a.openHome() + a.home.pointAt(func(line homeLine) bool { return line.remote != nil && line.remote.Cell == row.Cell }) + return tea.Batch(cmd, a.offerContinue(row, chatlist.Offer{})) +} + +func (a *app) teamsRemoteGo(m teamMember, local ...func() tea.Cmd) (tea.Cmd, bool) { + _, sentinel := teamstore.RemoteCell(m.Key) + if !a.teamsShare { + if !sentinel && !teamstore.OnOtherComputer(m.Key) { + return nil, false + } + a.tp.msg = "This chat is on " + a.teamsElsewhere(m) + ". Open it there." + a.note(a.tp.msg) + a.touch() + return nil, true + } + if m.Key == "" { + return nil, false + } + if a.hosted() { + _, remote := teamstore.RemoteCell(m.Key) + if remote || teamstore.OnOtherComputer(m.Key) { + a.tp.msg = "This chat is on the other computer. Open it there." + a.note(a.tp.msg) + a.touch() + return nil, true + } + return nil, false + } + if teamstore.OnOtherComputer(m.Key) { + say := func() tea.Cmd { + name := m.Handle + if name != "" { + name = "@" + name + } else { + name = m.Word + } + a.tp.msg = fmt.Sprintf("%s's chat is on %s and is not shared. Open it there.", name, a.teamsElsewhere(m)) + a.note(a.tp.msg) + a.touch() + return nil + } + device := strings.TrimPrefix(m.Key, "other-computer:device:") + device, _, _ = strings.Cut(device, ":") + if roster, ok := a.machines.(Roster); ok && !a.teamComputerKnown(device) { + return a.besideLine(func() func(bool) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) + defer cancel() + devices, err := roster.Roster(ctx) + return func(bool) tea.Cmd { + if err == nil { + a.devRow.version++ + a.devRow.devices = devices + } + return say() + } + }), true + } + return say(), true + } + id := a.teamMemberCell(m) + _, remote := teamstore.RemoteCell(m.Key) + for _, row := range a.machineRead.rows { + if row.Cell == id { + if !remote && !heldElsewhere(row) { + return nil, false + } + if a.machineRead.down { + a.note(a.machineRead.unavailableWord()) + return nil, true + } + return a.teamsContinue(row), true + } + } + // A stale local transcript can be the first thing opened after launch, + // before Home has read ownership. Wait for that reading as remote rows do. + if !remote && (a.machines == nil || !a.machineRead.at.IsZero()) { + return nil, false + } + if a.machines != nil { + src := a.machines + return a.besideLine(func() func(bool) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) + defer cancel() + rows, version, err := chatlist.RowsForSend(ctx, src) + return func(bool) tea.Cmd { + a.fileMachines(homeMachinesMsg{rows: rows, version: version, err: err}) + if err != nil { + a.note(a.machineRead.unavailableWord()) + return nil + } + for _, row := range rows { + if row.Cell == id { + if remote || heldElsewhere(row) { + return a.teamsContinue(row) + } + break + } + } + if !remote { + if len(local) > 0 { + return local[0]() + } + return a.trafficGo(m.Key) + } + a.tp.msg = "This chat is on " + a.teamsElsewhere(m) + " and is not available here. Open it there." + a.note(a.tp.msg) + return nil + } + }), true + } + a.tp.msg = "This chat is on " + a.teamsElsewhere(m) + ". Open it there." + a.note(a.tp.msg) + a.touch() + return nil, true +} + +type teamsRefreshTickMsg struct{} + +// teamsRefreshArm keeps the cached dropdown and completion model current even +// with no managed member open. Its command stats through ReadSince off paint. +func (a *app) teamsRefreshArm() tea.Cmd { + if !a.teamsShare || a.teamsDisk.refreshing || !a.wall.loaded || a.teamsOff() { + return nil + } + a.teamsDisk.refreshing = true + return teamsRefreshNext() +} +func teamsRefreshNext() tea.Cmd { + return surfaceTick(homeEvery, func(time.Time) tea.Msg { return teamsRefreshTickMsg{} }) +} +func (a *app) teamsRefreshTick() tea.Cmd { + if !a.teamsShare || !a.wall.loaded || a.teamsOff() { + a.teamsDisk.refreshing = false + return nil + } + return tea.Batch(a.trafficReadOf(nil, false), teamsRefreshNext()) +} + +func (a *app) teamComputerKnown(id string) bool { + return a.teamComputerName(id) != "the other computer" +} + +// Same-chat gestures consult only Home's cached ownership. A key press cannot +// create a new directory request merely to rediscover the front chat. +func (a *app) teamsRemoteFront(key string) (tea.Cmd, bool) { + if !a.teamsShare || key == "" { + return nil, false + } + member := teamMember{Key: key} + for _, t := range a.wall.teams { + if m, ok := t.Member(key); ok { + member = m + break + } + } + if a.hosted() { + return a.teamsRemoteGo(member) + } + if a.teamsElsewhere(member) == "" { + return nil, false + } + return a.teamsRemoteGo(member) +} diff --git a/internal/tui3/teamsrelay_test.go b/internal/tui3/teamsrelay_test.go new file mode 100644 index 0000000000..693cb8b3d5 --- /dev/null +++ b/internal/tui3/teamsrelay_test.go @@ -0,0 +1,469 @@ +package tui3 + +import ( + tea "charm.land/bubbletea/v2" + "fmt" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/config" + teamstore "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/teams/teamstest" +) + +func TestTeamMemberUsesExistingContinueHereQuestion(t *testing.T) { + celltest.Available(t) + a := continueHome(t, &fakeTaker{}, continueRows, 140) + enableSurfaceTeamSharing(t, a) + row := continueRows[0] + member := teamMember{Key: teamstore.RemoteKey(row.Cell), Word: row.Title} + a.wall.loaded = true + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{member}}} + a.trafficGo(member.Key) + ask, ok := a.homeAsking() + if !ok || ask.question.Head != continueAsk || a.home.armed != machineArm(row.Cell) { + t.Fatalf("member did not use existing continuation: %+v %v", ask, ok) + } +} + +func TestUnsyncedMemberSaysChatIsOnOtherComputer(t *testing.T) { + a := teamsPlaceLab(t) + enableSurfaceTeamSharing(t, a) + a.devRow.devices = []chatlist.Device{{ID: "a", Name: "My desktop"}} + cmd, remote := a.teamsRemoteGo(teamMember{Key: teamstore.OtherComputerKey("device:a"), Word: "local chat"}) + if !remote || cmd != nil || !strings.Contains(a.tp.msg, "My desktop and is not shared. Open it there.") { + t.Fatalf("unsynced member: %q", a.tp.msg) + } +} + +func TestAppliedTeamsRefreshDropdownAndAtListFromFileStamp(t *testing.T) { + a := teamsPlaceLab(t) + enableSurfaceTeamSharing(t, a) + teamsFlush(t, a) + a.comp.open, a.comp.query = true, "team:alpha" + a.fillMentions() + a.comp.rank() + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, team{ID: "synced-team", Name: "alpha", Members: []teamMember{{Key: teamstore.RemoteKey("shared-cell"), Word: "storage"}}}) + return nil + }); err != nil { + t.Fatal(err) + } + spend(t, a, a.trafficReadOf(nil, false)) + if len(a.comp.teamHits) != 1 || a.comp.teamHits[0].id != "synced-team" { + t.Fatal("open @ list missed apply") + } + got, ok := a.teamByID("synced-team") + if !ok || got.Name != "alpha" || len(a.teamTree().Teams) < 1 { + t.Fatal("surface team model missed apply") + } + // Removing the final team must also invalidate a nonempty cached model. + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { f.Teams = nil; return nil }); err != nil { + t.Fatal(err) + } + spend(t, a, a.trafficReadOf(nil, false)) + if len(a.comp.teamHits) != 0 { + t.Fatal("open @ list kept deleted team") + } + if len(a.wall.teams) != 0 { + t.Fatal("deleted team remained in dropdown model") + } +} + +func TestImportedStartsAndStopsNeverActOnSurface(t *testing.T) { + a, id, _, _ := trafficApp(t) + for _, kind := range []string{teamstore.KindStart, teamstore.KindStop} { + if cmd := a.trafficAct(id, teamstore.Entry{ID: "000000000001", Origin: "other/000000000001", Kind: kind, From: teamstore.FromManager, To: "storage"}); cmd != nil { + t.Fatal("imported history caused action") + } + } +} + +func TestTeamMemberFetchesItsContinueRowWhenHomeHasNotReadItYet(t *testing.T) { + celltest.Available(t) + for _, local := range []bool{false, true} { + t.Run(fmt.Sprintf("local=%v", local), func(t *testing.T) { + a := continueHome(t, &fakeTaker{}, continueRows, 140) + enableSurfaceTeamSharing(t, a) + row := continueRows[0] + a.machineRead.rows, a.machineRead.at = nil, time.Time{} + member := teamMember{Key: teamstore.RemoteKey(row.Cell), Word: row.Title, SyncID: "cell:" + row.Cell} + if local { + member.Key, member.File = "/old/local/transcript.jsonl", "/old/local/transcript.jsonl" + } + a.wall.loaded = true + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{member}}} + spend(t, a, a.trafficGo(member.Key)) + ask, ok := a.homeAsking() + if !ok || ask.question.Head != continueAsk { + t.Fatal("uncached team member did not fetch its continuation row") + } + }) + } +} + +// Render the real frame after entering through Teams or a chat tab. Inspecting +// home.ask alone would miss a question raised while Home was still hidden. +func TestMemberGesturesShowContinuationAndBothAnswersFromTeamsAndTabs(t *testing.T) { + celltest.Available(t) + for _, gesture := range []string{"teams", "tab"} { + for _, yes := range []bool{false, true} { + for _, local := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/yes=%v/local=%v", gesture, yes, local), func(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + mine := lab.session("project", "0000000000000001", "Mine", workspace, time.Now()) + a := lab.app(mine) + a.width, a.height = 200, 70 + src := &continuationListing{row: continueRows[0]} + a.machines = src + taker := &fakeTaker{on: func(id string) { + lab.session("project", id, "Port the picker", workspace, time.Now()) + src.change(func(row *chatlist.Row) { row.Mine, row.Status = true, chatlist.Here }) + }} + a.taker = taker + drain(t, a, a.askMachines()) + m := teamMember{Key: teamstore.RemoteKey(src.row.Cell), Word: src.row.Title, Handle: "worker", SyncID: "cell:" + src.row.Cell} + if local { + row := lab.session("project", src.row.Cell, src.row.Title, workspace, time.Now()) + m.File = row + m.Key = a.convKey(row) + m.Where = workspace + } + a.wall.loaded = true + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{m}, Manager: m.Key}} + a.profileDir = t.TempDir() + enableSurfaceTeamSharing(t, a) + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { f.Teams = teamsClone(a.wall.teams); return nil }); err != nil { + t.Fatal(err) + } + if gesture == "teams" { + drain(t, a, a.showPage(pageTeams)) + a.tp.sel = "alpha" + drain(t, a, a.teamsRead(true)) + if text := teamsFrameText(a); !strings.Contains(text, "Chat is on host-a") { + t.Fatalf("remote member shown as missing: %s", text) + } + target := teamsTargetOf(t, a, teamsActMember, "alpha") + drain(t, a, a.teamsDo(target)) + } else { + a.leavePlace() + drain(t, a, a.tabGo(chatTab{key: m.Key, file: m.File, where: m.Where, word: m.Word})) + } + frame, _, _ := a.frame() + if !a.at(pageHome) || !strings.Contains(plain(frame), continueAsk) { + t.Fatalf("question not on screen:\n%s", plain(frame)) + } + if a.home.armed != machineArm(src.row.Cell) { + t.Fatal("wrong armed row") + } + selected := a.home.lines[a.home.cursor] + if selected.remote == nil || selected.remote.Cell != src.row.Cell { + t.Fatal("remote row not selected") + } + before, _ := os.ReadFile(teamstore.Path(a.profileDir)) + if yes { + confirm(t, a) + if a.at(pageHome) || len(taker.cells) != 1 { + t.Fatal("yes did not open continued chat") + } + } else { + drive(t, a, key("2"), key("enter")) + after, _ := os.ReadFile(teamstore.Path(a.profileDir)) + if string(before) != string(after) { + t.Fatal("leaving the chat there edited its team") + } + if !a.at(pageHome) || len(taker.cells) != 0 || a.file != mine { + t.Fatal("leave it there changed the chat") + } + } + }) + } + } + } +} + +func TestUnpairedRefreshClockReadsChangedFileWithoutRepairingOrWritingAnything(t *testing.T) { + a := teamsPlaceLab(t) + enableSurfaceTeamSharing(t, a) + teamsFlush(t, a) + raw := []byte(`{"version":2,"teams":[{"id":"zz","name":"first","parent":"","members":[],"manager":"","made":"2026-01-01T00:00:00Z"},{"id":"aa","name":"second","parent":"","members":[],"manager":"","made":"2026-01-02T00:00:00Z"}]}`) + // app/v3 keeps teams in an SQLite store (#2066): the old file's teams are + // written through the store, and the refresh must leave what it stores. + teamstest.Write(t, a.profileDir, raw) + before := teamstest.JSON(t, a.profileDir) + spend(t, a, a.teamsRefreshTick()) + if after := teamstest.JSON(t, a.profileDir); string(after) != string(before) { + t.Fatal("refresh repaired the unpaired file") + } + if len(a.teamsDisk.queue) != 0 { + t.Fatal("refresh queued a repair") + } + if len(a.wall.teams) != 2 || a.wall.teams[0].ID != "zz" { + t.Fatal("unpaired refresh changed file order") + } +} + +func TestMirrorTailRefreshKeepsLocalCursorAndShowsHistoryInBothSurfaces(t *testing.T) { + a, id, _, _ := trafficApp(t) + enableSurfaceTeamSharing(t, a) + a.width, a.height = 200, 70 + trafficAppend(t, a, id, teamstore.Entry{Kind: teamstore.KindNote, From: teamstore.FromManager, To: teamstore.ToRoom, Text: "local tail", At: time.Now().Add(time.Hour)}) + spend(t, a, a.trafficRead()) + localCursor := a.traffic.cursor[id] + var lines []teamstore.Entry + words := map[string]string{teamstore.KindDirective: "Send", teamstore.KindStart: "Begin", teamstore.KindQuestion: "Ask", teamstore.KindStop: "End"} + for _, kind := range []string{teamstore.KindDirective, teamstore.KindStart, teamstore.KindQuestion, teamstore.KindStop} { + lines = append(lines, teamstore.Entry{ID: "desktop/" + kind, At: time.Now(), Origin: "desktop/" + kind, Kind: kind, From: teamstore.FromManager, To: "storage", Text: words[kind]}) + } + a.devRow.devices = []chatlist.Device{{ID: "desktop", Name: "My desktop"}} + if err := teamstore.WriteTrafficMirror(a.profileDir, id, lines); err != nil { + t.Fatal(err) + } + spend(t, a, a.trafficRead()) + if a.traffic.cursor[id] != localCursor || len(a.traffic.rows[id]) != 5 { + t.Fatal("mirror changed local cursor or duplicated history") + } + for _, line := range a.traffic.rows[id] { + if line.Origin != "" && a.trafficComputer(line) != "from My desktop" { + t.Fatal("history lacks computer name") + } + } + spend(t, a, a.trafficRead()) + if len(a.traffic.rows[id]) != 5 { + t.Fatal("quiet tick duplicated mirror") + } + a.sideToggleThread(sideThreadKey(id, sideGeneral)) + pane := strings.Join(railLines(t, a), "\n") + for _, want := range []string{"Send", "Ask", "started", "stopped", "from My desktop"} { + if !strings.Contains(pane, want) { + t.Fatalf("Traffic did not display %q:\n%s", want, pane) + } + } + spend(t, a, a.tookRoster(rosterMsg{devices: []chatlist.Device{{ID: "desktop", Name: "Renamed desktop"}}})) + if pane := strings.Join(railLines(t, a), "\n"); !strings.Contains(pane, "from Renamed desktop") { + t.Fatalf("cached Traffic kept an old computer name: %s", pane) + } + if a.traffic.seen[id] != localCursor { + t.Fatal("mirror changed the local seen position") + } + spend(t, a, a.showPage(pageTeams)) + spend(t, a, a.teamsSelect(id)) + spend(t, a, a.teamsRead(true)) + overview := teamsFrameText(a) + for _, want := range []string{"Send", "Begin", "Ask", "End"} { + if !strings.Contains(overview, want) { + t.Fatalf("Teams page did not display %q:\n%s", want, overview) + } + } + a.leavePlace() + lines[0].ID = "desktop/new-directive" + lines[0].Text = "New" + if err := teamstore.WriteTrafficMirror(a.profileDir, id, lines); err != nil { + t.Fatal(err) + } + spend(t, a, a.trafficRead()) + if pane := strings.Join(railLines(t, a), "\n"); !strings.Contains(pane, "New") || strings.Contains(pane, "Send") { + t.Fatalf("same-size older mirror window left stale rows: %s", pane) + } + if err := teamstore.WriteTrafficMirror(a.profileDir, id, nil); err != nil { + t.Fatal(err) + } + spend(t, a, a.trafficRead()) + if len(a.traffic.rows[id]) != 1 || a.traffic.cursor[id] != localCursor { + t.Fatal("mirror retirement retained history or changed cursor") + } +} + +// Typing in a team chat another computer holds is a message to that chat, not a +// Traffic line, so it must take exactly the door the same chat takes outside any +// team (continue.go's ownership read and its question). Team sharing must not put +// a refusal in front of that door, lose the words or write Traffic. +func TestTypingInATeamChatHeldElsewhereTakesTheSameDoorAsAnyChat(t *testing.T) { + celltest.Available(t) + type outcome struct { + draft string + asking bool + notes []string + } + send := func(inTeam bool) outcome { + lab := newHomeLab(t) + workspace := lab.workspace("project") + row := continueRows[0] + file := lab.session("project", row.Cell, row.Title, workspace, time.Now()) + a := lab.app(file) + a.profileDir = t.TempDir() + enableSurfaceTeamSharing(t, a) + a.machines = chatlist.Static{row} + drain(t, a, a.askMachines()) + a.leavePlace() + if inTeam { + key := a.convKey(file) + m := teamMember{Key: key, File: file, Word: row.Title, Handle: "worker", SyncID: "cell:" + row.Cell} + a.wall.loaded = true + a.wall.activeID = "alpha" + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{m}, Manager: key}} + } + a.input.setText("please do this here") + spend(t, a, a.enterCheckedLine(false)) + _, asking := a.homeAsking() + got := outcome{draft: a.input.String(), asking: asking} + for _, e := range a.entries { + if e.kind == entryNote { + got.notes = append(got.notes, plain(e.text)) + } + } + if lines, _ := teamstore.ReadTraffic(a.profileDir, "alpha", "", 0); len(lines) != 0 { + t.Fatalf("typing in the chat wrote Traffic: %+v", lines) + } + return got + } + alone, team := send(false), send(true) + if team.draft != "please do this here" { + t.Fatalf("the draft was lost: %q", team.draft) + } + if !reflect.DeepEqual(alone, team) { + t.Fatalf("team membership changed what typing in a held chat does:\nalone %+v\nteam %+v", alone, team) + } +} + +func enableSurfaceTeamSharing(t *testing.T, a *app) { + t.Helper() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + a.refreshProfileUI() +} + +// FIX4 F1: Update uses the adopted profile snapshot; a direct file write is +// not a setting adoption and cannot arm the sharing clock on the surface loop. +func TestTeamSharingClockUsesOnlyAdoptedProfileSettings(t *testing.T) { + a, _ := sheetApp(t) + a.wall.loaded = true + if cmd := a.teamsRefreshArm(); cmd != nil || a.teamsDisk.refreshing { + t.Fatal("the default-off profile armed the sharing clock") + } + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if !ok { + t.Fatal("missing sharing setting") + } + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + for _, msg := range []tea.Msg{struct{}{}, key("x"), key("backspace"), teamsRefreshTickMsg{}} { + a.Update(msg) + if a.teamsDisk.refreshing { + t.Fatal("the surface loop read an unadopted sharing setting") + } + } + a.refreshProfileUI() + a.Update(struct{}{}) + if !a.teamsDisk.refreshing { + t.Fatal("profile adoption did not arm the sharing clock") + } + reopened := newTestAppWithProfile(a.profileDir, &fakeAgent{}) + reopened.wall.loaded = true + if cmd := reopened.teamsRefreshArm(); cmd == nil || !reopened.teamsDisk.refreshing { + t.Fatal("boot did not adopt the enabled sharing setting") + } + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + if cmd := a.teamsRefreshTick(); cmd == nil || !a.teamsDisk.refreshing { + t.Fatal("the sharing tick read an unadopted file change") + } + a.settle() + if cmd := a.teamsRefreshTick(); cmd != nil || a.teamsDisk.refreshing { + t.Fatal("turn completion did not adopt the disabled sharing setting") + } +} + +func TestSharingOffStopsRefreshAndKeepsLocalChatShortcuts(t *testing.T) { + celltest.Available(t) + a := continueHome(t, &fakeTaker{}, continueRows, 140) + row := continueRows[0] + member := teamMember{Key: a.frontTabKey(), File: a.file, Word: row.Title, SyncID: "cell:" + row.Cell} + a.wall.loaded = true + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{member}}} + if cmd := a.teamsRefreshArm(); cmd != nil || a.teamsDisk.refreshing { + t.Fatal("sharing off armed a refresh clock") + } + a.teamsDisk.refreshing = true + if cmd := a.teamsRefreshTick(); cmd != nil || a.teamsDisk.refreshing { + t.Fatal("sharing off retained a refresh clock") + } + for _, gesture := range []func() tea.Cmd{ + func() tea.Cmd { return a.mentionChatPress(member.Key) }, + func() tea.Cmd { return a.trafficGo(member.Key) }, + func() tea.Cmd { return a.tabGo(chatTab{key: member.Key, file: member.File}) }, + } { + a.leavePlace() + gesture() + if _, ok := a.homeAsking(); ok { + t.Fatal("sharing off replaced a local shortcut with an ownership question") + } + } + if got := a.teamsElsewhere(member); got != "" { + t.Fatal("sharing off used directory ownership", got) + } + enableSurfaceTeamSharing(t, a) + if _, remote := a.teamsRemoteFront(member.Key); !remote { + t.Fatal("enabling did not enable cached ownership") + } +} + +func TestSettingsGeneralHasLocalTeamSharingChoiceAndSearch(t *testing.T) { + a, _ := sheetApp(t) + a.openSettings() + cursorTo(t, a, config.KeyTeamsShare) + item, ok := a.sheet.current() + if !ok || item.row.Key != config.KeyTeamsShare || item.meta.tab != tabGeneral || item.meta.label != "share teams" || item.meta.widget != widgetCycle { + t.Fatal("sharing row not on General", item) + } + if item.row.Value() != "off" { + t.Fatal("sharing must start off", item.row.Value()) + } + for _, want := range []string{"teams", "members and settings", "recent Traffic as read-only history", "off until turned on", "both computers must turn it on", "pairing required"} { + if !strings.Contains(item.meta.about, want) { + t.Fatal("sharing explanation omitted", want, item.meta.about) + } + } + if shown := strings.Join(settingsDetails(a, 90), " "); !strings.Contains(shown, "both computers") { + t.Fatal("sharing explanation hidden on General", shown) + } + a.wall.loaded = true + chooseSheetValue(t, a, "on") + if item.row.Value() != "on" || !a.teamsDisk.refreshing { + t.Fatal("saving the General choice did not enable sharing immediately") + } + chooseSheetValue(t, a, "off") + if item.row.Value() != "off" || a.teamsRefreshTick() != nil || a.teamsDisk.refreshing { + t.Fatal("saving the General choice did not stop sharing immediately") + } + a.sheet.build() + if current, ok := a.sheet.current(); !ok || current.row.Key != config.KeyTeamsShare { + t.Fatal("rebuilding General moved focus away from the sharing setting") + } + if itemAt(searchItems(t, a, "share teams"), config.KeyTeamsShare) < 0 { + t.Fatal("sharing setting not searchable") + } +} + +func TestSharingOffUsesOnlyTheOriginalTrafficReadDoor(t *testing.T) { + a, id, _, _ := trafficApp(t) + a.tp.sel = id + seam := a.teamsSeam() + seam.DisplayTraffic = func(string, string, string, int) ([]teamstore.Entry, string, string, bool, error) { + t.Error("sharing off called the new history door") + return nil, "", "", false, nil + } + a.teamsDisk.door = seam + trafficReadNow(t, a) + spend(t, a, a.teamsRead(false)) +} diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index 257fbb59ee..5a4e6f5da6 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -86,6 +86,7 @@ type trafficState struct { // cursor is each team's last entry id read, by team id; a team with none // has not been looked at yet, and its first read is the tail. cursor map[string]string + mirror map[string]string // rows is each team's cached entries, oldest first, at most trafficKeep. rows map[string][]teamstore.Entry // done is every stop and start acted on, by team id and entry id. @@ -122,13 +123,18 @@ type trafficTickMsg struct{} // trafficJob is one team's read: after the cursor, or the tail on a first look. type trafficJob struct { - id string - after string - first bool + id string + after string + mirror string + first bool } // trafficGot is what one team's read found. type trafficGot struct { + next string + mirror string + news []teamstore.Entry + reset bool trafficJob entries []teamstore.Entry err error @@ -254,7 +260,7 @@ func (a *app) trafficRead() tea.Cmd { return a.trafficReadOf(a.trafficIDs(), fal // clock, whose fold sets the next one; a turn that finds a read already out // leaves the answer to it and only comes round again. func (a *app) trafficReadOf(ids []string, tick bool) tea.Cmd { - if titles := a.trafficTitles(); len(titles) > 0 { + if titles := a.trafficTitles(); (len(ids) > 0 || !a.teamsShare) && len(titles) > 0 { // A member that joined before it had a title takes its tab's name, // and with it a handle, as an ordinary edit: [app.teamEdit] gives every // member this window has a tab for its tab's name on the way, and the @@ -270,10 +276,10 @@ func (a *app) trafficReadOf(ids []string, tick bool) tea.Cmd { var jobs []trafficJob for _, id := range ids { after, seen := a.traffic.cursor[id] - jobs = append(jobs, trafficJob{id: id, after: after, first: !seen}) + jobs = append(jobs, trafficJob{id: id, after: after, mirror: a.traffic.mirror[id], first: !seen}) } a.traffic.reading = true - seam, reserved, stamp, edits := a.teamsSeam(), teamReservedHues(a.pal), a.traffic.stamp, a.traffic.edits + seam, reserved, stamp, edits, sharing := a.teamsSeam(), teamReservedHues(a.pal), a.traffic.stamp, a.traffic.edits, a.teamsShare return a.besideLine(func() func(bool) tea.Cmd { got := make([]trafficGot, len(jobs)) for i, j := range jobs { @@ -282,7 +288,21 @@ func (a *app) trafficReadOf(ids []string, tick bool) tea.Cmd { after, limit = "", trafficKeep } entries, err := seam.Traffic(j.id, after, limit) - got[i] = trafficGot{trafficJob: j, entries: entries, err: err} + got[i] = trafficGot{trafficJob: j, entries: entries, news: entries, err: err} + if seam.DisplayTraffic != nil && err == nil && sharing { + display, next, mirror, reset, displayErr := seam.DisplayTraffic(j.id, after, j.mirror, trafficKeep) + if displayErr != nil { + teamstore.SharingError(a.profileDir, displayErr) + } + if displayErr == nil && reset { + got[i].entries, got[i].reset = display, true + } + got[i].mirror = mirror + // At opening only, the display reader may establish a cursor. + if j.first { + got[i].next = next + } + } } fresh, at, same, err := seam.ReadSince(stamp, reserved) if err != nil || same { @@ -302,7 +322,7 @@ func (a *app) trafficTake(got []trafficGot, fresh []team, at string, edits int, version := a.traffic.version changed := false if edits == a.traffic.edits && a.traffic.wrote == a.traffic.edits { - if fresh != nil { + if fresh != nil || at != a.traffic.stamp { a.teamAdopt(teamsClone(fresh)) changed = true } @@ -315,7 +335,17 @@ func (a *app) trafficTake(got []trafficGot, fresh []team, at string, edits int, } } var acts []tea.Cmd + needRoster := false for _, g := range got { + if g.news == nil && !g.reset { + g.news = g.entries + } + for _, e := range g.entries { + if e.Origin != "" { + id, _, _ := strings.Cut(e.Origin, "/") + needRoster = needRoster || !a.teamComputerKnown(id) + } + } if g.err != nil { continue } @@ -324,13 +354,33 @@ func (a *app) trafficTake(got []trafficGot, fresh []team, at string, edits int, // stale, and taking it would add its entries twice. continue } + if a.traffic.mirror == nil { + a.traffic.mirror = map[string]string{} + } + a.traffic.mirror[g.id] = g.mirror + if g.reset { + changed = changed || len(a.traffic.rows[g.id]) > 0 + a.traffic.rows[g.id] = nil + } if len(g.entries) == 0 { if g.first { a.traffic.cursor[g.id] = trafficFromStart } continue } - a.traffic.cursor[g.id] = g.entries[len(g.entries)-1].ID + next := g.next + if next == "" { + next = g.after + for _, e := range g.news { + if e.Origin == "" { + next = e.ID + } + } + } + if next == "" { + next = trafficFromStart + } + a.traffic.cursor[g.id] = next a.teamsTakeInteractions(g.id, g.entries) changed = true if g.first { @@ -347,7 +397,10 @@ func (a *app) trafficTake(got []trafficGot, fresh []team, at string, edits int, } continue } - for _, e := range g.entries { + for _, e := range g.news { + if e.Origin != "" || e.ID <= g.after { + continue + } if cmd := a.trafficAct(g.id, e); cmd != nil { acts = append(acts, cmd) } @@ -373,6 +426,9 @@ func (a *app) trafficTake(got []trafficGot, fresh []team, at string, edits int, a.traffic.ticking = false } } + if needRoster && a.teamsShare { + acts = append(acts, a.askRoster()) + } return tea.Batch(acts...) } @@ -381,7 +437,7 @@ func (a *app) trafficTake(got []trafficGot, fresh []team, at string, edits int, // trafficAct does what one new entry asks of this window, once per entry id: a // stop for a conversation it holds, a start when it holds the team's manager. func (a *app) trafficAct(teamID string, e teamstore.Entry) tea.Cmd { - if e.Kind != teamstore.KindStop && e.Kind != teamstore.KindStart { + if e.Origin != "" || (e.Kind != teamstore.KindStop && e.Kind != teamstore.KindStart) { return nil } done := teamID + "/" + e.ID diff --git a/internal/tui3/testdata/task_local_dev_9be6706b3.json b/internal/tui3/testdata/task_local_dev_9be6706b3.json new file mode 100644 index 0000000000..127ef54ecc --- /dev/null +++ b/internal/tui3/testdata/task_local_dev_9be6706b3.json @@ -0,0 +1,41 @@ +{ + "done": { + "Background work": " \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · done\u001b[39m\u001b[38;5;243m · \u001b[39m\u001b[38;5;146mopen result\u001b[39m", + "Tasks": " \u001b[38;5;110m✓\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243mdone\u001b[39m ", + "accepted conversation card": "\u001b[38;5;243m╭─ ? \u001b[39m \u001b[38;5;243m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\n\u001b[38;5;243m╰─ \u001b[39m\u001b[38;5;243mapproved\u001b[39m", + "compact Tasks": " \u001b[38;5;243mdone · now\u001b[39m", + "conversation landing": "\u001b[38;5;110m✓\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · done · ctrl+o\u001b[39m", + "conversation phase": "", + "conversation rail": "", + "open conversation landing": "\u001b[38;5;110m✓\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · done\u001b[39m", + "task page": "\u001b[38;5;252mdone · landed moments ago\u001b[39m", + "task preview": "\u001b[1m\u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[22m\n\u001b[38;5;110m✓\u001b[39m \u001b[38;5;243mdone\u001b[39m\n\n\u001b[38;5;243mmoments ago\u001b[39m\n\n\u001b[1m\u001b[38;5;186menter\u001b[39m\u001b[22m\u001b[38;5;186m open\u001b[39m", + "task room": "done" + }, + "failed": { + "Background work": " \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · incomplete\u001b[39m\u001b[38;5;243m · \u001b[39m\u001b[38;5;146mopen result\u001b[39m", + "Tasks": " \u001b[38;5;173m✕\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243mincomplete\u001b[39m ", + "accepted conversation card": "\u001b[38;5;243m╭─ ? \u001b[39m \u001b[38;5;243m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\n\u001b[38;5;243m╰─ \u001b[39m\u001b[38;5;243mapproved\u001b[39m", + "compact Tasks": " \u001b[38;5;243mincomplete · a fault · now\u001b[39m", + "conversation landing": "\u001b[38;5;173m✕\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · incomplete · ctrl+o\u001b[39m", + "conversation phase": "", + "conversation rail": "\u001b[38;5;173mincomplete · a fault\u001b[39m", + "open conversation landing": "\u001b[38;5;173m✕\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · incomplete\u001b[39m\n\u001b[38;5;243m a fault\u001b[39m", + "task page": "\u001b[38;5;252mincomplete · stopped moments ago\u001b[39m", + "task preview": "\u001b[1m\u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[22m\n\u001b[38;5;173m✕\u001b[39m \u001b[38;5;243mincomplete · a fault\u001b[39m\n\n\u001b[38;5;243mmoments ago\u001b[39m\n\n\u001b[1m\u001b[38;5;186menter\u001b[39m\u001b[22m\u001b[38;5;186m open\u001b[39m", + "task room": "incomplete" + }, + "running": { + "Background work": " \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · running\u001b[39m\u001b[38;5;243m · \u001b[39m\u001b[38;5;146mopen result\u001b[39m", + "Home watched Tasks": " \u001b[38;5;146m◐\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243manother window\u001b[39m ", + "Home watched compact Tasks": " \u001b[38;5;243mworking · another window · another window\u001b[39m", + "Tasks": " \u001b[38;5;146m◐\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243mworking\u001b[39m ", + "accepted conversation card": "\u001b[38;5;243m╭─ ? \u001b[39m \u001b[38;5;243m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\n\u001b[38;5;243m╰─ \u001b[39m\u001b[38;5;243mapproved\u001b[39m", + "compact Tasks": " \u001b[38;5;243mworking\u001b[39m", + "conversation phase": "", + "conversation rail": "", + "task page": "\u001b[38;5;252mworking\u001b[39m", + "task preview": "\u001b[1m\u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[22m\n\u001b[38;5;146m◐\u001b[39m \u001b[38;5;243mworking\u001b[39m\n\n\u001b[1m\u001b[38;5;186menter\u001b[39m\u001b[22m\u001b[38;5;186m open\u001b[39m", + "task room": "working" + } +} diff --git a/internal/tui3/testdata/task_local_r10_4443a1750.json b/internal/tui3/testdata/task_local_r10_4443a1750.json new file mode 100644 index 0000000000..de2e2cadb3 --- /dev/null +++ b/internal/tui3/testdata/task_local_r10_4443a1750.json @@ -0,0 +1,10 @@ +{ + "history/false/0": " \u001b[38;5;110m✓\u001b[39m \u001b[38;5;252mcreate hello.txt containing the word KIWI\u001b[39m \u001b[38;5;243mdone · 2 steps\u001b[39m ", + "history/false/1": " \u001b[38;5;243m2 steps · done\u001b[39m", + "history/true/0": " \u001b[38;5;110m✓\u001b[39m \u001b[38;5;252mcreate hello.txt containing the word KIWI\u001b[39m \u001b[38;5;243mdone · 2 steps\u001b[39m ", + "history/true/1": " \u001b[38;5;243m2 steps · done\u001b[39m", + "rail/160": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the whole content of notes.txt with the requested words\u001b[39m \u001b[38;5;243m4m\u001b[39m", + "rail/20": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the …\u001b[39m \u001b[38;5;243m4m\u001b[39m", + "rail/40": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the whole content of not…\u001b[39m \u001b[38;5;243m4m\u001b[39m", + "rail/80": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the whole content of notes.txt with the requested words\u001b[39m \u001b[38;5;243m4m\u001b[39m" +} diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 55f8d8ee03..9c47373b64 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -428,6 +428,9 @@ type CodexFlow interface { // Options configures one surface. type Options struct { + // TaskComputers is absent where this conversation cannot place tasks on + // paired computers, so the surface can leave those controls out together. + TaskComputers session.TaskComputers // Knowledge is the native vault on this machine. Hosted windows leave it nil. Knowledge *knowledge.Vault // WaitForRelease is a local lifecycle hint. A coordinating terminal must @@ -447,6 +450,10 @@ type Options struct { SaveFailure func(error) // ClosingStarted retains pending cleanup until the restored terminal can report it. ClosingStarted func(<-chan struct{}) + // SavingDurability retains local-save receipt predicates for chats being closed. + SavingDurability func(func() bool) + // SavingCompatibility retains whether each remote engine negotiated saving receipts. + SavingCompatibility func(func() bool) // ForceQuit cancels that wait when a second interrupt asks to leave immediately. ForceQuit func() // Takeover continues a chat from another machine here. Nil is a machine @@ -457,6 +464,8 @@ type Options struct { // (pair.go). Nil is a connection that cannot, and /pair then says so in one // sentence while the first-run screen leaves its code field out. Pairing Pairing + // SyncService names the service selected for this chat's sync notices. + SyncService string // Approvals answers a new device's request to join and revokes devices // (approve.go, devices.go). Nil is a connection that cannot, and the // screens are then absent. diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index 7607037b6a..109c7ed262 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -1985,8 +1985,8 @@ func TestNewOnAFreshConversationReplacesIt(t *testing.T) { t.Fatalf("replacing a fresh conversation left %d open", a.openCount()) } got := plain(frame(a)) - if !strings.Contains(got, "new session · /tmp/next.jsonl") { - t.Fatalf("/new has to name the file:\n%s", got) + if !strings.Contains(got, "new conversation · lab") { + t.Fatalf("/new has to name the project:\n%s", got) } } diff --git a/internal/tui3/workcompletion.go b/internal/tui3/workcompletion.go index 14f75767e1..b1738fe975 100644 --- a/internal/tui3/workcompletion.go +++ b/internal/tui3/workcompletion.go @@ -86,7 +86,7 @@ func (a *app) openWorkCompletion(c *workCompletion) tea.Cmd { } } if node := a.tasks[c.task]; node != nil { - return a.openTaskRecord(&session.TaskIndexEntry{ID: id, Title: node.label, Label: node.label, Status: string(node.state), SessionID: a.taskSheetSelfID(), Crew: node.crew, Cost: node.spent(), Model: node.model}) + return a.openTaskRecord(&session.TaskIndexEntry{ID: id, Title: node.label, Label: node.label, Status: string(node.state), SessionID: a.taskSheetSelfID(), Crew: node.crew, Cost: node.spent(), Model: node.model, Computer: node.computer, Unreachable: node.unreachable, RanElsewhere: node.ranElsewhere}) } } return nil @@ -141,12 +141,15 @@ func (a *app) completionRows(cs []workCompletion, width int) []row { word = session.ProjectTask(session.TaskFacts{State: session.TaskFailed}).Word } action := "" + placement := "" door := false if c.task != 0 { door = a.tasks[c.task] != nil + placement = a.taskComputerWords(a.tasks[c.task]) for _, saved := range a.comp.tasks { if saved.ID == strconv.FormatUint(c.task, 10) && a.taskSheetOwnsEntry(&saved) { door = true + placement = a.taskEntryComputerWords(saved) break } } @@ -165,11 +168,15 @@ func (a *app) completionRows(cs []workCompletion, width int) []row { hit = hitWorkCompletion } tail := railSep + word + if placement != "" { + tail += railSep + placement + } + facts := tail if action != "" { tail += railSep + action } name := fit(title, max(1, width-2-ansi.StringWidth(tail))) - line := " " + a.pal.ink(name) + a.pal.dim(railSep+word) + line := " " + a.pal.ink(name) + a.pal.dim(facts) if action != "" { line += a.pal.dim(railSep) + a.pal.accent(action) } diff --git a/internal/tui3/wormhole_contract_test.go b/internal/tui3/wormhole_contract_test.go index 4b6a0e90a3..9837d3f40c 100644 --- a/internal/tui3/wormhole_contract_test.go +++ b/internal/tui3/wormhole_contract_test.go @@ -22,6 +22,9 @@ func TestC5OnlineContinueOpensTheArrivedCopy(t *testing.T) { a := lab.app(mine) a.width, a.height = 200, 70 drain(t, a, a.openHome()) + if _, ok := a.homeLineOf("c-run"); !ok { + t.Fatal("fixture did not expose the local stale row") + } at := filepath.Join(lab.root, "copies", "c-run") work := filepath.Join(at, "work") transcript := filepath.Join(at, ".cell", "transcript.jsonl") @@ -45,8 +48,8 @@ func TestC5OnlineContinueOpensTheArrivedCopy(t *testing.T) { } }} drain(t, a, a.askMachines()) - if _, ok := a.homeLineOf("c-run"); !ok { - t.Fatal("fixture did not expose the local stale row") + if local, remote := rowsOf(a, "c-run"); local != 0 || remote != 1 { + t.Fatalf("local stale row and sync row did not merge into one row: local=%d remote=%d", local, remote) } standOn(t, a, "c-run") drive(t, a, key("enter")) diff --git a/internal/tui3/writer_wait_test.go b/internal/tui3/writer_wait_test.go new file mode 100644 index 0000000000..f99197e480 --- /dev/null +++ b/internal/tui3/writer_wait_test.go @@ -0,0 +1,153 @@ +package tui3 + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" +) + +type stoppedWriter struct{ *fakeAgent } + +func (stoppedWriter) RenewalStatus() (time.Time, time.Duration, bool) { + return time.Time{}, time.Second, false +} +func TestAWriterWaitCannotReviveADisplacedWriter(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + pending := &startingSendAgent{ready: make(chan struct{}), observed: make(chan struct{}, 1)} + if held, err := awaitSendWriter(ctx, pending, nil); held || !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled wait: %v %v", held, err) + } + ctx, deadline := context.WithDeadline(t.Context(), time.Unix(1, 0)) + defer deadline() + if held, err := awaitSendWriter(ctx, pending, nil); held || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expired wait: %v %v", held, err) + } + if held, err := awaitSendWriter(t.Context(), stoppedWriter{}, nil); held || err != nil { + t.Fatalf("displaced writer: %v %v", held, err) + } +} +func TestAWriterWaitTimeoutKeepsTheWholeMessage(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + a.input.setText(" keep " + pasteToken(1, 2) + " more") + a.pastes = []pasteChip{{n: 1, text: "whole\npaste"}} + a.chips = []chip{{path: "/tmp/kept", file: true}} + want := a.mainComposer() + msg := ownershipReadMsg{file: a.file, draft: a.input.String(), chips: a.chips, pastes: a.pastes, writerWaitFailed: true, host: "sync.example", notHeld: true, proof: true, reading: homeMachinesMsg{err: context.DeadlineExceeded}} + // The ownership result carries the unchanged submission through the same landing + // as an expired writer-start wait; it must never spend the send callback. + msg.resume = func() tea.Cmd { t.Fatal("expired writer sent"); return nil } + drain(t, a, a.afterOwnershipRead(msg)) + if len(agent.sent) != 0 || !reflect.DeepEqual(a.mainComposer(), want) { + t.Fatal("timeout changed the message") + } + if got := plain(lastNote(t, a)); got != "sync.example has not answered; your message is still in the box, press enter to send it" { + t.Fatal(got) + } +} +func TestAContinueExplainsThatTheMessageWasKept(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, old := heldLab(t, src, taker) + a.closeHome() + taker.taken = Taken{Fence: 4, Transcript: a.file} + a.input.setText("message kept for the second enter") + drive(t, a, key("enter")) + taker.on = func(string) { + src.change(func(row *chatlist.Row) { row.Mine, row.Status, row.Fence = true, chatlist.Here, 4 }) + } + confirm(t, a) + notes := strings.Join(noteTexts(a), "\n") + if strings.Count(notes, "your message is still in the box; press enter to send it") != 1 || a.input.String() != "message kept for the second enter" || len(old.sent) != 0 { + t.Fatalf("missing draft explanation: %s", notes) + } +} + +type otherWindowListing struct{ localWriterListing } + +func (otherWindowListing) SendLease(string) (string, uint64, error) { return heldCell, 5, nil } +func (s otherWindowListing) RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) { + return s.Static, 0, false, nil +} +func TestAnotherWindowsHoldCannotFinishAWaitingSend(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, _ := heldLab(t, src, taker) + taker.taken = Taken{Fence: 4, Transcript: a.file} + taker.on = func(string) { src.change(func(r *chatlist.Row) { r.Mine, r.Status, r.Fence = true, chatlist.Here, 4 }) } + standOn(t, a, heldCell) + confirm(t, a) + agent := &fakeAgent{model: "m"} + a.agent = stoppedWriter{agent} + other := src.row + other.Fence = 5 + a.machines = otherWindowListing{localWriterListing{Static: chatlist.Static{other}}} + a.input.setText("keep this for my window") + drive(t, a, key("enter")) + if len(agent.sent) != 0 || string(a.mainComposer().box.value) != "keep this for my window" { + t.Fatal("another window's current receipt revived a stopped writer") + } + if _, ok := a.homeAsking(); !ok { + t.Fatal("unheld window has no continue card") + } + taker.taken = Taken{Fence: 5, AlreadyHere: true, Transcript: a.file} + confirm(t, a) + if a.agent == agent || len(agent.sent) != 0 { + t.Fatal("stopped writer was reused after confirmation") + } + arrived, ok := a.agent.(*switchAgent) + if !ok || len(arrived.sent) != 0 || a.input.String() != "keep this for my window" { + t.Fatal("confirmation did not reopen with the message kept") + } +} + +func TestAWriterWaitDoesNotSendAnEditedPaste(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + src := &continuationListing{row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute)} + taker := &fakeTaker{} + a, _ := heldLab(t, src, taker) + taker.taken = Taken{Fence: 4, Transcript: a.file} + taker.on = func(string) { src.change(func(r *chatlist.Row) { r.Mine, r.Status, r.Fence = true, chatlist.Here, 4 }) } + standOn(t, a, heldCell) + confirm(t, a) + writer := &startingSendAgent{fakeAgent: &fakeAgent{model: "m"}, ready: make(chan struct{}), observed: make(chan struct{}, 1)} + a.agent = writer + a.machines = localWriterListing{Static: chatlist.Static{src.row}} + a.input.setText("keep " + pasteToken(1, 2) + " these words") + a.pastes = []pasteChip{{n: 1, text: "original\npaste"}} + result := make(chan []tea.Msg, 1) + cmd := stepKeyNoDrain(t, a, "enter") + go func() { result <- collectBatchMsgs(cmd) }() + select { + case <-writer.observed: + case <-result: + t.Fatal("writer wait returned early") + } + a.pastes[0].text = "edited\npaste" + writer.held.Store(true) + close(writer.ready) + for _, msg := range <-result { + drive(t, a, msg) + } + if len(writer.sent) != 0 || a.pastes[0].text != "edited\npaste" { + t.Fatal("an edited paste was sent or replaced") + } + if got := plain(lastNote(t, a)); got != "draft changed before sending; press enter to send your current words" { + t.Fatal(got) + } +} diff --git a/internal/vaultsync/best_effort.go b/internal/vaultsync/best_effort.go new file mode 100644 index 0000000000..774be1e3f8 --- /dev/null +++ b/internal/vaultsync/best_effort.go @@ -0,0 +1,39 @@ +package vaultsync + +// BestEffort keeps optional sharing out of the error path of other carried +// state. The local switch is checked again before every operation, including +// a restore after an in-flight request. Failed work retries on the next round. +func BestEffort(carrier Carrier, enabled func() bool, report func(error)) Carrier { + return bestEffort{Carrier: carrier, enabled: enabled, report: report} +} + +type bestEffort struct { + Carrier + report func(error) + enabled func() bool +} + +func (b bestEffort) finish(err error) error { + if err != nil && b.report != nil { + b.report(err) + } + return nil +} +func (b bestEffort) capture(v VaultFile) error { + if !b.enabled() { + return nil + } + return b.finish(b.Carrier.capture(v)) +} +func (b bestEffort) captureEdits(v VaultFile) error { + if !b.enabled() { + return nil + } + return b.finish(b.Carrier.captureEdits(v)) +} +func (b bestEffort) restore(v VaultFile) error { + if !b.enabled() { + return nil + } + return b.finish(b.Carrier.restore(v)) +} diff --git a/internal/vaultsync/device_history.go b/internal/vaultsync/device_history.go new file mode 100644 index 0000000000..226630ab64 --- /dev/null +++ b/internal/vaultsync/device_history.go @@ -0,0 +1,113 @@ +package vaultsync + +import ( + "encoding/json" + "errors" + "os" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/keys" +) + +// DeviceWindows carries replaceable history windows. Only the named device +// writes its values; receiving a window never makes that device its author. +// The source bounds the encoded windows before capture, across collections. +type DeviceWindows struct { + scope, device, ledger string + set Keyset + every time.Duration + now func() time.Time +} + +// Windows keeps the rate clock on disk because flush and idle rounds construct +// fresh carriers. Team records use Keyed and never pass through this clock. +func Windows(kind, device string, set Keyset, ledger string, every time.Duration) DeviceWindows { + return DeviceWindows{scope: "home:windows:" + kind, device: device, set: set, ledger: ledger, every: every, now: time.Now} +} + +func (w DeviceWindows) captureEdits(v VaultFile) error { return w.capture(v) } +func (w DeviceWindows) capture(v VaultFile) error { + local, err := w.set.Values() + if errors.Is(err, ErrDamaged) { + return nil + } + if err != nil { + return err + } + entries, err := v.Entries(w.scope) + if err != nil { + return err + } + var last time.Time + raw, err := os.ReadFile(w.ledger) + if err != nil && !os.IsNotExist(err) { + return err + } + _ = json.Unmarshal(raw, &last) + now := w.now() + due := last.IsZero() || now.Sub(last) >= w.every + own := map[string]string{} + for _, e := range entries { + team, device, ok := strings.Cut(e.Name, "/") + if !ok || device != w.device { + continue + } + own[team] = e.Value + // Deletion cannot wait for the publication clock. No other device's + // values are touched, even when that device has been revoked. + if !w.set.Holds(team) || (due && local[team] == "") { + if err := v.Delete(w.scope + "/" + e.Name); err != nil { + return err + } + } + } + if !due { + return w.restore(v) + } + changed := false + names := make([]string, 0, len(local)) + for name := range local { + names = append(names, name) + } + sort.Strings(names) + for _, team := range names { + value := local[team] + if own[team] == value { + continue + } + name := team + "/" + w.device + if err := v.PutAt(w.scope+"/"+name, keys.Entry{Name: name, Value: value, Scope: w.scope}, now); err != nil { + return err + } + changed = true + } + // A quiet round writes neither a value nor the local rate clock. + if changed { + raw, _ := json.Marshal(now) + if err := writeAtomic(w.ledger, raw, 0600); err != nil { + return err + } + } + // A revoked writer changes the directory without changing the sealed + // object. Refresh display filtering even when no pull will be needed. + return w.restore(v) +} + +func (w DeviceWindows) restore(v VaultFile) error { + entries, err := v.Entries(w.scope) + if err != nil { + return err + } + remote := map[string]string{} + for _, e := range entries { + team, device, ok := strings.Cut(e.Name, "/") + if ok && device != w.device && w.set.Holds(team) { + remote[e.Name] = e.Value + } + } + // Apply receives the complete current set, including its absence, so an + // expired or revoked window disappears from the display mirror atomically. + return ignoreDamaged(w.set.Apply(remote)) +} diff --git a/internal/vaultsync/device_history_test.go b/internal/vaultsync/device_history_test.go new file mode 100644 index 0000000000..48f19fab2a --- /dev/null +++ b/internal/vaultsync/device_history_test.go @@ -0,0 +1,72 @@ +package vaultsync + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/teams" +) + +// This source keeps authored values separate from received display values. +// Applying a remote window cannot change what the next capture publishes. +type windowSource struct { + local, received map[string]string + live map[string]bool +} + +func (s *windowSource) Values() (map[string]string, error) { return s.local, nil } +func (s *windowSource) Holds(name string) bool { return s.live[name] } +func (s *windowSource) Apply(values map[string]string) error { s.received = values; return nil } + +func TestDeviceWindowsRateLimitOnlyOwnTrafficAndDeleteOnTheNextRound(t *testing.T) { + home := t.TempDir() + v, err := keys.Open(home) + must(t, err) + s := &windowSource{local: map[string]string{"alpha": "first"}, live: map[string]bool{"alpha": true}} + w := Windows("traffic", "a", s, filepath.Join(home, "traffic.ledger"), teams.TrafficSyncEvery) + now := time.Unix(100, 0) + w.now = func() time.Time { return now } + must(t, w.capture(v)) + s.local["alpha"] = "second" + now = now.Add(teams.TrafficSyncEvery - time.Nanosecond) + must(t, w.capture(v)) + e, err := v.Get(w.scope + "/alpha/a") + must(t, err) + if e.Value != "first" { + t.Fatal("published before 30 seconds") + } + now = now.Add(time.Nanosecond) + must(t, w.capture(v)) + e, err = v.Get(w.scope + "/alpha/a") + must(t, err) + if e.Value != "second" { + t.Fatal("missed first eligible round") + } + must(t, v.PutAt(w.scope+"/alpha/b", keys.Entry{Name: "alpha/b", Scope: w.scope, Value: "remote"}, now)) + must(t, w.restore(v)) + if s.received["alpha/b"] != "remote" { + t.Fatal("lost remote window") + } + before, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + s.received = nil + must(t, w.capture(v)) + if s.received["alpha/b"] != "remote" { + t.Fatal("quiet capture skipped display refresh when the shared object did not change") + } + after, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + if string(before) != string(after) { + t.Fatal("quiet capture wrote or republished remote lines") + } + delete(s.live, "alpha") + delete(s.local, "alpha") + must(t, w.capture(v)) + if deleted, err := v.Deleted(w.scope + "/alpha/a"); err != nil || !deleted { + t.Fatal("deletion waited for rate limit") + } + if _, err := v.Get(w.scope + "/alpha/b"); err != nil { + t.Fatal("writer removed another writer's value") + } +} diff --git a/internal/vaultsync/doc.go b/internal/vaultsync/doc.go index e872bf85d2..524af09a45 100644 --- a/internal/vaultsync/doc.go +++ b/internal/vaultsync/doc.go @@ -11,19 +11,25 @@ // a tombstone, and two machines that edited different files keep both. The file // is written back at the same path with the same bytes and mode (files.go). // -// The carried-state rule. Two kinds of profile state ride in the vault beside -// the secrets, sealed with the cell key, pushed when they change, deleted by a -// tombstone and merged by the newer stamp exactly like a secret. credentials.json -// is one whole-file slot, stamped with the file's save time. The provider keys of -// the profile config (syncsetup) are one entry per key, like a secret's name: a key is -// stamped only when its value changed since the last capture (a local ledger of -// digests remembers), so an edit to anything else in config.json never ages a -// key; a machine with no place for a key has no opinion on it, and only a key -// this machine once shared can be tombstoned by it. Everything is captured -// before every push and pull and restored after every merge. A damaged copy is -// never captured, so it cannot bury a good one. The rest of config.json -// (budgets, rails, model picks) is never carried: those belong to the machine -// they were set on. +// THE CARRIED-STATE LAW. Credentials, provider keys, profile preferences, +// shared memories and portable teams ride beside secrets. Whole files use +// their save time; Keyed gives individual fields independent clocks and +// deletion tombstones. Only a key this machine once shared can be removed by +// it. Existing Keys keep their original equal-stamp, removal and apply rules. +// Only team records use GuardedKeys to preserve edits across a network round +// and check ownership before removal. A damaged copy is neither captured nor +// overwritten. Teams and their Traffic carriers run only while this computer's +// local sharing switch is on; that switch is never a synced preference. +// +// ONE TRAFFIC VALUE PER WRITING DEVICE AND TEAM. DeviceWindows replaces a +// writer's bounded window without republishing received history. The source +// owns the limits (internal/teams): 32 lines and 16 KiB per team, text shortened +// past 2 KiB, 64 KiB across a device's teams, publication at most every 30 +// seconds. Team records are never rate-limited. Removing a team's own value +// happens on the next round; no writer deletes another writer's value. A +// receiving source replaces its display mirror from locally known paired +// writers, or every other writer when no local roster is available. Optional +// team sharing uses BestEffort so its failures cannot block other carried state. // // Nothing here ever logs, returns or notifies a secret value; only names. package vaultsync diff --git a/internal/vaultsync/keyed.go b/internal/vaultsync/keyed.go index e518ba15e9..51874d8dc4 100644 --- a/internal/vaultsync/keyed.go +++ b/internal/vaultsync/keyed.go @@ -16,8 +16,8 @@ import ( // vault before a push or pull, and restored from it after a merge. type Carrier interface { capture(v VaultFile) error - // captureEdits is capture that never records a removal, for the moment - // before a pull, when a gap here may only mean the state was never sent. + // captureEdits preserves local edits before a pull. Each carrier keeps + // its own rules for removals. captureEdits(v VaultFile) error restore(v VaultFile) error } @@ -29,9 +29,9 @@ type Keyset interface { // Values answers every key held here by name. ErrDamaged means what is held // cannot be read, and is neither captured nor written over. Values() (map[string]string, error) - // Holds reports whether this machine has a place for the named key. A key - // with no place here (a service this machine does not list) is not an - // opinion of this machine: it is never captured, removed or tombstoned. + // Holds reports whether this machine has a place for the named key, so a + // restore cannot introduce keys it cannot use. GuardedKeys also checks it + // before a removal; ordinary Keys retains its ledger-based removal rule. Holds(name string) bool // Apply sets each named key to its value, and removes it when the value is // empty. It changes nothing else the machine holds. @@ -48,9 +48,12 @@ type Keyset interface { // captured or restored per key, which is also how a removal is told from a key // that was never here: only a key the ledger knows can be tombstoned. type Keyed struct { - scope string - set Keyset - ledger string + scope string + set Keyset + ledger string + ownedRemovals bool + // Captured values guard an apply across the network round, not only the write. + baseline *map[string]string } // Keys carries set under the reserved scope named by kind, remembering digests @@ -60,6 +63,17 @@ func Keys(kind string, set Keyset, ledgerPath string) Keyed { return Keyed{scope: "home:keys:" + kind, set: set, ledger: ledgerPath} } +// GuardedKeys opts a carrier into cross-round edit protection and ownership +// checks for removals. Ordinary Keys retain their existing capture and restore rules. +// Each explicit capture starts a guarded snapshot; pulls preserve it until the +// next capture so edits made afterward remain detectable. +func GuardedKeys(kind string, set Keyset, ledgerPath string) Keyed { + k := Keys(kind, set, ledgerPath) + k.baseline = new(map[string]string) + k.ownedRemovals = true + return k +} + func (k Keyed) id(name string) string { return k.scope + "/" + name } // keyedRun is one capture or restore: the vault, what is held now, and the @@ -91,12 +105,21 @@ func (k Keyed) capture(v VaultFile) error { if err != nil { return err } + if k.baseline != nil { + *k.baseline = run.held + } return run.finish(run.captureAll()) } -// captureEdits is capture: a key is tombstoned only when the ledger says this -// machine held it, so a key it was never given is already not a removal. -func (k Keyed) captureEdits(v VaultFile) error { return k.capture(v) } +// captureEdits preserves an existing guarded snapshot. Capturing again here +// would let idle sync mark an unpublished edit as received, hiding it from the +// next capture. Ordinary keys keep their existing capture and removal rules. +func (k Keyed) captureEdits(v VaultFile) error { + if k.baseline != nil && *k.baseline != nil { + return nil + } + return k.capture(v) +} func (k Keyed) restore(v VaultFile) error { run, err := k.begin(v) @@ -146,7 +169,7 @@ func (r *keyedRun) captureAll() error { } } for name := range r.digests { - if _, still := r.held[name]; !still { + if _, still := r.held[name]; !still && (!r.ownedRemovals || r.set.Holds(name)) { remove := func() error { return r.v.Delete(r.id(name)) } if timed, ok := r.set.(interface{ KeyTime(string) time.Time }); ok { if vault, ok := r.v.(interface{ DeleteAt(string, time.Time) error }); ok { @@ -258,7 +281,30 @@ func (r *keyedRun) restoreAll() error { if guarded, ok := r.set.(interface { ApplyIf(map[string]string, map[string]string) error }); ok { - apply = func() error { return guarded.ApplyIf(changes, r.held) } + expected := r.held + if r.baseline != nil && *r.baseline != nil { + expected = *r.baseline + } + apply = func() error { + if err := guarded.ApplyIf(changes, expected); err != nil { + return err + } + // A CAS retry may restore twice in one network round. Advance + // only keys this restore could apply, so its own writes are + // not mistaken for local edits on the second merge. + if r.baseline != nil && *r.baseline != nil { + for name, value := range changes { + if r.held[name] == expected[name] { + if value == "" { + delete(expected, name) + } else { + expected[name] = value + } + } + } + } + return nil + } } if err := apply(); err != nil { return err diff --git a/internal/vaultsync/relay_guard_test.go b/internal/vaultsync/relay_guard_test.go new file mode 100644 index 0000000000..da947a4c86 --- /dev/null +++ b/internal/vaultsync/relay_guard_test.go @@ -0,0 +1,143 @@ +package vaultsync + +import ( + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/keys" +) + +type relayGuardedKeys struct{ heldKeys } + +func (g relayGuardedKeys) ApplyIf(changes, expected map[string]string) error { + for key, value := range changes { + if g.heldKeys[key] != expected[key] { + continue + } + if value == "" { + delete(g.heldKeys, key) + } else { + g.heldKeys[key] = value + } + } + return nil +} + +func TestKeyedGuardKeepsAnEditMadeWhilePullIsInFlight(t *testing.T) { + r := newRig(t) + a, b := r.machine(), r.machine() + left := relayGuardedKeys{heldKeys{"name": "original"}} + right := relayGuardedKeys{heldKeys{}} + a.Carry = []Carrier{GuardedKeys("relay-guard", left, filepath.Join(a.home, "relay.ledger"))} + b.Carry = []Carrier{GuardedKeys("relay-guard", right, filepath.Join(b.home, "relay.ledger"))} + must(t, a.Push(ctx)) + must(t, b.Pull(ctx)) + left.heldKeys["name"] = "remote edit" + must(t, a.Push(ctx)) + b.Dir = &raced{Client: b.Dir, hook: func() { right.heldKeys["name"] = "typed during request" }} + must(t, b.Pull(ctx)) + if right.heldKeys["name"] != "typed during request" { + t.Fatal("pull lost an edit made during its network request") + } + must(t, b.Push(ctx)) + must(t, a.Pull(ctx)) + if left.heldKeys["name"] != "typed during request" { + t.Fatal("preserved edit did not travel next round") + } +} + +func TestGuardedPullLeavesLateEditsForTheNextIdleCapture(t *testing.T) { + r := newRig(t) + a, b := r.machine(), r.machine() + left := relayGuardedKeys{heldKeys{"name": "original"}} + right := relayGuardedKeys{heldKeys{}} + a.Carry = []Carrier{GuardedKeys("teams", left, filepath.Join(a.home, "teams.ledger"))} + b.Carry = []Carrier{GuardedKeys("teams", right, filepath.Join(b.home, "teams.ledger"))} + must(t, a.Push(ctx)) + must(t, b.Pull(ctx)) + must(t, a.Capture()) + left.heldKeys["name"] = "renamed while idle" + must(t, a.Pull(ctx)) + before, _ := keys.Fingerprint(a.home) + must(t, a.Capture()) + after, _ := keys.Fingerprint(a.home) + if before == after { + t.Fatal("pull hid an unpublished edit from the next idle capture") + } + must(t, a.Push(ctx)) + must(t, b.Pull(ctx)) + if right.heldKeys["name"] != "renamed while idle" { + t.Fatal("the next idle capture did not publish the late edit") + } +} + +func TestKeyedGuardAcceptsASecondMergeDuringCASRetry(t *testing.T) { + r := newRig(t) + machine := r.machine() + source := relayGuardedKeys{heldKeys{"name": "original"}} + carrier := GuardedKeys("retry-guard", source, filepath.Join(machine.home, "retry.ledger")) + must(t, carrier.capture(machine.vault)) + for _, value := range []string{"first merged name", "second merged name"} { + must(t, machine.vault.Put(carrier.id("name"), keys.Entry{Name: "name", Scope: carrier.scope, Value: value})) + must(t, carrier.restore(machine.vault)) + if source.heldKeys["name"] != value { + t.Fatal("first merge's write was mistaken for a local edit") + } + } + source.heldKeys["name"] = "typed during retry" + must(t, machine.vault.Put(carrier.id("name"), keys.Entry{Name: "name", Scope: carrier.scope, Value: "third remote name"})) + must(t, carrier.restore(machine.vault)) + if source.heldKeys["name"] != "typed during retry" { + t.Fatal("retry overwrote a local edit") + } +} + +type disappearingKeys struct { + heldKeys + accepts bool +} + +func (s *disappearingKeys) Holds(string) bool { return s.accepts } + +func TestExistingCarriersTombstoneRemovedKeysEvenWithoutAPlace(t *testing.T) { + for _, kind := range []string{"provider-keys", "preferences"} { + t.Run(kind, func(t *testing.T) { + r := newRig(t) + machine := r.machine() + source := &disappearingKeys{heldKeys: heldKeys{"key": "secret"}, accepts: true} + carrier := Keys(kind, source, filepath.Join(machine.home, "keys.ledger")) + must(t, carrier.capture(machine.vault)) + delete(source.heldKeys, "key") + source.accepts = false + must(t, carrier.capture(machine.vault)) + removed, err := machine.vault.Removed(carrier.id("")) + must(t, err) + if len(removed) != 1 || removed[0] != carrier.id("key") { + t.Fatalf("removal did not tombstone: %v", removed) + } + }) + } +} + +func TestDisabledTeamCarriersDoNoVaultWorkEvenWhenAlreadyConstructed(t *testing.T) { + r := newRig(t) + machine := r.machine() + enabled := true + carrier := BestEffort(GuardedKeys("teams", heldKeys{"name": "alpha"}, filepath.Join(machine.home, "teams.ledger")), func() bool { return enabled }, func(err error) { t.Fatal(err) }) + must(t, carrier.capture(machine.vault)) + enabled = false + counted := &countedVault{VaultFile: machine.vault} + must(t, carrier.capture(counted)) + must(t, carrier.captureEdits(counted)) + must(t, carrier.restore(counted)) + if counted.calls != 0 { + t.Fatal("disabled team carrier touched vault", counted.calls) + } + windows := BestEffort(Windows("team-traffic", "writer", nil, "", 0), func() bool { return enabled }, func(err error) { t.Fatal(err) }) + must(t, windows.capture(counted)) + must(t, windows.captureEdits(counted)) + must(t, windows.restore(counted)) + if counted.calls != 0 { + t.Fatal("disabled Traffic carrier touched vault", counted.calls) + } +} diff --git a/relay/hosted/README.md b/relay/hosted/README.md index 622df60831..685446723f 100644 --- a/relay/hosted/README.md +++ b/relay/hosted/README.md @@ -157,14 +157,17 @@ canonical names, so the stricter side is the safe one. | `MAILBOX` | Durable Object `Mailbox` | one pairing, one per nameplate | | `LINK_GATE` | Durable Object `LinkGate` (SQLite) | link-request codes, per-IP counters, one object | | `LINK_REQUEST` | Durable Object `LinkRequest` | one pending request, one per code | +| `MACHINE` | Durable Object `MachineDO` (SQLite) | one machine name's `codeaf serve` socket and the surfaces dialled to it | +| `MACHINE_DIAL_LIMIT` | Rate limit (`30` / `60 s`) | per-network dial ceiling at the Worker front door before `MACHINE` wakes | +| `MACHINE_REGISTER_LIMIT` | Rate limit (`10` / `60 s`) | per-network registration ceiling at the Worker front door before `MACHINE` wakes | | `LEASE_POLICY` | var | `amended` (default: 90 s, a publish renews) or `stage1` (30 s leases) | | `CAF_LIMITS` | var, optional | JSON that overrides the numbers below | | `BASE_PATH` | var, optional | a path prefix every route is served beneath (for example, `/fabric`); empty, the default, serves from the root. A request outside the prefix is `404 not_found` | | `TRUST_PROXY` | var, optional | `1` takes the caller's network from the first `X-Forwarded-For` address (the self-hosted relay's `--trust-proxy`); for test rigs only, never production, where `CF-Connecting-IP` is the one truth | `wrangler.toml` declares all of them and the migration `v1` that creates the four classes as -SQLite-backed; migration `v2` adds the link classes and `v3-raft-members` adds the three-anchor -namespace. A deployment's own configuration must carry the `RAFT_MEMBERS` binding, the +SQLite-backed; migration `v2` adds the link classes, `v3-raft-members` adds the three-anchor +namespace and `v4-machines` adds `MachineDO`. A deployment's own configuration must carry the `RAFT_MEMBERS` binding, the `v3-raft-members` migration, the `src/raft-worker.js` entry and the build command that `wrangler.toml` declares; the first authenticated use of an existing identity then upgrades it in place, and that upgrade cannot be rolled back to the single-object writer. @@ -176,6 +179,25 @@ Running an older relay after the upgrade is not supported. Restore current code with the same complete storage to resume; the original directory and encrypted frames remain retained. +## Machine doors + +`codeaf serve` registers at `/v1/engine` and `codeaf chat --at ` dials at `/v1/dial/`, the +two doors of the self-hosted `codeaf relay` (`internal/relay`). A Worker answers only WebSocket +upgrades, so both doors take a WebSocket offering the `aforge-relay/1` subprotocol and answer anything +else with `426`, which is what tells a client to use the WebSocket. Over it run the same bytes as +over the self-hosted relay's raw upgrade: the registration proof, then the carrier's frames, one frame +to a message (`src/engine/carrier.js`, a port of `handshake.go` and `mux.go`). + +Each machine name has one `MachineDO` (`src/engine/machine.js`). It checks that the name is the one +the machine's key derives and that the machine holds that key, then holds the machine's socket and +splices each dialled socket onto it as one stream. It reads no payload. Every socket hibernates, and +the routing lives in socket tags and attachments, so a quiet machine costs nothing: the machine +proves the socket alive with WebSocket pings, which the platform answers without waking the object. +A second registration under a live name is refused; one with the **same public key** under a name whose +socket stopped answering (a pong is asked for and not given within 5 s) replaces it, which is how a +machine whose network dropped comes back before the platform has closed its old socket. A colliding +key never replaces an existing registration, silent or live. + ## Base path A deployment can share a hostname by using a path prefix, such as @@ -229,6 +251,8 @@ npx wrangler deploy --config wrangler.custom.toml | link requests: creates an hour, pending at once, per IP | 10, 3 | `429 rate_limited`, `Retry-After` | | link requests: reads a minute, misses a minute, open polls, per IP | 60, 20, 4 | `429 rate_limited` | | link requests: live in all | 2,000 | `503 full` | +| machine doors: surfaces on one machine at once (`MAX_STREAMS`) | 16 | `429` | +| machine doors: dials and registrations a minute, per network at the Worker front (`MACHINE_DIAL_LIMIT`, `MACHINE_REGISTER_LIMIT`) and per machine name in memory | 30, 10 | `429` | Stored quotas (bytes, objects, frames a day), the stats counts and the per-IP counters live in the identity object's SQLite and survive eviction. A frame the store already holds costs no quota, so a client that resends is never refused diff --git a/relay/hosted/src/engine/carrier.js b/relay/hosted/src/engine/carrier.js new file mode 100644 index 0000000000..77f000d8dd --- /dev/null +++ b/relay/hosted/src/engine/carrier.js @@ -0,0 +1,86 @@ +// The carrier's bytes, shared by the machine object and its tests: frames (mux.go), the registration verdict and the +// possession proof (handshake.go). Nothing here reads a payload. + +/** PROTOCOL is the upgrade token both ends spell; an on-the-wire identifier, not product prose. */ +export const PROTOCOL = 'aforge-relay/1'; // legacy-name +/** The headers of a registration: the name claimed and the machine's public key, base64 raw-url. */ +export const HEADER_NAME = 'aforge-name'; // legacy-name: persisted on the wire. +export const HEADER_KEY = 'aforge-key'; // legacy-name: persisted on the wire. + +const LABEL = new TextEncoder().encode('aforge relay registration v1'); // legacy-name + +// The tuning of internal/relay/relay.go, kept in lockstep under the same names. +export const MAX_PAYLOAD = 1 << 17; // relay.MaxPayload +export const MAX_STREAMS = 16; // relay.MaxStreams +export const HANDSHAKE_MS = 20000; // relay.HandshakeWithin +export const PROBE_MS = 5000; // relay.ProbeWithin +export const DIALS_PER_MINUTE = 30; // relay.DialsPerMinute (and MACHINE_DIAL_LIMIT in wrangler.toml) +export const REGISTRATIONS_PER_MINUTE = 10; // relay.RegistrationsPerMinute (and MACHINE_REGISTER_LIMIT in wrangler.toml) + +export const TOO_MANY_DIALS = 'too many connections from here — wait a minute'; +export const TOO_MANY_REGISTRATIONS = 'too many registrations from here — wait a minute'; + +/** The two doors, beneath the deployment's base path. */ +export const ENGINE_PATH = '/v1/engine'; +export const DIAL_PREFIX = '/v1/dial/'; + +/** plain is a refusal in the relay's own sentence, which the Go client passes on as it is. */ +export const plain = (status, sentence) => new Response(sentence, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } }); +const FRAME_HEADER = 13; + +export const OP = { open: 1, data: 2, close: 3, ping: 4, pong: 5 }; +const VERDICT_READY = 1; +const VERDICT_REFUSED = 2; + + +/** frame is one carrier frame: an op byte, an 8-byte stream id, a 4-byte length, the payload. */ +export function frame(op, stream, payload = new Uint8Array(0)) { + const out = new Uint8Array(FRAME_HEADER + payload.length); + const view = new DataView(out.buffer); + out[0] = op; + view.setBigUint64(1, BigInt(stream)); + view.setUint32(9, payload.length); + out.set(payload, FRAME_HEADER); + return out; +} + +/** readFrame parses one message as one frame, or answers null for a message that is not exactly one. */ +export function readFrame(bytes) { + if (bytes.length < FRAME_HEADER) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const length = view.getUint32(9); + if (length > MAX_PAYLOAD || bytes.length !== FRAME_HEADER + length) return null; + return { op: bytes[0], stream: view.getBigUint64(1).toString(), payload: bytes.subarray(FRAME_HEADER) }; +} + +/** verdict is the relay's last word on a registration: ready, or refused with a sentence. */ +export function verdict(reason = '') { + if (!reason) return new Uint8Array([VERDICT_READY, 0]); + const said = new TextEncoder().encode(reason).subarray(0, 255); + const out = new Uint8Array(2 + said.length); + out[0] = VERDICT_REFUSED; + out[1] = said.length; + out.set(said, 2); + return out; +} + +/** registrationProof is the MAC the machine must answer a challenge with (handshake.go's registrationProof). */ +export async function registrationProof(shared, nonce, name, publicKey) { + const ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveBits']); + const key = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: nonce, info: LABEL }, ikm, 256); + const mac = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const body = new Uint8Array([...new TextEncoder().encode(name), ...publicKey]); + return new Uint8Array(await crypto.subtle.sign('HMAC', mac, body)); +} + +/** challenge mints the relay's half of the proof: the 64 bytes it sends, and the proof it then expects. */ +export async function challenge(name, publicKey) { + const ephemeral = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); + const offered = await crypto.subtle.importKey('raw', publicKey, { name: 'X25519' }, false, []); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: offered }, ephemeral.privateKey, 256)); + const nonce = crypto.getRandomValues(new Uint8Array(32)); + const sent = new Uint8Array(64); + sent.set(new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey))); + sent.set(nonce, 32); + return { sent, want: await registrationProof(shared, nonce, name, publicKey) }; +} diff --git a/relay/hosted/src/engine/door.js b/relay/hosted/src/engine/door.js new file mode 100644 index 0000000000..a83fc9d079 --- /dev/null +++ b/relay/hosted/src/engine/door.js @@ -0,0 +1,57 @@ +// The front door's half of the machine doors: refuse what needs no object to refuse, then hand the upgrade to the +// one object that holds that machine name (machine.js). +import { ipOf } from '../wire.js'; +import { validName } from './names.js'; +import { + DIALS_PER_MINUTE, DIAL_PREFIX, ENGINE_PATH, HEADER_NAME, PROTOCOL, REGISTRATIONS_PER_MINUTE, + TOO_MANY_DIALS, TOO_MANY_REGISTRATIONS, plain, +} from './carrier.js'; + +/** isMachineDoor is a path this module serves. */ +export const isMachineDoor = (pathname) => pathname === ENGINE_PATH || pathname.startsWith(DIAL_PREFIX); + +/** + * fallbackBuckets bounds per-caller cost in Worker memory if a custom deployment forgot the [[ratelimits]] bindings in + * wrangler.toml. When the bindings are present, Cloudflare enforces the rate across isolates at the edge. + */ +const fallbackBuckets = new Map(); + +async function allowCaller(request, env, register) { + const ip = ipOf(request, env); + const limiter = register ? env.MACHINE_REGISTER_LIMIT : env.MACHINE_DIAL_LIMIT; + if (limiter && typeof limiter.limit === 'function') { + const { success } = await limiter.limit({ key: ip }); + return Boolean(success); + } + const perMinute = register ? REGISTRATIONS_PER_MINUTE : DIALS_PER_MINUTE; + const minute = Math.floor(Date.now() / 60000); + for (const [key, bucket] of fallbackBuckets) if (bucket.minute !== minute) fallbackBuckets.delete(key); + const key = (register ? 'register ' : 'dial ') + ip; + const bucket = fallbackBuckets.get(key) ?? { minute, count: 0 }; + bucket.count += 1; + fallbackBuckets.set(key, bucket); + return bucket.count <= perMinute; +} + +/** + * serveMachineDoor routes a registration or a dial. Only a WebSocket offering the carrier's protocol is taken: a + * Worker cannot answer codeaf's raw upgrade, and the 426 is what tells a client to use the WebSocket. Rate limits and + * name validity are enforced here before env.MACHINE is touched so one caller cannot enumerate names or instantiate + * arbitrary Durable Objects. + */ +export async function serveMachineDoor(request, env) { + const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map((p) => p.trim()); + if ((request.headers.get('upgrade') ?? '').toLowerCase() !== 'websocket' || !offered.includes(PROTOCOL)) { + return plain(426, `this door speaks ${PROTOCOL} over a WebSocket`); + } + const { pathname } = new URL(request.url); + const register = pathname === ENGINE_PATH; + if (!(await allowCaller(request, env, register))) { + return plain(429, register ? TOO_MANY_REGISTRATIONS : TOO_MANY_DIALS); + } + const name = register ? request.headers.get(HEADER_NAME) ?? '' : pathname.slice(DIAL_PREFIX.length); + if (!validName(name)) return plain(400, 'that is not a machine name'); + const answer = await env.MACHINE.get(env.MACHINE.idFromName(name)).fetch(request); + // An object's answer has immutable headers, and the Worker stamps Codeaf-Now on every answer. + return new Response(answer.body, { status: answer.status, headers: answer.headers, webSocket: answer.webSocket }); +} diff --git a/relay/hosted/src/engine/machine.js b/relay/hosted/src/engine/machine.js new file mode 100644 index 0000000000..0279ed9799 --- /dev/null +++ b/relay/hosted/src/engine/machine.js @@ -0,0 +1,27 @@ +// Durable Object wrapper around MachineRoom (room.js). Kept thin so Node's test runner can exercise MachineRoom +// without importing `cloudflare:workers`. +import { DurableObject } from 'cloudflare:workers'; +import { MachineRoom } from './room.js'; + +export class MachineDO extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + this.room = new MachineRoom(ctx, env); + } + + fetch(request) { + return this.room.fetch(request); + } + + webSocketMessage(ws, message) { + return this.room.webSocketMessage(ws, message); + } + + webSocketClose(ws, code) { + return this.room.webSocketClose(ws, code); + } + + webSocketError(ws) { + return this.room.webSocketError(ws); + } +} diff --git a/relay/hosted/src/engine/names.js b/relay/hosted/src/engine/names.js new file mode 100644 index 0000000000..5eff35bd58 --- /dev/null +++ b/relay/hosted/src/engine/names.js @@ -0,0 +1,63 @@ +// Machine names, ported from internal/relay/name.go: a name is derived from the machine's own key, so the +// relay can refuse a registration under a name that key does not derive. test/engine.test.js holds the port +// to vectors the Go side computes. + +// NAME_SALT is an on-the-wire identifier, not product prose. +const NAME_SALT = new TextEncoder().encode('aforge relay name\0'); // legacy-name + +const WORDS = [ + 'acorn', 'alder', 'amber', 'anchor', 'apple', 'arbor', 'arrow', 'ash', + 'aspen', 'atlas', 'aurora', 'autumn', 'azure', 'badger', 'bamboo', 'banjo', + 'basil', 'beacon', 'beetle', 'birch', 'bishop', 'bison', 'blossom', 'bluff', + 'bobcat', 'bonfire', 'boulder', 'bramble', 'brass', 'breeze', 'bridge', 'bronze', + 'brook', 'buffalo', 'burrow', 'cabin', 'cactus', 'camel', 'canary', 'candle', + 'canoe', 'canyon', 'cardinal', 'carrot', 'cascade', 'cedar', 'cello', 'chalk', + 'cherry', 'chestnut', 'chimney', 'cinder', 'citrus', 'clover', 'cobalt', 'comet', + 'compass', 'copper', 'coral', 'cottage', 'cougar', 'crane', 'crater', 'cricket', + 'crimson', 'crocus', 'cypress', 'daffodil', 'dahlia', 'daisy', 'dapple', 'dawn', + 'delta', 'denim', 'dingo', 'dolphin', 'domino', 'donkey', 'dragon', 'driftwood', + 'dune', 'eagle', 'ember', 'emerald', 'falcon', 'fennel', 'fern', 'ferry', + 'fiddle', 'finch', 'flint', 'flute', 'forest', 'fossil', 'fountain', 'foxglove', + 'gadget', 'gallery', 'garnet', 'gazelle', 'geode', 'geyser', 'ginger', 'glacier', + 'glider', 'granite', 'grotto', 'guitar', 'gulch', 'gypsum', 'harbor', 'harvest', + 'hazel', 'heather', 'hedge', 'heron', 'hickory', 'hollow', 'honey', 'hornet', + 'ibis', 'iceberg', 'indigo', 'iris', 'island', 'ivory', 'jackal', 'jasmine', + 'jasper', 'jetty', 'jigsaw', 'juniper', 'kayak', 'kelp', 'kestrel', 'kettle', + 'kingfisher', 'kite', 'koala', 'lagoon', 'lamp', 'lantern', 'larch', 'lattice', + 'lavender', 'ledger', 'lemon', 'lichen', 'lighthouse', 'lilac', 'linen', 'lobster', + 'lotus', 'lumber', 'lupine', 'lynx', 'magnet', 'magnolia', 'mahogany', 'mallard', + 'mango', 'maple', 'marble', 'marigold', 'marlin', 'marsh', 'meadow', 'mesa', + 'meteor', 'mica', 'midnight', 'mimosa', 'mineral', 'mint', 'mirror', 'mist', + 'mongoose', 'moonlight', 'moraine', 'mosaic', 'moss', 'mulberry', 'mustang', 'nectar', + 'nettle', 'nimbus', 'nomad', 'nutmeg', 'oasis', 'obsidian', 'ocelot', 'octopus', + 'olive', 'onyx', 'opal', 'orchard', 'orchid', 'oriole', 'osprey', 'otter', + 'paddle', 'pampas', 'pansy', 'papaya', 'parsley', 'pebble', 'pelican', 'pepper', + 'periwinkle', 'petal', 'pewter', 'phoenix', 'piano', 'pigeon', 'pilot', 'pine', + 'pistachio', 'plateau', 'plover', 'plum', 'pollen', 'pomelo', 'poppy', 'porcelain', + 'prairie', 'puffin', 'pumice', 'quail', 'quartz', 'quiver', 'rabbit', 'raccoon', + 'radish', 'rafter', 'rapids', 'raven', 'redwood', 'reef', 'rhubarb', 'ribbon', + 'river', 'robin', 'rosemary', 'rudder', 'saffron', 'sage', 'salmon', 'sandal', + 'sapphire', 'sardine', 'satchel', 'scarlet', 'seagull', 'sequoia', 'shale', 'willow', +]; + +/** nameFor is the name a machine's 32-byte X25519 public key derives. */ +export async function nameFor(publicKey) { + const salted = new Uint8Array(NAME_SALT.length + publicKey.length); + salted.set(NAME_SALT); + salted.set(publicKey, NAME_SALT.length); + const sum = new Uint8Array(await crypto.subtle.digest('SHA-256', salted)); + return `${WORDS[sum[0]]}-${WORDS[sum[1]]}-${10 + (sum[2] % 90)}`; +} + +const WORD_SET = new Set(WORDS); + +/** + * validName reports whether name is one nameFor can produce: two words from WORDS and a number 10–99. Unlike the Go + * relay's in-memory table lookup, each distinct name on Workers addresses a Durable Object, so anything outside the + * real name space is refused at the front door before idFromName can create an object for it. + */ +export function validName(name) { + if (typeof name !== 'string') return false; + const parts = name.split('-'); + return parts.length === 3 && WORD_SET.has(parts[0]) && WORD_SET.has(parts[1]) && /^[1-9][0-9]$/.test(parts[2]); +} diff --git a/relay/hosted/src/engine/room.js b/relay/hosted/src/engine/room.js new file mode 100644 index 0000000000..e3c3a38612 --- /dev/null +++ b/relay/hosted/src/engine/room.js @@ -0,0 +1,326 @@ +// One machine name's room: `codeaf serve` registers at /v1/engine and a surface dials it at /v1/dial/. Kept free +// of the `cloudflare:workers` base class so Node unit tests can exercise the real socket state machine directly, while +// MachineDO in machine.js wraps it as a Durable Object. +// +// THE RELAY READS NO PAYLOAD. What crosses here is the registration proof and the carrier's frames; a frame's +// payload is the tunnel's ciphertext, moved between two sockets and never decoded. +// +// EVERY SOCKET HIBERNATES. Each frame is one WebSocket message (the Go client writes a frame in one write), so the +// object keeps no half-read bytes across a wake-up, and the routing lives in socket tags and attachments, which +// survive hibernation. A quiet machine therefore costs nothing: its liveness is proved by WebSocket pings from the +// machine, which the platform answers without waking the object. The one timer is a registration's bounded wait for +// an older socket's pong under the same key, while the object is awake anyway. +import { nameFor } from './names.js'; +import { ipOf } from '../wire.js'; +import { + DIALS_PER_MINUTE, ENGINE_PATH, HANDSHAKE_MS, HEADER_KEY, HEADER_NAME, MAX_PAYLOAD, MAX_STREAMS, OP, PROBE_MS, + PROTOCOL, REGISTRATIONS_PER_MINUTE, TOO_MANY_DIALS, TOO_MANY_REGISTRATIONS, + challenge, frame, plain as refuse, readFrame, verdict, +} from './carrier.js'; + +const ENGINE = 'engine'; +const DIAL = 'dial'; + +function sameBytes(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i]; + return diff === 0; +} + +const b64 = (bytes) => btoa(String.fromCharCode(...bytes)); +const unb64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); +const bytesOf = (message) => (typeof message === 'string' ? new TextEncoder().encode(message) : new Uint8Array(message)); + +export class MachineRoom { + constructor(ctx, env, options = {}) { + this.ctx = ctx; + this.env = env; + this.probeMs = options.probeMs ?? PROBE_MS; + this.handshakeMs = options.handshakeMs ?? HANDSHAKE_MS; + this.clock = options.clock ?? Date.now; + /** Registrations waiting for an older socket's pong, kept in memory because the object is awake while they wait. */ + this.probes = new Set(); + /** claiming is a registration between its proof and its verdict; a second one meanwhile is refused. */ + this.claiming = false; + /** buckets are this minute's counts per caller network and door, kept as a second line behind door.js. */ + this.buckets = new Map(); + } + + async fetch(request) { + this.#sweepPending(); + const register = new URL(request.url).pathname === ENGINE_PATH; + if (!this.#allow(ipOf(request, this.env), register ? 'register' : 'dial', register ? REGISTRATIONS_PER_MINUTE : DIALS_PER_MINUTE)) { + return refuse(429, register ? TOO_MANY_REGISTRATIONS : TOO_MANY_DIALS); + } + return register ? this.#register(request) : this.#dial(); + } + + /** allow spends one of a caller's per-minute allowance at this machine's door. */ + #allow(ip, door, perMinute) { + const minute = Math.floor(this.clock() / 60000); + for (const [key, bucket] of this.buckets) if (bucket.minute !== minute) this.buckets.delete(key); + const key = door + ' ' + ip; + const bucket = this.buckets.get(key) ?? { minute, count: 0 }; + bucket.count += 1; + this.buckets.set(key, bucket); + return bucket.count <= perMinute; + } + + /** + * sweepPending evicts /v1/engine upgrades that never sent their possession proof within HANDSHAKE_MS. Running on + * arrivals instead of a resting timer keeps idle registered machines hibernating at zero duration cost. + */ + #sweepPending() { + const now = this.clock(); + for (const ws of this.ctx.getWebSockets(ENGINE)) { + const seen = ws.deserializeAttachment(); + if (seen?.role === 'pending' && typeof seen.at === 'number' && now - seen.at >= this.handshakeMs) { + ws.serializeAttachment({ ...seen, role: 'ended' }); + try { + ws.close(1008, 'registration timed out'); + } catch {} + } + } + } + + /** register accepts a machine's socket and sends the challenge; the proof arrives as its first message. */ + async #register(request) { + const name = request.headers.get(HEADER_NAME) ?? ''; + let key; + try { + key = unb64((request.headers.get(HEADER_KEY) ?? '').replace(/-/g, '+').replace(/_/g, '/')); + } catch { + return refuse(400, 'that key is not readable'); + } + if (key.length !== 32) return refuse(400, `a machine key is 32 bytes and that one is ${key.length}`); + // RULE ONE: the name must be the one this key derives, recomputed rather than trusted. + if ((await nameFor(key)) !== name) return refuse(400, 'that name does not belong to that key'); + let sent, want; + try { + ({ sent, want } = await challenge(name, key)); + } catch { + return refuse(400, 'that key is not a usable machine key'); + } + const { 0: client, 1: server } = new WebSocketPair(); + this.ctx.acceptWebSocket(server, [ENGINE]); + server.serializeAttachment({ role: 'pending', want: b64(want), key: b64(key), name, at: this.clock() }); + try { + server.send(sent); + } catch { + server.serializeAttachment({ role: 'ended', name }); + try { + server.close(1011, 'closed'); + } catch {} + return refuse(503, 'the registration closed before it began'); + } + return new Response(null, { status: 101, webSocket: client, headers: { 'sec-websocket-protocol': PROTOCOL } }); + } + + /** dial matches a surface to the machine and opens a stream on its carrier. */ + async #dial() { + const engine = this.#engine(); + if (!engine) return refuse(404, 'no machine is connected under that name'); + const active = this.ctx.getWebSockets(DIAL).filter((ws) => ws.deserializeAttachment()?.role === 'surface'); + if (active.length >= MAX_STREAMS) { + return refuse(429, `that machine already has ${MAX_STREAMS} connections open, which is as many as the relay carries at once`); + } + const id = String((this.ctx.storage.kv.get('next') ?? 0) + 1); + this.ctx.storage.kv.put('next', Number(id)); + // Tell the machine before accepting the surface socket into ctx: if engine has already begun closing, throwing + // here must not leave an orphaned DIAL socket occupying one of MAX_STREAMS forever. + try { + engine.send(frame(OP.open, id)); + } catch { + this.#endEngine(engine, 1011, 'the machine left'); + return refuse(404, 'no machine is connected under that name'); + } + const { 0: client, 1: server } = new WebSocketPair(); + this.ctx.acceptWebSocket(server, [DIAL, 's:' + id]); + server.serializeAttachment({ role: 'surface', id }); + return new Response(null, { status: 101, webSocket: client, headers: { 'sec-websocket-protocol': PROTOCOL } }); + } + + /** engine is the registered machine's socket, or null. */ + #engine(except) { + return this.ctx.getWebSockets(ENGINE).find((ws) => ws !== except && ws.deserializeAttachment()?.role === 'engine') ?? null; + } + + async webSocketMessage(ws, message) { + const seen = ws.deserializeAttachment(); + if (!seen || seen.role === 'ended') return; + const bytes = bytesOf(message); + if (seen.role === 'surface') return this.#fromSurface(seen.id, bytes); + if (seen.role === 'pending') return this.#proof(ws, seen, bytes); + if (seen.role === 'engine') return this.#fromMachine(ws, bytes); + } + + /** fromSurface carries a surface's bytes to the machine, cut into carrier-sized frames. */ + #fromSurface(id, bytes) { + const engine = this.#engine(); + if (!engine) return this.#closeStream(id, 1011, 'the machine left'); + try { + for (let at = 0; at < bytes.length; at += MAX_PAYLOAD) { + engine.send(frame(OP.data, id, bytes.subarray(at, at + MAX_PAYLOAD))); + } + } catch { + this.#endEngine(engine, 1011, 'the machine left'); + } + } + + /** fromMachine routes one carrier frame from the machine. */ + #fromMachine(ws, bytes) { + const got = readFrame(bytes); + if (!got) return this.#endEngine(ws, 1002, 'that frame is not one carrier frame'); + if (got.op === OP.pong) return this.probes.forEach((probe) => probe(ws)); + if (got.op === OP.ping) { + try { + ws.send(frame(OP.pong, got.stream)); + } catch { + this.#endEngine(ws, 1011, 'closed'); + } + return; + } + const surface = this.ctx.getWebSockets('s:' + got.stream).find((s) => s.deserializeAttachment()?.role === 'surface'); + if (!surface) return; + if (got.op === OP.data) { + try { + surface.send(got.payload); + } catch { + this.#gone(surface); + try { + surface.close(1011, 'closed'); + } catch {} + } + } + if (got.op === OP.close) this.#closeStream(got.stream, 1000, 'closed'); + } + + /** + * proof checks the machine's answer and, rule three, never lets a live registration be taken over. Only the SAME + * public key may probe and replace an older socket that went silent; a colliding key is refused whether or not the + * current socket answers within PROBE_MS. + */ + async #proof(ws, seen, bytes) { + this.#sweepPending(); + if (ws.deserializeAttachment()?.role !== 'pending') return; + let want; + try { + want = unb64(seen.want); + } catch { + return this.#refuseRegistration(ws, 'that machine did not prove it holds the key its name is derived from'); + } + if (!sameBytes(bytes, want)) { + return this.#refuseRegistration(ws, 'that machine did not prove it holds the key its name is derived from'); + } + const older = this.#engine(ws); + if (this.claiming) { + return this.#refuseRegistration(ws, 'that machine is already connected to the relay under this name'); + } + if (older) { + const olderKey = older.deserializeAttachment()?.key; + if (!olderKey || !seen.key || !sameBytes(unb64(olderKey), unb64(seen.key))) { + return this.#refuseRegistration(ws, 'that machine is already connected to the relay under this name'); + } + } + this.claiming = true; + try { + if (older && (await this.#answers(older))) { + return this.#refuseRegistration(ws, 'that machine is already connected to the relay under this name'); + } + // If the new registrant disconnected while we waited on older's pong, neither evict older nor promote ws. + if (ws.deserializeAttachment()?.role !== 'pending') return; + try { + ws.send(verdict()); + } catch { + ws.serializeAttachment({ ...seen, role: 'ended' }); + try { + ws.close(1011, 'closed'); + } catch {} + return; + } + if (older) this.#endEngine(older, 1001, 'replaced by a newer registration after it stopped answering'); + ws.serializeAttachment({ role: 'engine', name: seen.name, key: seen.key }); + } finally { + this.claiming = false; + } + } + + /** + * answers pings an older socket under the same key and reports whether it pongs. A machine whose network dropped can + * leave a socket the platform has not yet closed; a registration that holds the same key replaces only a silent one. + */ + #answers(older) { + return new Promise((resolve) => { + const probe = (from) => from === older && done(true); + const done = (alive) => { + this.probes.delete(probe); + clearTimeout(timer); + resolve(alive); + }; + const timer = setTimeout(() => done(false), this.probeMs); + this.probes.add(probe); + try { + older.send(frame(OP.ping, 0)); + } catch { + done(false); + } + }); + } + + #refuseRegistration(ws, reason) { + const seen = ws.deserializeAttachment(); + if (seen) ws.serializeAttachment({ ...seen, role: 'ended' }); + try { + ws.send(verdict(reason)); + ws.close(1008, 'registration refused'); + } catch {} + } + + async webSocketClose(ws, code) { + this.#gone(ws); + try { + ws.close(code === 1005 || code === 1006 || code === 1015 ? 1000 : code, 'closed'); + } catch {} + } + + async webSocketError(ws) { + this.#gone(ws); + try { + ws.close(1011, 'closed'); + } catch {} + } + + /** gone tells the other end of whatever the socket that ended was carrying, once. */ + #gone(ws) { + const seen = ws.deserializeAttachment(); + if (!seen || seen.role === 'ended') return; + ws.serializeAttachment({ ...seen, role: 'ended' }); + if (seen.role === 'surface') { + try { + this.#engine()?.send(frame(OP.close, seen.id)); + } catch {} + } else if (seen.role === 'engine') { + for (const surface of this.ctx.getWebSockets(DIAL)) { + const id = surface.deserializeAttachment()?.id; + if (id) this.#closeStream(id, 1001, 'the machine left'); + } + } + } + + #endEngine(ws, code, reason) { + this.#gone(ws); + try { + ws.close(code, reason); + } catch {} + } + + #closeStream(id, code, reason) { + for (const surface of this.ctx.getWebSockets('s:' + id)) { + surface.serializeAttachment({ role: 'ended', id }); + try { + surface.close(code, reason); + } catch {} + } + } +} diff --git a/relay/hosted/src/front.js b/relay/hosted/src/front.js index 1dad964b44..9e7ba58ee5 100644 --- a/relay/hosted/src/front.js +++ b/relay/hosted/src/front.js @@ -8,6 +8,7 @@ import { checkDeclared,readBody } from './body.js'; import { serveLink } from './link/front.js'; import { isPagePath, servePage } from './page.js'; import { servePair } from './pair/front.js'; +import { isMachineDoor, serveMachineDoor } from './engine/door.js'; import { matchRoute } from './routes.js'; import { identityOf } from './verify.js'; import { notFound, respond } from './wire.js'; @@ -36,6 +37,7 @@ export function dispatch(outer, env) { const { pathname } = new URL(request.url); if (pathname.startsWith('/v1/link/')) return serveLink(request, env); if (pathname.startsWith('/v1/pair')) return servePair(request, env); + if (isMachineDoor(pathname)) return serveMachineDoor(request, env); if (pathname.startsWith('/v1/') || pathname.startsWith('/v2/cluster/')) return forward(request, env); throw notFound(); } diff --git a/relay/hosted/src/identity.js b/relay/hosted/src/identity.js index 7498d3f9bb..27f496f265 100644 --- a/relay/hosted/src/identity.js +++ b/relay/hosted/src/identity.js @@ -119,7 +119,7 @@ export class IdentityDO extends DurableObject { const reply=await this.host.operation(async()=>{ await this.host.ensure(fromBase64Url(request.headers.get('codeaf-identity'))); await this.host.wakeMaintenance(); - return enrolling?this.host.enroll(this.host.proof(request,body)):this.host.control(request.method,who.device,request.headers.get('codeaf-cert'),this.host.proof(request,body)); + return enrolling?this.host.enroll(this.host.proof(request,body)):this.host.control(request.method,who.device,request.headers.get('codeaf-cert'),this.host.proof(request,body),request.headers.get('Codeaf-Cluster-Names')==='1'); });this.watchers=this.host.watchers;reply.headers.set('cache-control','no-store');return reply; } if(url.pathname==='/v2/cluster/api'){ diff --git a/relay/hosted/src/raft/hosted.js b/relay/hosted/src/raft/hosted.js index eafa2c07fe..e6ab7b6a93 100644 --- a/relay/hosted/src/raft/hosted.js +++ b/relay/hosted/src/raft/hosted.js @@ -583,10 +583,15 @@ export class HostedIdentity { if(/target|transfer|busy|barrier not applied/.test(error.message))return false;throw error; } } - async control(method,actor,certificate,proof){ + async control(method,actor,certificate,proof,names=false){ await this.reconcileMembership();const out=await this.personal(method==='POST'?'lead':'status',{actor,certificate,proof}); if(out.code!=='ok')return new Response('this computer is not allowed to coordinate this cluster',{status:out.code==='bad_request'?400:403}); - if(method!=='POST')return this.jsonBytes(unb64(out.wire)); + if(method!=='POST'){ + // Older clients decode status strictly, so member labels require an explicit opt-in. + if(names)return this.jsonBytes(unb64(out.wire)); + const status=decodeCanonical(dec.decode(unb64(out.wire)));delete status.members; + return this.jsonBytes(encodeCanonical(status)); + } const command=decodeCanonical(dec.decode(unb64(out.wire))),result=await this.commit(unb64(out.wire)); const refusal=code=>new Response(encodeCanonical({code,sequence:command.sequence}),{status:409,headers:{'content-type':'application/json'}}); if(result.value.code!=='ok')return refusal(result.value.code); diff --git a/relay/hosted/src/worker.js b/relay/hosted/src/worker.js index 1b6dc3bfcc..2cfc7fc8f2 100644 --- a/relay/hosted/src/worker.js +++ b/relay/hosted/src/worker.js @@ -12,6 +12,7 @@ export { LinkGate } from './link/gate.js'; export { LinkRequest } from './link/request.js'; export { PairGate } from './pair/gate.js'; export { Mailbox } from './pair/mailbox.js'; +export { MachineDO } from './engine/machine.js'; export default { fetch: (request, env) => respond(() => dispatch(request, env)), diff --git a/relay/hosted/test/config.test.js b/relay/hosted/test/config.test.js index 2cc437d7b8..11453ad189 100644 --- a/relay/hosted/test/config.test.js +++ b/relay/hosted/test/config.test.js @@ -12,8 +12,12 @@ test('R30 the base config uses the Raft entry and an append-only v3-raft-members assert.match(text, /name = "RAFT_MEMBERS"\s+class_name = "RaftMemberDO"/); assert.match(text, /\[build\]\s+command = "sh \.\.\/\.\.\/scripts\/raft-hosted-build\.sh"/); const tags = [...text.matchAll(/^tag = "([^"]+)"/gm)].map((m) => m[1]); - assert.deepEqual(tags, ['v1', 'v2', 'v3-raft-members']); + assert.deepEqual(tags, ['v1', 'v2', 'v3-raft-members', 'v4-machines']); assert.match(text, /tag = "v3-raft-members"\s+new_sqlite_classes = \["RaftMemberDO"\]/); assert.doesNotMatch(text, /deleted_classes|renamed_classes/); assert.match(text, /name = "IDENTITY"\s+class_name = "IdentityDO"/); + assert.match(text, /name = "MACHINE"\s+class_name = "MachineDO"/); + assert.match(text, /tag = "v4-machines"\s+new_sqlite_classes = \["MachineDO"\]/); + assert.match(text, /name = "MACHINE_DIAL_LIMIT"[\s\S]*?limit = 30,\s*period = 60/); + assert.match(text, /name = "MACHINE_REGISTER_LIMIT"[\s\S]*?limit = 10,\s*period = 60/); }); diff --git a/relay/hosted/test/engine.test.js b/relay/hosted/test/engine.test.js new file mode 100644 index 0000000000..7ac5a3b7f5 --- /dev/null +++ b/relay/hosted/test/engine.test.js @@ -0,0 +1,495 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nameFor, validName } from '../src/engine/names.js'; +import { + DIALS_PER_MINUTE, ENGINE_PATH, HEADER_KEY, HEADER_NAME, MAX_PAYLOAD, MAX_STREAMS, OP, PROTOCOL, + REGISTRATIONS_PER_MINUTE, TOO_MANY_DIALS, TOO_MANY_REGISTRATIONS, + frame, readFrame, registrationProof, verdict, +} from '../src/engine/carrier.js'; +import { serveMachineDoor } from '../src/engine/door.js'; +import { MachineRoom } from '../src/engine/room.js'; + +const hex = (text) => Uint8Array.from(text.match(/../g), (b) => parseInt(b, 16)); +const toHex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); +const run = (from, n = 32) => Uint8Array.from({ length: n }, (_, i) => (from + i) & 0xff); +const b64url = (bytes) => Buffer.from(bytes).toString('base64url'); + +// PKCS#8 header for a raw 32-byte X25519 private seed (RFC 8410). +const PKCS8_X25519 = hex('302e020100300506032b656e04220420'); + +async function keyFromSeed(seed32) { + const pkcs8 = new Uint8Array(PKCS8_X25519.length + 32); + pkcs8.set(PKCS8_X25519); + pkcs8.set(seed32, PKCS8_X25519.length); + const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'X25519' }, true, ['deriveBits']); + // JWK export of an X25519 private key carries the derived raw public key in `x`. + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + const publicKey = new Uint8Array(Buffer.from(jwk.x, 'base64url')); + return { privateKey, publicKey, name: await nameFor(publicKey) }; +} + +let cachedCollision = null; + +// collidingKeys finds two distinct X25519 keypairs whose public keys derive the exact same machine name. +async function collidingKeys() { + if (cachedCollision) return cachedCollision; + const seen = new Map(); + for (let i = 1; ; i += 1) { + const seed = new Uint8Array(32); + new DataView(seed.buffer).setUint32(0, i, true); + const kp = await keyFromSeed(seed); + const other = seen.get(kp.name); + if (other && toHex(other.publicKey) !== toHex(kp.publicKey)) { + cachedCollision = [other, kp]; + return cachedCollision; + } + seen.set(kp.name, kp); + } +} + +async function answerChallenge(challengeMessage, name, kp) { + const relayPub = await crypto.subtle.importKey('raw', challengeMessage.subarray(0, 32), { name: 'X25519' }, false, []); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: relayPub }, kp.privateKey, 256)); + const nonce = challengeMessage.subarray(32, 64); + return registrationProof(shared, nonce, name, kp.publicKey); +} + +// makeRoom harnesses MachineRoom with a real in-memory hibernatable-WebSocket context in Node. +function makeRoom(options = {}) { + const realPair = globalThis.WebSocketPair; + const realResponse = globalThis.Response; + const sockets = []; + const kv = new Map(); + const clock = { now: 1_000_000 }; + + function makeSocket(peerRef) { + const ws = { + tags: [], + attachment: null, + sent: [], + closed: null, + accepted: false, + sendFail: false, + peer: peerRef, + serializeAttachment(value) { + this.attachment = structuredClone(value); + }, + deserializeAttachment() { + return this.attachment ? structuredClone(this.attachment) : null; + }, + send(data) { + if (this.sendFail || this.closed) throw new Error('WebSocket is closing'); + const copy = typeof data === 'string' ? data : new Uint8Array(data); + this.sent.push(copy); + if (this.peer && this.peer.onmessage) this.peer.onmessage(copy); + }, + close(code = 1000, reason = '') { + this.closed = [code, reason]; + }, + }; + return ws; + } + + globalThis.WebSocketPair = class { + constructor() { + const client = { sent: [], onmessage: null }; + const server = makeSocket(client); + client.server = server; + Object.assign(this, { 0: client, 1: server }); + } + }; + + globalThis.Response = class extends realResponse { + constructor(body, init = {}) { + const { status = 200, webSocket, ...rest } = init; + super(status === 101 ? null : body, { ...rest, status: status === 101 ? 200 : status }); + this.realStatus = status; + this.webSocket = webSocket; + } + get status() { + return this.realStatus; + } + }; + + const ctx = { + storage: { kv: { get: (k) => kv.get(k), put: (k, v) => kv.set(k, v) } }, + acceptWebSocket(ws, tags = []) { + ws.accepted = true; + ws.tags = [...tags]; + sockets.push(ws); + }, + getWebSockets(tag) { + // keepClosing simulates Cloudflare keeping a socket in getWebSockets while a silent peer has not echoed Close. + return sockets.filter((ws) => (!ws.closed || ws.keepClosing) && (tag === undefined || ws.tags.includes(tag))); + }, + }; + + const env = options.env ?? {}; + const room = new MachineRoom(ctx, env, { + probeMs: options.probeMs ?? 25, + handshakeMs: options.handshakeMs ?? 20_000, + clock: () => clock.now, + }); + + const restore = () => { + globalThis.WebSocketPair = realPair; + globalThis.Response = realResponse; + }; + + async function register(kp, { answerPings = true, ip = '198.51.100.1' } = {}) { + const req = new Request('https://relay.example' + ENGINE_PATH, { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': PROTOCOL, + 'cf-connecting-ip': ip, + [HEADER_NAME]: kp.name, + [HEADER_KEY]: b64url(kp.publicKey), + }, + }); + const res = await room.fetch(req); + assert.equal(res.status, 101); + const server = res.webSocket.server; + assert.equal(server.sent.length, 1); + const proof = await answerChallenge(server.sent[0], kp.name, kp); + res.webSocket.onmessage = (msg) => { + if (!answerPings) return; + const parsed = readFrame(msg); + if (parsed && parsed.op === OP.ping) { + void room.webSocketMessage(server, frame(OP.pong, parsed.stream)); + } + }; + await room.webSocketMessage(server, proof); + const last = server.sent.at(-1); + return { res, client: res.webSocket, server, verdict: last }; + } + + async function dial(name, ip = '198.51.100.2') { + const req = new Request('https://relay.example/v1/dial/' + name, { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': PROTOCOL, + 'cf-connecting-ip': ip, + }, + }); + return room.fetch(req); + } + + return { room, ctx, sockets, clock, register, dial, restore }; +} + +async function withRoom(options, fn) { + const harness = makeRoom(options); + try { + return await fn(harness); + } finally { + harness.restore(); + } +} + +// stubLimiter mimics Cloudflare's [[ratelimits]] binding (`env..limit({ key })`). +function stubLimiter(limit) { + const counts = new Map(); + return { + counts, + async limit({ key }) { + const next = (counts.get(key) ?? 0) + 1; + counts.set(key, next); + return { success: next <= limit }; + }, + }; +} + +// Computed by internal/relay's NameFor; the port must derive the same name from the same key. +test('a machine name is the one the Go side derives from the same key, and validName accepts only real names', async () => { + for (const [key, name] of [ + ['000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 'lantern-amber-86'], + ['0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20', 'jasmine-dragon-58'], + ['7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e', 'quartz-donkey-28'], + ['ff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e', 'lavender-rhubarb-13'], + ]) { + assert.equal(await nameFor(hex(key)), name); + assert.ok(validName(name)); + } + assert.ok(validName('otter-lamp-10')); + assert.ok(validName('willow-acorn-99')); + for (const bad of [ + 'otter-lamp-4', + 'Otter-lamp-42', + 'otter-lamp', + 'otter-lamp-42-x', + 'otterlampotter-x-42', + 'a-a-42', + 'notaword-lamp-42', + 'otter-notaword-42', + 'otter-lamp-00', + 'otter-lamp-09', + ]) { + assert.ok(!validName(bad), bad); + } +}); + +// Computed by internal/relay's registrationProof over the same shared secret, nonce, name and key. +test('the possession proof is the one the Go side computes', async () => { + const key = run(200); + const proof = await registrationProof(run(0), run(100), 'bluff-meadow-35', key); + assert.equal(toHex(proof), '858616a2eaed21cdcae7e1c815c02ec366f7cc550f63ccf189881aa641feff82'); +}); + +test('a frame is one message and a message that is not exactly one frame is refused', () => { + const sent = frame(2, '7', new Uint8Array([1, 2, 3])); + assert.deepEqual([...sent.subarray(0, 13)], [2, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 3]); + const got = readFrame(sent); + assert.equal(got.op, 2); + assert.equal(got.stream, '7'); + assert.deepEqual([...got.payload], [1, 2, 3]); + assert.equal(readFrame(sent.subarray(0, 15)), null); + assert.equal(readFrame(new Uint8Array([...sent, 9])), null); + const huge = new Uint8Array(13); + new DataView(huge.buffer).setUint32(9, MAX_PAYLOAD + 1); + assert.equal(readFrame(huge), null); +}); + +test('a verdict is ready, or refused with its sentence', () => { + assert.deepEqual([...verdict()], [1, 0]); + const said = verdict('no'); + assert.deepEqual([...said], [2, 2, 110, 111]); +}); + +// Contract item 4 (part 1): invalid names are refused in the Worker front before any Durable Object is touched. +test('the Worker front refuses invalid machine names before touching a Durable Object', async () => { + let touched = 0; + const env = { + MACHINE_DIAL_LIMIT: stubLimiter(DIALS_PER_MINUTE), + MACHINE_REGISTER_LIMIT: stubLimiter(REGISTRATIONS_PER_MINUTE), + MACHINE: { + idFromName() { + touched += 1; + throw new Error('MACHINE.idFromName must not be called for an invalid name'); + }, + get() { + touched += 1; + throw new Error('MACHINE.get must not be called for an invalid name'); + }, + }, + }; + + for (const bad of ['a-a-42', 'otter-lamp-00', 'otter-lamp-09', 'notaword-lamp-42', 'otter-lamp-999']) { + const dialRes = await serveMachineDoor( + new Request('https://relay.example/v1/dial/' + bad, { + headers: { upgrade: 'websocket', 'sec-websocket-protocol': PROTOCOL, 'cf-connecting-ip': '203.0.113.7' }, + }), + env, + ); + assert.equal(dialRes.status, 400); + assert.equal(await dialRes.text(), 'that is not a machine name'); + } + + const regRes = await serveMachineDoor( + new Request('https://relay.example' + ENGINE_PATH, { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': PROTOCOL, + 'cf-connecting-ip': '203.0.113.7', + [HEADER_NAME]: 'a-a-42', + }, + }), + env, + ); + assert.equal(regRes.status, 400); + assert.equal(await regRes.text(), 'that is not a machine name'); + assert.equal(touched, 0); +}); + +// Contract item 4 (part 2): rate limits follow the caller network across distinct names before touching env.MACHINE. +test('the 31st dial and 11th registration in a minute from one network across distinct names are refused with 429', async () => { + const dialLimiter = stubLimiter(DIALS_PER_MINUTE); + const regLimiter = stubLimiter(REGISTRATIONS_PER_MINUTE); + const reached = []; + const env = { + MACHINE_DIAL_LIMIT: dialLimiter, + MACHINE_REGISTER_LIMIT: regLimiter, + MACHINE: { + idFromName: (name) => name, + get: (id) => ({ + async fetch() { + reached.push(id); + return new Response('no machine is connected under that name', { status: 404 }); + }, + }), + }, + }; + + for (let i = 0; i < DIALS_PER_MINUTE; i += 1) { + const name = `acorn-alder-${10 + i}`; + const res = await serveMachineDoor( + new Request('https://relay.example/v1/dial/' + name, { + // Distinct hosts inside one IPv6 /64 share one caller network bucket, just as ipOf enforces elsewhere. + headers: { upgrade: 'websocket', 'sec-websocket-protocol': PROTOCOL, 'cf-connecting-ip': `2001:db8:1:2::${i + 1}` }, + }), + env, + ); + assert.equal(res.status, 404); + } + assert.equal(reached.length, DIALS_PER_MINUTE); + + const thirtyFirst = await serveMachineDoor( + new Request('https://relay.example/v1/dial/acorn-alder-50', { + headers: { upgrade: 'websocket', 'sec-websocket-protocol': PROTOCOL, 'cf-connecting-ip': '2001:db8:1:2::99' }, + }), + env, + ); + assert.equal(thirtyFirst.status, 429); + assert.equal(await thirtyFirst.text(), TOO_MANY_DIALS); + assert.equal(reached.length, DIALS_PER_MINUTE, 'the 31st dial must not wake a Durable Object'); + + for (let i = 0; i < REGISTRATIONS_PER_MINUTE; i += 1) { + const name = `amber-anchor-${10 + i}`; + const res = await serveMachineDoor( + new Request('https://relay.example' + ENGINE_PATH, { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': PROTOCOL, + 'cf-connecting-ip': '198.51.100.44', + [HEADER_NAME]: name, + }, + }), + env, + ); + assert.equal(res.status, 404); + } + const eleventh = await serveMachineDoor( + new Request('https://relay.example' + ENGINE_PATH, { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': PROTOCOL, + 'cf-connecting-ip': '198.51.100.44', + [HEADER_NAME]: 'amber-anchor-30', + }, + }), + env, + ); + assert.equal(eleventh.status, 429); + assert.equal(await eleventh.text(), TOO_MANY_REGISTRATIONS); + assert.equal(reached.length, DIALS_PER_MINUTE + REGISTRATIONS_PER_MINUTE, 'the 11th registration must not wake a Durable Object'); +}); + +// Contract item 3: key-checked replacement on MachineRoom (different key + silent old -> refused; +// same key + live old -> refused; same key + silent old -> replaces). +test('only the same public key may replace a silent registration', async () => { + const [owner, squatter] = await collidingKeys(); + assert.equal(owner.name, squatter.name); + assert.notEqual(toHex(owner.publicKey), toHex(squatter.publicKey)); + + // 1. Different key + silent old -> refused without evicting the owner. + await withRoom({ probeMs: 20 }, async (h) => { + const first = await h.register(owner, { answerPings: false }); + assert.deepEqual([...first.verdict], [1, 0]); + + const impostor = await h.register(squatter, { answerPings: true, ip: '198.51.100.9' }); + assert.equal(impostor.verdict[0], 2, 'a colliding key must be refused even when the owner is silent'); + assert.match(new TextDecoder().decode(impostor.verdict.subarray(2)), /already connected/); + assert.equal(first.server.closed, null, 'the silent owner must not be evicted by a different key'); + }); + + // 2. Same key + live old -> refused. + await withRoom({ probeMs: 20 }, async (h) => { + const first = await h.register(owner, { answerPings: true }); + assert.deepEqual([...first.verdict], [1, 0]); + + const second = await h.register(owner, { answerPings: true }); + assert.equal(second.verdict[0], 2, 'a live registration must not be replaced even by the same key'); + assert.equal(first.server.closed, null); + }); + + // 3. Same key + silent old -> replaces and routes new dials to the new socket. + await withRoom({ probeMs: 20 }, async (h) => { + const first = await h.register(owner, { answerPings: false }); + assert.deepEqual([...first.verdict], [1, 0]); + + const second = await h.register(owner, { answerPings: true }); + assert.deepEqual([...second.verdict], [1, 0], 'the same key must replace its own silent socket'); + assert.equal(first.server.closed?.[0], 1001); + + const surface = await h.dial(owner.name); + assert.equal(surface.status, 101); + const opened = readFrame(second.server.sent.at(-1)); + assert.equal(opened?.op, OP.open); + }); +}); + +// Contract item 7 (Finding 6 & Finding 1 socket hygiene): throwing engine.send in #dial leaks no stream slot, +// closing/errored surface sockets free MAX_STREAMS immediately, dropped pending registrants do not evict older, +// and abandoned pending upgrades are swept past HANDSHAKE_MS. +test('MachineRoom cleans up throwing sends, closing surfaces, dropped challengers and stale pending sockets', async () => { + const [owner] = await collidingKeys(); + + // 1. engine.send throwing during #dial does not leak a DIAL socket or return 500. + await withRoom({ probeMs: 20 }, async (h) => { + const first = await h.register(owner); + first.server.sendFail = true; + const failedDial = await h.dial(owner.name); + assert.equal(failedDial.status, 404); + assert.equal(h.ctx.getWebSockets('dial').length, 0, 'failed #dial must not accept an orphaned surface socket'); + }); + + // 2. 16 surface sockets that error/close while their peer withholds Close echo still free MAX_STREAMS at once. + await withRoom({ probeMs: 20 }, async (h) => { + await h.register(owner); + const surfaces = []; + for (let i = 0; i < MAX_STREAMS; i += 1) { + const res = await h.dial(owner.name, `198.51.100.${10 + i}`); + assert.equal(res.status, 101); + res.webSocket.server.keepClosing = true; + surfaces.push(res.webSocket.server); + } + const full = await h.dial(owner.name, '198.51.100.99'); + assert.equal(full.status, 429); + + await h.room.webSocketError(surfaces[0]); + assert.equal(surfaces[0].deserializeAttachment()?.role, 'ended'); + const reopened = await h.dial(owner.name, '198.51.100.100'); + assert.equal(reopened.status, 101, 'an errored surface socket must release its MAX_STREAMS slot immediately'); + }); + + // 3. A new same-key registrant that drops during the PROBE_MS wait does not evict older. + await withRoom({ probeMs: 30 }, async (h) => { + const first = await h.register(owner, { answerPings: false }); + const req = new Request('https://relay.example' + ENGINE_PATH, { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': PROTOCOL, + 'cf-connecting-ip': '198.51.100.2', + [HEADER_NAME]: owner.name, + [HEADER_KEY]: b64url(owner.publicKey), + }, + }); + const res = await h.room.fetch(req); + const challenger = res.webSocket.server; + const proof = await answerChallenge(challenger.sent[0], owner.name, owner); + const pendingProof = h.room.webSocketMessage(challenger, proof); + await h.room.webSocketClose(challenger, 1006); + await pendingProof; + assert.equal(first.server.closed, null, 'a challenger that disconnected mid-probe must not evict the existing socket'); + assert.equal(challenger.deserializeAttachment()?.role, 'ended'); + }); + + // 4. Abandoned pending /v1/engine sockets are swept once HANDSHAKE_MS elapses. + await withRoom({ handshakeMs: 500 }, async (h) => { + const req = new Request('https://relay.example' + ENGINE_PATH, { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': PROTOCOL, + 'cf-connecting-ip': '198.51.100.3', + [HEADER_NAME]: owner.name, + [HEADER_KEY]: b64url(owner.publicKey), + }, + }); + const res = await h.room.fetch(req); + const pending = res.webSocket.server; + assert.equal(pending.deserializeAttachment()?.role, 'pending'); + h.clock.now += 501; + await h.dial(owner.name); + assert.equal(pending.closed?.[0], 1008); + assert.equal(pending.deserializeAttachment()?.role, 'ended'); + }); +}); diff --git a/relay/hosted/wrangler.toml b/relay/hosted/wrangler.toml index c6be7524bf..0a3f6fa9d3 100644 --- a/relay/hosted/wrangler.toml +++ b/relay/hosted/wrangler.toml @@ -5,6 +5,11 @@ compatibility_date = "2026-09-01" # Local development and the base of every deployment: `wrangler dev --local` runs this in workerd # with a simulated R2 bucket and Durable Objects. See README.md for what a deployment changes. +# THE RELAY KEEPS NO LOGS. Observability is off, as it is on the live production +# Worker, so no deploy turns on persisted request logs or traces for any channel. +[observability] +enabled = false + [[r2_buckets]] binding = "FRAMES" bucket_name = "codeaf-relay-frames" @@ -33,6 +38,10 @@ class_name = "LinkGate" name = "LINK_REQUEST" class_name = "LinkRequest" +[[durable_objects.bindings]] +name = "MACHINE" +class_name = "MachineDO" + [[migrations]] tag = "v1" new_sqlite_classes = ["IdentityDO", "PairGate", "Mailbox", "NewcomerGate"] @@ -55,3 +64,20 @@ class_name = "RaftMemberDO" [[migrations]] tag = "v3-raft-members" new_sqlite_classes = ["RaftMemberDO"] + +[[migrations]] +tag = "v4-machines" +new_sqlite_classes = ["MachineDO"] + +# Front-door per-network rate limits for /v1/dial/ and /v1/engine, +# matching DialsPerMinute (30) and RegistrationsPerMinute (10) in +# internal/relay/relay.go before any MachineDO wakes. +[[ratelimits]] +name = "MACHINE_DIAL_LIMIT" +namespace_id = "1001" +simple = { limit = 30, period = 60 } + +[[ratelimits]] +name = "MACHINE_REGISTER_LIMIT" +namespace_id = "1002" +simple = { limit = 10, period = 60 } diff --git a/scripts/relay-deploy.sh b/scripts/relay-deploy.sh new file mode 100644 index 0000000000..94f0907405 --- /dev/null +++ b/scripts/relay-deploy.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# Deploy and verify one release channel's hosted relay Worker. +# +# Production (caf-fabric, used by stable and rc) stays off unless the +# repository variable RELAY_DEPLOY_PRODUCTION is literally "true". Dev and +# staging deploy automatically whenever the relay's input fingerprint differs +# from the live Worker's version tag. +# +# Every deploy proves the live Worker with internal/relay/hosted_test.go and +# `codeaf-release service --url` before binaries can be published. If either +# proof fails and a prior live version id was recorded, Wrangler rolls the +# Worker back to that version and fails the job. Note that a rollback cannot +# undo a one-way storage upgrade, which is why every relay change reaches dev +# first and staging a week later. +set -euo pipefail + +channel="${CHANNEL:-}" +case "$channel" in + stable|rc) + if [ "${RELAY_DEPLOY_PRODUCTION:-}" != "true" ]; then + echo "Production relay deploys are off; set repository variable RELAY_DEPLOY_PRODUCTION=true to enable caf-fabric deploys." + exit 0 + fi + ;; + staging|dev) + ;; + *) + echo "CHANNEL must be one of stable, rc, staging, or dev" >&2 + exit 2 + ;; +esac + +if [ -z "${RELEASE_SHA:-}" ]; then + echo "RELEASE_SHA must be set" >&2 + exit 2 +fi +if [ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then + echo "CLOUDFLARE_ACCOUNT_ID must be set" >&2 + exit 2 +fi +if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then + echo "CLOUDFLARE_API_TOKEN must be set" >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +hosted_dir="$repo_root/relay/hosted" + +work_tmp="$(mktemp -d "${TMPDIR:-/tmp}/codeaf-relay-deploy.XXXXXX")" +wrangler_cfg="${RELAY_WRANGLER_CONFIG_OUT:-$hosted_dir/wrangler.ci.toml}" +cleanup() { + rm -rf "$work_tmp" + rm -f "$wrangler_cfg" +} +trap cleanup EXIT + +redact_secrets() { + ACCOUNT_TO_REDACT="$CLOUDFLARE_ACCOUNT_ID" TOKEN_TO_REDACT="$CLOUDFLARE_API_TOKEN" node -e ' + const fs = require("node:fs"); + let text = fs.readFileSync(0, "utf8"); + for (const key of ["TOKEN_TO_REDACT", "ACCOUNT_TO_REDACT"]) { + const val = process.env[key] || ""; + if (val.length > 0) { + text = text.split(val).join("[REDACTED]"); + } + } + process.stdout.write(text); + ' +} + +fingerprint="$(bash "${RELAY_FINGERPRINT_SCRIPT:-$repo_root/scripts/relay-fingerprint.sh}")" +if [[ ! "$fingerprint" =~ ^[0-9a-f]{64}$ ]]; then + echo "invalid relay fingerprint output" >&2 + exit 1 +fi +tag="${fingerprint:0:16}" + +bash "$repo_root/scripts/relay-wrangler-config.sh" "$channel" > "$wrangler_cfg" +worker_name="$(awk -F'"' '/^name = "/ {print $2; exit}' "$wrangler_cfg")" +bucket_name="$(awk -F'"' '/^bucket_name = "/ {print $2; exit}' "$wrangler_cfg")" +if [ -z "$worker_name" ] || [ -z "$bucket_name" ]; then + echo "failed to read worker_name or bucket_name from generated Wrangler config" >&2 + exit 1 +fi + +relay_url="$(cd "$repo_root" && CODEAF_TELEMETRY=off DO_NOT_TRACK=1 go run ./cmd/codeaf-release relay-url --channel "$channel")" +if [ -z "$relay_url" ]; then + echo "failed to resolve relay URL for channel $channel" >&2 + exit 1 +fi + +api_base="${CLOUDFLARE_API_BASE:-https://api.cloudflare.com/client/v4}" + +cf_get() { + local endpoint="$1" out_body="$2" + printf 'header = "Authorization: Bearer %s"\n' "$CLOUDFLARE_API_TOKEN" | \ + curl -sS -o "$out_body" -w '%{http_code}' --config - "$endpoint" +} + +dep_body="$work_tmp/deployments.json" +dep_code="$(cf_get "${api_base}/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${worker_name}/deployments" "$dep_body")" + +parsed_dep="$(node - "$dep_code" "$dep_body" "$worker_name" <<'JS' +const fs = require('node:fs'); +const [httpCode, bodyPath, workerName] = process.argv.slice(2); +let data = {}; +try { + data = JSON.parse(fs.readFileSync(bodyPath, 'utf8')); +} catch { + if (httpCode === '404') { + process.stdout.write('\n\n'); + process.exit(0); + } + console.error(`Cloudflare deployments response for ${workerName} was not valid JSON (HTTP ${httpCode}).`); + process.exit(1); +} +const codes = Array.isArray(data.errors) ? data.errors.map((e) => Number(e.code)) : []; +if (httpCode === '404' || codes.includes(10007) || codes.includes(10090)) { + process.stdout.write('\n\n'); + process.exit(0); +} +if (!httpCode.startsWith('2') || data.success !== true) { + console.error(`Cloudflare deployments lookup failed for ${workerName} (HTTP ${httpCode}, error codes: ${codes.join(',') || 'none'}).`); + process.exit(1); +} +const deployments = (data.result && Array.isArray(data.result.deployments)) ? data.result.deployments : []; +if (deployments.length === 0) { + process.stdout.write('\n\n'); + process.exit(0); +} +const latest = deployments[0] || {}; +const versions = Array.isArray(latest.versions) ? latest.versions : []; +const primary = versions.find((v) => v && v.percentage === 100) || versions[0] || {}; +const versionId = typeof primary.version_id === 'string' ? primary.version_id : ''; +const ann = (latest.annotations && typeof latest.annotations === 'object') ? latest.annotations : {}; +const tag = typeof ann['workers/tag'] === 'string' ? ann['workers/tag'] : ''; +process.stdout.write(`${versionId}\n${tag}\n`); +JS +)" + +live_version_id="$(printf '%s\n' "$parsed_dep" | sed -n '1p')" +live_tag="$(printf '%s\n' "$parsed_dep" | sed -n '2p')" + +if [ -n "$live_version_id" ] && [ -z "$live_tag" ]; then + ver_body="$work_tmp/version.json" + ver_code="$(cf_get "${api_base}/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${worker_name}/versions/${live_version_id}" "$ver_body")" + live_tag="$(node - "$ver_code" "$ver_body" "$worker_name" <<'JS' +const fs = require('node:fs'); +const [httpCode, bodyPath, workerName] = process.argv.slice(2); +if (httpCode === '404') { + process.exit(0); +} +let data = {}; +try { + data = JSON.parse(fs.readFileSync(bodyPath, 'utf8')); +} catch { + console.error(`Cloudflare version response for ${workerName} was not valid JSON (HTTP ${httpCode}).`); + process.exit(1); +} +if (!httpCode.startsWith('2') || data.success !== true) { + const codes = Array.isArray(data.errors) ? data.errors.map((e) => Number(e.code)) : []; + console.error(`Cloudflare version lookup failed for ${workerName} (HTTP ${httpCode}, error codes: ${codes.join(',') || 'none'}).`); + process.exit(1); +} +const ann = (data.result && data.result.annotations && typeof data.result.annotations === 'object') ? data.result.annotations : {}; +const tag = typeof ann['workers/tag'] === 'string' ? ann['workers/tag'] : ''; +process.stdout.write(tag); +JS +)" +fi + +if [ -n "$live_tag" ] && [ "$live_tag" = "$tag" ]; then + echo "Relay ${worker_name} already matches fingerprint tag ${tag}; skipping deploy." + exit 0 +fi + +set +e +r2_out="$( + cd "$hosted_dir" && \ + CI=true WRANGLER_SEND_METRICS=false WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true \ + npx wrangler r2 bucket create "$bucket_name" --config "$wrangler_cfg" &1 +)" +r2_rc=$? +set -e +if [ "$r2_rc" -ne 0 ]; then + if printf '%s\n' "$r2_out" | grep -Eq 'code:[[:space:]]*10004|\[code:[[:space:]]*10004\]|already exists'; then + echo "R2 bucket ${bucket_name} already exists." + else + printf '%s\n' "$r2_out" | redact_secrets >&2 + exit "$r2_rc" + fi +else + printf '%s\n' "$r2_out" | redact_secrets +fi + +echo "Deploying ${worker_name} (${relay_url}) with tag ${tag} and message ${RELEASE_SHA} (prior version: ${live_version_id:-none})." +( + cd "$hosted_dir" + CI=true WRANGLER_SEND_METRICS=false WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true \ + npx wrangler deploy --config "$wrangler_cfg" --tag "$tag" --message "$RELEASE_SHA" &1 | redact_secrets + +verify_live_relay() { + ( + cd "$repo_root" && + CODEAF_TELEMETRY=off DO_NOT_TRACK=1 CODEAF_RELAY_HOSTED_URL="$relay_url" \ + go test -count=1 -run '^TestAHostedRelay' ./internal/relay && + CODEAF_TELEMETRY=off DO_NOT_TRACK=1 \ + go run ./cmd/codeaf-release service --url "$relay_url" + ) +} + +if ! verify_live_relay; then + echo "Post-deploy verification failed for ${worker_name} at ${relay_url}." >&2 + # A Worker code rollback cannot undo a one-way storage upgrade, which is + # why every relay change reaches dev first and staging a week later. + echo "Note: a rollback cannot undo a one-way storage upgrade, which is why every relay change reaches dev first and staging a week later." >&2 + if [ -n "$live_version_id" ]; then + echo "Rolling back ${worker_name} to recorded version ${live_version_id}." >&2 + ( + cd "$hosted_dir" + CI=true WRANGLER_SEND_METRICS=false WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true \ + npx wrangler rollback "$live_version_id" --config "$wrangler_cfg" \ + --message "Rollback ${worker_name} after failed verification of ${RELEASE_SHA}" --yes &1 | redact_secrets >&2 || echo "Rollback command failed for ${worker_name} version ${live_version_id}." >&2 + else + echo "No prior live version existed for ${worker_name}; nothing to roll back." >&2 + fi + exit 1 +fi + +echo "Verified ${worker_name} at ${relay_url} (tag ${tag})." diff --git a/scripts/relay-deploy_test.sh b/scripts/relay-deploy_test.sh new file mode 100644 index 0000000000..3ab2a607d2 --- /dev/null +++ b/scripts/relay-deploy_test.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +deploy_script="$repo_root/scripts/relay-deploy.sh" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/relay-deploy-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +fake_bin="$tmp/bin" +mkdir -p "$fake_bin" + +# Deterministic 64-hex fingerprint stub (first 16 hex = 0123456789abcdef). +cat > "$tmp/fake-fp.sh" <<'EOF' +#!/usr/bin/env bash +echo "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +EOF +chmod +x "$tmp/fake-fp.sh" + +# Fake curl: records calls, reads stdin config for Bearer token without network I/O. +cat > "$fake_bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +stdin_cfg="$(cat)" +printf 'CURL %s\n' "$*" >> "$MOCK_LOG" +case "$stdin_cfg" in + *"Authorization: Bearer "*) ;; + *) echo "missing Authorization header in curl --config stdin" >&2; exit 9 ;; +esac +out="" +endpoint="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -w|--config) shift 2 ;; + -*) shift ;; + *) endpoint="$1"; shift ;; + esac +done +case "$endpoint" in + */deployments) + cp "$MOCK_DEPLOYMENTS_BODY" "$out" + printf '%s' "${MOCK_DEPLOYMENTS_CODE:-200}" + ;; + */versions/*) + cp "$MOCK_VERSION_BODY" "$out" + printf '%s' "${MOCK_VERSION_CODE:-200}" + ;; + *) + echo "unexpected curl endpoint: $endpoint" >&2 + exit 9 + ;; +esac +EOF +chmod +x "$fake_bin/curl" + +# Fake npx: intercepts wrangler commands and logs every invocation. +cat > "$fake_bin/npx" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'NPX %s\n' "$*" >> "$MOCK_LOG" +if [ "${MOCK_ECHO_SECRET:-0}" = "1" ]; then + echo "leaked token=${CLOUDFLARE_API_TOKEN} acct=${CLOUDFLARE_ACCOUNT_ID}" +fi +case "$*" in + "wrangler r2 bucket create "*) + if [ "${MOCK_R2_EXISTS:-0}" = "1" ]; then + echo "✘ [ERROR] A request to the Cloudflare API failed. [code: 10004] The bucket you tried to create already exists, and you own it." >&2 + exit 1 + fi + echo "Created bucket." + ;; + "wrangler deploy "*) + echo "Deployed." + ;; + "wrangler rollback "*) + echo "Rolled back." + ;; + *) + echo "unexpected npx command: $*" >&2 + exit 9 + ;; +esac +EOF +chmod +x "$fake_bin/npx" + +# Fake go: handles relay-url, hosted_test.go conformance, and codeaf-release service --url. +cat > "$fake_bin/go" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'GO %s (HOSTED_URL=%s TELEMETRY=%s DNT=%s)\n' \ + "$*" "${CODEAF_RELAY_HOSTED_URL:-}" "${CODEAF_TELEMETRY:-}" "${DO_NOT_TRACK:-}" >> "$MOCK_LOG" +case "$*" in + "run ./cmd/codeaf-release relay-url --channel stable"|"run ./cmd/codeaf-release relay-url --channel rc") + echo "https://codeaf.agentfield.ai/fabric" + ;; + "run ./cmd/codeaf-release relay-url --channel staging") + echo "https://caf-fabric-staging.instrument-santosh.workers.dev" + ;; + "run ./cmd/codeaf-release relay-url --channel dev") + echo "https://caf-fabric-dev.instrument-santosh.workers.dev" + ;; + "test -count=1 -run ^TestAHostedRelay ./internal/relay") + if [ "${MOCK_FAIL_HOSTED_TEST:-0}" = "1" ]; then + echo "FAIL hosted_test" >&2 + exit 1 + fi + echo "ok internal/relay" + ;; + "run ./cmd/codeaf-release service --url "*) + if [ "${MOCK_FAIL_SERVICE_GATE:-0}" = "1" ]; then + echo "missing capability" >&2 + exit 1 + fi + echo "service ok" + ;; + *) + echo "unexpected go command: $*" >&2 + exit 9 + ;; +esac +EOF +chmod +x "$fake_bin/go" + +run_deploy() { + PATH="$fake_bin:$PATH" \ + MOCK_LOG="$tmp/mock.log" \ + MOCK_DEPLOYMENTS_BODY="$tmp/deployments.json" \ + MOCK_VERSION_BODY="$tmp/version.json" \ + RELAY_FINGERPRINT_SCRIPT="$tmp/fake-fp.sh" \ + RELAY_WRANGLER_CONFIG_OUT="$tmp/wrangler.ci.toml" \ + bash "$deploy_script" "$@" +} + +# 1. Production is OFF unless RELAY_DEPLOY_PRODUCTION is literally "true". +for ch in stable rc; do + for val in "" "false" "TRUE" "1" "yes"; do + : > "$tmp/mock.log" + out="$(CHANNEL="$ch" RELAY_DEPLOY_PRODUCTION="$val" run_deploy)" + [ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" = "1" ] || fail "expected 1 log line when production off ($ch/$val), got: $out" + [[ "$out" == *"RELAY_DEPLOY_PRODUCTION=true"* ]] || fail "missing enablement hint: $out" + [ ! -s "$tmp/mock.log" ] || fail "production-off run contacted tools: $(cat "$tmp/mock.log")" + done +done + +# 2. Matching live tag skips with one log line and deploys nothing. +: > "$tmp/mock.log" +cat > "$tmp/deployments.json" <<'EOF' +{"success":true,"result":{"deployments":[{"id":"dep-1","versions":[{"version_id":"ver-live-1","percentage":100}]}]}} +EOF +cat > "$tmp/version.json" <<'EOF' +{"success":true,"result":{"id":"ver-live-1","annotations":{"workers/tag":"0123456789abcdef"}}} +EOF +out="$( + CHANNEL="dev" \ + RELEASE_SHA="deadbeefcafe" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + run_deploy +)" +[ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" = "1" ] || fail "expected 1 skip line, got: $out" +[[ "$out" == *"already matches fingerprint tag 0123456789abcdef; skipping deploy."* ]] || fail "unexpected skip line: $out" +if grep -q '^NPX ' "$tmp/mock.log"; then + fail "skip path invoked wrangler: $(cat "$tmp/mock.log")" +fi + +# 3. Missing tag (today's live Worker) triggers deploy + both proofs; existing R2 bucket (10004) is idempotent; secrets are redacted. +: > "$tmp/mock.log" +cat > "$tmp/version.json" <<'EOF' +{"success":true,"result":{"id":"ver-untagged","annotations":{}}} +EOF +out="$( + CHANNEL="staging" \ + RELEASE_SHA="abc123456789" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_R2_EXISTS="1" \ + MOCK_ECHO_SECRET="1" \ + run_deploy 2>&1 +)" +[[ "$out" != *"acct_secret_xyz"* ]] || fail "CLOUDFLARE_ACCOUNT_ID leaked in output: $out" +[[ "$out" != *"tok_secret_xyz"* ]] || fail "CLOUDFLARE_API_TOKEN leaked in output: $out" +grep -Fq "NPX wrangler r2 bucket create caf-fabric-staging-frames" "$tmp/mock.log" || fail "missing R2 create for staging" +grep -Fq "NPX wrangler deploy --config $tmp/wrangler.ci.toml --tag 0123456789abcdef --message abc123456789" "$tmp/mock.log" || fail "missing wrangler deploy with tag and message" +grep -Fq "GO test -count=1 -run ^TestAHostedRelay ./internal/relay (HOSTED_URL=https://caf-fabric-staging.instrument-santosh.workers.dev TELEMETRY=off DNT=1)" "$tmp/mock.log" || fail "missing hosted_test proof" +grep -Fq "GO run ./cmd/codeaf-release service --url https://caf-fabric-staging.instrument-santosh.workers.dev" "$tmp/mock.log" || fail "missing codeaf-release service proof" + +# 4. Production deploys caf-fabric when RELAY_DEPLOY_PRODUCTION=true. +: > "$tmp/mock.log" +CHANNEL="stable" \ +RELAY_DEPLOY_PRODUCTION="true" \ +RELEASE_SHA="mainsha999" \ +CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ +CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ +run_deploy >/dev/null +grep -Fq "NPX wrangler r2 bucket create caf-fabric-frames" "$tmp/mock.log" || fail "missing production R2 bucket create" +grep -Fq "GO run ./cmd/codeaf-release service --url https://codeaf.agentfield.ai/fabric" "$tmp/mock.log" || fail "missing production service proof" + +# 5. Failed hosted_test rolls back to recorded live version id, warns about one-way storage upgrades, and fails the job. +: > "$tmp/mock.log" +cat > "$tmp/deployments.json" <<'EOF' +{"success":true,"result":{"deployments":[{"id":"dep-prev","versions":[{"version_id":"ver-rollback-target","percentage":100}]}]}} +EOF +cat > "$tmp/version.json" <<'EOF' +{"success":true,"result":{"id":"ver-rollback-target","annotations":{"workers/tag":"ffffffffffffffff"}}} +EOF +set +e +fail_out="$( + CHANNEL="dev" \ + RELEASE_SHA="badcommit1" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_FAIL_HOSTED_TEST="1" \ + run_deploy 2>&1 +)" +fail_rc=$? +set -e +[ "$fail_rc" -ne 0 ] || fail "expected failed hosted_test to fail relay-deploy.sh" +[[ "$fail_out" == *"cannot undo a one-way storage upgrade"* ]] || fail "missing one-way storage upgrade warning: $fail_out" +grep -Fq "NPX wrangler rollback ver-rollback-target --config $tmp/wrangler.ci.toml" "$tmp/mock.log" || fail "missing wrangler rollback to recorded version" + +# 6. Failed codeaf-release service gate also rolls back and fails the job. +: > "$tmp/mock.log" +set +e +fail_out2="$( + CHANNEL="dev" \ + RELEASE_SHA="badcommit2" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_FAIL_SERVICE_GATE="1" \ + run_deploy 2>&1 +)" +fail_rc2=$? +set -e +[ "$fail_rc2" -ne 0 ] || fail "expected failed service gate to fail relay-deploy.sh" +grep -Fq "NPX wrangler rollback ver-rollback-target" "$tmp/mock.log" || fail "missing rollback after service gate failure" + +# 7. First deploy of a brand-new Worker (404 / 10007): failure skips rollback cleanly and still fails the job. +: > "$tmp/mock.log" +cat > "$tmp/deployments.json" <<'EOF' +{"success":false,"errors":[{"code":10007,"message":"workers.api.error.script_not_found"}]} +EOF +set +e +first_fail_out="$( + CHANNEL="dev" \ + RELEASE_SHA="firstbad" \ + CLOUDFLARE_ACCOUNT_ID="acct_secret_xyz" \ + CLOUDFLARE_API_TOKEN="tok_secret_xyz" \ + MOCK_DEPLOYMENTS_CODE="404" \ + MOCK_FAIL_SERVICE_GATE="1" \ + run_deploy 2>&1 +)" +first_fail_rc=$? +set -e +[ "$first_fail_rc" -ne 0 ] || fail "expected failed first deploy to fail relay-deploy.sh" +if grep -q "wrangler rollback" "$tmp/mock.log"; then + fail "unexpected rollback when no prior version existed: $(cat "$tmp/mock.log")" +fi +[[ "$first_fail_out" == *"No prior live version existed"* ]] || fail "missing no-prior-version log: $first_fail_out" + +echo "PASS: relay-deploy_test" diff --git a/scripts/relay-fingerprint.sh b/scripts/relay-fingerprint.sh new file mode 100644 index 0000000000..a787a1c310 --- /dev/null +++ b/scripts/relay-fingerprint.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Print one deterministic sha256 over the hosted relay's inputs (paths and +# contents, in sorted order): +# - relay/hosted/** (tracked source inputs, excluding node_modules, .wrangler +# and generated build products) +# - scripts/raft-hosted-build.sh +# - scripts/raft-proof-footprint.sh +# - go.mod and go.sum +# - the Go source of every package in +# `GOOS=wasip1 GOARCH=wasm go list -deps ./cmd/codeaf-raft-wasm` +# that lives in this module +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +root="$(cd "${RELAY_FINGERPRINT_ROOT:-$repo_root}" && pwd)" + +list_file="$(mktemp "${TMPDIR:-/tmp}/relay-fingerprint.XXXXXX")" +trap 'rm -f "$list_file"' EXIT + +( + cd "$root" + + find relay/hosted \ + \( -path 'relay/hosted/node_modules' -o -path 'relay/hosted/.wrangler' \) -prune \ + -o -type f \ + ! -path 'relay/hosted/src/raft/core.wasm' \ + ! -path 'relay/hosted/wrangler.ci.toml' \ + -print + + for fixed in \ + scripts/raft-hosted-build.sh \ + scripts/raft-proof-footprint.sh \ + go.mod \ + go.sum; do + if [ ! -f "$fixed" ]; then + echo "missing relay input: $fixed" >&2 + exit 1 + fi + printf '%s\n' "$fixed" + done + + GOOS=wasip1 GOARCH=wasm go list -buildvcs=false -deps \ + -f '{{if .Module}}{{range .GoFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .CgoFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .SFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .HFiles}}{{$.Dir}}/{{.}} +{{end}}{{range .EmbedFiles}}{{$.Dir}}/{{.}} +{{end}}{{end}}' \ + ./cmd/codeaf-raft-wasm | while IFS= read -r abs_path; do + [ -n "$abs_path" ] || continue + case "$abs_path" in + "$root"/*) + printf '%s\n' "${abs_path#"$root"/}" + ;; + esac + done +) | LC_ALL=C sort -u > "$list_file" + +if [ ! -s "$list_file" ]; then + echo "relay-fingerprint found no input files" >&2 + exit 1 +fi + +( + cd "$root" + tr '\n' '\0' < "$list_file" | xargs -0 sha256sum -b -- +) | sha256sum | awk '{print $1}' diff --git a/scripts/relay-fingerprint_test.sh b/scripts/relay-fingerprint_test.sh new file mode 100644 index 0000000000..a9ac4587ee --- /dev/null +++ b/scripts/relay-fingerprint_test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +fp_script="$repo_root/scripts/relay-fingerprint.sh" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/relay-fingerprint-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Build a lightweight sandbox mirror of the module so mutations never touch the real tree. +sandbox="$tmp/repo" +mkdir -p "$sandbox/scripts" "$sandbox/relay" "$sandbox/cmd" "$sandbox/internal/tui3" +cp "$repo_root/go.mod" "$repo_root/go.sum" "$sandbox/" +cp "$repo_root/scripts/raft-hosted-build.sh" "$repo_root/scripts/raft-proof-footprint.sh" "$sandbox/scripts/" +cp -R "$repo_root/cmd/codeaf-raft-wasm" "$sandbox/cmd/" + +# Copy relay/hosted without heavy node_modules/.wrangler. +mkdir -p "$sandbox/relay/hosted" +( + cd "$repo_root/relay/hosted" + find . \( -path './node_modules' -o -path './.wrangler' \) -prune -o -type f -print | while IFS= read -r rel; do + mkdir -p "$sandbox/relay/hosted/$(dirname "$rel")" + cp "$rel" "$sandbox/relay/hosted/$rel" + done +) + +# Copy every in-tree wasm dependency package (including replaced third_party/raft) plus one unrelated package. +( + cd "$repo_root" + GOOS=wasip1 GOARCH=wasm go list -buildvcs=false -deps \ + -f '{{if .Module}}{{.Dir}}{{end}}' \ + ./cmd/codeaf-raft-wasm +) | sort -u | while IFS= read -r pkg_dir; do + [ -n "$pkg_dir" ] || continue + case "$pkg_dir" in + "$repo_root"/*) + rel_dir="${pkg_dir#"$repo_root"/}" + mkdir -p "$sandbox/$(dirname "$rel_dir")" + rm -rf "$sandbox/$rel_dir" + cp -R "$pkg_dir" "$sandbox/$rel_dir" + ;; + esac +done +if [ -f "$repo_root/third_party/raft/go.mod" ]; then + mkdir -p "$sandbox/third_party/raft" + cp "$repo_root/third_party/raft/go.mod" "$sandbox/third_party/raft/go.mod" + if [ -f "$repo_root/third_party/raft/go.sum" ]; then + cp "$repo_root/third_party/raft/go.sum" "$sandbox/third_party/raft/go.sum" + fi +fi + +cat > "$sandbox/internal/tui3/unrelated.go" <<'EOF' +package tui3 + +const Unrelated = 1 +EOF + +base="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[[ "$base" =~ ^[0-9a-f]{64}$ ]] || fail "fingerprint is not 64 lowercase hex chars: $base" + +again="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[ "$base" = "$again" ] || fail "fingerprint is not deterministic: $base vs $again" + +# Generated/ignored files under relay/hosted must not change the fingerprint. +mkdir -p "$sandbox/relay/hosted/node_modules/fake" "$sandbox/relay/hosted/.wrangler/state" "$sandbox/relay/hosted/src/raft" +echo "ignored" > "$sandbox/relay/hosted/node_modules/fake/index.js" +echo "ignored" > "$sandbox/relay/hosted/.wrangler/state/cache" +echo "ignored" > "$sandbox/relay/hosted/src/raft/core.wasm" +echo "ignored" > "$sandbox/relay/hosted/wrangler.ci.toml" +with_ignored="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[ "$base" = "$with_ignored" ] || fail "ignored artifacts changed fingerprint: $base vs $with_ignored" + +# Unrelated Go packages and test files must not change the fingerprint. +echo "// comment" >> "$sandbox/internal/tui3/unrelated.go" +echo "// test edit" >> "$sandbox/internal/raftcore/core_test.go" +after_unrelated="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +[ "$base" = "$after_unrelated" ] || fail "unrelated package or _test.go changed fingerprint: $base vs $after_unrelated" + +# Every declared input category must change the fingerprint when its content or path changes. +check_mutation_changes() { + local label="$1" target="$2" trailer="$3" + cp "$target" "$tmp/saved" + printf '\n%s\n' "$trailer" >> "$target" + local mutated + mutated="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" + mv "$tmp/saved" "$target" + [ "$mutated" != "$base" ] || fail "editing $label did not change fingerprint" + local restored + restored="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" + [ "$restored" = "$base" ] || fail "restoring $label did not restore fingerprint" +} + +check_mutation_changes "relay/hosted JS" "$sandbox/relay/hosted/src/worker.js" "// change" +check_mutation_changes "raft-hosted-build.sh" "$sandbox/scripts/raft-hosted-build.sh" "# change" +check_mutation_changes "raft-proof-footprint.sh" "$sandbox/scripts/raft-proof-footprint.sh" "# change" +check_mutation_changes "go.mod" "$sandbox/go.mod" "// change" +check_mutation_changes "go.sum" "$sandbox/go.sum" "" + +# Mutating a Go source file in a wasm-dep package must change the fingerprint. +cp "$sandbox/internal/raftcore/core.go" "$tmp/core.go.bak" +printf '\n// relay fingerprint probe\n' >> "$sandbox/internal/raftcore/core.go" +wasm_mutated="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +mv "$tmp/core.go.bak" "$sandbox/internal/raftcore/core.go" +[ "$wasm_mutated" != "$base" ] || fail "editing wasm dependency Go source did not change fingerprint" + +# Renaming a file while keeping identical contents must change the fingerprint (paths are hashed). +mv "$sandbox/relay/hosted/src/base.js" "$sandbox/relay/hosted/src/base-renamed.js" +renamed_fp="$(RELAY_FINGERPRINT_ROOT="$sandbox" bash "$fp_script")" +mv "$sandbox/relay/hosted/src/base-renamed.js" "$sandbox/relay/hosted/src/base.js" +[ "$renamed_fp" != "$base" ] || fail "renaming a file with identical bytes did not change fingerprint" + +echo "PASS: relay-fingerprint_test" diff --git a/scripts/relay-wrangler-config.sh b/scripts/relay-wrangler-config.sh new file mode 100644 index 0000000000..01cf601879 --- /dev/null +++ b/scripts/relay-wrangler-config.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Generate a per-channel Wrangler configuration from relay/hosted/wrangler.toml. +# +# relay/hosted/wrangler.toml is the single source of Durable Object bindings, +# migrations, build command, observability and baseline rate-limit ids. This +# script substitutes only the channel's Worker name, account id, R2 bucket, +# base path, routing mode, and per-account rate-limit namespace offset. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +channel="$1" +case "$channel" in + production|stable|rc) + mode="production" + worker_name="caf-fabric" + bucket_name="caf-fabric-frames" + base_path="/fabric" + rate_offset=0 + ;; + staging) + mode="workers_dev" + worker_name="caf-fabric-staging" + bucket_name="caf-fabric-staging-frames" + base_path="" + rate_offset=100 + ;; + dev) + mode="workers_dev" + worker_name="caf-fabric-dev" + bucket_name="caf-fabric-dev-frames" + base_path="" + rate_offset=200 + ;; + *) + echo "unknown relay channel: $channel (expected production, stable, rc, staging, or dev)" >&2 + exit 2 + ;; +esac + +account_id="${CLOUDFLARE_ACCOUNT_ID:-}" +if [ -z "$account_id" ]; then + echo "CLOUDFLARE_ACCOUNT_ID must be set in the environment" >&2 + exit 2 +fi +case "$account_id" in + *[!A-Za-z0-9_-]*) + echo "CLOUDFLARE_ACCOUNT_ID contains unexpected characters" >&2 + exit 2 + ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +base_toml="${RELAY_WRANGLER_BASE:-$repo_root/relay/hosted/wrangler.toml}" + +if [ ! -f "$base_toml" ]; then + echo "missing base Wrangler config: $base_toml" >&2 + exit 1 +fi + +awk \ + -v mode="$mode" \ + -v worker_name="$worker_name" \ + -v bucket_name="$bucket_name" \ + -v base_path="$base_path" \ + -v rate_offset="$rate_offset" \ + -v account_id="$account_id" \ +' +BEGIN { + section = "" + saw_name = 0 + saw_compat = 0 + saw_bucket = 0 + saw_vars = 0 + emitted_base_path = 0 + ratelimits = 0 +} +/^[[:space:]]*\[/ { + if (section == "[vars]" && !emitted_base_path) { + printf "BASE_PATH = \"%s\"\n", base_path + emitted_base_path = 1 + } + section = $0 + sub(/^[[:space:]]+/, "", section) + sub(/[[:space:]]+$/, "", section) + if (section == "[vars]") { + saw_vars = 1 + } + print $0 + next +} +section == "" && /^name[[:space:]]*=/ { + printf "name = \"%s\"\n", worker_name + saw_name = 1 + next +} +section == "" && /^compatibility_date[[:space:]]*=/ { + print $0 + printf "account_id = \"%s\"\n", account_id + # No channel publishes a preview URL per version, as the live production + # Worker does not; the one address a channel answers on is its own. + print "preview_urls = false" + if (mode == "production") { + print "workers_dev = false" + print "routes = [" + print " { pattern = \"codeaf.agentfield.ai/fabric*\", zone_name = \"agentfield.ai\" }," + print " { pattern = \"codeaf.agentfield.ai/p/*\", zone_name = \"agentfield.ai\" }," + print "]" + } else { + print "workers_dev = true" + } + saw_compat = 1 + next +} +section == "[[r2_buckets]]" && /^bucket_name[[:space:]]*=/ { + printf "bucket_name = \"%s\"\n", bucket_name + saw_bucket = 1 + next +} +section == "[vars]" && /^BASE_PATH[[:space:]]*=/ { + printf "BASE_PATH = \"%s\"\n", base_path + emitted_base_path = 1 + next +} +section == "[[ratelimits]]" && /^namespace_id[[:space:]]*=/ { + val = $0 + sub(/^[^"]*"/, "", val) + sub(/".*$/, "", val) + if (val !~ /^[0-9]+$/) { + print "non-numeric ratelimit namespace_id: " $0 > "/dev/stderr" + exit 1 + } + printf "namespace_id = \"%d\"\n", (val + 0) + rate_offset + ratelimits++ + next +} +{ + print $0 +} +END { + if (section == "[vars]" && !emitted_base_path) { + printf "BASE_PATH = \"%s\"\n", base_path + emitted_base_path = 1 + } + if (!saw_name || !saw_compat || !saw_bucket || !saw_vars || ratelimits < 2) { + printf "incomplete base wrangler.toml (name=%d compat=%d bucket=%d vars=%d ratelimits=%d)\n", \ + saw_name, saw_compat, saw_bucket, saw_vars, ratelimits > "/dev/stderr" + exit 1 + } +} +' "$base_toml" diff --git a/scripts/relay-wrangler-config_test.sh b/scripts/relay-wrangler-config_test.sh new file mode 100644 index 0000000000..3468d6bf35 --- /dev/null +++ b/scripts/relay-wrangler-config_test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +gen="$repo_root/scripts/relay-wrangler-config.sh" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/relay-wrangler-config-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Refuses missing or unknown channel and missing account id. +if CLOUDFLARE_ACCOUNT_ID="acct_test" bash "$gen" >/dev/null 2>&1; then + fail "expected missing channel to fail" +fi +if CLOUDFLARE_ACCOUNT_ID="acct_test" bash "$gen" preview >/dev/null 2>&1; then + fail "expected unknown channel to fail" +fi +if env -u CLOUDFLARE_ACCOUNT_ID bash "$gen" dev >/dev/null 2>&1; then + fail "expected missing CLOUDFLARE_ACCOUNT_ID to fail" +fi +if CLOUDFLARE_ACCOUNT_ID='bad"quote' bash "$gen" dev >/dev/null 2>&1; then + fail "expected malformed CLOUDFLARE_ACCOUNT_ID to fail" +fi + +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" production > "$tmp/prod.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" stable > "$tmp/stable.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" rc > "$tmp/rc.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" staging > "$tmp/staging.toml" +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" bash "$gen" dev > "$tmp/dev.toml" + +cmp -s "$tmp/prod.toml" "$tmp/stable.toml" || fail "stable config differs from production" +cmp -s "$tmp/prod.toml" "$tmp/rc.toml" || fail "rc config differs from production" + +assert_contains() { + local file="$1" needle="$2" + grep -Fq -- "$needle" "$file" || fail "$file missing: $needle" +} + +assert_not_contains() { + local file="$1" needle="$2" + if grep -Fq -- "$needle" "$file"; then + fail "$file unexpectedly contains: $needle" + fi +} + +# Production shape: routes, BASE_PATH = "/fabric", workers_dev = false, observability, 1001/1002. +assert_contains "$tmp/prod.toml" 'name = "caf-fabric"' +assert_contains "$tmp/prod.toml" 'account_id = "acct_0123456789abcdef"' +assert_contains "$tmp/prod.toml" 'workers_dev = false' +assert_contains "$tmp/prod.toml" '{ pattern = "codeaf.agentfield.ai/fabric*", zone_name = "agentfield.ai" }' +assert_contains "$tmp/prod.toml" '{ pattern = "codeaf.agentfield.ai/p/*", zone_name = "agentfield.ai" }' +assert_contains "$tmp/prod.toml" 'bucket_name = "caf-fabric-frames"' +assert_contains "$tmp/prod.toml" 'BASE_PATH = "/fabric"' +assert_contains "$tmp/prod.toml" '[observability]' +assert_contains "$tmp/prod.toml" 'preview_urls = false' +assert_not_contains "$tmp/prod.toml" '[observability.logs]' +assert_not_contains "$tmp/prod.toml" '[observability.traces]' +assert_not_contains "$tmp/prod.toml" 'persist = true' +assert_contains "$tmp/prod.toml" 'namespace_id = "1001"' +assert_contains "$tmp/prod.toml" 'namespace_id = "1002"' +assert_not_contains "$tmp/prod.toml" 'workers_dev = true' + +# Staging shape: workers_dev = true, no routes, empty BASE_PATH, +100 rate-limit ids. +assert_contains "$tmp/staging.toml" 'name = "caf-fabric-staging"' +assert_contains "$tmp/staging.toml" 'account_id = "acct_0123456789abcdef"' +assert_contains "$tmp/staging.toml" 'workers_dev = true' +assert_contains "$tmp/staging.toml" 'bucket_name = "caf-fabric-staging-frames"' +assert_contains "$tmp/staging.toml" 'BASE_PATH = ""' +assert_contains "$tmp/staging.toml" '[observability]' +assert_contains "$tmp/staging.toml" 'namespace_id = "1101"' +assert_contains "$tmp/staging.toml" 'namespace_id = "1102"' +assert_not_contains "$tmp/staging.toml" 'routes =' +assert_not_contains "$tmp/staging.toml" 'zone_name' + +# Dev shape: workers_dev = true, no routes, empty BASE_PATH, +200 rate-limit ids. +assert_contains "$tmp/dev.toml" 'name = "caf-fabric-dev"' +assert_contains "$tmp/dev.toml" 'account_id = "acct_0123456789abcdef"' +assert_contains "$tmp/dev.toml" 'workers_dev = true' +assert_contains "$tmp/dev.toml" 'bucket_name = "caf-fabric-dev-frames"' +assert_contains "$tmp/dev.toml" 'BASE_PATH = ""' +assert_contains "$tmp/dev.toml" '[observability]' +assert_contains "$tmp/dev.toml" 'namespace_id = "1201"' +assert_contains "$tmp/dev.toml" 'namespace_id = "1202"' +assert_not_contains "$tmp/dev.toml" 'routes =' +assert_not_contains "$tmp/dev.toml" 'zone_name' + +# Migrations come straight from the base wrangler.toml and are never copied in the script. +cp "$repo_root/relay/hosted/wrangler.toml" "$tmp/extended-base.toml" +cat >> "$tmp/extended-base.toml" <<'EOF' + +[[migrations]] +tag = "v99-append-only-check" +new_sqlite_classes = ["FutureDO"] +EOF +CLOUDFLARE_ACCOUNT_ID="acct_0123456789abcdef" RELAY_WRANGLER_BASE="$tmp/extended-base.toml" \ + bash "$gen" dev > "$tmp/extended-out.toml" +assert_contains "$tmp/extended-out.toml" 'tag = "v99-append-only-check"' + +# Forbidden Workers never appear in the generator, deploy script, workflow, or generated configs. +for forbidden in caf-relay-staging caf-relay-a caf-relay-c codeaf-pool; do + for target in \ + "$gen" \ + "$repo_root/.github/workflows/release.yml" \ + "$tmp/prod.toml" \ + "$tmp/staging.toml" \ + "$tmp/dev.toml"; do + assert_not_contains "$target" "$forbidden" + done + if [ -f "$repo_root/scripts/relay-deploy.sh" ]; then + assert_not_contains "$repo_root/scripts/relay-deploy.sh" "$forbidden" + fi +done + +# Validate parsed TOML structure through Wrangler's own config reader when installed. +if [ -d "$repo_root/relay/hosted/node_modules/wrangler" ] && command -v node >/dev/null 2>&1; then + node --input-type=module - "$repo_root/relay/hosted" "$tmp/prod.toml" "$tmp/staging.toml" "$tmp/dev.toml" <<'JS' +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const [hostedDir, prodPath, stagingPath, devPath] = process.argv.slice(2); +const require = createRequire(hostedDir + '/package.json'); +const { experimental_readRawConfig } = require('wrangler'); + +const prod = experimental_readRawConfig({ config: prodPath }).rawConfig; +assert.equal(prod.name, 'caf-fabric'); +assert.equal(prod.account_id, 'acct_0123456789abcdef'); +assert.equal(prod.workers_dev, false); +assert.equal(prod.vars.BASE_PATH, '/fabric'); +assert.equal(prod.vars.LEASE_POLICY, 'amended'); +assert.equal(prod.r2_buckets[0].bucket_name, 'caf-fabric-frames'); +assert.equal(prod.routes.length, 2); +// The live production Worker keeps no logs and publishes no preview URLs, and +// no channel's deploy may change that. +assert.equal(prod.observability.enabled, false); +assert.equal(prod.observability.logs, undefined); +assert.equal(prod.observability.traces, undefined); +assert.equal(prod.preview_urls, false); +assert.deepEqual(prod.ratelimits.map((r) => r.namespace_id), ['1001', '1002']); + +const staging = experimental_readRawConfig({ config: stagingPath }).rawConfig; +assert.equal(staging.name, 'caf-fabric-staging'); +assert.equal(staging.workers_dev, true); +assert.equal(staging.routes, undefined); +assert.equal(staging.vars.BASE_PATH, ''); +assert.equal(staging.r2_buckets[0].bucket_name, 'caf-fabric-staging-frames'); +assert.deepEqual(staging.ratelimits.map((r) => r.namespace_id), ['1101', '1102']); + +const dev = experimental_readRawConfig({ config: devPath }).rawConfig; +assert.equal(dev.name, 'caf-fabric-dev'); +assert.equal(dev.workers_dev, true); +assert.equal(dev.routes, undefined); +assert.equal(dev.vars.BASE_PATH, ''); +assert.equal(dev.r2_buckets[0].bucket_name, 'caf-fabric-dev-frames'); +assert.deepEqual(dev.ratelimits.map((r) => r.namespace_id), ['1201', '1202']); +JS +fi + +echo "PASS: relay-wrangler-config_test"