From bbc43d19d58d6e3723cba89cbde7d6759833bcc2 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 23:20:33 -0400 Subject: [PATCH 01/20] Name the sync service in its own failures, and keep each error's class A relay that is down, slow or full now says so in one sentence family (internal/syncwords) instead of blaming the person's network or other machines. Status classes and causes stay exactly as before; only the words a person reads change. Co-Authored-By: Claude Opus 5.5 --- internal/chatlist/copy.go | 52 ++++-- internal/chatlist/devices.go | 6 +- internal/chatlist/devices_test.go | 7 +- internal/chatlist/l4fix_words_test.go | 29 ++++ internal/chatlist/offer_test.go | 8 +- internal/directory/http.go | 2 +- internal/pairbox/http.go | 8 + internal/pairbox/l4_http_test.go | 25 +++ internal/relay/client.go | 21 ++- internal/relayserve/control_test.go | 2 +- internal/relayserve/l4_help_test.go | 24 +++ internal/relayserve/main.go | 22 ++- internal/syncsetup/cluster.go | 32 ++++ internal/syncsetup/l4b2_names_test.go | 53 ++++++ internal/syncsetup/l4fix_close_test.go | 187 ++++++++++++++++++++++ internal/syncsetup/member_control_test.go | 4 +- internal/syncsetup/relayurl.go | 2 + internal/syncwords/errors.go | 116 ++++++++++++++ internal/syncwords/l4_test.go | 87 ++++++++++ internal/syncwords/words.go | 98 ++++++++++++ 20 files changed, 756 insertions(+), 29 deletions(-) create mode 100644 internal/chatlist/l4fix_words_test.go create mode 100644 internal/pairbox/l4_http_test.go create mode 100644 internal/relayserve/l4_help_test.go create mode 100644 internal/syncsetup/l4b2_names_test.go create mode 100644 internal/syncsetup/l4fix_close_test.go create mode 100644 internal/syncwords/errors.go create mode 100644 internal/syncwords/l4_test.go create mode 100644 internal/syncwords/words.go diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index 76f3e307a9..110d045cf3 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 @@ -16,23 +18,23 @@ 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" // What the relay's refusals say (one sentence each, in the order the // 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" @@ -145,7 +147,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: @@ -271,13 +273,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. @@ -374,3 +376,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/directory/http.go b/internal/directory/http.go index 0fd199b0e7..f6b414c294 100644 --- a/internal/directory/http.go +++ b/internal/directory/http.go @@ -204,7 +204,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/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/relay/client.go b/internal/relay/client.go index b13be9c8dd..e10de96022 100644 --- a/internal/relay/client.go +++ b/internal/relay/client.go @@ -39,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") @@ -156,7 +158,7 @@ func Dial(ctx context.Context, service, name string) (io.ReadWriteCloser, error) 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 @@ -258,11 +260,11 @@ func refusal(base, path string, status int, said string) error { // stream ceiling; that machine is busy, not unreachable. return ErrTooMany case status >= 500: - return fmt.Errorf("%w: %s answered %d", ErrUnreachable, base, status) + return upstreamFailure{base: base, status: status} case relaySentence(said): return errors.New(said) default: - return fmt.Errorf("the relay at %s refused this connection (%d) — check the address it was given", base, status) + return fmt.Errorf("the sync service at %s refused this connection (%d) — check the address it was given", base, status) } } @@ -306,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/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 88fc99a4b2..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`: @@ -24,6 +24,7 @@ import ( // 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") @@ -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/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/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/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/relayurl.go b/internal/syncsetup/relayurl.go index 5424aa83ee..a56e7c71e4 100644 --- a/internal/syncsetup/relayurl.go +++ b/internal/syncsetup/relayurl.go @@ -9,6 +9,7 @@ import ( "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" ) const ( @@ -84,6 +85,7 @@ var hostedRelay = HostedRelayURL func init() { pair.LinkOrigin = LinkOriginOf(HostedRelayURL) + syncwords.UseHosted(HostedRelayURL, HostedLinkHosts()) } // SavedURLFile is where a computer keeps the relay another device told it to 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) +} From 531b278da73af3c0e75b6bc0662bc04225f0604a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 23:20:33 -0400 Subject: [PATCH 02/20] Pair through the right sync service, and let an unused computer join Every printed codeaf pair command carries --via on a self-hosted service. A computer whose only chats never had a turn sent can join without --replace; one with real chats is told how many and what --replace does. A removed computer can come back with --replace and a fresh device key. Removal by name refuses rather than guess when a roster cannot be read. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/approvals.go | 40 +++- cmd/codeaf/approvals_test.go | 8 +- cmd/codeaf/cell_list_test.go | 2 +- cmd/codeaf/chatv3_at.go | 65 ++++-- cmd/codeaf/chatv3_beside.go | 1 + cmd/codeaf/chatv3_host.go | 14 +- cmd/codeaf/chatv3_pair.go | 27 ++- cmd/codeaf/devices.go | 258 +++++++++++++++++++---- cmd/codeaf/devices_test.go | 9 +- cmd/codeaf/displaced_watch_test.go | 2 +- cmd/codeaf/l4_devices_test.go | 106 ++++++++++ cmd/codeaf/l4_help_test.go | 29 +++ cmd/codeaf/l4_machine_test.go | 25 +++ cmd/codeaf/l4_pair_test.go | 117 ++++++++++ cmd/codeaf/l4b2_devices_test.go | 19 ++ cmd/codeaf/l4b2_pair_test.go | 35 +++ cmd/codeaf/l4fix2_devices_test.go | 34 +++ cmd/codeaf/l4fix2_service_test.go | 38 ++++ cmd/codeaf/l4fix_at_test.go | 93 ++++++++ cmd/codeaf/l4fix_devices_test.go | 99 +++++++++ cmd/codeaf/l4fix_unpaired_test.go | 36 ++++ cmd/codeaf/main.go | 8 +- cmd/codeaf/pair.go | 25 ++- cmd/codeaf/pair_status.go | 55 +++++ cmd/codeaf/pair_terminal.go | 59 +++++- cmd/codeaf/pair_test.go | 8 +- cmd/codeaf/pairlink.go | 46 +++- cmd/codeaf/pairlink_test.go | 4 +- cmd/codeaf/pairroute.go | 18 +- internal/catalog/catalog.go | 23 +- internal/catalog/l4fix2_names_test.go | 23 ++ internal/devname/device.go | 31 +++ internal/identity/chats.go | 50 +++++ internal/identity/joinable.go | 34 +-- internal/identity/l4_joinable_test.go | 54 +++++ internal/identity/l4fix2_startup_test.go | 183 ++++++++++++++++ internal/identity/pristine.go | 20 +- internal/identity/pristine_judges.go | 7 + internal/identity/startup_database.go | 131 ++++++++++++ internal/pair/book.go | 18 +- internal/pair/boxlink.go | 5 +- internal/pair/code.go | 14 +- internal/pair/command.go | 76 +++++++ internal/pair/deadline.go | 4 +- internal/pair/dial.go | 16 +- internal/pair/edge_relay_test.go | 2 +- internal/pair/endtoend_test.go | 2 +- internal/pair/errors.go | 43 +++- internal/pair/join.go | 49 ++++- internal/pair/l4_join_test.go | 79 +++++++ internal/pair/l4_words_test.go | 87 ++++++++ internal/pair/l4fix2_weight_test.go | 24 +++ internal/pair/l4fix_rejoin_test.go | 67 ++++++ internal/pair/l4fix_serve_test.go | 54 +++++ internal/pair/l4fix_service_test.go | 34 +++ internal/pair/lines.go | 38 ++-- internal/pair/lines_test.go | 2 +- internal/pair/linkjoin.go | 15 +- internal/pair/list.go | 62 +++++- internal/pair/manual_test.go | 4 +- internal/pair/offer.go | 6 +- internal/pair/pair_test.go | 6 +- internal/pair/serve.go | 43 +++- 63 files changed, 2356 insertions(+), 230 deletions(-) create mode 100644 cmd/codeaf/l4_devices_test.go create mode 100644 cmd/codeaf/l4_help_test.go create mode 100644 cmd/codeaf/l4_machine_test.go create mode 100644 cmd/codeaf/l4_pair_test.go create mode 100644 cmd/codeaf/l4b2_devices_test.go create mode 100644 cmd/codeaf/l4b2_pair_test.go create mode 100644 cmd/codeaf/l4fix2_devices_test.go create mode 100644 cmd/codeaf/l4fix2_service_test.go create mode 100644 cmd/codeaf/l4fix_at_test.go create mode 100644 cmd/codeaf/l4fix_devices_test.go create mode 100644 cmd/codeaf/l4fix_unpaired_test.go create mode 100644 cmd/codeaf/pair_status.go create mode 100644 internal/catalog/l4fix2_names_test.go create mode 100644 internal/devname/device.go create mode 100644 internal/identity/chats.go create mode 100644 internal/identity/l4_joinable_test.go create mode 100644 internal/identity/l4fix2_startup_test.go create mode 100644 internal/identity/startup_database.go create mode 100644 internal/pair/command.go create mode 100644 internal/pair/l4_join_test.go create mode 100644 internal/pair/l4_words_test.go create mode 100644 internal/pair/l4fix2_weight_test.go create mode 100644 internal/pair/l4fix_rejoin_test.go create mode 100644 internal/pair/l4fix_serve_test.go create mode 100644 internal/pair/l4fix_service_test.go 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/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_at.go b/cmd/codeaf/chatv3_at.go index c5f5e459fd..224ce15d0a 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -40,12 +40,17 @@ 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" @@ -146,10 +151,11 @@ 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) }, @@ -202,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) { @@ -264,7 +274,7 @@ 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+", ~/.codeaf/v3/remote/relay, then the address this machine syncs through") + 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 @@ -280,7 +290,7 @@ func runServe(args []string) error { if service == "" { // 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 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") + 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) @@ -305,14 +315,19 @@ func runServe(args []string) error { 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 @@ -331,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_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_pair.go b/cmd/codeaf/chatv3_pair.go index 7c530aeaf9..f0ab4cd5b6 100644 --- a/cmd/codeaf/chatv3_pair.go +++ b/cmd/codeaf/chatv3_pair.go @@ -34,7 +34,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) } @@ -49,6 +52,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, chatJoinRefusal(explainSyncUnreachable(err, route.URL)) } @@ -72,6 +80,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) } @@ -85,3 +96,17 @@ func (chatPairDoor) HasOwnChats() bool { // errChatReplaceRefusal is the plain refusal, said the chat's way: the way out // is a keystroke in the chat the person is already sitting in. var errChatReplaceRefusal = errors.New("this computer already has chats of its own, so they stay local and nothing was changed; to take the other computer's chats instead, type /pair --replace and confirm") + +// 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/devices.go b/cmd/codeaf/devices.go index b7b801de5e..c59e1f945a 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -10,27 +10,29 @@ 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 +62,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 +84,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 +101,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 +123,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 } @@ -205,15 +268,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 @@ -247,7 +338,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 } @@ -255,12 +346,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) { @@ -329,7 +426,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 @@ -343,14 +441,31 @@ 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 { 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: chatlist.DeviceName(l.Devices, id, open), This: id == c.self, Stopped: d.Revoked, Platform: d.Platform}, id: id, }) } + for i := range rows { + rows[i].baseName = rows[i].Name + } + for i := range rows { + duplicate := false + for j := range rows { + if i != j && strings.EqualFold(rows[i].baseName, rows[j].baseName) { + duplicate = true + } + } + rows[i].Name = devname.DeviceLabel(rows[i].Name, rows[i].id, duplicate) + 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)) @@ -366,25 +481,36 @@ 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 "" } shown := make([]pair.ChatsDevice, len(rows)) for i, row := range rows { shown[i] = row.ChatsDevice + if duplicates != nil { + shown[i].Name = devname.DeviceLabel(row.baseName, row.id, duplicates[strings.ToLower(row.baseName)]) + } + } + 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 + return slices.DeleteFunc(rows, func(r chatsRow) bool { return !devname.MatchesDevice(name, r.baseName, r.id) }), err } func (c chatsKind) find(name string) (int, error) { @@ -398,7 +524,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: @@ -431,38 +557,80 @@ func (c chatsKind) explain(err error) error { case errors.Is(err, directory.ErrNotFound): return errors.New("that device is not in your list any more — `codeaf devices` shows the ones there are") } - 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 - } - 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 relay == "" { + relay = syncsetup.Resolve(home.Dir()).URL } - if errors.Is(err, context.DeadlineExceeded) || network != nil && network.Timeout() { - return pair.ServiceTimeout(err, relay) - } - 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_test.go b/cmd/codeaf/devices_test.go index dc3b3e96f4..b4e3f3d5e1 100644 --- a/cmd/codeaf/devices_test.go +++ b/cmd/codeaf/devices_test.go @@ -196,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") @@ -209,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/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_help_test.go b/cmd/codeaf/l4_help_test.go new file mode 100644 index 0000000000..14fbb2cb56 --- /dev/null +++ b/cmd/codeaf/l4_help_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" +) + +// L4-M2a, L4-M8a, L4-M8b: help exposes the real pairing code and both service overrides. +func TestL4PairAndRelayHelp(t *testing.T) { + if strings.Contains(usageText, "six-digit code for your chats") || !strings.Contains(usageText, "codeaf relay") { + t.Fatal("top-level help hides the service or 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..1de5873550 --- /dev/null +++ b/cmd/codeaf/l4_machine_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/pair" +) + +// L4-M3a, L4-M3b: the approver sees the extra permission, then the check and choice. +func TestL4MachineApprovalForPairedDevice(t *testing.T) { + question := machineApprovalQuestion("laptop-a", "red green blue", true) + if question != "laptop-a has the same name as one of your devices; let it run work here too? Same three words on that screen: red green blue?" { + t.Fatal(question) + } + out := &pairScreenText{} + person := newTerminal(strings.NewReader("y\n"), out) + if !confirmMachine(person, context.Background(), "laptop-a", "red green blue", true) { + t.Fatal("yes declined") + } + if strings.Index(out.String(), pair.PairingWeight) > strings.Index(out.String(), "y / n") { + t.Fatal(out.String()) + } +} diff --git a/cmd/codeaf/l4_pair_test.go b/cmd/codeaf/l4_pair_test.go new file mode 100644 index 0000000000..efbb7985a0 --- /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(), "Paired with laptop-a. Run codeaf to open your chats.") { + 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(), "Paired with laptop-a. Run codeaf to open your chats.") { + t.Fatal(out.String()) + } + }) + } +} 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/l4fix_at_test.go b/cmd/codeaf/l4fix_at_test.go new file mode 100644 index 0000000000..7d71c09e73 --- /dev/null +++ b/cmd/codeaf/l4fix_at_test.go @@ -0,0 +1,93 @@ +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: The actual --at door carries the welcome's chosen name into the window and its other conversations. +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 + 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() { 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 + 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: machine.Name()}); err != nil { + t.Fatal(err) + } + stopFinalSaveProgress() + takeFinalSaveFailure() + if !visited { + t.Fatal("surface never opened") + } +} 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 e6d74dae86..b4e8d654be 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" @@ -194,6 +195,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() { @@ -610,9 +612,11 @@ 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 + 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 relay [--listen host:port] [--store path] + run your own sync service 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 @@ -740,6 +744,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/pair.go b/cmd/codeaf/pair.go index c28d9eaaa9..618e46cf0d 100644 --- a/cmd/codeaf/pair.go +++ b/cmd/codeaf/pair.go @@ -110,9 +110,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 { @@ -121,6 +121,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) } @@ -198,12 +204,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 } @@ -217,12 +228,18 @@ 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 + } joined, err := pair.Join(ctx, route, d.joining(replace), typed, joinScreen{d.term}) if err != nil { return d.endedByPerson(ctx, err) } - d.term.say(joinedSentence(joined)) + if f, err := pullFleet(d.joining(replace).Home, route)(ctx); err == nil { + joined.With = f.With + } d.recordOwnDeviceOrSay(ctx, route) + d.term.say(joinedSentence(joined)) 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..a731d6ca12 100644 --- a/cmd/codeaf/pair_terminal.go +++ b/cmd/codeaf/pair_terminal.go @@ -13,7 +13,9 @@ import ( "strings" "sync" + "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/pair" ) @@ -71,8 +73,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 +83,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 +119,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 +132,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 +159,43 @@ 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, sameNameAsPairedDevice(asked, label)) + } +} + +// machineApprovalQuestion names the extra permission at the approval decision. +func machineApprovalQuestion(label, words string, sameName bool) string { + if sameName { + return label + " has the same name as one of your devices; let it run work here too? Same three words on that screen: " + words + "?" + } + return pair.AskMachineLine(label, words) +} + +// sameNameAsPairedDevice is a descriptive name lookup, never an access decision. +// The code check and explicit yes are still required for every machine key. +func sameNameAsPairedDevice(ctx context.Context, label string) bool { + route, err := pairMailbox("") + if err != nil { + return false + } + client, id, err := homeDirectory(home.Dir(), route) + if err != nil { + return false + } + listing, err := client.List(ctx) + if err != nil { + return false } + for _, dev := range listing.Devices { + if !dev.Revoked && openName(directory.MetadataKey(id.CellKey()), dev.Name) == label { + return true + } + } + return false +} + +// confirmMachine puts the permission weight at the point of the serving decision. +func confirmMachine(person *terminal, ctx context.Context, label, words string, sameName bool) bool { + person.say(pair.PairingWeight) + return person.confirm(ctx, machineApprovalQuestion(label, words, sameName)) } diff --git a/cmd/codeaf/pair_test.go b/cmd/codeaf/pair_test.go index 8cb68fa522..62b5a1ac62 100644 --- a/cmd/codeaf/pair_test.go +++ b/cmd/codeaf/pair_test.go @@ -192,8 +192,12 @@ 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 TestPairBothDoorsOnATerminal(t *testing.T) { rig := newPairRig(t) + if _, err := devname.Set(rig.homeA, "laptop-a"); err != nil { + t.Fatal(err) + } shown := rig.show(t) code := shown.code(t) @@ -217,8 +221,8 @@ func TestPairBothDoorsOnATerminal(t *testing.T) { } wanted := pair.WaitingChatsLine(asked[1]) - if got := joiner.String(); got != wanted+"\n"+pair.JoinedLine+"\n" { - t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s\n%s", got, wanted, pair.JoinedLine) + if got := joiner.String(); got != wanted+"\n"+"Paired with laptop-a. Run codeaf to open your chats."+"\n" { + t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s\n%s", got, wanted, "Paired with laptop-a. Run codeaf to open your chats.") } 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 3b8cc0346c..2b170c2a9a 100644 --- a/cmd/codeaf/pairlink.go +++ b/cmd/codeaf/pairlink.go @@ -15,10 +15,10 @@ import ( "errors" "net/http" "runtime" + "sort" "time" "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/reqsign" @@ -63,12 +63,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 } } @@ -92,7 +100,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), } } @@ -109,6 +117,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 + } joined, err := pair.JoinByLink(ctx, d.requests(route), d.linking(route, replace), inviteScreen{d.term}) if err != nil && !errors.Is(err, pair.ErrPairedNotRead) { return d.endedByPerson(ctx, err) @@ -117,8 +128,8 @@ func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) err d.term.say(err.Error()) return nil } - d.term.say(pair.JoinedFleetLine(joined.Fleet.Workspaces)) d.recordOwnDeviceOrSay(ctx, route) + d.term.say(pair.PairedWithLine(joined.Fleet.With)) return nil } @@ -129,13 +140,13 @@ func (d pairDoor) approve(ctx context.Context, relay, typed string) error { if err != nil { return err } - if relay == "" { - 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 @@ -151,11 +162,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 } @@ -183,3 +194,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 67e99a8846..a8fed76454 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`) first, _ := identity.Load(rig.homeA) second, err := identity.Load(rig.homeB) if err != nil || second.ID() != first.ID() { @@ -154,7 +154,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 22df5b1bf9..2c3c62ce43 100644 --- a/cmd/codeaf/pairroute.go +++ b/cmd/codeaf/pairroute.go @@ -13,6 +13,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" @@ -44,7 +45,7 @@ func pairMailbox(relayFlag string) (pair.Mailbox, error) { flag := strings.TrimSpace(relayFlag) base := flag if flag == "" { - relay := syncsetup.Resolve(home.Dir()) + relay := pairingRelay(home.Dir()) if relay.Off { return pair.Mailbox{}, pair.ErrSyncOff } @@ -61,7 +62,7 @@ func pairMailbox(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 +99,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/internal/catalog/catalog.go b/internal/catalog/catalog.go index 8544aadbd5..1519c9e48b 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -35,6 +35,7 @@ const ( // connection checks, so a catalog discovery can read is also connectable. MaxCatalogBytes = 16 << 20 cacheName = "model-catalog.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. @@ -1520,6 +1521,26 @@ func cachePath(dir, source, base string) string { return filepath.Join(dir, stem+"-"+key+extension) } +// 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 { + if name == cacheName { + return true + } + extension := filepath.Ext(cacheName) + stem := strings.TrimSuffix(cacheName, 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 // default-service/default-base case; every other pair gets sixteen hex digits. func CacheKey(source, base string) string { @@ -1529,7 +1550,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/devname/device.go b/internal/devname/device.go new file mode 100644 index 0000000000..a4ac19efe4 --- /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 name + " #" + id[max(0, len(id)-TailLength):] +} + +// 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/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/joinable.go b/internal/identity/joinable.go index 23d0087784..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. @@ -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..0fd4624365 --- /dev/null +++ b/internal/identity/l4fix2_startup_test.go @@ -0,0 +1,183 @@ +package identity_test + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "runtime" + "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) + ctx, cancel := context.WithTimeout(context.Background(), 5*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) + } + runtime.Gosched() + } + 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/pristine.go b/internal/identity/pristine.go index 6128831b11..fd02f0539b 100644 --- a/internal/identity/pristine.go +++ b/internal/identity/pristine.go @@ -31,9 +31,18 @@ func Pristine(home string) (bool, error) { var startupRules = rules{ File: never, DeviceFile: never, legacyKeyFile: never, soloFile: 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, @@ -41,6 +50,11 @@ var startupRules = rules{ }), "telemetry": within(rules{"first_run": never, "install_id": never, "notice_seen": never}), "v3": within(rules{ + "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": never, "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, @@ -57,9 +71,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..254a0678ab 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,12 @@ 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 +} + // 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..2d3a9e217c --- /dev/null +++ b/internal/identity/startup_database.go @@ -0,0 +1,131 @@ +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" + 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/pair/book.go b/internal/pair/book.go index 84724ea821..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" @@ -184,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) } } @@ -193,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 { @@ -308,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/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..08d25d081d --- /dev/null +++ b/internal/pair/command.go @@ -0,0 +1,76 @@ +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. +func linkService(typed string) string { + if via := LinkRelay(typed); via != "" { + return via + } + u, err := url.Parse(strings.TrimSpace(typed)) + if err != nil { + return "" + } + if u.Scheme == "codeaf" || webRelay(u) && strings.HasPrefix(u.Path, "/p/") { + return syncwords.HostedURL + } + return "" +} + +// 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/dial.go b/internal/pair/dial.go index 72970af632..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,9 @@ 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 @@ -168,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 @@ -219,13 +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/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 8256611345..55dd5b8c51 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,29 +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("the service at %s syncs chats but does not connect machines — run your own with `codeaf relay` and set %s to it, or reach that machine with --host over ssh", service, RelayEnv) + 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. @@ -86,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 @@ -138,15 +141,15 @@ 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
") + 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
") // 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. ErrRelayTooOld = errors.New("that sync service is too old for pairing") // ErrDifferentChats is a computer that already has chats of its own. - ErrDifferentChats = errors.New("this computer already has chats of its own, so they stay local and nothing was changed; to replace them with the other device's, run this again with --replace") + ErrDifferentChats = errors.New("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") // ErrAlreadyPaired is a computer that already holds the chats being offered. ErrAlreadyPaired = errors.New("already paired; nothing changed") // ErrCannotReach is a relay that does not answer, with its host after it. @@ -163,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. @@ -189,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 c497e0c3da..3e6a51dbea 100644 --- a/internal/pair/join.go +++ b/internal/pair/join.go @@ -11,6 +11,7 @@ import ( "context" "crypto/rand" "errors" + "fmt" "slices" "github.com/Agent-Field/codeaf/internal/identity" @@ -44,6 +45,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 @@ -103,7 +106,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 @@ -123,7 +126,19 @@ func adopt(j Joining, grant Grant) (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 must renew this computer's key, + // even when the shared account is unchanged: its old key may be revoked. + dev, err := identity.NewDevice(grant.Identity) + if err != nil { + return Joined{}, err + } + if err := identity.Install(j.Home, grant.Identity, dev); err != nil { + return Joined{}, err + } + // The common adoption tail applies replacement cleanup and service persistence. case err == nil && slices.Contains(grant.Replaces, held.ID()): return Joined{Successor: true}, follow(j, held, grant) } @@ -141,7 +156,7 @@ func adopt(j Joining, grant Grant) (Joined, error) { } } if _, err := identity.Adopt(j.Home, grant.Identity, j.Replace || merge); err != nil { - return Joined{}, adoptFailure(err) + return Joined{}, adoptionFailure(j.Home, err) } if merge { if err := keys.CommitReseal(j.Home); err != nil { @@ -185,3 +200,31 @@ func adoptFailure(err error) error { } return err } + +// adoptionFailure counts the chats that replacement would leave on this computer. +func adoptionFailure(home string, err error) error { + if errors.Is(err, identity.ErrDifferent) { + return differentChats(home) + } + return err +} + +func differentChats(home string) error { + n, err := identity.ChatCount(home) + if err != nil { + return ErrDifferentChats + } + if n == 0 { + return ErrDifferentChats + } + noun := "chats" + if n == 1 { + noun = "chat" + } + return differentChatError{fmt.Sprintf("this computer already has %d %s of its own, so nothing was changed. To pair it anyway, run this again with --replace: those %d %s stay on this computer but no longer sync; shared memories and settings are cleared", n, noun, n, noun)} +} + +type differentChatError struct{ line string } + +func (e differentChatError) Error() string { return e.line } +func (e differentChatError) Unwrap() error { return ErrDifferentChats } diff --git a/internal/pair/l4_join_test.go b/internal/pair/l4_join_test.go new file mode 100644 index 0000000000..032ad1f4b2 --- /dev/null +++ b/internal/pair/l4_join_test.go @@ -0,0 +1,79 @@ +package pair + +import ( + "errors" + "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-H2b: the refusal counts submitted chats and explains the replacement truthfully. +func TestL4PairCountsChats(t *testing.T) { + h := t.TempDir() + if _, err := identity.EnsureSolo(h); err != nil { + t.Fatal(err) + } + for _, name := range []string{"a", "b", "c"} { + p := filepath.Join(h, "v3/projects/project", name, ".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) + } + } + _, err := (LinkJoining{Joining: Joining{Home: h}}).free() + if !errors.Is(err, ErrDifferentChats) || !strings.Contains(err.Error(), "already has 3 chats of its own") || !strings.Contains(err.Error(), "--replace") || !strings.Contains(err.Error(), "stay on this computer") { + t.Fatalf("%v", 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) + } +} diff --git a/internal/pair/l4_words_test.go b/internal/pair/l4_words_test.go new file mode 100644 index 0000000000..7baec32a72 --- /dev/null +++ b/internal/pair/l4_words_test.go @@ -0,0 +1,87 @@ +package pair + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/wireauth" + "strings" + "testing" +) + +// 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 next step. +func TestL4PairCompletionVoice(t *testing.T) { + if got := (Joined{With: "laptop-a"}).Sentence(); got != "Paired with laptop-a. Run codeaf to open your chats." { + 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: serving retains its rendezvous name beside the familiar device name. +func TestL4ServeUsesDeviceName(t *testing.T) { + h := Host{DisplayName: "desk-b"} + got := h.banner("seagull-lichen-63", nil, ErrOffering) + if !strings.Contains(got, "this machine is reachable as desk-b (seagull-lichen-63)") { + 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/l4fix_rejoin_test.go b/internal/pair/l4fix_rejoin_test.go new file mode 100644 index 0000000000..f5bbea3c5f --- /dev/null +++ b/internal/pair/l4fix_rejoin_test.go @@ -0,0 +1,67 @@ +package pair + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// 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 := syncsetup.PutOwnDevice(ctx, dir.For(a.ID()), r.a, a, "laptop-a"); err != nil { + t.Fatal(err) + } + if err := syncsetup.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 := syncsetup.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..0653221f77 --- /dev/null +++ b/internal/pair/l4fix_service_test.go @@ -0,0 +1,34 @@ +package pair + +import ( + "net/url" + "testing" +) + +// L4-R6: Equivalent URLs approve on this service, while meaningful differences refuse. +func TestL4FixCanonicalServices(t *testing.T) { + 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 c4d184f68d..f1aed0cb1d 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,15 +36,16 @@ 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. This device now has your chats." +const openChatsLine = ". Run codeaf to open your chats." +const JoinedLine = "Paired with your other device" + openChatsLine // 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 @@ -51,13 +55,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 ────────────────────────────────────────────────────────── @@ -103,13 +107,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 @@ -142,8 +140,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. @@ -181,3 +178,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 + openChatsLine +} diff --git a/internal/pair/lines_test.go b/internal/pair/lines_test.go index f4f706ec36..349dff6873 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: "Paired with your other device. Run codeaf to open your chats.", 1: "Paired with your other device. Run codeaf to open your chats.", 3: "Paired with your other device. Run codeaf to open your chats.", } { 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 c6f2335beb..612633b433 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 { @@ -82,9 +86,12 @@ func (j LinkJoining) free() (LinkJoining, error) { return j, nil } fresh, err := identity.Joinable(j.Home) - if err != nil || !fresh { + if err != nil { return j, ErrDifferentChats } + if !fresh { + return j, differentChats(j.Home) + } j.MergeState = true return j, nil } @@ -218,7 +225,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/list.go b/internal/pair/list.go index d6e7821f73..bfb24c0b66 100644 --- a/internal/pair/list.go +++ b/internal/pair/list.go @@ -18,6 +18,7 @@ import ( "encoding/base64" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/charmbracelet/x/ansi" "strings" "time" @@ -37,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()) @@ -55,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 } @@ -98,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 ` — it stops syncing your chats, but keeps what it already holds.\n") return out.String() @@ -132,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 757817be78..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{ @@ -68,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) @@ -102,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 { diff --git a/internal/pair/offer.go b/internal/pair/offer.go index 6c868aca15..77f86c5536 100644 --- a/internal/pair/offer.go +++ b/internal/pair/offer.go @@ -66,7 +66,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 } @@ -82,7 +82,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) @@ -95,7 +95,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 56fed9b108..352a1bfd04 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,6 +53,8 @@ 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 @@ -70,7 +75,7 @@ const steadyAfter = time.Minute // TooManyRegistrationsLine is what `codeaf serve` prints when the relay turns // registrations away at its per-minute ceiling. -const TooManyRegistrationsLine = "the service is turning registrations away right now — waiting before trying again" +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. @@ -114,6 +119,9 @@ func (h *Host) say(line string) { } func (h *Host) banner(name string, code *Code, codeErr error) string { + if h.DisplayName != "" { + name = h.DisplayName + " (" + name + ")" + } if codeErr == nil { return strings.TrimRight(Lines(name, code), "\n") } @@ -140,7 +148,7 @@ func (h *Host) sayBanner(line string, force bool) { // 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 @@ -178,9 +186,16 @@ func (h *Host) Run(ctx context.Context) error { // 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) } tellLost() case errors.Is(err, relay.ErrNoDoor) && announced: @@ -189,12 +204,12 @@ func (h *Host) Run(ctx context.Context) error { tellLost() case errors.Is(err, relay.ErrNoDoor): // Trying again cannot add a door the service does not have. - return NoDoor(h.Service) + 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 { @@ -380,12 +395,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 @@ -393,3 +413,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 +} From 8834798e9502259703bbeb9cc6c82f43d41ff888 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 23:20:33 -0400 Subject: [PATCH 03/20] Say a quit saved here when only the upload is still pending A close that saved on this computer but could not reach the sync service prints that it is saved here and exits 0. A failed local write still says it could not save. The receipt is negotiated in the hello, so mixed old and new builds keep their former words and exit codes. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_layout.go | 4 +- cmd/codeaf/chatv3_surface.go | 24 +- cmd/codeaf/engine.go | 2 + cmd/codeaf/final_save.go | 327 +++++++++++++++++-- cmd/codeaf/l4_final_save_test.go | 263 +++++++++++++++ cmd/codeaf/l4_progress_test.go | 46 +++ cmd/codeaf/l4_syncdrive_test.go | 43 +++ cmd/codeaf/l4fix2_solo_test.go | 59 ++++ cmd/codeaf/l4fix_compat_test.go | 120 +++++++ cmd/codeaf/quit_handoff_test.go | 17 +- cmd/codeaf/quit_save_test.go | 6 +- cmd/codeaf/quit_tail_test.go | 42 ++- cmd/codeaf/quit_wait_test.go | 11 +- cmd/codeaf/sync_progress.go | 41 +++ cmd/codeaf/syncdrive.go | 13 +- cmd/codeaf/syncdrive_live.go | 13 +- internal/cellstore/l4_outage_test.go | 31 ++ internal/cellstore/sealwatch.go | 54 ++- internal/cellsync/batch.go | 24 +- internal/cellsync/branch.go | 3 +- internal/cellsync/l4fix_close_test.go | 111 +++++++ internal/cellsync/publish.go | 5 +- internal/remote/client.go | 91 +++++- internal/remote/l4_saving_test.go | 106 ++++++ internal/remote/l4b2_send_test.go | 25 ++ internal/remote/l4fix2_notices_test.go | 40 +++ internal/remote/l4fix_compat_test.go | 89 +++++ internal/remote/modelsource_wire_law_test.go | 4 + internal/remote/server.go | 78 ++++- internal/remote/wire.go | 15 + internal/session/agent.go | 12 +- internal/session/l4_saving_test.go | 32 ++ internal/session/saving_error.go | 3 + internal/session/session.go | 3 +- 34 files changed, 1665 insertions(+), 92 deletions(-) create mode 100644 cmd/codeaf/l4_final_save_test.go create mode 100644 cmd/codeaf/l4_progress_test.go create mode 100644 cmd/codeaf/l4_syncdrive_test.go create mode 100644 cmd/codeaf/l4fix2_solo_test.go create mode 100644 cmd/codeaf/l4fix_compat_test.go create mode 100644 cmd/codeaf/sync_progress.go create mode 100644 internal/cellstore/l4_outage_test.go create mode 100644 internal/cellsync/l4fix_close_test.go create mode 100644 internal/remote/l4_saving_test.go create mode 100644 internal/remote/l4b2_send_test.go create mode 100644 internal/remote/l4fix2_notices_test.go create mode 100644 internal/remote/l4fix_compat_test.go create mode 100644 internal/session/l4_saving_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 3160e1890a..04eaddd840 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -824,9 +824,9 @@ func v3SeatWith(place session.Place, say func(string), drives *driveBook) (execu 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 := drives.driveOf(c, cellstore.EngineFor(place.Workspace), watch.Report) + drive := drives.driveOf(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_surface.go b/cmd/codeaf/chatv3_surface.go index 608d80cce4..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) @@ -188,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/engine.go b/cmd/codeaf/engine.go index c1fe33eca2..6f65a4e031 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) }, @@ -1169,6 +1170,7 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote. 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 { 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/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_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/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/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/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/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 9bf6fb9a46..220f2e9f60 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -87,7 +87,9 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), change return nil, err } if err != nil || !ok { - report(err) + if err != nil { + report(err) + } return nil, err } if syncsetup.Quiet(home.Dir()) { @@ -206,3 +208,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 e9bc53973e..f2aa9b7b68 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -11,6 +11,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 @@ -77,7 +78,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() } @@ -141,12 +142,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/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/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/cellsync/batch.go b/internal/cellsync/batch.go index d1d1b979cd..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 { @@ -543,7 +555,7 @@ func (b *Batcher) lost(ctx context.Context, seen Driving) { 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 a40f7b92d7..9b700124f6 100644 --- a/internal/cellsync/branch.go +++ b/internal/cellsync/branch.go @@ -11,6 +11,7 @@ import ( "sync" "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 @@ -50,7 +51,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/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 1f752eecf2..3c6907d8df 100644 --- a/internal/cellsync/publish.go +++ b/internal/cellsync/publish.go @@ -9,6 +9,7 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cell" "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 @@ -80,7 +81,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 @@ -144,7 +145,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/remote/client.go b/internal/remote/client.go index 811c6bdd13..399a42b6ef 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. @@ -182,7 +185,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 @@ -230,7 +235,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 @@ -273,6 +280,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 @@ -481,6 +490,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. // @@ -741,15 +761,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. // @@ -827,6 +868,12 @@ func (c *Client) read() { switch frame.Kind { case "observed", "observerClosed": c.observerFrame(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": @@ -910,6 +957,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) @@ -925,6 +980,9 @@ func (c *Client) deliver(frame Frame) { } if frame.Error != "" { err := errors.New(frame.Error) + if c.Welcome().LocalSavingReceipts && frame.SyncPending && frame.LocalSaved { + err = syncwords.UploadFailure(err) + } if frame.UnsavedTranscript != "" { err = session.SavingError(frame.UnsavedTranscript, err) } @@ -968,6 +1026,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 — @@ -1105,6 +1164,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() @@ -1949,9 +2014,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) } @@ -2690,3 +2761,9 @@ func (c *Client) DeleteTask(file, id string) error { // 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/server.go b/internal/remote/server.go index 1cde1e9903..953d122c50 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" @@ -130,6 +132,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 @@ -292,7 +296,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 @@ -365,6 +371,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 } @@ -405,9 +414,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. @@ -538,6 +549,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(), @@ -852,6 +864,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)) } @@ -1254,6 +1269,9 @@ func (sess *Session) welcomeLocked(s *server) Welcome { note += newer } return Welcome{ + LocalSavingReceipts: s.localSavingReceipts, + Machine: MachineName(), + SyncService: sess.engine.SyncService, Version: Version, Workspace: sess.engine.Workspace, SessionFile: sess.engine.SessionFile, @@ -1696,7 +1714,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 @@ -1956,6 +1976,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()) @@ -2067,10 +2089,38 @@ 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) 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() + result.SyncPending = result.LocalSaved && syncwords.UploadPending(err) var unsaved *session.UnsavedChatError if errors.As(err, &unsaved) { result.UnsavedTranscript = unsaved.Transcript @@ -3440,6 +3490,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/wire.go b/internal/remote/wire.go index 7189a1b6af..cf9a3e2a33 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -450,6 +450,10 @@ type Frame struct { // Error is a call that failed, on "result" frames, and the reason on // "fatal" frames. Error string `json:"error,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"` @@ -859,6 +863,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"` @@ -1034,6 +1043,12 @@ type StreamCursor struct { // Welcome is the server's answer ("welcome"): the facts a surface needs before // its first frame, which are the same facts newApp reads off a local agent. type Welcome struct { + // 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. diff --git a/internal/session/agent.go b/internal/session/agent.go index 9b16d463db..a5e3b9c231 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2674,10 +2674,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/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/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 b5958f9efb..527bee129c 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3213,7 +3213,8 @@ type Agent struct { // (issue #381). It is made under the same lock that sets `closed`, so a // caller that loses the race is guaranteed to find a channel to wait on // rather than a nil one. - closeDone chan struct{} + closeDone 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. From f86b4d60ed32b30a8c32ca21f01648b1f153aff4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 23:20:33 -0400 Subject: [PATCH 04/20] Ask before removing a device, and draw device marks through the glyph door /devices asks once before removing a computer (y removes, any other key keeps it), lists this device and an Add another device row, and Home stays Home for /devices, /pair and /settings. Platform and presence marks come from tokens slots. A refused send keeps the person's words in the box. Co-Authored-By: Claude Opus 5.5 --- internal/devname/device.go | 2 +- internal/devname/devname.go | 3 +- internal/dirwatch/feed.go | 19 +- internal/dirwatch/l4b2_presence_test.go | 93 +++++++++ internal/iconlaw/iconlaw_test.go | 30 ++- internal/tui2/tokens/glyph.go | 12 ++ internal/tui2/tokens/glyphset.go | 6 + internal/tui2/tokens/glyphset_test.go | 1 + internal/tui2/tokens/l4b2_platform_test.go | 32 +++ internal/tui2/tokens/nerdfont.go | 6 + .../tokens/testdata/nerdfont_glyphnames.json | 15 ++ internal/tui3/app.go | 52 ++++- internal/tui3/approve.go | 26 +-- internal/tui3/approve_test.go | 8 +- internal/tui3/continue_2070_test.go | 2 +- internal/tui3/devices.go | 101 +++++++--- internal/tui3/devices_rename_test.go | 6 +- internal/tui3/home.go | 6 +- internal/tui3/homeband_addmachine.go | 45 +++-- internal/tui3/homeband_devices.go | 2 +- internal/tui3/homeband_resume.go | 20 ++ internal/tui3/homeband_resume_test.go | 2 +- internal/tui3/homeband_standing.go | 11 +- internal/tui3/homecell_keepnote_test.go | 8 +- internal/tui3/homelive_test.go | 4 +- internal/tui3/homepanel_continue.go | 8 +- internal/tui3/homepanel_continue_test.go | 8 +- internal/tui3/homepanel_heldelsewhere.go | 4 +- internal/tui3/homepanel_machines_test.go | 10 +- internal/tui3/homepanel_takedoor_test.go | 2 +- internal/tui3/homeslash.go | 4 +- internal/tui3/hostlink.go | 27 ++- internal/tui3/input.go | 13 ++ internal/tui3/keeper.go | 57 +++++- internal/tui3/l4_pair_test.go | 69 +++++++ internal/tui3/l4b2_devices_test.go | 156 +++++++++++++++ internal/tui3/l4b2_home_test.go | 183 ++++++++++++++++++ internal/tui3/l4b2_latency_test.go | 27 +++ internal/tui3/l4b2_send_test.go | 26 +++ internal/tui3/l4fix2_notices_test.go | 30 +++ internal/tui3/l4fix_devices_test.go | 39 ++++ internal/tui3/l4fix_quit_test.go | 51 +++++ internal/tui3/l4fix_sync_words_test.go | 77 ++++++++ internal/tui3/manual_devices_test.go | 5 +- internal/tui3/notices.go | 6 +- internal/tui3/notices_test.go | 4 +- internal/tui3/ownership_deadline_test.go | 2 +- internal/tui3/pages.go | 20 +- internal/tui3/pair.go | 90 ++++++++- internal/tui3/pairdrag_test.go | 2 +- internal/tui3/place_home.go | 4 + internal/tui3/place_settings.go | 6 + internal/tui3/relay_surface_test.go | 2 +- internal/tui3/removed_computer.go | 7 +- internal/tui3/renewal_notice.go | 55 +++++- internal/tui3/settings.go | 4 + internal/tui3/settings_devices.go | 5 +- internal/tui3/settings_devices_test.go | 6 +- internal/tui3/statusnote.go | 2 +- internal/tui3/takeover.go | 1 + internal/tui3/tui3.go | 6 + internal/tui3/tui3_test.go | 4 +- 62 files changed, 1398 insertions(+), 136 deletions(-) create mode 100644 internal/dirwatch/l4b2_presence_test.go create mode 100644 internal/tui2/tokens/l4b2_platform_test.go create mode 100644 internal/tui3/l4_pair_test.go create mode 100644 internal/tui3/l4b2_devices_test.go create mode 100644 internal/tui3/l4b2_home_test.go create mode 100644 internal/tui3/l4b2_latency_test.go create mode 100644 internal/tui3/l4b2_send_test.go create mode 100644 internal/tui3/l4fix2_notices_test.go create mode 100644 internal/tui3/l4fix_devices_test.go create mode 100644 internal/tui3/l4fix_quit_test.go create mode 100644 internal/tui3/l4fix_sync_words_test.go diff --git a/internal/devname/device.go b/internal/devname/device.go index a4ac19efe4..2f0b408ed5 100644 --- a/internal/devname/device.go +++ b/internal/devname/device.go @@ -13,7 +13,7 @@ func DeviceLabel(name, id string, duplicate bool) string { if !duplicate { return name } - return name + " #" + id[max(0, len(id)-TailLength):] + return DistinctName(name, id) } // MatchesDevice accepts both the printed label and its space-free terminal form. diff --git a/internal/devname/devname.go b/internal/devname/devname.go index 035ae54fa6..0c5316b1e7 100644 --- a/internal/devname/devname.go +++ b/internal/devname/devname.go @@ -125,6 +125,5 @@ 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 { - const tail = 4 - return name + " #" + id[max(len(id)-tail, 0):] + return name + " #" + id[max(len(id)-TailLength, 0):] } 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/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/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 8a317bf736..b959115329 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2166,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 @@ -2742,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 @@ -2875,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. @@ -2882,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 @@ -3097,6 +3103,7 @@ 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, @@ -3127,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 == "", @@ -6015,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 @@ -6523,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 } @@ -7332,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) } @@ -9010,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 @@ -9626,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 6920be6250..096df9a746 100644 --- a/internal/tui3/approve.go +++ b/internal/tui3/approve.go @@ -35,6 +35,7 @@ import ( "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 @@ -148,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 { @@ -225,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 } @@ -259,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 @@ -354,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/continue_2070_test.go b/internal/tui3/continue_2070_test.go index f46656510d..3460de083e 100644 --- a/internal/tui3/continue_2070_test.go +++ b/internal/tui3/continue_2070_test.go @@ -509,7 +509,7 @@ func TestHostedSurfaceNeverOffersContinueOrShowsHeldElsewhere(t *testing.T) { } screen := homeText(a) - if strings.Contains(screen, "running on host-b") || strings.Contains(screen, chatlist.OfferContinue) || strings.Contains(screen, "Other Remote") { + 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 { diff --git a/internal/tui3/devices.go b/internal/tui3/devices.go index 163beca0d3..11a88239ef 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 @@ -20,6 +20,7 @@ package tui3 import ( "context" "fmt" + "github.com/Agent-Field/codeaf/internal/devname" "slices" "sort" "strings" @@ -30,22 +31,25 @@ import ( "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." + 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 +57,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 +67,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 @@ -109,7 +116,7 @@ func (m devicesMsg) land(a *app) tea.Cmd { if !a.ownsDeviceCard(m.card) { return nil } - wasAdd := a.at(pageSettings) && a.sheet.dev == m.card && a.sheet.onAddDeviceRow() + wasAdd := m.card.loaded && m.card.cursor == len(m.card.rowsOf) if a.at(pageSettings) && a.sheet.dev == m.card { a.sheet.syncDeviceCursor() } @@ -130,6 +137,9 @@ 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 } @@ -241,6 +251,9 @@ func (c *deviceCard) mark(id string) { } func (c *deviceCard) hint() string { + if c.removing != nil { + return devicesRemoveKeys + } if c.naming != nil { return devicesNameKeys } @@ -252,7 +265,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) @@ -349,12 +365,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))) } @@ -400,7 +421,7 @@ func (c *deviceCard) rowText(d DeviceRow, now time.Time, pal palette) string { 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)) } @@ -453,12 +474,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": @@ -469,8 +502,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 { @@ -479,6 +512,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) @@ -496,7 +536,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 { @@ -515,3 +555,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/home.go b/internal/tui3/home.go index 10bf639b91..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 }, diff --git a/internal/tui3/homeband_addmachine.go b/internal/tui3/homeband_addmachine.go index e51dcdde60..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" ) @@ -48,14 +49,16 @@ const ( // 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 = "> " - addMachineShow = addMachineKey + " how" - addMachineHide = addMachineKey + " hide" + 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 ) @@ -95,7 +98,7 @@ func (a *app) addMachineWanted() bool { 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]. @@ -163,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 @@ -172,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))) } @@ -193,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) { @@ -247,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() { @@ -279,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 b576662573..86a377ca39 100644 --- a/internal/tui3/homeband_devices.go +++ b/internal/tui3/homeband_devices.go @@ -139,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 { diff --git a/internal/tui3/homeband_resume.go b/internal/tui3/homeband_resume.go index 552d55f631..886cc916e1 100644 --- a/internal/tui3/homeband_resume.go +++ b/internal/tui3/homeband_resume.go @@ -42,6 +42,7 @@ import ( "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" ) const ( @@ -67,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 } @@ -122,6 +124,21 @@ 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.hosted() && a.taker != nil && !a.machineRead.computerRemoved { row = a.resumeState().pick() @@ -197,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)) diff --git a/internal/tui3/homeband_resume_test.go b/internal/tui3/homeband_resume_test.go index 1e9925937c..d176327652 100644 --- a/internal/tui3/homeband_resume_test.go +++ b/internal/tui3/homeband_resume_test.go @@ -26,7 +26,7 @@ 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"}, 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/homelive_test.go b/internal/tui3/homelive_test.go index c193ece2f9..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) } diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 9c013e0fab..94ac86042f 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -98,7 +98,7 @@ const ( 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" @@ -172,7 +172,7 @@ func (a *app) homeMachineEnter(line homeLine) tea.Cmd { return nil } if a.machineRead.computerRemoved { - a.home.say(chatlist.Removed, "") + a.home.say(chatlist.RemovedChat, "") return nil } row := *line.remote @@ -305,7 +305,7 @@ func (a *app) raiseContinue(row chatlist.Row, at int) tea.Cmd { return nil } if a.machineRead.computerRemoved { - a.home.say(chatlist.Removed, "") + a.home.say(chatlist.RemovedChat, "") return nil } if a.sayCellsUnavailable() { @@ -503,7 +503,7 @@ func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { } if a.machineRead.computerRemoved { a.restoreTakeDraft(msg) - a.home.say(chatlist.Removed, "") + a.home.say(chatlist.RemovedChat, "") return nil } if a.homeTaking != "" && a.homeTakeAutomatic { diff --git a/internal/tui3/homepanel_continue_test.go b/internal/tui3/homepanel_continue_test.go index 4d092a1c7a..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) { @@ -493,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 d4287d4649..364491e286 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -272,7 +272,7 @@ 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 { @@ -429,7 +429,7 @@ func (a *app) continueFailure(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) { 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_takedoor_test.go b/internal/tui3/homepanel_takedoor_test.go index 77e1f8ee07..a462daca2f 100644 --- a/internal/tui3/homepanel_takedoor_test.go +++ b/internal/tui3/homepanel_takedoor_test.go @@ -62,7 +62,7 @@ func TestListedHereAndHeldElsewhereDrawsTheDoor(t *testing.T) { 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/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 41f2bd8e97..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 @@ -1487,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 6ac5d450a8..a6d372b04c 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1282,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) @@ -1419,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) } }() @@ -1751,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/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/manual_devices_test.go b/internal/tui3/manual_devices_test.go index 71cdb298d2..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,9 +23,9 @@ 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": { 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 f063e946e1..f6dca4d5d5 100644 --- a/internal/tui3/ownership_deadline_test.go +++ b/internal/tui3/ownership_deadline_test.go @@ -23,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, "sync.example did not answer in 25s; still trying") || 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) } } diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 8cfd865ada..57cffbae30 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(homeMessage, nil) + add(a.homeFootLine(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 17f33b0e81..f9e6c256db 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)) } @@ -387,6 +420,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()) @@ -432,6 +468,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) @@ -446,3 +485,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_settings.go b/internal/tui3/place_settings.go index 6ad2a187e6..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" ) @@ -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 { 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/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/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/settings.go b/internal/tui3/settings.go index 4a87104a7b..836a6cc356 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -2298,6 +2298,10 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { } 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 diff --git a/internal/tui3/settings_devices.go b/internal/tui3/settings_devices.go index aaac23dd3f..3541ec4ffc 100644 --- a/internal/tui3/settings_devices.go +++ b/internal/tui3/settings_devices.go @@ -143,7 +143,7 @@ func (a *app) ensureSheetDevices() tea.Cmd { if a.sheet.devicesUnavailable() != "" || a.sheet.dev != nil { return nil } - card := &deviceCard{presence: a.presence} + card := a.newDeviceCard() a.sheet.dev = card if !a.sheet.searching() { a.sheet.build() @@ -359,6 +359,9 @@ func (s *sheet) devicesDetails(frameWidth, bodyWidth int) []string { // 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() } diff --git a/internal/tui3/settings_devices_test.go b/internal/tui3/settings_devices_test.go index 53ed4be1a2..18f16afcb1 100644 --- a/internal/tui3/settings_devices_test.go +++ b/internal/tui3/settings_devices_test.go @@ -211,7 +211,7 @@ func TestSettingsDevicesAddRowLeavesSettingsAndStartsBarePair(t *testing.T) { // `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 TestSettingsDevicesRKeyRemovesAnotherRowRefusesOwnRowAndIgnoresAddAndRemovedRows(t *testing.T) { +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}, @@ -240,8 +240,8 @@ func TestSettingsDevicesRKeyRemovesAnotherRowRefusesOwnRowAndIgnoresAddAndRemove t.Fatalf("r on add row acted: door=%q line=%q", door.did(), a.sheet.dev.line) } - // Removable row: revokes, marks removed, and puts devicesRevoked on the foot line. - r.press("up", "r") + // 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) 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/tui3.go b/internal/tui3/tui3.go index e037ddda1f..9c47373b64 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -450,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 @@ -460,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) } } From bfbc0a3c8d1fb2ae28fe699e77cd0862d45e4320 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 23:20:34 -0400 Subject: [PATCH 05/20] Name cluster members by device on an opt-in status header Services that predate Codeaf-Cluster-Names send no labels, and the command then says another computer rather than claiming one was removed. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/cluster.go | 113 +++++++++++++++++++++++-- cmd/codeaf/cluster_test.go | 8 +- cmd/codeaf/l4_cluster_test.go | 55 ++++++++++++ cmd/codeaf/l4b2_cluster_test.go | 49 +++++++++++ internal/clusterctl/control.go | 11 ++- internal/clusterctl/l4_names_test.go | 45 ++++++++++ internal/clusterstate/personal_host.go | 11 ++- internal/raftdirectory/control.go | 2 +- relay/hosted/src/identity.js | 2 +- relay/hosted/src/raft/hosted.js | 9 +- 10 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 cmd/codeaf/l4_cluster_test.go create mode 100644 cmd/codeaf/l4b2_cluster_test.go create mode 100644 internal/clusterctl/l4_names_test.go diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index a44d6a2c32..15a156d2b2 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,14 +62,21 @@ 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 explainSyncUnreachable(clusterCommandMode(ctx, s, args[0], os.Stdout, *verbose), s.Relay) } // 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 { +func clusterCommandMode(ctx context.Context, c clusterDoor, verb string, out io.Writer, verbose bool) error { + return clusterCommand(ctx, c, verb, out, verbose) +} + +func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer, details ...bool) error { + verbose := len(details) > 0 && details[0] + if verb == "status" { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, clusterStatusTimeout) @@ -69,6 +88,11 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ if err != nil { return explainSyncUnreachable(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 } @@ -76,6 +100,9 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ if err != nil { return explainSyncUnreachable(explainRemovedDevice(err), "") } + if !verbose { + return printClusterStatus(out, s, clusterDeviceNames(ctx, c, s)) + } if s.Leader != 0 { fmt.Fprintf(out, "coordinator %d\n", s.Leader) } @@ -125,3 +152,75 @@ func boundedClusterOutput(ctx context.Context, out io.Writer, work func(context. return explainSyncUnreachable(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_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/l4_cluster_test.go b/cmd/codeaf/l4_cluster_test.go new file mode 100644 index 0000000000..eb935a8a82 --- /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 := clusterCommand(context.Background(), l4Cluster{}, "status", &out); 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 := clusterCommand(context.Background(), l4Cluster{}, "lead", &out); 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/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/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/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/relay/hosted/src/identity.js b/relay/hosted/src/identity.js index 50e02ca336..d9ae55bac0 100644 --- a/relay/hosted/src/identity.js +++ b/relay/hosted/src/identity.js @@ -116,7 +116,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 6f8bf28542..e54c5d69a9 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); From 180036e9649747eddeb33fa8db9df2149124c9e0 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 23:20:34 -0400 Subject: [PATCH 06/20] Bring the manual and the end-to-end words in step with pairing and sync wording Co-Authored-By: Claude Opus 5.5 --- .../manual/chat/add-or-remove-a-computer.md | 154 ++++++++++++++---- internal/manual/chat/commands.md | 8 +- .../continuing-a-chat-on-another-computer.md | 42 +++-- .../manual/chat/coordination-and-workers.md | 26 ++- internal/manual/chat/home.md | 20 +-- internal/manual/chat/keys.md | 40 ++++- internal/manual/chat/naming-a-device.md | 4 +- .../chat/reaching-this-machine-without-ssh.md | 52 ++++-- .../manual/chat/relay-hosted-or-your-own.md | 22 ++- .../manual/chat/relay-limits-and-refusals.md | 4 +- .../manual/chat/running-from-the-terminal.md | 12 +- .../manual/chat/running-on-another-machine.md | 24 ++- internal/manual/chat/screen.md | 2 +- internal/manual/chat/sessions-and-rewind.md | 4 +- .../chat/use-this-on-another-computer.md | 55 +++++-- 15 files changed, 348 insertions(+), 121 deletions(-) diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 44d464b4e2..b79f6f61e9 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. Your chats and your work can follow you to a second device. You add it in two steps. There is no account to make and no form to fill. @@ -24,10 +24,20 @@ 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. -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 @@ -46,19 +56,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/#` 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 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 @@ -71,9 +81,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.` @@ -93,7 +103,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`: +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 /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 lists your paired computers 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. +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 -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`): +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) removes the computer under the cursor at once, marks its row `removed`, and says `desk was removed — it can no longer reach your chats.` 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. +- `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 no computers, and `enter add` answers `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`, as `/pair` does. +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 @@ -186,15 +203,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 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 @@ -202,16 +229,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 @@ -232,3 +259,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 d089952e2f..0fafe3b58b 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -339,9 +339,9 @@ command). |---|---|---|---| | `/pair` | `/sync`, `/link`, `/laptop` | — | shows a code like `42-715-302`, valid 10 minutes, that gives your chats to another computer, then asks `y / n` when that computer types it | | `/pair` | `/sync`, `/link`, `/laptop` | `` | on the other computer: uses a code from `/pair` there, and shows three words to compare while it waits | -| `/pair` | `/sync`, `/link`, `/laptop` | ` --replace` | on the other computer: like ``, but a computer with chats of its own is asked first, and an explicit yes replaces them with the shared ones — chats sealed under the old identity cannot be read afterwards | +| `/pair` | `/sync`, `/link`, `/laptop` | ` --replace` | on the other computer: like ``, but a computer with chats of its own is asked first, and only an explicit `y` joins the shared chats; existing local chats stay here but stop syncing, shared memories and settings clear, and local provider keys stay here | | `/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 (`/settings` → `Devices` holds the same list plus `Add another device`) | +| `/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. 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 0c30632454..31e3a21c35 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -12,7 +12,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 ``` @@ -73,11 +73,11 @@ The card still says `this chat was last held here; last saved turn 2h ago` when 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. @@ -95,7 +95,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. @@ -170,11 +170,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`. @@ -199,12 +199,12 @@ every failure stays where you are looking: on Home's card with `1 retry` and `2 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 `running on `. Nothing changed. -- `other machines unreachable`: this device cannot reach the service. Nothing can be moved until it can. +- `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 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. +- `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. @@ -214,7 +214,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win 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` on the card with `1 retry` and `2 leave it there`; its local copy is not replaced, and its row goes back to `running 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. +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 @@ -309,7 +309,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. @@ -440,3 +444,19 @@ Run `codeaf doctor --fix` to repair what is safe. It prints one line per repair: - `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/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 58af353b43..fd25122259 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. @@ -21,7 +21,7 @@ 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 +33,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 +71,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/home.md b/internal/manual/chat/home.md index 8309efe333..44f2ba57d6 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 @@ -340,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 @@ -358,12 +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 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 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 @@ -1547,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: diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index a7e91eceb6..1b0d6bc00d 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 @@ -3829,3 +3827,29 @@ in the roster, and press `x`. Neither route opens the task room. The stop card opens on `keep going`: choose `stop it` and press Enter to confirm, or click that answer. Escape cancels the card. A half-typed message is preserved; with text in the box, an ordinary `x` types the letter instead. + +## 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/naming-a-device.md b/internal/manual/chat/naming-a-device.md index e9f4d4b836..cc8e1adbc4 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`, `/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. +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. @@ -59,4 +59,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` and `/settings` → `Devices` then add 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/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index 966f88e073..b78dcac9e4 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -112,14 +112,14 @@ If the relay turns registrations away — it allows 10 registrations a minute fr address — `codeaf serve` backs off the same way and says once: ``` -the service is turning registrations away right now — waiting before trying again +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 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 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 ``` A relay that cannot be reached when it starts stops it with the sentence in *When --at @@ -127,7 +127,7 @@ will not open*. With sync turned off (`CODEAF_SYNC_URL=off`) and no `CODEAF_RELA is no relay at all: ``` -no sync address is set up on this machine, so there is nowhere to be reachable from — set CODEAF_RELAY to your sync 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: @@ -148,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: ``` @@ -158,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 @@ -353,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 @@ -411,25 +413,25 @@ There are five different reasons, and each says which one it is, in one sentence **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 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 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:** @@ -441,10 +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 +`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 relay at https://service.example.com/wrong-prefix refused this connection (404) — check the address it was given`. +`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 @@ -554,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` shows its device name beside the connection name, for example `this machine is reachable as desk-b (seagull-lichen-63)`. The `--at` window’s bar shows `desk-b`. This build still needs the connection name `seagull-lichen-63` in `codeaf chat --at`; syncing has not yet published a device-name lookup for machine keys. + +## 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. + +When the asking name matches a name in your paired devices, the approver says `laptop-a has the same name as one of your devices; let it run work here too?`, followed by the three-word check and `y / n`. That name is descriptive; the code check and your explicit approval still decide whether this machine key gets access. + +## 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 56281e7b03..86a0b43d37 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -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, 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 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`. 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. +- **`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. @@ -59,3 +59,21 @@ Moving chats and pairing are available by default on Linux, macOS and native Win 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. `codeaf --help` lists this command, 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 36d4797582..13801d9b0b 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -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 f23dc933fc..a60d40b1bb 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -342,9 +342,9 @@ 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 `--replace` lets this computer drop chats of its own for the ones it is given. The page *Pairing your chats with a second computer* has the whole story. +**`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 service to go through, and `--replace` keeps this computer’s old chat files locally while replacing shared keys and clearing shared memories, preferences and the shared key vault; provider keys in local settings remain. The page *Pairing your chats with a second computer* has the whole story. ## The architecture map — codeaf atlas [map], /atlas [map], what talks to what, how two computers pair and continue a chat @@ -451,14 +451,14 @@ 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 @@ -505,7 +505,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). 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 740a84d066..bff86664bd 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -2011,7 +2011,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 8c1f328424..045710f1c6 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -1002,6 +1002,6 @@ 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. diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index f586ac04a7..a9987db45f 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -39,7 +39,7 @@ Pair both computers first with `codeaf pair` or `/pair`, on Linux, macOS or nati computers through the saved or hosted relay. `CODEAF_SYNC_URL=off` stops relay traffic. Older chats become movable through independent copies after pairing; their original layout and files stay untouched for older builds. Recent chats are prepared in the background, and opening any other older chat prepares its copy. A never-paired home sends no relay requests. -## 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. @@ -66,7 +66,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`: @@ -90,13 +90,13 @@ 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. This device now has your chats. +Paired with laptop-a. Run codeaf to open your chats. ``` The code is now dead. It cannot pair a third computer. @@ -185,13 +185,13 @@ It is not a view of your chats. It is a copy of you. Pair only a computer you ow Pairing shares your chats. It does not share remote access. A paired computer is not a key to the tools on this machine. That is a different code, from `codeaf serve`. Read *Reaching a machine with a pairing code* for that door. The two doors are compared in a table on that page. -If the other computer already has chats of its own, it keeps them and nothing changes. It says: +A computer whose conversations have no submitted turns can pair directly. If it has chats with submitted turns, the refusal counts them and explains `--replace`. For three chats: ``` -this computer already has chats of its own, so they stay local and nothing was changed; to replace them with the other device's, run this again with --replace +this computer already has 3 chats of its own, so nothing was changed. To pair it anyway, run this again with --replace: those 3 chats stay on this computer but no longer sync; shared memories and settings are cleared ``` -Run `codeaf pair --replace` if you want the chats of the other computer instead. In the chat, type `/pair --replace`: it asks first, states that this machine's own chats will be permanently replaced and that chats sealed under the old identity can no longer be read afterwards, and replaces them only after an explicit yes. A no, or closing the panel with esc, leaves everything as it was. +Run `codeaf pair --replace` if you want the chats of the other computer instead. In the chat, type `/pair --replace`: it asks first: `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 an explicit `y` proceeds. A no, or closing the panel with esc, leaves everything as it was. If the other computer already has the same chats, it says: @@ -215,13 +215,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 (in the chat, `/devices` and `/settings` → `Devices` list your paired computers). 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 ` 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 ` on the other computer. It does not recommend unavailable chat pairing. The group of computers you paired with `/pair` has this heading: @@ -297,7 +293,7 @@ to a relay, so your other computers can open them. Which relay is one setting, 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 ` (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 @@ -320,7 +316,7 @@ What the relay does see is short: the number at the start of the code, the time, 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
+no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via
``` ## Pair through your own relay, or stop relay traffic with CODEAF_SYNC_URL=off @@ -343,7 +339,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 @@ -355,7 +351,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: @@ -446,3 +442,28 @@ 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 codeaf finds local work it 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 ` 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`. 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. + +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. From 17789aeff98c962f62cec771d74cd2efc5fbd881 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 00:42:52 -0400 Subject: [PATCH 07/20] Fit pairing's service check and the help page to per-channel relays The link check recognises this build's own hosted origin however it is spelled, and leaves pair.LinkRelay exactly as #2079 wrote it. The rejoin test writes its device record itself, because syncsetup now imports pair. The front-page help is back under its line cap: codeaf relay keeps its own --help and the manual no longer says the front page lists it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/l4_help_test.go | 7 +++--- cmd/codeaf/main.go | 2 -- .../manual/chat/relay-hosted-or-your-own.md | 2 +- internal/pair/command.go | 16 ++++++++----- internal/pair/l4fix_rejoin_test.go | 23 +++++++++++++++---- internal/pair/l4fix_service_test.go | 8 +++++++ 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/cmd/codeaf/l4_help_test.go b/cmd/codeaf/l4_help_test.go index 14fbb2cb56..3e17da13fe 100644 --- a/cmd/codeaf/l4_help_test.go +++ b/cmd/codeaf/l4_help_test.go @@ -7,10 +7,11 @@ import ( "testing" ) -// L4-M2a, L4-M8a, L4-M8b: help exposes the real pairing code and both service overrides. +// 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") || !strings.Contains(usageText, "codeaf relay") { - t.Fatal("top-level help hides the service or gives the wrong code shape") + 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) { diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index b4e8d654be..686c688a2b 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -615,8 +615,6 @@ Housekeeping — changes state on disk or on the network 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 relay [--listen host:port] [--store path] - run your own sync service 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 diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index 86a0b43d37..3e4673a000 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -62,7 +62,7 @@ A chat you write on this computer is never held back by an older service. Releas ## 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. `codeaf --help` lists this command, and `codeaf help env` lists both service variables. +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. diff --git a/internal/pair/command.go b/internal/pair/command.go index 08d25d081d..34261e310d 100644 --- a/internal/pair/command.go +++ b/internal/pair/command.go @@ -37,19 +37,23 @@ func CheckLinkService(typed, address string) error { // 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. +// hosted link is waiting somewhere else, however the origin was spelled. func linkService(typed string) string { - if via := LinkRelay(typed); via != "" { - return via - } u, err := url.Parse(strings.TrimSpace(typed)) if err != nil { return "" } - if u.Scheme == "codeaf" || webRelay(u) && strings.HasPrefix(u.Path, "/p/") { + 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 "" + return LinkRelay(typed) } // canonicalService normalizes URL spelling without erasing a service's path or protocol. diff --git a/internal/pair/l4fix_rejoin_test.go b/internal/pair/l4fix_rejoin_test.go index f5bbea3c5f..aadb36da28 100644 --- a/internal/pair/l4fix_rejoin_test.go +++ b/internal/pair/l4fix_rejoin_test.go @@ -1,14 +1,29 @@ package pair import ( + "context" + "runtime" "testing" "time" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/identity" - "github.com/Agent-Field/codeaf/internal/syncsetup" ) +// 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) @@ -30,10 +45,10 @@ func TestL4FixRemovedComputerRejoins(t *testing.T) { t.Fatal(err) } dir := directory.NewMemory(time.Now) - if err := syncsetup.PutOwnDevice(ctx, dir.For(a.ID()), r.a, a, "laptop-a"); err != nil { + if err := putOwnDevice(ctx, dir.For(a.ID()), r.a, a, "laptop-a"); err != nil { t.Fatal(err) } - if err := syncsetup.PutOwnDevice(ctx, dir.For(old.ID()), r.a, old, "desk-b"); err != nil { + 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 { @@ -57,7 +72,7 @@ func TestL4FixRemovedComputerRejoins(t *testing.T) { if err := fresh.Cert.Verify(r.a.PublicKey()); err != nil { t.Fatal(err) } - if err := syncsetup.PutOwnDevice(ctx, dir.For(fresh.ID()), r.a, fresh, "desk-b"); err != nil { + 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) diff --git a/internal/pair/l4fix_service_test.go b/internal/pair/l4fix_service_test.go index 0653221f77..b9bc139e6d 100644 --- a/internal/pair/l4fix_service_test.go +++ b/internal/pair/l4fix_service_test.go @@ -3,10 +3,18 @@ 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) } From d60136db54ca4ecc17ffaa20b5b325504aa69e20 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 00:43:42 -0400 Subject: [PATCH 08/20] Change entry for #2087 Co-Authored-By: Claude Opus 5.5 --- .../2087-pairing-connection-words.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/changes/unreleased/2087-pairing-connection-words.md 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..09b1a5b0af --- /dev/null +++ b/docs/changes/unreleased/2087-pairing-connection-words.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: Pairing and sync screens say which service failed, a computer opened once can pair, and removing a device 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, and a home with 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. From b48c1758d45f9873eb815b99d0de37a04042a469 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 00:43:51 -0400 Subject: [PATCH 09/20] Keep the #2087 change title under one line Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/2087-pairing-connection-words.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/2087-pairing-connection-words.md b/docs/changes/unreleased/2087-pairing-connection-words.md index 09b1a5b0af..8ad48e31ae 100644 --- a/docs/changes/unreleased/2087-pairing-connection-words.md +++ b/docs/changes/unreleased/2087-pairing-connection-words.md @@ -1,6 +1,6 @@ --- kind: fixed -title: Pairing and sync screens say which service failed, a computer opened once can pair, and removing a device asks first +title: Pairing names the failing sync service, an unused computer can join, and removal asks first pr: 2087 surface: [chat, engine, remote, docs] invalidates: From cce8a77b2168e32ecbffe38e3bcba6d6ea104632 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 00:55:07 -0400 Subject: [PATCH 10/20] Say "that chat stays" when the refusal counts one chat The hand walk read "those 1 chat stay on this computer but no longer sync". Co-Authored-By: Claude Opus 5.5 --- internal/pair/join.go | 6 +++--- internal/pair/l4_join_test.go | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/pair/join.go b/internal/pair/join.go index 3e6a51dbea..437c18281e 100644 --- a/internal/pair/join.go +++ b/internal/pair/join.go @@ -217,11 +217,11 @@ func differentChats(home string) error { if n == 0 { return ErrDifferentChats } - noun := "chats" + count, kept := fmt.Sprintf("%d chats", n), fmt.Sprintf("those %d chats stay on this computer but no longer sync", n) if n == 1 { - noun = "chat" + count, kept = "1 chat", "that chat stays on this computer but no longer syncs" } - return differentChatError{fmt.Sprintf("this computer already has %d %s of its own, so nothing was changed. To pair it anyway, run this again with --replace: those %d %s stay on this computer but no longer sync; shared memories and settings are cleared", n, noun, n, noun)} + return differentChatError{"this computer already has " + count + " of its own, so nothing was changed. To pair it anyway, run this again with --replace: " + kept + "; shared memories and settings are cleared"} } type differentChatError struct{ line string } diff --git a/internal/pair/l4_join_test.go b/internal/pair/l4_join_test.go index 032ad1f4b2..4b255a8812 100644 --- a/internal/pair/l4_join_test.go +++ b/internal/pair/l4_join_test.go @@ -71,6 +71,25 @@ func TestL4PairCountsChats(t *testing.T) { } } +// L4-H2b: one chat is counted in the singular, as the hand walk read it. +func TestL4PairCountsOneChat(t *testing.T) { + h := t.TempDir() + if _, err := identity.EnsureSolo(h); err != nil { + t.Fatal(err) + } + p := filepath.Join(h, "v3/projects/project", "a", ".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) + } + _, err := (LinkJoining{Joining: Joining{Home: h}}).free() + if !errors.Is(err, ErrDifferentChats) || !strings.Contains(err.Error(), "already has 1 chat of its own") || !strings.Contains(err.Error(), "that chat stays on this computer but no longer syncs") || strings.Contains(err.Error(), "those 1") { + t.Fatalf("%v", 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" { From c23af36204c09d060f230e6eeba42036dc32d76c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 01:50:40 -0400 Subject: [PATCH 11/20] Keep cluster, devices and terminal approval on the service this computer uses Cluster and device errors name the service the command used, never this build's hosted default. Verbose cluster status prints its membership labels as before. Roster names stay plain so a reused devbox name still finds the joined computer; duplicate tails are added only when the list is drawn. A terminal approving a self-hosted page's link keeps that page's service when it matches this computer's, and a link on another service is still refused before its code is sent. Existing tests change only where a deliberate L4 sentence or the solo device list (this device is listed) changed what they read. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/c10_selfhost_pair_test.go | 2 +- cmd/codeaf/chatv3_at_test.go | 7 ++- cmd/codeaf/cluster.go | 42 +++++++++----- cmd/codeaf/cluster_deadline_test.go | 7 ++- cmd/codeaf/cluster_unreachable_test.go | 4 +- cmd/codeaf/devices.go | 50 ++++++++++++----- cmd/codeaf/devices_outage_test.go | 5 +- cmd/codeaf/l4_cluster_test.go | 4 +- cmd/codeaf/l4fix3_cluster_test.go | 33 +++++++++++ cmd/codeaf/l4fix3_devices_test.go | 55 +++++++++++++++++++ cmd/codeaf/pairlink.go | 7 +++ cmd/codeaf/privacy_test.go | 3 +- .../manual/chat/add-or-remove-a-computer.md | 2 + .../manual/chat/coordination-and-workers.md | 2 + 14 files changed, 183 insertions(+), 40 deletions(-) create mode 100644 cmd/codeaf/l4fix3_cluster_test.go create mode 100644 cmd/codeaf/l4fix3_devices_test.go 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/chatv3_at_test.go b/cmd/codeaf/chatv3_at_test.go index 2e94b34071..97f59902ce 100644 --- a/cmd/codeaf/chatv3_at_test.go +++ b/cmd/codeaf/chatv3_at_test.go @@ -78,7 +78,7 @@ func TestWithNoRelayBothDoorsSayWhatIsWrongAndWhatToDoInstead(t *testing.T) { 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) } @@ -92,11 +92,14 @@ 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 diff --git a/cmd/codeaf/cluster.go b/cmd/codeaf/cluster.go index 15a156d2b2..478899a117 100644 --- a/cmd/codeaf/cluster.go +++ b/cmd/codeaf/cluster.go @@ -63,30 +63,45 @@ func runCluster(args []string) error { ctx, cancel := context.WithTimeout(context.Background(), clusterctl.ForwardTimeout) defer cancel() defer terminalSyncProgress(s.Relay)() - return explainSyncUnreachable(clusterCommandMode(ctx, s, args[0], os.Stdout, *verbose), s.Relay) + return clusterCommandMode(ctx, s, args[0], os.Stdout, *verbose, s.Relay) } -// 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 clusterCommandMode(ctx context.Context, c clusterDoor, verb string, out io.Writer, verbose bool) error { - return clusterCommand(ctx, c, verb, out, verbose) +// 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 clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writer, details ...bool) error { - verbose := len(details) > 0 && details[0] +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. 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}) @@ -98,7 +113,7 @@ func clusterCommand(ctx context.Context, c clusterDoor, verb string, out io.Writ } 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)) @@ -123,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 @@ -149,7 +165,7 @@ 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() } } 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_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/devices.go b/cmd/codeaf/devices.go index c59e1f945a..31e0aaf3c6 100644 --- a/cmd/codeaf/devices.go +++ b/cmd/codeaf/devices.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "net/url" "os" "runtime" "slices" @@ -445,22 +446,14 @@ func (c chatsKind) rows() ([]chatsRow, error) { 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, Platform: d.Platform}, + 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].baseName = rows[i].Name - } - for i := range rows { - duplicate := false - for j := range rows { - if i != j && strings.EqualFold(rows[i].baseName, rows[j].baseName) { - duplicate = true - } - } - rows[i].Name = devname.DeviceLabel(rows[i].Name, rows[i].id, duplicate) 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) @@ -494,13 +487,19 @@ func chatsRowsTable(rows []chatsRow, now time.Time, duplicates map[string]bool) 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 - if duplicates != nil { - shown[i].Name = devname.DeviceLabel(row.baseName, row.id, duplicates[strings.ToLower(row.baseName)]) - } + 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) @@ -510,9 +509,27 @@ func chatsRowsTable(rows []chatsRow, now time.Time, duplicates map[string]bool) func (c chatsKind) called(name string) ([]chatsRow, error) { rows, err := c.rows() + 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) { rows, err := c.called(name) return len(rows), err @@ -563,7 +580,10 @@ func (c chatsKind) explain(err error) error { // 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 relay == "" { + // 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 + if relay == "" && !errors.As(err, &request) { relay = syncsetup.Resolve(home.Dir()).URL } return syncwords.Explain(err, relay) 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/l4_cluster_test.go b/cmd/codeaf/l4_cluster_test.go index eb935a8a82..3aec2eae32 100644 --- a/cmd/codeaf/l4_cluster_test.go +++ b/cmd/codeaf/l4_cluster_test.go @@ -25,7 +25,7 @@ func (l4Cluster) ClusterNames(context.Context, clusterctl.Status) map[uint64]str // 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 := clusterCommand(context.Background(), l4Cluster{}, "status", &out); err != nil { + 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"} { @@ -39,7 +39,7 @@ func TestL4ClusterNames(t *testing.T) { } } out.Reset() - if err := clusterCommand(context.Background(), l4Cluster{}, "lead", &out); err != nil { + if err := clusterCommandMode(context.Background(), l4Cluster{}, "lead", &out, false); err != nil { t.Fatal(err) } if out.String() != "laptop-a now coordinates your devices\n" { 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/pairlink.go b/cmd/codeaf/pairlink.go index 2b170c2a9a..6fdb293eea 100644 --- a/cmd/codeaf/pairlink.go +++ b/cmd/codeaf/pairlink.go @@ -19,6 +19,7 @@ import ( "time" "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/reqsign" @@ -140,6 +141,12 @@ func (d pairDoor) approve(ctx context.Context, relay, typed string) error { if err != nil { return err } + // 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 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/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index b79f6f61e9..c33111923e 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -33,6 +33,8 @@ Click `+ Add another device` or press `alt+d` to open the steps. Click the headi 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. 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? diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index fd25122259..dcf7a9408e 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -17,6 +17,8 @@ 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. From dba304e63c61d7f2d18273141cc35cbb893f8785 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 01:50:40 -0400 Subject: [PATCH 12/20] Keep Home's whole credit warning beside its message Moving the account notice off the key-hint line left the credit warning beside the footer's project, where it was cut off. It now sits once at the right of Home's message row, as on dev. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/openrouter-credits.md | 4 +++ internal/tui3/hometip.go | 7 +++- internal/tui3/l4fix3_credits_test.go | 39 ++++++++++++++++++++++ internal/tui3/pages.go | 4 +-- internal/tui3/removed_continuation_test.go | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 internal/tui3/l4fix3_credits_test.go 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/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/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/pages.go b/internal/tui3/pages.go index 57cffbae30..9718d32a54 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1368,8 +1368,8 @@ func placeFrameWithBar(a *app, width, height int, fallthrough default: if homeMessage != "" { - add(homeMessage, nil) - add(a.homeFootLine(width, pal), nil) + 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 { 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 { From 471059b193e55cccea313b76741a4b9c8d03d86d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 03:29:51 -0400 Subject: [PATCH 13/20] Let the first-start fixture wait for its knowledge worker without spinning The wait read the worker's database in a tight loop, which starves the writer it waits for on a loaded machine. It now polls every 10 ms, and its ceiling is for setup, not a speed claim. Co-Authored-By: Claude Opus 5.5 --- internal/identity/l4fix2_startup_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/identity/l4fix2_startup_test.go b/internal/identity/l4fix2_startup_test.go index 0fd4624365..199bc61330 100644 --- a/internal/identity/l4fix2_startup_test.go +++ b/internal/identity/l4fix2_startup_test.go @@ -5,7 +5,6 @@ import ( "database/sql" "os" "path/filepath" - "runtime" "testing" "time" @@ -63,7 +62,9 @@ func firstStart(t *testing.T) string { startupFile(t, home, startupChat+"/"+name, "") } vault := knowledge.Open(home) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + // 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() { @@ -82,7 +83,12 @@ func firstStart(t *testing.T) string { if ctx.Err() != nil { t.Fatal("startup knowledge worker did not finish", <-done) } - runtime.Gosched() + // 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 From 2d099a5d3fefac795fadf7d8a289584c04d0840e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 04:05:28 -0400 Subject: [PATCH 14/20] A window opened once with a provider connected can still pair without --replace The picker's model list is cached as models-.json for a non-default service, and the empty-computer rule knew only models.json, so a computer that had opened codeaf once with Ollama connected was told it had local work. internal/catalog now owns that file name, the window builds its path through it, and pairing accepts exactly those names. A keyless end-to-end test opens the real window with a non-default service, sends nothing and pairs, and a second window that sends one turn is refused. Co-Authored-By: Claude Opus 5.5 --- internal/catalog/catalog.go | 35 +++- internal/catalog/l4fix4_picker_test.go | 28 +++ internal/e2e/l4fix4_joinable_e2e_test.go | 167 ++++++++++++++++++ internal/identity/l4fix4_picker_test.go | 34 ++++ internal/identity/pristine.go | 2 +- internal/identity/pristine_judges.go | 5 + .../manual/chat/add-or-remove-a-computer.md | 2 +- .../chat/use-this-on-another-computer.md | 14 +- internal/tui3/models.go | 16 +- 9 files changed, 280 insertions(+), 23 deletions(-) create mode 100644 internal/catalog/l4fix4_picker_test.go create mode 100644 internal/e2e/l4fix4_joinable_e2e_test.go create mode 100644 internal/identity/l4fix4_picker_test.go diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 1519c9e48b..0c5cff9eba 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -35,6 +35,7 @@ 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 @@ -1516,19 +1517,41 @@ 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 { - if name == cacheName { + 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(cacheName) - stem := strings.TrimSuffix(cacheName, extension) + extension := filepath.Ext(legacy) + stem := strings.TrimSuffix(legacy, extension) key, ok := strings.CutPrefix(name, stem+"-") if !ok { return false 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/e2e/l4fix4_joinable_e2e_test.go b/internal/e2e/l4fix4_joinable_e2e_test.go new file mode 100644 index 0000000000..f80317ed10 --- /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, "[Home]"); !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/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 fd02f0539b..2bd60c7395 100644 --- a/internal/identity/pristine.go +++ b/internal/identity/pristine.go @@ -55,7 +55,7 @@ var startupRules = rules{ "automations.db-wal": never, "automations.db-shm": never, "clock.lock": never, "clock.log": never, "environment.salt": never, "windows": never, }), - "history.jsonl": onlyCommands, "models.json": never, "lanes.json": never, "lanes.json.lock": 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{ diff --git a/internal/identity/pristine_judges.go b/internal/identity/pristine_judges.go index 254a0678ab..5a3cef782a 100644 --- a/internal/identity/pristine_judges.go +++ b/internal/identity/pristine_judges.go @@ -64,6 +64,11 @@ 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/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index c33111923e..a6d2aa9c7e 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -215,7 +215,7 @@ this device was removed by another of your devices, so this chat stays here only ## 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 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. +`/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 diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index a9987db45f..cb073fe350 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -185,7 +185,7 @@ It is not a view of your chats. It is a copy of you. Pair only a computer you ow Pairing shares your chats. It does not share remote access. A paired computer is not a key to the tools on this machine. That is a different code, from `codeaf serve`. Read *Reaching a machine with a pairing code* for that door. The two doors are compared in a table on that page. -A computer whose conversations have no submitted turns can pair directly. If it has chats with submitted turns, the refusal counts them and explains `--replace`. For three chats: +A computer whose conversations have no submitted turns can pair directly. If it already has its own saved pairing keys and chats with submitted turns, the refusal counts them and explains `--replace`. Chats made with `codeaf chat --once` before any pairing keys exist are kept on first pairing and can sync with the newly joined computers. For three chats on a computer with its own keys: ``` this computer already has 3 chats of its own, so nothing was changed. To pair it anyway, run this again with --replace: those 3 chats stay on this computer but no longer sync; shared memories and settings are cleared @@ -447,7 +447,7 @@ If your devices cannot be listed, `codeaf devices` exits with an error explainin 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 codeaf finds local work it 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`. +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 @@ -464,6 +464,12 @@ field. Use `codeaf pair approve ` 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`. 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. +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. -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. +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, chats made with `codeaf chat --once` are kept when it first pairs without `--replace`. They become part of the chats it can sync with the newly joined computers. Older chats are copied independently for syncing; their originals stay untouched and remain listed until a completed copy is ready. Pairing does not discard or hide them. + +A computer that already has its own saved pairing keys follows the local-work refusal and explicit `--replace` rules above. diff --git a/internal/tui3/models.go b/internal/tui3/models.go index 4f539c707f..60991a54fa 100644 --- a/internal/tui3/models.go +++ b/internal/tui3/models.go @@ -117,12 +117,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. @@ -131,11 +129,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 From 68289b746a8336093b275f10b722b73a2c486085 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 04:05:28 -0400 Subject: [PATCH 15/20] Refuse a used, never-paired computer before the pairing code is exchanged The code road refused only after the exchange, so the approving computer had already said the new device was paired. A solo computer whose work rules out a merge now refuses before anything is sent. Chats written before any identity existed keep dev's behaviour: they come along and sync, which two tests pin. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/l4fix4_preidentity_test.go | 106 ++++++++++++++++++ .../2087-pairing-connection-words.md | 8 +- internal/pair/join.go | 9 ++ internal/pair/l4fix4_preflight_test.go | 90 +++++++++++++++ internal/pair/l4fix4_preidentity_test.go | 70 ++++++++++++ 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 cmd/codeaf/l4fix4_preidentity_test.go create mode 100644 internal/pair/l4fix4_preflight_test.go create mode 100644 internal/pair/l4fix4_preidentity_test.go 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/docs/changes/unreleased/2087-pairing-connection-words.md b/docs/changes/unreleased/2087-pairing-connection-words.md index 8ad48e31ae..75920ca64e 100644 --- a/docs/changes/unreleased/2087-pairing-connection-words.md +++ b/docs/changes/unreleased/2087-pairing-connection-words.md @@ -4,7 +4,7 @@ title: Pairing names the failing sync service, an unused computer can join, and 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, and a home with real chats is told how many and what --replace does." + - "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." @@ -17,3 +17,9 @@ invalidates: 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. diff --git a/internal/pair/join.go b/internal/pair/join.go index 437c18281e..5096dbd72d 100644 --- a/internal/pair/join.go +++ b/internal/pair/join.go @@ -62,6 +62,15 @@ func Join(ctx context.Context, route Mailbox, j Joining, typed string, ui JoinUI if err != nil { return Joined{}, err } + // A solo identity cannot be the grant's identity, so known local work can + // refuse before the approver spends an approval or hears a success receipt. + if !j.Replace { + if _, err := identity.Load(j.Home); err == nil && identity.Solo(j.Home) { + if can, err := identity.Joinable(j.Home); err != nil || !can { + return Joined{}, differentChats(j.Home) + } + } + } if err := checkRelay(ctx, route); err != nil { return Joined{}, err } diff --git a/internal/pair/l4fix4_preflight_test.go b/internal/pair/l4fix4_preflight_test.go new file mode 100644 index 0000000000..b98cb7fd39 --- /dev/null +++ b/internal/pair/l4fix4_preflight_test.go @@ -0,0 +1,90 @@ +package pair + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "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 +} + +// L4-C: A used solo home refuses before even checking the mailbox. Replacement, +// startup-only homes and previously paired homes retain their existing roads. +func TestL4Fix4SoloJoinRefusesBeforeContactingMailbox(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()) + refuse := (tc.used || tc.name == "local work") && !tc.paired && !tc.replace + if refuse { + if !errors.Is(err, ErrDifferentChats) || box.calls != 0 { + t.Fatalf("join = %v; mailbox calls = %d", err, box.calls) + } + if tc.used && !strings.Contains(err.Error(), "already has 1 chat of its own") { + t.Fatal(err) + } + } else if !errors.Is(err, box.err) || box.calls != 1 { + t.Fatalf("existing road = %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) + } + }) + } +} From 3a3473650b119ef5a8a328b8f185d0fd08358c3e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 04:09:41 -0400 Subject: [PATCH 16/20] Wait for Home by the tuiwords needle, not a bracketed tab The selected tab reads [Home] only in some terminal modes, so the new window test timed out on a Home that was already drawn. Co-Authored-By: Claude Opus 5.5 --- internal/e2e/l4fix4_joinable_e2e_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/e2e/l4fix4_joinable_e2e_test.go b/internal/e2e/l4fix4_joinable_e2e_test.go index f80317ed10..b151067d5f 100644 --- a/internal/e2e/l4fix4_joinable_e2e_test.go +++ b/internal/e2e/l4fix4_joinable_e2e_test.go @@ -120,7 +120,7 @@ func TestL4ProviderWindowCanJoinUntilATurnIsSent(t *testing.T) { 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, "[Home]"); !ok { + 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") From 82f2b64257a45812a138ef227ccbd1145c2492aa Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 05:13:38 -0400 Subject: [PATCH 17/20] Say when a pre-pairing chat --once chat reaches the other computers Hello's check on a Mac found that such a chat is kept at pairing but is published the next time codeaf's window opens on that computer, in place rather than as a copy. The manual now says that instead of implying it syncs at once. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/use-this-on-another-computer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index cb073fe350..239cdffe7f 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -185,7 +185,7 @@ It is not a view of your chats. It is a copy of you. Pair only a computer you ow Pairing shares your chats. It does not share remote access. A paired computer is not a key to the tools on this machine. That is a different code, from `codeaf serve`. Read *Reaching a machine with a pairing code* for that door. The two doors are compared in a table on that page. -A computer whose conversations have no submitted turns can pair directly. If it already has its own saved pairing keys and chats with submitted turns, the refusal counts them and explains `--replace`. Chats made with `codeaf chat --once` before any pairing keys exist are kept on first pairing and can sync with the newly joined computers. For three chats on a computer with its own keys: +A computer whose conversations have no submitted turns can pair directly. If it already has its own saved pairing keys and chats with submitted turns, the refusal counts them and explains `--replace`. Chats made with `codeaf chat --once` before any pairing keys exist are kept on first pairing, and reach the newly joined computers the next time codeaf's window opens here. For three chats on a computer with its own keys: ``` this computer already has 3 chats of its own, so nothing was changed. To pair it anyway, run this again with --replace: those 3 chats stay on this computer but no longer sync; shared memories and settings are cleared @@ -470,6 +470,6 @@ Once this computer has its own saved pairing keys, a sent turn, tracked work, a ## What happens to chats made with chat --once before the first pairing? -If this computer has no saved pairing keys yet, chats made with `codeaf chat --once` are kept when it first pairs without `--replace`. They become part of the chats it can sync with the newly joined computers. Older chats are copied independently for syncing; their originals stay untouched and remain listed until a completed copy is ready. Pairing does not discard or hide them. +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. From c2e80c395e5925cb127dad3513459e82c9719998 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 06:21:59 -0400 Subject: [PATCH 18/20] Import devname once in the devices card after the rebase onto #2085 Co-Authored-By: Claude Opus 5.5 --- internal/tui3/devices.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui3/devices.go b/internal/tui3/devices.go index 11a88239ef..a8830488d2 100644 --- a/internal/tui3/devices.go +++ b/internal/tui3/devices.go @@ -20,7 +20,6 @@ package tui3 import ( "context" "fmt" - "github.com/Agent-Field/codeaf/internal/devname" "slices" "sort" "strings" From ef469cad95cbd5276855c63d27cf2d28813a734b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 06:54:23 -0400 Subject: [PATCH 19/20] Lead serve's line with the machine name chat --at accepts serve printed the device name first (`reachable as beta (canoe-paddle-65)`), and `codeaf chat --at beta` is refused by the name check. The machine name comes first again, as on dev, and the device name follows only as a hint; every instruction keeps the bare machine name. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/l4fix_at_test.go | 14 ++++++++--- internal/pair/l4_words_test.go | 44 ++++++++++++++++++++++++++++++---- internal/pair/serve.go | 18 ++++++++++---- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/cmd/codeaf/l4fix_at_test.go b/cmd/codeaf/l4fix_at_test.go index 7d71c09e73..3580511c37 100644 --- a/cmd/codeaf/l4fix_at_test.go +++ b/cmd/codeaf/l4fix_at_test.go @@ -18,7 +18,7 @@ import ( "github.com/Agent-Field/codeaf/internal/tui3" ) -// L4-G6: The actual --at door carries the welcome's chosen name into the window and its other conversations. +// 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) @@ -48,9 +48,14 @@ func TestL4FixAtWindowUsesDeviceName(t *testing.T) { 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() { close(ready) }) + once.Do(func() { + first, _, _ := strings.Cut(line, "\n") + target = strings.Fields(first)[5] + close(ready) + }) } }, Open: func(pipe io.ReadWriteCloser) { pipes <- pipe @@ -64,6 +69,9 @@ func TestL4FixAtWindowUsesDeviceName(t *testing.T) { 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 @@ -82,7 +90,7 @@ func TestL4FixAtWindowUsesDeviceName(t *testing.T) { } return nil } - if err := openChatV3At(atLaunch{target: machine.Name()}); err != nil { + if err := openChatV3At(atLaunch{target: target}); err != nil { t.Fatal(err) } stopFinalSaveProgress() diff --git a/internal/pair/l4_words_test.go b/internal/pair/l4_words_test.go index 7baec32a72..a6e26c13ec 100644 --- a/internal/pair/l4_words_test.go +++ b/internal/pair/l4_words_test.go @@ -2,9 +2,12 @@ package pair import ( "errors" - "github.com/Agent-Field/codeaf/internal/wireauth" + "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. @@ -46,11 +49,42 @@ func TestL4PairCompletionVoice(t *testing.T) { } } -// L4-M1a: serving retains its rendezvous name beside the familiar device name. +// L4-M1a, L4-FIX5-a: the first serving name is accepted by --at; the device name is a hint. func TestL4ServeUsesDeviceName(t *testing.T) { - h := Host{DisplayName: "desk-b"} - got := h.banner("seagull-lichen-63", nil, ErrOffering) - if !strings.Contains(got, "this machine is reachable as desk-b (seagull-lichen-63)") { + 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) } } diff --git a/internal/pair/serve.go b/internal/pair/serve.go index 352a1bfd04..203179739a 100644 --- a/internal/pair/serve.go +++ b/internal/pair/serve.go @@ -119,13 +119,21 @@ func (h *Host) say(line string) { } func (h *Host) banner(name string, code *Code, codeErr error) string { - if h.DisplayName != "" { - name = h.DisplayName + " (" + name + ")" - } + var line string if codeErr == nil { - return strings.TrimRight(Lines(name, code), "\n") + 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 " this machine is reachable as " + name + return line } func (h *Host) sayBanner(line string, force bool) { From d0955c615d3f7cd8f49b49687139c8846c62a50b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 06:54:23 -0400 Subject: [PATCH 20/20] Ask remote-work approval with dev's question, not a name guess "has the same name as one of your devices" matched only the asker's label, and the approving computer has no verified link from that request to a paired device, so it misdescribed a computer that is paired. The question is dev's again; the weight line stays on the approving side. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/l4_machine_test.go | 37 ++++++++++++------ cmd/codeaf/pair_terminal.go | 39 ++----------------- .../2087-pairing-connection-words.md | 5 +++ .../chat/reaching-this-machine-without-ssh.md | 4 +- 4 files changed, 35 insertions(+), 50 deletions(-) diff --git a/cmd/codeaf/l4_machine_test.go b/cmd/codeaf/l4_machine_test.go index 1de5873550..16b7d7cd76 100644 --- a/cmd/codeaf/l4_machine_test.go +++ b/cmd/codeaf/l4_machine_test.go @@ -8,18 +8,31 @@ import ( "github.com/Agent-Field/codeaf/internal/pair" ) -// L4-M3a, L4-M3b: the approver sees the extra permission, then the check and choice. +// L4-M3a, L4-M3b, L4-FIX5-b: a label cannot prove a paired device; explain the permission before asking. func TestL4MachineApprovalForPairedDevice(t *testing.T) { - question := machineApprovalQuestion("laptop-a", "red green blue", true) - if question != "laptop-a has the same name as one of your devices; let it run work here too? Same three words on that screen: red green blue?" { - t.Fatal(question) - } - out := &pairScreenText{} - person := newTerminal(strings.NewReader("y\n"), out) - if !confirmMachine(person, context.Background(), "laptop-a", "red green blue", true) { - t.Fatal("yes declined") - } - if strings.Index(out.String(), pair.PairingWeight) > strings.Index(out.String(), "y / n") { - t.Fatal(out.String()) + 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/pair_terminal.go b/cmd/codeaf/pair_terminal.go index a731d6ca12..62ae02b2be 100644 --- a/cmd/codeaf/pair_terminal.go +++ b/cmd/codeaf/pair_terminal.go @@ -13,9 +13,7 @@ import ( "strings" "sync" - "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/pair" ) @@ -159,43 +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 confirmMachine(person, asked, label, words, sameNameAsPairedDevice(asked, label)) + return confirmMachine(person, asked, label, words) } } -// machineApprovalQuestion names the extra permission at the approval decision. -func machineApprovalQuestion(label, words string, sameName bool) string { - if sameName { - return label + " has the same name as one of your devices; let it run work here too? Same three words on that screen: " + words + "?" - } - return pair.AskMachineLine(label, words) -} - -// sameNameAsPairedDevice is a descriptive name lookup, never an access decision. -// The code check and explicit yes are still required for every machine key. -func sameNameAsPairedDevice(ctx context.Context, label string) bool { - route, err := pairMailbox("") - if err != nil { - return false - } - client, id, err := homeDirectory(home.Dir(), route) - if err != nil { - return false - } - listing, err := client.List(ctx) - if err != nil { - return false - } - for _, dev := range listing.Devices { - if !dev.Revoked && openName(directory.MetadataKey(id.CellKey()), dev.Name) == label { - return true - } - } - return false -} - // confirmMachine puts the permission weight at the point of the serving decision. -func confirmMachine(person *terminal, ctx context.Context, label, words string, sameName bool) bool { +func confirmMachine(person *terminal, ctx context.Context, label, words string) bool { person.say(pair.PairingWeight) - return person.confirm(ctx, machineApprovalQuestion(label, words, sameName)) + return person.confirm(ctx, pair.AskMachineLine(label, words)) } diff --git a/docs/changes/unreleased/2087-pairing-connection-words.md b/docs/changes/unreleased/2087-pairing-connection-words.md index 75920ca64e..1a000d9c67 100644 --- a/docs/changes/unreleased/2087-pairing-connection-words.md +++ b/docs/changes/unreleased/2087-pairing-connection-words.md @@ -23,3 +23,8 @@ 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/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index b78dcac9e4..e41685ebb2 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -561,13 +561,13 @@ never what was sent. Stop it with SIGTERM and requests already under way finish ## The serving computer’s device name and the remote window bar -When this computer is paired for sync, `codeaf serve` shows its device name beside the connection name, for example `this machine is reachable as desk-b (seagull-lichen-63)`. The `--at` window’s bar shows `desk-b`. This build still needs the connection name `seagull-lichen-63` in `codeaf chat --at`; syncing has not yet published a device-name lookup for machine keys. +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. -When the asking name matches a name in your paired devices, the approver says `laptop-a has the same name as one of your devices; let it run work here too?`, followed by the three-word check and `y / n`. That name is descriptive; the code check and your explicit approval still decide whether this machine key gets access. +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