From 48ec0e2a605dc5a90e9430ab1d914911b03e7bb9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 11:34:00 -0400 Subject: [PATCH 1/6] config: team sharing reads on unless this computer chose off A missing or empty teams.share now reads on. An explicit off stays off, and an unreadable config file or a wrong-typed value reads off, so a damaged file never switches on a computer that had chosen off. TeamsSharingAt combines the preference with an injected pairing gate; filememo gains an opt-in strict read for that one preference. Co-Authored-By: Claude Opus 5.5 --- internal/config/settings.go | 2 +- internal/config/teams_share.go | 42 +++++++-- internal/config/teams_share_test.go | 129 ++++++++++++++++++++++++++-- internal/filememo/filememo.go | 26 +++++- internal/filememo/filememo_test.go | 39 +++++++++ 5 files changed, 223 insertions(+), 15 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 2f6938090f..055ab470ae 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2291,7 +2291,7 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyTeamsShare, Category: CategoryInterface, Kind: SettingChoice, Label: "share teams", Choices: teamsShareChoices, - Hint: "Share teams, their members and settings, and recent Traffic as read-only history; off until turned on, and both computers must turn it on (pairing required).", + Hint: "Share teams, their members and settings, and recent Traffic as read-only history with your paired computers. On by default; turning it off on either computer stops sharing.", read: func() string { if TeamsShareAt(dir) { return "on" diff --git a/internal/config/teams_share.go b/internal/config/teams_share.go index 368965c7c2..9290b55c59 100644 --- a/internal/config/teams_share.go +++ b/internal/config/teams_share.go @@ -1,11 +1,43 @@ package config +import ( + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/filememo" +) + var teamsShareChoices = []string{"off", "on"} -// TeamsShareAt reads the local opt-in at use time, so a running round or chat -// stops sharing as soon as this computer turns it off. Missing or damaged -// settings leave the existing local-only behavior in place. +// This preference must distinguish absence from a failed read. The shared +// settings memo deliberately treats either as missing for other settings. +var teamsShareMemo = filememo.Stamped(SettingsGeneration, func(path string, raw []byte, missing bool) (bool, error) { + if missing { + return true, nil + } + var values map[string]json.RawMessage + if json.Unmarshal(raw, &values) != nil || values == nil { + return false, nil + } + encoded, exists := values[KeyTeamsShare] + if !exists { + return true, nil + } + var value *string + if json.Unmarshal(encoded, &value) != nil || value == nil { + return false, nil + } + return *value == "" || *value == "on", nil +}) + +// TeamsShareAt reads the local preference at use time. Missing choices default +// on, but damaged files must not erase a person's earlier decision to turn off. func TeamsShareAt(profileDir string) bool { - value, _ := persistedString(profileDir, KeyTeamsShare) - return value == "on" + on, _ := teamsShareMemo.ReadStrict(BudgetConfigPath(profileDir)) + return on +} + +// TeamsSharingAt combines the preference with the caller's pairing gate without +// making configuration depend on relay setup. An absent gate keeps local work local. +func TeamsSharingAt(profileDir string, paired func() bool) bool { + return TeamsShareAt(profileDir) && paired != nil && paired() } diff --git a/internal/config/teams_share_test.go b/internal/config/teams_share_test.go index be0bacafef..15a2b61dc8 100644 --- a/internal/config/teams_share_test.go +++ b/internal/config/teams_share_test.go @@ -1,16 +1,61 @@ package config -import "testing" +import ( + "os" + "path/filepath" + "testing" +) -func TestTeamSharingIsLocalOffByDefaultAndChangesImmediately(t *testing.T) { +// F6 keeps absent choices on and fails safe when a saved preference is damaged. +func TestTeamSharingPreferenceFailsSafe(t *testing.T) { + for _, tc := range []struct { + name string + value any + want bool + }{ + {name: "missing", want: true}, + {name: "empty", value: "", want: true}, + {name: "on", value: "on", want: true}, + {name: "off", value: "off", want: false}, + {name: "invalid", value: "maybe", want: false}, + {name: "wrong type", value: false, want: false}, + {name: "null", value: (*string)(nil), want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + values := map[string]any{} + if tc.value != nil { + values[KeyTeamsShare] = tc.value + } + dir := profileDirWith(t, values) + if got := TeamsShareAt(dir); got != tc.want { + t.Fatalf("TeamsShareAt = %v, want %v", got, tc.want) + } + row, ok := registry(t, dir).Row(KeyTeamsShare) + if !ok { + t.Fatal("missing team sharing setting") + } + want := "on" + if !tc.want { + want = "off" + } + if got := row.Value(); got != want { + t.Fatalf("Settings value = %q, want %q", got, want) + } + }) + } +} + +// C1, C2, C3 and C9 keep the default and immediate local choice consistent with Settings. +// F6 keeps the local preference and its Settings value consistent. +func TestTeamSharingIsLocalOnByDefaultAndChangesImmediately(t *testing.T) { dir := t.TempDir() rows := registry(t, dir) row, ok := rows.Row("teams.share") if !ok { t.Fatal("missing team sharing setting") } - if row.Value() != "off" { - t.Fatal("team sharing must start off") + if !TeamsShareAt(dir) || row.Value() != "on" { + t.Fatal("team sharing must start on") } presentation := row.ChatPresentation() if row.Category != CategoryInterface || presentation.Category != "General" || presentation.Scope != "This computer" || presentation.Activation != "Applies immediately" { @@ -19,7 +64,7 @@ func TestTeamSharingIsLocalOffByDefaultAndChangesImmediately(t *testing.T) { if err := row.Apply("on"); err != nil { t.Fatal(err) } - if row.Value() != "on" { + if !TeamsShareAt(dir) || row.Value() != "on" { t.Fatal("setting did not turn on") } preferences, err := ReadPreferences(dir) @@ -32,16 +77,86 @@ func TestTeamSharingIsLocalOffByDefaultAndChangesImmediately(t *testing.T) { if err := ApplyPreferences(dir, map[string]string{row.Key: `"off"`}); err != nil { t.Fatal(err) } - if row.Value() != "on" { + if !TeamsShareAt(dir) || row.Value() != "on" { t.Fatal("remote preferences changed the local switch") } if err := row.Apply("off"); err != nil { t.Fatal(err) } - if row.Value() != "off" { + if TeamsShareAt(dir) || row.Value() != "off" { t.Fatal("setting did not turn off") } if err := row.Apply("maybe"); err == nil { t.Fatal("invalid choice accepted") } + if TeamsShareAt(dir) || row.Value() != "off" { + t.Fatal("invalid choice changed the local switch") + } + if err := ApplyPreferences(dir, map[string]string{row.Key: `"on"`}); err != nil { + t.Fatal(err) + } + if TeamsShareAt(dir) || row.Value() != "off" { + t.Fatal("remote preferences enabled the local switch") + } + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if !TeamsShareAt(dir) || row.Value() != "on" { + t.Fatal("setting did not turn back on") + } +} + +// F6 makes an unreadable configuration resolve to off in both the reader and Settings. +func TestTeamSharingUnreadablePreferenceFailsSafe(t *testing.T) { + for _, raw := range []string{`{"teams.share":"off"`, `null`, `[]`} { + t.Run(raw, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if TeamsShareAt(dir) { + t.Fatal("damaged config enabled sharing") + } + row, _ := registry(t, dir).Row(KeyTeamsShare) + if row.Value() != "off" { + t.Fatal("Settings did not show off", row.Value()) + } + }) + } + t.Run("unreadable file", func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "config.json"), 0700); err != nil { + t.Fatal(err) + } + if TeamsShareAt(dir) { + t.Fatal("unreadable config enabled sharing") + } + row, _ := registry(t, dir).Row(KeyTeamsShare) + if row.Value() != "off" { + t.Fatal("Settings did not show off", row.Value()) + } + }) +} + +// F6 does not keep a cached on after the configuration becomes unreadable. +func TestTeamSharingCachedOnFailsSafeAfterReadPermissionIsRemoved(t *testing.T) { + dir := profileDirWith(t, map[string]any{KeyTeamsShare: "on"}) + if !TeamsShareAt(dir) { + t.Fatal("fixture did not warm on") + } + path := BudgetConfigPath(dir) + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + defer os.Chmod(path, 0600) + if _, err := os.ReadFile(path); err == nil { + t.Skip("this process can read files without read permissions") + } + if TeamsShareAt(dir) { + t.Fatal("cached on survived an unreadable configuration") + } + row, _ := registry(t, dir).Row(KeyTeamsShare) + if row.Value() != "off" { + t.Fatal("Settings kept cached on", row.Value()) + } } diff --git a/internal/filememo/filememo.go b/internal/filememo/filememo.go index 2d07504899..08e79fa40e 100644 --- a/internal/filememo/filememo.go +++ b/internal/filememo/filememo.go @@ -85,6 +85,7 @@ type reading[T any] struct { modTime time.Time missing bool stamp uint64 + mode os.FileMode } // New binds a memo to its derivation. @@ -110,11 +111,28 @@ func Stamped[T any](stamp func() uint64, derive Derive[T]) *Memo[T] { // every call would be a read per call for a file that is broken until somebody // fixes it, which is the exact cost this type exists to remove. func (m *Memo[T]) Read(path string) (T, error) { + return m.read(path, false) +} + +// ReadStrict preserves filesystem errors instead of deriving the missing-file +// default. Privacy preferences must also invalidate a warm value when file +// permissions change, even though chmod leaves size and modification time alone. +func (m *Memo[T]) ReadStrict(path string) (T, error) { + return m.read(path, true) +} + +func (m *Memo[T]) read(path string, strict bool) (T, error) { now, missing := os.Stat(path) + if strict && missing != nil && !os.IsNotExist(missing) { + var zero T + return zero, missing + } var size int64 var modTime time.Time + var mode os.FileMode if missing == nil { size, modTime = now.Size(), now.ModTime() + mode = now.Mode() } absent := missing != nil stamp := uint64(0) @@ -124,7 +142,7 @@ func (m *Memo[T]) Read(path string) (T, error) { m.mu.Lock() defer m.mu.Unlock() - if held, known := m.held[path]; known && held.fresh(size, modTime, absent, stamp) { + if held, known := m.held[path]; known && held.fresh(size, modTime, absent, stamp) && (!strict || held.mode == mode) { return held.value, held.err } @@ -132,6 +150,10 @@ func (m *Memo[T]) Read(path string) (T, error) { if !absent { bytes, err := os.ReadFile(path) if err != nil { + if strict { + var zero T + return zero, err + } // The file went away between the stat and the read, or became // unreadable. NOTHING IS HELD FOR IT: the reading this memo would // key on is the one that just turned out to be a lie, so the next @@ -146,7 +168,7 @@ func (m *Memo[T]) Read(path string) (T, error) { } m.held[path] = reading[T]{ value: value, err: err, - size: size, modTime: modTime, missing: absent, stamp: stamp, + size: size, modTime: modTime, missing: absent, stamp: stamp, mode: mode, } return value, err } diff --git a/internal/filememo/filememo_test.go b/internal/filememo/filememo_test.go index d090092c5b..0a722be5ff 100644 --- a/internal/filememo/filememo_test.go +++ b/internal/filememo/filememo_test.go @@ -119,3 +119,42 @@ func TestACallersStampInvalidatesWithoutTouchingTheFile(t *testing.T) { t.Fatalf("after the stamp moved the memo still says %q, want the new contents", value) } } + +// F6 preserves the ordinary absence fallback while strict readers fail closed on unreadable files. +func TestStrictReadsKeepErrorsWithoutChangingOrdinaryFallback(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + memo := New(func(_ string, raw []byte, missing bool) (string, error) { + if missing { + return "missing", nil + } + return string(raw), nil + }) + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + if got, err := memo.ReadStrict(path); err == nil || got != "" { + t.Fatal("strict read hid a filesystem error", got, err) + } + if got, err := memo.Read(path); err != nil || got != "missing" { + t.Fatal("ordinary fallback changed", got, err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if got, err := memo.ReadStrict(path); err != nil || got != "missing" { + t.Fatal("strict absence did not derive default", got, err) + } + var reads int + warm := New(func(_ string, raw []byte, missing bool) (string, error) { reads++; return string(raw), nil }) + if err := os.WriteFile(path, []byte("on"), 0600); err != nil { + t.Fatal(err) + } + for range 20 { + if got, err := warm.ReadStrict(path); err != nil || got != "on" { + t.Fatal(got, err) + } + } + if reads != 1 { + t.Fatal("strict warm reads repeated decoding", reads) + } +} From 15dc1aa01f7e2fa86131244884ba3896e1cfe972 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 11:34:00 -0400 Subject: [PATCH 2/6] teams: first shared merge loses nothing, and replace says where teams went Before the first sharing round, this computer's teams are copied to teams.before-sharing.json (hard link, or an exclusive-create fallback) and incoming changes wait for that copy. A merge records a cleared manager appointment only in an empty former-manager slot and never renames teams. Pairing replace returns where teams were set aside. Shared Traffic publishes at most every two minutes, inside the relay's daily frame allowance. Sync carriers use effective sharing (preference and pairing). Co-Authored-By: Claude Opus 5.5 --- internal/pair/join.go | 15 +- internal/pair/lines.go | 17 +- internal/pair/linkjoin.go | 4 +- internal/pair/state.go | 35 ++-- internal/pair/teams_relay_test.go | 19 +- internal/pair/teams_replace_test.go | 37 +++- internal/syncsetup/member_client_test.go | 5 + internal/syncsetup/take.go | 6 +- internal/syncsetup/team_locality_test.go | 3 +- internal/syncsetup/teams.go | 40 +++++ internal/syncsetup/teams_test.go | 205 +++++++++++++++++++++- internal/teams/relay.go | 11 +- internal/teams/relay_test.go | 111 ++++++++++++ internal/teams/sharing_notice.go | 65 ++++++- internal/teams/stamp.go | 9 +- internal/teams/store.go | 66 +++++++ internal/teams/store_test.go | 48 +++++ internal/teams/traffic_mirror_test.go | 42 ++++- internal/teams/traffic_refresh_test.go | 12 +- internal/teams/traffic_sync.go | 7 +- internal/vaultsync/device_history_test.go | 5 +- 21 files changed, 705 insertions(+), 57 deletions(-) diff --git a/internal/pair/join.go b/internal/pair/join.go index 5096dbd72d..dec433bde4 100644 --- a/internal/pair/join.go +++ b/internal/pair/join.go @@ -38,6 +38,8 @@ type Joining struct { MergeState bool // ProfileDir names the preferences being joined, when outside Home. ProfileDir string + // TeamsPaired supplies the live quiet gate after adopting the new identity. + TeamsPaired func() bool // SaveSyncURL is told the relay the other device syncs through, when it is // not the default. Nil ignores it. SaveSyncURL func(url string) error @@ -45,6 +47,8 @@ type Joining struct { // Joined is how a pairing ended on the joining device. type Joined struct { + // TeamsAside names the preserved team file after explicit replacement. + TeamsAside string // 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 @@ -172,15 +176,18 @@ func adopt(j Joining, grant Grant) (Joined, error) { return Joined{}, err } } + joined := Joined{} if j.Replace { - if err := replaceState(j.Home, j.ProfileDir); err != nil { - return Joined{}, err + path, err := replaceState(j.Home, j.ProfileDir, j.TeamsPaired) + joined.TeamsAside = path + if err != nil { + return joined, err } } if grant.SyncURL != "" && j.SaveSyncURL != nil { - return Joined{}, j.SaveSyncURL(grant.SyncURL) + return joined, j.SaveSyncURL(grant.SyncURL) } - return Joined{}, nil + return joined, nil } // follow moves this computer from an identity that was rotated to the one that diff --git a/internal/pair/lines.go b/internal/pair/lines.go index f1aed0cb1d..782dee842d 100644 --- a/internal/pair/lines.go +++ b/internal/pair/lines.go @@ -39,6 +39,17 @@ func WaitingChatsLine(words string) string { return "Waiting for approval on your other device; it should show: " + words } +// ReplaceAskLine describes the state replacement before either surface acts. +const ReplaceAskLine = "Pair with your other devices? This computer's existing chats stay here but stop syncing; shared memories and settings are cleared; with share teams on, teams are set aside while the other devices supply theirs. Provider keys saved on this computer stay here." + +// TeamsAsideLine keeps a preserved team's recovery path in the pairing receipt. +func TeamsAsideLine(path string) string { + if path == "" { + return "" + } + return "This computer's teams were set aside to " + path + "." +} + // JoinedLine is what the joining device says when it worked. const openChatsLine = ". Run codeaf to open your chats." const JoinedLine = "Paired with your other device" + openChatsLine @@ -57,7 +68,11 @@ func (j Joined) Sentence() string { case j.Successor: return PairedWithLine(j.With) } - return PairedWithLine(j.With) + line := PairedWithLine(j.With) + if j.TeamsAside != "" { + line += " " + TeamsAsideLine(j.TeamsAside) + } + return line } // PairedChatsLine is what the sharing device says when it worked. diff --git a/internal/pair/linkjoin.go b/internal/pair/linkjoin.go index 612633b433..b0f1546710 100644 --- a/internal/pair/linkjoin.go +++ b/internal/pair/linkjoin.go @@ -198,10 +198,10 @@ func (a *asker) install(ctx context.Context, j LinkJoining, g LinkGrant) (LinkJo } joined, err := adopt(j.Joining, g.Grant) if err != nil { - return LinkJoined{}, err + return LinkJoined{Joined: joined}, err } if err := identity.Install(j.Home, g.Identity, dev); err != nil { - return LinkJoined{}, err + return LinkJoined{Joined: joined}, err } fleet, err := j.Pull(ctx) if err != nil { diff --git a/internal/pair/state.go b/internal/pair/state.go index 52fb54e064..6bf31d858f 100644 --- a/internal/pair/state.go +++ b/internal/pair/state.go @@ -10,43 +10,58 @@ import ( // replaceState removes only the shared classes the person chose to replace. // The database's other state and machine facts survive this explicit door. -func replaceState(home, profile string) error { +func replaceState(home, profile string, paired func() bool) (string, error) { + aside := "" if profile == "" { profile = home } preferences, err := config.ReadPreferences(profile) if err != nil { - return err + return aside, err } for key := range preferences { preferences[key] = "" } if err := config.ApplyPreferences(profile, preferences); err != nil { - return err + return aside, err } path := filepath.Join(home, "graph.db") if _, err := os.Stat(path); err == nil { brain, err := store.Open(path) if err != nil { - return err + return aside, err } err = brain.ClearSharedMemories() _ = brain.Close() if err != nil { - return err + return aside, err } } names := []string{"preferences-sync.ledger", "memory-sync.ledger", "provider-keys.ledger", "vault.enc"} - if config.TeamsShareAt(profile) { - if _, err := teams.SetAside(profile); err != nil && !os.IsNotExist(err) { - return err + if config.TeamsSharingAt(profile, paired) { + path, err := teams.SetAside(profile) + if err != nil && !os.IsNotExist(err) { + return aside, err + } + if err == nil { + aside = path } names = append(names, "teams-sync.ledger", "teams-traffic-sync.ledger") } for _, name := range names { if err := os.Remove(filepath.Join(home, name)); err != nil && !os.IsNotExist(err) { - return err + return aside, err } } - return nil + return aside, nil +} + +// HasTeams includes unreadable files conservatively because replacement could +// otherwise set aside a person's teams without first describing that action. +func HasTeams(profile string) bool { + f, err := teams.Snapshot(profile) + if err == nil { + return len(f.Teams) > 0 + } + return true } diff --git a/internal/pair/teams_relay_test.go b/internal/pair/teams_relay_test.go index 7f5fe693f9..b9d9ef4030 100644 --- a/internal/pair/teams_relay_test.go +++ b/internal/pair/teams_relay_test.go @@ -133,6 +133,10 @@ func relayTeamsRigSharing(t *testing.T, share bool) relayTeamsRig { if share { setRelayTeamSharing(t, aHome, "on") setRelayTeamSharing(t, bHome, "on") + } else { + // C9 keeps sharing-off fixtures independent of the default on paired computers. + setRelayTeamSharing(t, aHome, "off") + setRelayTeamSharing(t, bHome, "off") } for i, s := range []*syncsetup.Sync{a, b} { if err := syncsetup.PutOwnDevice(t.Context(), s.Dir, s.Identity, s.Device, []string{"laptop", "desktop"}[i]); err != nil { @@ -245,6 +249,7 @@ func TestTeamsConcurrentMembersKeepBothHandlesAndOrderInEitherSyncOrder(t *testi // Both homes use a real paired relay. Only the display mirror receives the // other writer's window; an older local-log reader therefore sees none of it. +// F1 preserves the existing sharing fixture and history contract. func TestPairedTrafficMirrorsOnceWithoutWritingTheReceivingLog(t *testing.T) { r := newRelayTeamsRig(t) relayTeamCreate(t, r) @@ -271,7 +276,7 @@ func TestPairedTrafficMirrorsOnceWithoutWritingTheReceivingLog(t *testing.T) { if _, err := os.Stat(teams.TrafficPath(r.b.Home, "alpha")); !os.IsNotExist(err) { t.Fatal("receiving local log was created") } - display, err := teams.ReadHistory(r.b.Home, "alpha", 0) + display, err := teams.ReadHistory(r.b.Home, "alpha", 0, true) if err != nil || len(display) != 6 { t.Fatalf("mirror: %+v %v", display, err) } @@ -453,6 +458,7 @@ func TestUnpairedTeamsAndTrafficKeepOriginalBytesAndMakeNoRequest(t *testing.T) } } +// F1 preserves the existing sharing fixture and history contract. func TestTrafficMirrorsKeepConcurrentLocalSequenceCollisionsSeparate(t *testing.T) { r := newRelayTeamsRig(t) relayTeamCreate(t, r) @@ -466,7 +472,7 @@ func TestTrafficMirrorsKeepConcurrentLocalSequenceCollisionsSeparate(t *testing. r.round(t) } for _, s := range []*syncsetup.Sync{r.a, r.b} { - lines, err := teams.ReadHistory(s.Home, "alpha", 0) + lines, err := teams.ReadHistory(s.Home, "alpha", 0, true) if err != nil || len(lines) != 2 { t.Fatalf("concurrent origin union: %+v %v", lines, err) } @@ -597,6 +603,7 @@ func TestPairedMemberLocationUsesTheCurrentHolderEvenWithALocalTranscript(t *tes } // Display failures cannot hold unrelated profile changes behind the mirror. +// F1 preserves the existing sharing fixture and history contract. func TestUnwritableTrafficMirrorStillSharesChangedPreferences(t *testing.T) { r := newRelayTeamsRig(t) relayTeamCreate(t, r) @@ -620,7 +627,7 @@ func TestUnwritableTrafficMirrorStillSharesChangedPreferences(t *testing.T) { t.Fatal(err) } r.round(t) - lines, err := teams.ReadHistory(r.a.Home, "alpha", 0) + lines, err := teams.ReadHistory(r.a.Home, "alpha", 0, true) if err != nil || len(lines) != 1 || lines[0].Text != "retry mirror" { t.Fatal("mirror did not retry", lines, err) } @@ -689,6 +696,8 @@ func relayTeamVaultValues(t *testing.T, s *syncsetup.Sync) map[string]string { return values } +// C4 and C9 keep either computer's explicit off effective at the real paired relay. +// F1 preserves the existing sharing fixture and history contract. func TestTeamSharingNeedsBothLocalSwitchesAndOffRoundsPreserveForeignValues(t *testing.T) { r := relayTeamsRigSharing(t, false) setRelayTeamSharing(t, r.a.Home, "on") @@ -750,6 +759,8 @@ func TestTeamSharingNeedsBothLocalSwitchesAndOffRoundsPreserveForeignValues(t *t } } +// C9 preserves the sharing-off byte and polling budgets with explicit local choices. +// F1 preserves the existing sharing fixture and history contract. func TestPairedSharingOffKeepsLocalBytesEmptyTeamVaultAndExistingPolling(t *testing.T) { r := relayTeamsRigSharing(t, false) // Match the existing hosted polling fixture's ordinary preference vault, @@ -790,7 +801,7 @@ func TestPairedSharingOffKeepsLocalBytesEmptyTeamVaultAndExistingPolling(t *test } for _, s := range []*syncsetup.Sync{r.a, r.b} { if len(relayTeamVaultValues(t, s)) != 0 { - t.Fatal("default-off vault contains team values") + t.Fatal("sharing-off vault contains team values") } } raw, _ := os.ReadFile(teams.Path(r.a.Home)) diff --git a/internal/pair/teams_replace_test.go b/internal/pair/teams_replace_test.go index 45c2a52fde..92499b681c 100644 --- a/internal/pair/teams_replace_test.go +++ b/internal/pair/teams_replace_test.go @@ -3,12 +3,15 @@ package pair import ( "bytes" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/teams" "os" "path/filepath" + "strings" "testing" ) +// F3 preserves the described replacement behavior and recovery receipts. func TestTeamsReplaceMovesLocalTeamsAsideAndKeepsTraffic(t *testing.T) { home := t.TempDir() row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: home}).Row(config.KeyTeamsShare) @@ -29,7 +32,7 @@ func TestTeamsReplaceMovesLocalTeamsAsideAndKeepsTraffic(t *testing.T) { if err := os.WriteFile(filepath.Join(home, "teams-traffic-sync.ledger"), []byte(`"2026-01-01T00:00:00Z"`), 0600); err != nil { t.Fatal(err) } - if err := replaceState(home, home); err != nil { + if _, err := replaceState(home, home, func() bool { return true }); err != nil { t.Fatal(err) } aside, _ := filepath.Glob(teams.Path(home) + ".unreadable-*") @@ -49,8 +52,14 @@ func TestTeamsReplaceMovesLocalTeamsAsideAndKeepsTraffic(t *testing.T) { } } +// C9 pins off explicitly so replacement still preserves local teams and sharing ledgers. +// F3 preserves the described replacement behavior and recovery receipts. func TestTeamsReplacementWithSharingOffPreservesTeamsAndLedgers(t *testing.T) { home := t.TempDir() + sharing, _ := config.NewSettings(config.SettingsOptions{ProfileDir: home}).Row(config.KeyTeamsShare) + if err := sharing.Apply("off"); err != nil { + t.Fatal(err) + } if err := teams.Update(home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "alpha", Name: "local"}}; return nil }); err != nil { t.Fatal(err) } @@ -68,7 +77,7 @@ func TestTeamsReplacementWithSharingOffPreservesTeamsAndLedgers(t *testing.T) { } before[path] = string(raw) } - if err := replaceState(home, home); err != nil { + if _, err := replaceState(home, home, func() bool { return true }); err != nil { t.Fatal(err) } for _, path := range paths { @@ -78,3 +87,27 @@ func TestTeamsReplacementWithSharingOffPreservesTeamsAndLedgers(t *testing.T) { } } } + +// F3 returns the preserved file through real adoption and names it in the receipt. +func TestPairReplacementReceiptNamesPreservedTeamsFile(t *testing.T) { + home := t.TempDir() + if err := teams.Update(home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(teams.Path(home)) + other, err := identity.Ensure(t.TempDir()) + if err != nil { + t.Fatal(err) + } + joined, err := adopt(Joining{Home: home, Replace: true, TeamsPaired: func() bool { return true }}, Grant{Identity: other}) + if err != nil { + t.Fatal(err) + } + kept, err := os.ReadFile(joined.TeamsAside) + if err != nil || !bytes.Equal(before, kept) { + t.Fatal("receipt did not identify original team file", joined, err) + } + if !strings.Contains(joined.Sentence(), "This computer's teams were set aside to "+joined.TeamsAside+".") { + t.Fatal("receipt omitted teams recovery path", joined.Sentence()) + } +} diff --git a/internal/syncsetup/member_client_test.go b/internal/syncsetup/member_client_test.go index 214a81db85..7b24efc542 100644 --- a/internal/syncsetup/member_client_test.go +++ b/internal/syncsetup/member_client_test.go @@ -433,6 +433,7 @@ func TestR23PersonalLearnerStopsAtEveryUse(t *testing.T) { // H4/H5/R26/R27/C30 drive ten virtual idle minutes on the REAL native relay // and real open home state rounds, then require remote and local state changes // to pass the normal quorum-backed read/write doors and arrive within a round. +// F1 preserves the existing sharing fixture and history contract. func TestR26R27C30OpenLearnerIdleAndStateWake(t *testing.T) { r := newMemberRig(t) a := r.home(nil) @@ -594,6 +595,8 @@ func TestR26R27C30OpenLearnerIdleAndStateWake(t *testing.T) { // H6/R23/C30 pins the hosted capability boundary and its unchanged polling // behavior. A relay with no control/enrollment door never opens a member store. +// C9 pins off explicitly so team carriers cannot affect the hosted polling budget. +// F1 preserves the existing sharing fixture and history contract. func TestR23C30HostedStateKeepsExistingPolling(t *testing.T) { server := relay(t) s, ok, e := Open(machine(t, server.URL)) @@ -601,6 +604,8 @@ func TestR23C30HostedStateKeepsExistingPolling(t *testing.T) { if !ok { t.Fatal("open") } + sharing, _ := config.NewSettings(config.SettingsOptions{ProfileDir: s.Home}).Row(config.KeyTeamsShare) + mustNil(t, sharing.Apply("off")) s.memberRound(t.Context()) s.memberRound(t.Context()) if s.member != nil || !s.memberUnsupported { diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 444879a647..16aa688fb7 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -771,8 +771,10 @@ func (s *Sync) profile() string { return s.Home } -// teamsSharing reads this computer's local opt-in for team sharing. -func (s *Sync) teamsSharing() bool { return config.TeamsShareAt(s.profile()) } +// teamsSharing leaves every carrier quiet when this computer has not paired. +func (s *Sync) teamsSharing() bool { + return config.TeamsSharingAt(s.profile(), func() bool { return MayTalk(s.Home) }) +} // carried is the profile state that travels in the vault beside the secrets. func (s *Sync) carried() []vaultsync.Carrier { diff --git a/internal/syncsetup/team_locality_test.go b/internal/syncsetup/team_locality_test.go index 159607193c..f455fac660 100644 --- a/internal/syncsetup/team_locality_test.go +++ b/internal/syncsetup/team_locality_test.go @@ -64,6 +64,7 @@ func TestTeamOwnershipUsesOneShortCachedReadingAndFailureAllowsLocalWork(t *test } } +// F1 preserves the existing sharing fixture and history contract. func TestTrafficRosterFilterNeverRequestsTheDirectory(t *testing.T) { root := t.TempDir() enableSyncTeamSharing(t, root) @@ -88,7 +89,7 @@ func TestTrafficRosterFilterNeverRequestsTheDirectory(t *testing.T) { if err := p.Apply(map[string]string{"alpha/elsewhere": teamValue([]teams.Entry{{ID: "elsewhere/1", Text: "history"}})}); err != nil { t.Fatal(err) } - lines, err := teams.ReadHistory(root, "alpha", 0) + lines, err := teams.ReadHistory(root, "alpha", 0, true) if err != nil || len(lines) != 1 || dir.calls != 0 { t.Fatal("fallback did not mirror other writers locally", lines, err, dir.calls) } diff --git a/internal/syncsetup/teams.go b/internal/syncsetup/teams.go index 2c92215087..2428d9c96b 100644 --- a/internal/syncsetup/teams.go +++ b/internal/syncsetup/teams.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" "sort" @@ -322,7 +323,24 @@ func (p teamKeys) values(f *teams.File, m *teamIdentities) (map[string]string, e return values, nil } +// Capture can create the ledger before restore, so backup belongs before +// either step and before Refresh can repair a newly continued local path. +func (p teamKeys) beforeSharing() error { + if _, err := os.Stat(filepath.Join(p.home, "teams-sync.ledger")); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + if err := teams.BackupBeforeSharing(p.profile); err != nil { + return fmt.Errorf("could not save teams.before-sharing.json; incoming teams are waiting: %w", err) + } + return nil +} + func (p teamKeys) Values() (map[string]string, error) { + if err := p.beforeSharing(); err != nil { + return nil, err + } f, err := teams.Snapshot(p.profile) if err != nil { teams.SharingError(p.profile, err) @@ -346,6 +364,9 @@ func (p teamKeys) Apply(changes map[string]string) error { return p.ApplyIf(chan // ApplyIf compares each field inside Update's lock with the pre-network // snapshot. A simultaneous local edit remains for the next capture to send. func (p teamKeys) ApplyIf(changes, expected map[string]string) error { + if err := p.beforeSharing(); err != nil { + return err + } return teams.Update(p.profile, func(f *teams.File) error { m, err := p.identities(f) if err != nil { @@ -462,6 +483,22 @@ func (p teamKeys) decode(values map[string]string, m *teamIdentities) ([]teams.T t.Manager = member.Key } } + if manager != "" && t.Manager == "" && (t.FormerManager == "" || t.FormerManager == manager) { + // A concurrent removal can arrive before its manager field. Keep + // the appointment and its portable identity in the existing history. + t.FormerManager = manager + known := false + for _, former := range t.FormerMembers { + known = known || former.Key == manager + } + if !known { + former := teams.Member{Key: manager} + if local, ok := m.local[manager]; ok { + former = m.portable(local) + } + t.FormerMembers = append(t.FormerMembers, former) + } + } formerManager := t.FormerManager if formerManager != "" { t.FormerManager = m.realized(teams.Member{Key: formerManager}).Key @@ -487,6 +524,9 @@ func (p teamKeys) decode(values map[string]string, m *teamIdentities) ([]teams.T // bookkeeping failed. No discovery work occurs unless an unresolved cell is // present and its discovery stamp has moved since the last apply. func (p teamKeys) Refresh() error { + if err := p.beforeSharing(); err != nil { + return err + } f, err := teams.Snapshot(p.profile) if err != nil { teams.SharingError(p.profile, err) diff --git a/internal/syncsetup/teams_test.go b/internal/syncsetup/teams_test.go index 67634aa8c7..c903af1d10 100644 --- a/internal/syncsetup/teams_test.go +++ b/internal/syncsetup/teams_test.go @@ -1,7 +1,9 @@ package syncsetup import ( + "bytes" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -19,6 +21,7 @@ import ( "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/vaultsync" ) func TestTeamFieldsAndUnknownMemberFieldsSurviveGuardedApply(t *testing.T) { @@ -328,6 +331,7 @@ func TestMergedTeamsFollowCreationTimeWhileUnpairedFileKeepsItsOrder(t *testing. // When the real cell engine is available, this drives the continuation door // that owns the lease, not a separate call to the bookkeeping helper. +// F1 supplies the same live pairing gate as production while proving continuation cursor repair. func TestContinuingAChatKeepsItsLeaseDespiteDamagedTeamsAndLaterHeals(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("CODEAF_TELEMETRY", "off") @@ -343,7 +347,7 @@ func TestContinuingAChatKeepsItsLeaseDespiteDamagedTeamsAndLaterHeals(t *testing if err := os.Remove(transcriptOf(h.cell)); err != nil { t.Fatal(err) } - source, err := session.New(session.Config{Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), ProfileDir: h.a.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: "http://127.0.0.1:1", System: "Continuation fixture"}) + source, err := session.New(session.Config{TeamsPaired: func() bool { return MayTalk(h.a.Home) }, Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), ProfileDir: h.a.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: "http://127.0.0.1:1", System: "Continuation fixture"}) if err != nil { t.Fatal(err) } @@ -375,7 +379,7 @@ func TestContinuingAChatKeepsItsLeaseDespiteDamagedTeamsAndLaterHeals(t *testing t.Fatal(err) } } - unpaired, err := session.New(session.Config{Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), ProfileDir: h.a.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: model.URL, System: "Continuation fixture"}) + unpaired, err := session.New(session.Config{TeamsPaired: func() bool { return MayTalk(h.a.Home) }, Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), ProfileDir: h.a.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: model.URL, System: "Continuation fixture"}) if err != nil { t.Fatal(err) } @@ -457,7 +461,7 @@ func TestContinuingAChatKeepsItsLeaseDespiteDamagedTeamsAndLaterHeals(t *testing t.Fatal(err) } work := workspaceOf(took.Taken.Cell.Root) - receiver, err := session.New(session.Config{Workspace: work, Place: session.Place{Dir: took.Taken.Cell.Root, Workspace: work}, SessionFile: m.File, ProfileDir: h.b.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: model.URL, System: "Continuation fixture"}) + receiver, err := session.New(session.Config{TeamsPaired: func() bool { return MayTalk(h.b.Home) }, Workspace: work, Place: session.Place{Dir: took.Taken.Cell.Root, Workspace: work}, SessionFile: m.File, ProfileDir: h.b.Home, Model: "stub/chat", APIKey: "fixture", BaseURL: model.URL, System: "Continuation fixture"}) if err != nil { t.Fatal(err) } @@ -496,6 +500,7 @@ func TestContinuingAChatKeepsItsLeaseDespiteDamagedTeamsAndLaterHeals(t *testing } } +// F1 preserves the existing sharing fixture and history contract. func TestTrafficMirrorsRetireRevokedDevicesWithoutRepublishingThem(t *testing.T) { home := t.TempDir() if err := teams.Update(home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "alpha", Name: "alpha"}}; return nil }); err != nil { @@ -507,7 +512,7 @@ func TestTrafficMirrorsRetireRevokedDevicesWithoutRepublishingThem(t *testing.T) if err := p.Apply(values); err != nil { t.Fatal(err) } - got, _ := teams.ReadHistory(home, "alpha", 0) + got, _ := teams.ReadHistory(home, "alpha", 0, true) if len(got) != 1 { t.Fatal(got) } @@ -519,7 +524,7 @@ func TestTrafficMirrorsRetireRevokedDevicesWithoutRepublishingThem(t *testing.T) if err := p.Apply(values); err != nil { t.Fatal(err) } - got, _ = teams.ReadHistory(home, "alpha", 0) + got, _ = teams.ReadHistory(home, "alpha", 0, true) if len(got) != 0 { t.Fatal("revoked device retained mirror", got) } @@ -576,6 +581,8 @@ func enableSyncTeamSharing(t *testing.T, profile string) { } } +// C9 pins off explicitly so continuation still preserves legacy team files and cursors. +// F1 preserves the existing sharing fixture and history contract. func TestSharingOffContinuesTeamMemberWithoutRebindingOrChangingLegacyFiles(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("CODEAF_TELEMETRY", "off") @@ -583,8 +590,12 @@ func TestSharingOffContinuesTeamMemberWithoutRebindingOrChangingLegacyFiles(t *t h := newTwoHomes(t) key := teams.ConversationIdentity(transcriptOf(h.cell)) for _, s := range []*Sync{h.a, h.b} { + sharing, _ := config.NewSettings(config.SettingsOptions{ProfileDir: s.Home}).Row(config.KeyTeamsShare) + if err := sharing.Apply("off"); err != nil { + t.Fatal(err) + } if config.TeamsShareAt(s.Home) { - t.Fatal("new paired home opted into sharing") + t.Fatal("paired home ignored the explicit off choice") } if err := teams.Update(s.Home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "alpha", Name: "local", Members: []teams.Member{{Key: key, File: transcriptOf(h.cell), Word: "worker"}}}} @@ -656,3 +667,185 @@ func TestSharingOffContinuesTeamMemberWithoutRebindingOrChangingLegacyFiles(t *t } } } + +// F2 backs up the exact local file before the first carrier round and never rewrites that copy. +func TestFirstTeamSharingRoundBacksUpLocalTeamsOnce(t *testing.T) { + r := newKeyRig(t) + a, b := r.machine(), r.machine() + for i, m := range []*keyMachine{a, b} { + if err := teams.Update(m.profile, func(f *teams.File) error { + f.Teams = []teams.Team{{ID: fmt.Sprintf("team-%d", i), Name: "harbor"}} + return nil + }); err != nil { + t.Fatal(err) + } + m.Carry = []vaultsync.Carrier{syncedTeams(m.profile, m.profile, fmt.Sprintf("device-%d", i), nil)} + } + before, _ := os.ReadFile(teams.Path(b.profile)) + a.push(t) + b.pull(t) + backup := filepath.Join(b.profile, "teams.before-sharing.json") + got, err := os.ReadFile(backup) + if err != nil || string(got) != string(before) { + t.Fatalf("first merge backup: %s %v", got, err) + } + info, _ := os.Stat(backup) + f, err := teams.Load(b.profile) + if err != nil || len(f.Teams) != 2 { + t.Fatal("merge did not apply after backup", f, err) + } + if err := teams.Update(b.profile, func(f *teams.File) error { f.Teams[0].Name = "edited"; return nil }); err != nil { + t.Fatal(err) + } + b.push(t) + a.pull(t) + b.pull(t) + after, _ := os.ReadFile(backup) + next, _ := os.Stat(backup) + if string(after) != string(before) || !next.ModTime().Equal(info.ModTime()) { + t.Fatal("second round rewrote original backup") + } + // A preexisting copy also wins if an interrupted first round left no ledger. + if err := os.Remove(filepath.Join(b.profile, "teams-sync.ledger")); err != nil { + t.Fatal(err) + } + b.pull(t) + after, _ = os.ReadFile(backup) + if string(after) != string(before) { + t.Fatal("retry overwrote backup") + } +} + +// R2/F2 preserves the first former appointment through guarded membership removal and recapture. +func TestMergedMemberRemovalKeepsFormerManagerAppointment(t *testing.T) { + for _, former := range []string{"", "local-manager", "earlier"} { + t.Run("former="+former, func(t *testing.T) { + dir := t.TempDir() + previous := []teams.Member{{Key: "earlier", SyncID: "device:a:earlier", Word: "earlier manager"}} + if err := teams.Update(dir, func(f *teams.File) error { + f.Teams = []teams.Team{{ID: "alpha", Name: "harbor", Manager: "local-manager", FormerManager: former, FormerMembers: previous, Members: []teams.Member{{Key: "local-manager", SyncID: "device:a:manager", Word: "manager"}}}} + return nil + }); err != nil { + t.Fatal(err) + } + p := teamKeys{dir, dir, "a", nil} + before, err := p.Values() + if err != nil { + t.Fatal(err) + } + if err := p.ApplyIf(map[string]string{"alpha/member/device:a:manager": ""}, before); err != nil { + t.Fatal(err) + } + f, err := teams.Snapshot(dir) + if err != nil { + t.Fatal(err) + } + got := f.Teams[0] + want, portable := former, before["alpha/field/former_manager"] + if want == "" { + want, portable = "local-manager", before["alpha/field/manager"] + } + if got.Manager != "" || got.FormerManager != want { + t.Fatalf("merge replaced the former appointment: %+v", got) + } + after, err := p.Values() + if err != nil { + t.Fatal(err) + } + if after["alpha/field/former_manager"] != portable { + t.Fatal("former appointment lost its portable identity", after) + } + if former == "earlier" && len(got.FormerMembers) != 1 { + t.Fatal("occupied history gained the cleared appointment", got.FormerMembers) + } + }) + } +} + +// R2/F2 keeps the demoted root's active and former appointments through guarded apply. +func TestMergedRootDemotionKeepsFormerManagerAppointment(t *testing.T) { + for _, former := range []string{"", "earlier"} { + dir := t.TempDir() + if err := teams.Update(dir, func(f *teams.File) error { + f.Teams = []teams.Team{{ID: "b", Name: teams.RootName, Root: true, Manager: "active", FormerManager: former, FormerMembers: []teams.Member{{Key: "earlier", SyncID: "device:b:earlier"}}, Members: []teams.Member{{Key: "active", SyncID: "device:b:active", Word: "manager"}}}} + return nil + }); err != nil { + t.Fatal(err) + } + p := teamKeys{dir, dir, "b", nil} + before, err := p.Values() + if err != nil { + t.Fatal(err) + } + if err := p.ApplyIf(map[string]string{"a/exists": "true", "a/field/name": teamValue(teams.RootName), "a/field/root": "true"}, before); err != nil { + t.Fatal(err) + } + f, err := teams.Snapshot(dir) + if err != nil { + t.Fatal(err) + } + got, _ := f.Team("b") + if got.Root || got.Manager != "active" || got.FormerManager != former { + t.Fatalf("demotion changed appointments: %+v", got) + } + } +} + +// R5/F2 refuses incoming changes until backup succeeds and reports retries on the sync notice path once. +func TestTeamBackupFailureWaitsAndNotifiesOnce(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + if err := teams.Update(dir, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(teams.Path(dir)) + if err != nil { + t.Fatal(err) + } + backup := filepath.Join(dir, "teams.before-sharing.json") + // A symlink loop deterministically refuses even privileged processes. + if err := os.Symlink(filepath.Base(teams.Path(dir)), filepath.Join(dir, "original-teams")); err != nil { + t.Fatal(err) + } + if err := os.Remove(teams.Path(dir)); err != nil { + t.Fatal(err) + } + if err := os.Symlink("original-teams", teams.Path(dir)); err != nil { + t.Fatal(err) + } + p := teamKeys{dir, dir, "local", nil} + teams.SharingError(dir, errors.New("an earlier independent sharing failure")) + var notices []string + stop := teams.WatchSharingErrors("", func(line string) { notices = append(notices, line) }) + defer stop() + for i := 0; i < 2; i++ { + err := p.ApplyIf(map[string]string{"incoming/exists": "true"}, nil) + if err == nil { + t.Fatal("incoming team applied without a backup") + } + teams.SharingError(dir, err) + } + if len(notices) != 2 || !strings.Contains(notices[1], "teams.before-sharing.json") { + t.Fatalf("backup notice was lost or repeated: %v", notices) + } + if _, err := os.Lstat(backup); !os.IsNotExist(err) { + t.Fatal("failed backup left a copy", err) + } + if err := os.Remove(teams.Path(dir)); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(teams.Path(dir), before, 0600); err != nil { + t.Fatal(err) + } + if err := p.ApplyIf(map[string]string{"incoming/exists": "true"}, nil); err != nil { + t.Fatal(err) + } + f, err := teams.Snapshot(dir) + if err != nil || len(f.Teams) != 2 { + t.Fatal("retry did not apply after backup recovered", f, err) + } + got, err := os.ReadFile(backup) + if err != nil || !bytes.Equal(got, before) { + t.Fatal("recovery backup lost local teams", string(got), err) + } +} diff --git a/internal/teams/relay.go b/internal/teams/relay.go index f7796d296e..a65e7641be 100644 --- a/internal/teams/relay.go +++ b/internal/teams/relay.go @@ -80,7 +80,10 @@ func (f *File) RepairMerged() { } assignHandles(t) sort.SliceStable(t.Members, func(i, j int) bool { return order[t.Members[i].Key] < order[t.Members[j].Key] }) - if !t.Holds(t.Manager) { + if t.Manager != "" && !t.Holds(t.Manager) { + if t.FormerManager == "" || t.FormerManager == t.Manager { + t.FormerManager = t.Manager + } t.Manager = "" } } @@ -98,7 +101,11 @@ func (f *File) RepairMerged() { } sort.Strings(pairs) parts := strings.Split(pairs[0], "\x00") - f.Teams[index[parts[len(parts)-1]]].Manager = "" + team := &f.Teams[index[parts[len(parts)-1]]] + if team.FormerManager == "" || team.FormerManager == team.Manager { + team.FormerManager = team.Manager + } + team.Manager = "" } assignHomes(f.Teams) } diff --git a/internal/teams/relay_test.go b/internal/teams/relay_test.go index 40740f89ca..34d3fe0a4e 100644 --- a/internal/teams/relay_test.go +++ b/internal/teams/relay_test.go @@ -2,6 +2,7 @@ package teams import ( "encoding/json" + "fmt" "testing" "time" ) @@ -28,6 +29,7 @@ func TestMergeRepairsParentsHandlesManagerAndHomeDeterministically(t *testing.T) } } +// F1 preserves the existing sharing fixture and history contract. func TestMergeKeepsOneReportingHomeAndRepairsConcurrentManagerDuties(t *testing.T) { f := &File{Teams: []Team{ {ID: "bb", Manager: "manager-b", Members: []Member{{Key: "manager-b", Word: "leader-b"}, {Key: "shared", Word: "storage", Home: true}}}, @@ -53,3 +55,112 @@ func TestMergeKeepsOneReportingHomeAndRepairsConcurrentManagerDuties(t *testing. t.Fatal("concurrent manager duties were not repaired deterministically") } } + +// R1/F2 preserves original team names and both managers in either merge order. +func TestFirstMergeKeepsBothTeamNamesAndGlobalManagersDeterministically(t *testing.T) { + at := time.Unix(10, 0) + left := []Team{ + {ID: "a-root", Name: RootName, Root: true, Made: at, Manager: "a-manager", Members: []Member{{Key: "a-manager", Word: "a manager", Handle: "boss"}}}, + {ID: "a-harbor", Name: "harbor", Made: at}, + } + right := []Team{ + {ID: "b-root", Name: RootName, Root: true, Made: at.Add(time.Second), Manager: "b-manager", Members: []Member{{Key: "b-manager", Word: "b manager", Handle: "boss"}}}, + {ID: "b-harbor", Name: "harbor", Made: at.Add(time.Second)}, + } + // Each computer starts from its own valid file before either sees the other. + originals := [][]Team{left, right} + for i, original := range originals { + dir := t.TempDir() + if err := Update(dir, func(f *File) error { + for _, t := range original { + f.Teams = append(f.Teams, t.Clone()) + } + return nil + }); err != nil { + t.Fatal(err) + } + file, err := Snapshot(dir) + if err != nil { + t.Fatal(err) + } + originals[i] = file.Teams + } + left, right = originals[0], originals[1] + var previous []byte + for _, order := range [][]Team{append(append([]Team{}, left...), right...), append(append([]Team{}, right...), left...)} { + dir := t.TempDir() + if err := Update(dir, func(f *File) error { + for _, team := range order { + f.Teams = append(f.Teams, team.Clone()) + } + f.RepairMerged() + return nil + }); err != nil { + t.Fatal(err) + } + f, err := Load(dir) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"a-harbor", "b-harbor"} { + team, ok := f.Team(id) + if !ok || team.Name != "harbor" { + t.Fatalf("merge changed an original name: %+v", team) + } + } + root, _ := f.Root() + other, _ := f.Team("b-root") + if root.ID != "a-root" || root.Manager != "a-manager" || other.Root || other.Manager != "b-manager" || other.Parent != root.ID || other.FormerManager != "" { + t.Fatalf("leadership lost: root=%+v other=%+v", root, other) + } + raw, _ := json.Marshal(f.Teams) + if previous != nil && string(previous) != string(raw) { + t.Fatalf("merge order changed bytes:\n%s\n%s", previous, raw) + } + previous = raw + f.RepairMerged() + again, _ := json.Marshal(f.Teams) + if string(raw) != string(again) { + t.Fatal("repair is not idempotent") + } + } +} + +// R2/F2 records cleared appointments only when the former-manager slot can hold them. +func TestMergePreservesClearedManagerAppointment(t *testing.T) { + for _, former := range []string{"", "shared", "earlier"} { + for _, missing := range []bool{false, true} { + t.Run(fmt.Sprintf("former=%s/missing=%v", former, missing), func(t *testing.T) { + f := &File{Teams: []Team{ + {ID: "a", Name: "a", Manager: "shared", Members: []Member{{Key: "shared", Word: "manager"}}}, + {ID: "b", Name: "b", Manager: "shared", FormerManager: former, Members: []Member{{Key: "shared", Word: "manager"}}}, + }} + if missing { + f.Teams[1].Members = nil + } + f.RepairMerged() + want := former + if want == "" { + want = "shared" + } + if f.Teams[1].Manager != "" || f.Teams[1].FormerManager != want { + t.Fatalf("cleared appointment replaced history: %+v", f.Teams[1]) + } + }) + } + } +} + +// R2/F2 keeps an active manager out of former appointments when its second root is demoted. +func TestMergeDemotedRootKeepsFormerManager(t *testing.T) { + for _, former := range []string{"", "earlier"} { + f := &File{Teams: []Team{ + {ID: "a", Name: RootName, Root: true}, + {ID: "b", Name: RootName, Root: true, Manager: "active", FormerManager: former, Members: []Member{{Key: "active", Word: "manager"}}}, + }} + f.RepairMerged() + if f.Teams[1].Root || f.Teams[1].Manager != "active" || f.Teams[1].FormerManager != former { + t.Fatalf("demotion changed appointments: %+v", f.Teams[1]) + } + } +} diff --git a/internal/teams/sharing_notice.go b/internal/teams/sharing_notice.go index d3969a77a0..ba1ec818ca 100644 --- a/internal/teams/sharing_notice.go +++ b/internal/teams/sharing_notice.go @@ -3,17 +3,72 @@ package teams import ( "log" "sync" + + "github.com/Agent-Field/codeaf/internal/config" ) -var sharingNotices sync.Map +var sharingNotices = struct { + sync.Mutex + profiles map[string]*sharingNotice +}{profiles: map[string]*sharingNotice{}} + +type sharingNotice struct { + say func(string) + pending []string + seen map[string]bool +} + +// WatchSharingErrors connects optional sharing failures to the existing sync +// notice desk. Early failures wait for the surface, and retries say each failure +// once per process rather than flooding the person or consuming another error. +func WatchSharingErrors(profile string, say func(string)) func() { + profile = config.ProfilePath(profile, "") + sharingNotices.Lock() + n := sharingNotices.profiles[profile] + if n == nil { + n = &sharingNotice{seen: map[string]bool{}} + sharingNotices.profiles[profile] = n + } + n.say = say + pending := n.pending + n.pending = nil + sharingNotices.Unlock() + for _, line := range pending { + say(line) + } + return func() { + sharingNotices.Lock() + n.say = nil + sharingNotices.Unlock() + } +} -// SharingError uses the application's existing log destination, once per -// profile and process. Retrying optional sharing must not flood that log. +// SharingError keeps retry reporting on the same notice path for every team +// carrier. A failed backup therefore cannot disappear into the chat log. func SharingError(profile string, err error) { if err == nil { return } - if _, seen := sharingNotices.LoadOrStore(profile, true); !seen { - log.Printf("teams: sharing will retry: %v", err) + profile = config.ProfilePath(profile, "") + line := "teams: sharing will retry: " + err.Error() + sharingNotices.Lock() + n := sharingNotices.profiles[profile] + if n == nil { + n = &sharingNotice{seen: map[string]bool{}} + sharingNotices.profiles[profile] = n + } + if n.seen[line] { + sharingNotices.Unlock() + return + } + n.seen[line] = true + say := n.say + if say == nil { + n.pending = append(n.pending, line) + } + sharingNotices.Unlock() + log.Print(line) + if say != nil { + say(line) } } diff --git a/internal/teams/stamp.go b/internal/teams/stamp.go index a113d5b27b..c25e136e3e 100644 --- a/internal/teams/stamp.go +++ b/internal/teams/stamp.go @@ -190,16 +190,19 @@ func (w *Watch) mark(key, stamp, after string) { // History refreshes only the display tail. Each consumer returns its own mirror // version, so one hosted window cannot acknowledge another window's change. // Local news and its cursor are read separately through Traffic. -func (w *Watch) History(profile, team, after, version string, limit int) ([]Entry, string, string, bool, error) { +func (w *Watch) History(profile, team, after, version string, limit int, sharing bool) ([]Entry, string, string, bool, error) { if err := safeTeamID(team); err != nil { return nil, after, version, false, err } - mirror := stampOf(TrafficMirrorPath(profile, team)) + mirror := "local" + if sharing { + mirror = stampOf(TrafficMirrorPath(profile, team)) + } reset := after == "" || version != mirror if !reset { return nil, after, mirror, false, nil } - entries, err := ReadHistory(profile, team, limit) + entries, err := ReadHistory(profile, team, limit, sharing) if err != nil { return nil, after, version, false, err } diff --git a/internal/teams/store.go b/internal/teams/store.go index 1ffccdc191..b935418c17 100644 --- a/internal/teams/store.go +++ b/internal/teams/store.go @@ -237,6 +237,72 @@ func Inspect(profileDir string, fn func(*File) error) error { }) } +// BackupBeforeSharing preserves the original bytes before any first merge. +// A synced temporary copy is linked into place when possible. Filesystems +// without hard links use an exclusive write, so an earlier copy always wins. +func BackupBeforeSharing(profileDir string) error { + return backupBeforeSharing(profileDir, os.Link) +} + +func backupBeforeSharing(profileDir string, link func(string, string) error) error { + return withLock(profileDir, lockWait, func() error { + backup := config.ProfilePath(profileDir, "teams.before-sharing.json") + if _, err := os.Lstat(backup); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + raw, err := os.ReadFile(Path(profileDir)) + if os.IsNotExist(err) { + raw, err = os.ReadFile(legacyPath(profileDir)) + } + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(backup), ".teams-before-sharing-*") + if err != nil { + return err + } + defer os.Remove(temp.Name()) + if _, err := temp.Write(raw); err != nil { + temp.Close() + return err + } + if err := temp.Sync(); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := link(temp.Name(), backup); err == nil || os.IsExist(err) { + return nil + } + copy, err := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if os.IsExist(err) { + return nil + } + if err != nil { + return err + } + if _, err = copy.Write(raw); err == nil { + err = copy.Sync() + } + closed := copy.Close() + if err == nil { + err = closed + } + if err != nil { + // A failed fallback must not look like a completed backup on retry. + _ = os.Remove(backup) + } + return err + }) +} + // SetAside moves an unreadable teams file out of the way, to // .unreadable- beside it, so that starting again does not // overwrite a file that may hold every team a person made. It moves teams.json diff --git a/internal/teams/store_test.go b/internal/teams/store_test.go index 17a92c311c..795e84bbd7 100644 --- a/internal/teams/store_test.go +++ b/internal/teams/store_test.go @@ -485,3 +485,51 @@ func TestWrapUpRoundTripThroughChange(t *testing.T) { t.Fatalf("a cleared wrap was still written:\n%s", raw) } } + +// R5/F2 preserves a synced first-sharing copy on filesystems that reject hard links. +func TestBackupBeforeSharingFallsBackWithoutOverwriting(t *testing.T) { + dir := t.TempDir() + raw := []byte(`{"version":2,"teams":[{"id":"local","name":"harbor"}]}`) + if err := os.WriteFile(Path(dir), raw, 0600); err != nil { + t.Fatal(err) + } + link := func(string, string) error { return errors.New("hard links unavailable") } + if err := backupBeforeSharing(dir, link); err != nil { + t.Fatal(err) + } + backup := filepath.Join(dir, "teams.before-sharing.json") + got, err := os.ReadFile(backup) + if err != nil || string(got) != string(raw) { + t.Fatalf("fallback backup = %s, %v", got, err) + } + info, err := os.Stat(backup) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("backup permissions", info, err) + } + if err := os.WriteFile(Path(dir), []byte("later teams"), 0600); err != nil { + t.Fatal(err) + } + if err := backupBeforeSharing(dir, link); err != nil { + t.Fatal(err) + } + got, err = os.ReadFile(backup) + if err != nil || string(got) != string(raw) { + t.Fatal("fallback overwrote original backup", string(got), err) + } + if err := os.Remove(backup); err != nil { + t.Fatal(err) + } + link = func(_, target string) error { + if err := os.WriteFile(target, raw, 0600); err != nil { + t.Fatal(err) + } + return errors.New("another publisher won") + } + if err := backupBeforeSharing(dir, link); err != nil { + t.Fatal(err) + } + got, err = os.ReadFile(backup) + if err != nil || string(got) != string(raw) { + t.Fatal("exclusive fallback replaced an existing backup", string(got), err) + } +} diff --git a/internal/teams/traffic_mirror_test.go b/internal/teams/traffic_mirror_test.go index 8069613928..98665bfd7d 100644 --- a/internal/teams/traffic_mirror_test.go +++ b/internal/teams/traffic_mirror_test.go @@ -11,6 +11,7 @@ import ( ) // A mirror must remain invisible to every old and current local delivery reader. +// F1 preserves the existing sharing fixture and history contract. func TestTrafficMirrorNeverChangesLocalLogAndKeepsPortableThreads(t *testing.T) { dir := t.TempDir() if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromYou, To: ToManager, Text: "local", Origin: "forged"}); err != nil { @@ -34,7 +35,7 @@ func TestTrafficMirrorNeverChangesLocalLogAndKeepsPortableThreads(t *testing.T) if len(local) != 1 { t.Fatal(local) } - got, err := ReadHistory(dir, "alpha", 0) + got, err := ReadHistory(dir, "alpha", 0, true) if err != nil || len(got) != 3 { t.Fatalf("%+v %v", got, err) } @@ -106,23 +107,24 @@ func TestDeletingATeamRemovesItsDisplayMirrorAlongWithItsFolder(t *testing.T) { } } +// F1 preserves the existing sharing fixture and history contract. func TestOverviewTailCannotHideAMirrorChangeFromTheTrafficPane(t *testing.T) { dir := t.TempDir() if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromManager, To: ToRoom, Text: "local"}); err != nil { t.Fatal(err) } var w Watch - _, cursor, version, _, err := w.History(dir, "alpha", "", "", 100) + _, cursor, version, _, err := w.History(dir, "alpha", "", "", 100, true) if err != nil { t.Fatal(err) } if err := WriteTrafficMirror(dir, "alpha", []Entry{{ID: "a/1", At: time.Now(), Text: "new mirror"}}); err != nil { t.Fatal(err) } - if _, _, _, _, err := w.History(dir, "alpha", "", "", 100); err != nil { + if _, _, _, _, err := w.History(dir, "alpha", "", "", 100, true); err != nil { t.Fatal(err) } - lines, next, _, reset, err := w.History(dir, "alpha", cursor, version, 100) + lines, next, _, reset, err := w.History(dir, "alpha", cursor, version, 100, true) if err != nil || !reset || next != cursor || len(lines) != 2 { t.Fatal("overview consumed the pane's refresh", lines, next, reset, err) } @@ -141,6 +143,7 @@ func TestTrafficExportTakesOnlyTheNewestLineCount(t *testing.T) { } } +// F1 preserves the existing sharing fixture and history contract. func TestMirrorSurvivesLocalRotationWithoutDuplicatingRemoteLines(t *testing.T) { dir := t.TempDir() old := trafficRotateBytes @@ -166,7 +169,7 @@ func TestMirrorSurvivesLocalRotationWithoutDuplicatingRemoteLines(t *testing.T) t.Fatal("rotation admitted mirror into local log") } } - all, _ := ReadHistory(dir, "alpha", 0) + all, _ := ReadHistory(dir, "alpha", 0, true) count := 0 for _, e := range all { if e.Origin == "a/1" { @@ -191,6 +194,7 @@ func TestHistoryThreadsFollowTimeAcrossWritersAndKeepReferences(t *testing.T) { } } +// F1 preserves the existing sharing fixture and history contract. func TestHistoryWithoutMirrorKeepsLocalSequenceWhenClockMovesBack(t *testing.T) { profile := t.TempDir() for _, at := range []time.Time{time.Unix(200, 0), time.Unix(100, 0)} { @@ -202,7 +206,7 @@ func TestHistoryWithoutMirrorKeepsLocalSequenceWhenClockMovesBack(t *testing.T) if err != nil { t.Fatal(err) } - history, err := ReadHistory(profile, "alpha", 0) + history, err := ReadHistory(profile, "alpha", 0, true) if err != nil { t.Fatal(err) } @@ -215,3 +219,29 @@ func TestHistoryWithoutMirrorKeepsLocalSequenceWhenClockMovesBack(t *testing.T) } } } + +// F7 hides retained mirror history when sharing is off or unpaired without deleting it. +func TestHistoryWithoutEffectiveSharingKeepsMirrorOnDisk(t *testing.T) { + dir := t.TempDir() + if err := AppendTraffic(dir, "alpha", Entry{Kind: KindNote, From: FromYou, To: ToRoom, Text: "local"}); err != nil { + t.Fatal(err) + } + if err := WriteTrafficMirror(dir, "alpha", []Entry{{ID: "other/1", Kind: KindNote, Text: "foreign"}}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(TrafficMirrorPath(dir, "alpha")) + for _, sharing := range []bool{true, false, true} { + got, err := ReadHistory(dir, "alpha", 0, sharing) + want := 1 + if sharing { + want = 2 + } + if err != nil || len(got) != want { + t.Fatalf("sharing=%v history=%+v err=%v", sharing, got, err) + } + } + after, _ := os.ReadFile(TrafficMirrorPath(dir, "alpha")) + if !bytes.Equal(before, after) { + t.Fatal("history reader changed retained mirror") + } +} diff --git a/internal/teams/traffic_refresh_test.go b/internal/teams/traffic_refresh_test.go index df1aceed19..0d8936acc3 100644 --- a/internal/teams/traffic_refresh_test.go +++ b/internal/teams/traffic_refresh_test.go @@ -27,13 +27,14 @@ func TestTrafficExportKeepsNewestWindowAcrossRotation(t *testing.T) { } } +// F1 preserves the existing sharing fixture and history contract. func TestMirrorRefreshCannotAdvancePastUnprocessedLocalNews(t *testing.T) { dir := t.TempDir() if err := AppendTraffic(dir, "alpha", Entry{From: FromManager, To: ToRoom, Kind: KindNote}); err != nil { t.Fatal(err) } var w Watch - _, cursor, version, _, err := w.History(dir, "alpha", "", "", 200) + _, cursor, version, _, err := w.History(dir, "alpha", "", "", 200, true) if err != nil { t.Fatal(err) } @@ -45,23 +46,24 @@ func TestMirrorRefreshCannotAdvancePastUnprocessedLocalNews(t *testing.T) { if err := WriteTrafficMirror(dir, "alpha", []Entry{{ID: "elsewhere/1", At: time.Now()}}); err != nil { t.Fatal(err) } - _, next, _, _, err := w.History(dir, "alpha", cursor, version, 200) + _, next, _, _, err := w.History(dir, "alpha", cursor, version, 200, true) if err != nil || next != cursor { t.Fatalf("mirror acknowledged unread local lines: %q -> %q: %v", cursor, next, err) } } +// F1 preserves the existing sharing fixture and history contract. func TestEachHistoryConsumerSeesMirrorVersionIndependently(t *testing.T) { dir := t.TempDir() if err := AppendTraffic(dir, "alpha", Entry{From: FromManager, To: ToRoom, Kind: KindNote}); err != nil { t.Fatal(err) } var w Watch - _, cursor, v1, _, err := w.History(dir, "alpha", "", "", 200) + _, cursor, v1, _, err := w.History(dir, "alpha", "", "", 200, true) if err != nil { t.Fatal(err) } - _, _, v2, _, err := w.History(dir, "alpha", "", "", 200) + _, _, v2, _, err := w.History(dir, "alpha", "", "", 200, true) if err != nil { t.Fatal(err) } @@ -69,7 +71,7 @@ func TestEachHistoryConsumerSeesMirrorVersionIndependently(t *testing.T) { t.Fatal(err) } for _, version := range []string{v1, v2} { - lines, next, current, reset, err := w.History(dir, "alpha", cursor, version, 200) + lines, next, current, reset, err := w.History(dir, "alpha", cursor, version, 200, true) if err != nil || !reset || next != cursor || current == version || len(lines) != 2 { t.Fatal("consumer missed mirror", lines, next, current, reset, err) } diff --git a/internal/teams/traffic_sync.go b/internal/teams/traffic_sync.go index e935ea178b..060b985200 100644 --- a/internal/teams/traffic_sync.go +++ b/internal/teams/traffic_sync.go @@ -20,7 +20,7 @@ const ( TrafficSyncBytes = 16 << 10 TrafficSyncTextBytes = 2 << 10 TrafficSyncDeviceBytes = 64 << 10 - TrafficSyncEvery = 30 * time.Second + TrafficSyncEvery = 2 * time.Minute ) // TrafficForSync reads this computer's bounded tail across rotation. Received @@ -188,11 +188,14 @@ func sortHistory(lines []Entry) { // ReadHistory merges a local tail with the display mirror. Delivery always // uses ReadTraffic instead; a mirror id is never a delivery position. -func ReadHistory(profile, team string, limit int) ([]Entry, error) { +func ReadHistory(profile, team string, limit int, sharing bool) ([]Entry, error) { local, err := ReadTraffic(profile, team, "", limit) if err != nil { return nil, err } + if !sharing { + return local, nil + } remote, err := readTrafficFile(TrafficMirrorPath(profile, team)) if err != nil { SharingError(profile, err) diff --git a/internal/vaultsync/device_history_test.go b/internal/vaultsync/device_history_test.go index 48f19fab2a..d082a35a6f 100644 --- a/internal/vaultsync/device_history_test.go +++ b/internal/vaultsync/device_history_test.go @@ -21,6 +21,7 @@ func (s *windowSource) Values() (map[string]string, error) { return s.local, n func (s *windowSource) Holds(name string) bool { return s.live[name] } func (s *windowSource) Apply(values map[string]string) error { s.received = values; return nil } +// F5 bounds history publications without delaying team record edits. func TestDeviceWindowsRateLimitOnlyOwnTrafficAndDeleteOnTheNextRound(t *testing.T) { home := t.TempDir() v, err := keys.Open(home) @@ -31,12 +32,12 @@ func TestDeviceWindowsRateLimitOnlyOwnTrafficAndDeleteOnTheNextRound(t *testing. w.now = func() time.Time { return now } must(t, w.capture(v)) s.local["alpha"] = "second" - now = now.Add(teams.TrafficSyncEvery - time.Nanosecond) + now = now.Add(2*time.Minute - time.Nanosecond) must(t, w.capture(v)) e, err := v.Get(w.scope + "/alpha/a") must(t, err) if e.Value != "first" { - t.Fatal("published before 30 seconds") + t.Fatalf("published before the two-minute history allowance (%v)", teams.TrafficSyncEvery) } now = now.Add(time.Nanosecond) must(t, w.capture(v)) From b615412b400ee86321beab6c7846099b5066f92a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 11:34:00 -0400 Subject: [PATCH 3/6] surfaces: unpaired computers behave as before; hosted windows follow the engine Every surface reads effective sharing: an unpaired computer keeps its old refresh, completion, cursor and Traffic behavior. Pairing is read off the event loop. A window on another computer's engine follows that engine's sharing. A local team member opens like the same chat from Home. A cached team digest is dropped when sharing stops. Pairing replace asks about teams only on a terminal and never waits on a silent pipe. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3.go | 1 + cmd/codeaf/chatv3_host_teams.go | 17 +- cmd/codeaf/chatv3_host_teams_test.go | 37 +++ cmd/codeaf/chatv3_pair.go | 4 + cmd/codeaf/chatv3_pair_test.go | 176 +++++++++++ cmd/codeaf/chatv3_surface.go | 8 + cmd/codeaf/engine.go | 1 + cmd/codeaf/pair.go | 29 ++ cmd/codeaf/pairlink.go | 11 +- cmd/codeaf/pairroute.go | 1 + cmd/codeaf/team_resume.go | 3 + internal/remote/server.go | 2 + internal/remote/teams.go | 19 +- internal/remote/teams_history_test.go | 117 ++++++- internal/remote/wire_teams.go | 11 +- internal/session/agent.go | 2 + internal/session/loop.go | 4 + internal/session/mention.go | 6 +- internal/session/session.go | 7 +- internal/session/team.go | 40 ++- internal/session/team_locality.go | 7 +- internal/session/team_nest.go | 3 +- internal/session/team_questions.go | 3 +- internal/session/team_relay_test.go | 172 ++++++++++- internal/session/team_test.go | 5 + internal/session/tools_team.go | 2 +- internal/tui3/app.go | 24 +- internal/tui3/chattabs.go | 2 +- internal/tui3/conversationdelete_test.go | 6 +- internal/tui3/homepanel_machines.go | 8 + internal/tui3/pair.go | 20 +- internal/tui3/pair_test.go | 2 + internal/tui3/pairdrag_test.go | 29 +- internal/tui3/removed_computer.go | 6 + internal/tui3/settings_live.go | 85 ++++- internal/tui3/settingsteams_host_test.go | 9 +- internal/tui3/teamsacts.go | 2 +- internal/tui3/teamseam.go | 11 +- internal/tui3/teamseam_test.go | 8 +- internal/tui3/teamsoverview.go | 3 + internal/tui3/teamspage_test.go | 3 +- internal/tui3/teamsread_test.go | 3 +- internal/tui3/teamsrelay.go | 14 +- internal/tui3/teamsrelay_test.go | 377 +++++++++++++++++++++-- internal/tui3/teamtraffic.go | 22 +- internal/tui3/teamwritesaid_test.go | 2 +- internal/tui3/tui3.go | 2 + 47 files changed, 1217 insertions(+), 109 deletions(-) diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 1f73bfcc5b..fd12a18115 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1015,6 +1015,7 @@ func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place sessio cfg := session.Config{ TeamComputers: teamComputers, + TeamsPaired: teamPaired, Workspace: workspace, Model: chosen, APIKey: settings.APIKey, diff --git a/cmd/codeaf/chatv3_host_teams.go b/cmd/codeaf/chatv3_host_teams.go index 89136c57ad..70de30a537 100644 --- a/cmd/codeaf/chatv3_host_teams.go +++ b/cmd/codeaf/chatv3_host_teams.go @@ -114,7 +114,7 @@ func hostTeamsSeam(far hostFar, welcome remote.Welcome) tui3.TeamsSeam { // when the engine answers them, so an older engine's window has a seam whose // delegation doors are nil, which the surface says rather than guesses at. func (h *hostTeams) seam() tui3.TeamsSeam { - s := tui3.TeamsSeam{Load: h.load, ReadSince: h.readSince, Update: h.update, Traffic: h.readTraffic} + s := tui3.TeamsSeam{Sharing: h.sharing, Load: h.load, ReadSince: h.readSince, Update: h.update, Traffic: h.readTraffic} if h.history != nil { s.DisplayTraffic = func(team, after, version string, limit int) ([]teamstore.Entry, string, string, bool, error) { got, err := h.history(team, after, version, limit) @@ -306,3 +306,18 @@ func (h *hostTeams) forgetChecked(team string, expected []string) ([]string, err } return reply.Gone, nil } + +// sharing asks the engine rather than borrowing the viewing computer's pairing. +func (h *hostTeams) sharing() (bool, error) { + _, stamp, _ := h.held(nil) + reading, err := h.read(stamp, h.reservedHues()) + if err != nil { + return false, err + } + if reading.Sharing != nil { + return *reading.Sharing, nil + } + // Older engines gate their own history. Keep that display door available + // when they cannot report the gate separately. + return h.history != nil, nil +} diff --git a/cmd/codeaf/chatv3_host_teams_test.go b/cmd/codeaf/chatv3_host_teams_test.go index 0bed218f6f..7ea8eb93e9 100644 --- a/cmd/codeaf/chatv3_host_teams_test.go +++ b/cmd/codeaf/chatv3_host_teams_test.go @@ -2,6 +2,8 @@ package main import ( "errors" + "github.com/Agent-Field/codeaf/internal/config" + "sync/atomic" "testing" "github.com/Agent-Field/codeaf/internal/remote" @@ -239,3 +241,38 @@ func TestHostTeamsKeepsClosedHistoryOffTheWire(t *testing.T) { t.Fatal("the host seam offered to read closed history from this laptop") } } + +// R4/F1/F7 carries effective sharing from the actual engine response through the hosted seam. +func TestHostedTeamsSeamReadsEngineEffectiveSharing(t *testing.T) { + dir := t.TempDir() + var paired atomic.Bool + loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: &quietAgent{}, ProfileDir: dir, TeamsPaired: paired.Load}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + seam := hostTeamsSeam(hostFar{client: loop.Client}, loop.Client.Welcome()) + if seam.Sharing == nil { + t.Fatal("hosted seam has no engine sharing read") + } + if _, _, _, err := seam.ReadSince("", nil); err != nil { + t.Fatal(err) + } + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTeamsShare) + for _, tc := range []struct { + paired bool + choice string + want bool + }{{true, "on", true}, {false, "on", false}, {true, "off", false}, {true, "on", true}} { + paired.Store(tc.paired) + if err := row.Apply(tc.choice); err != nil { + t.Fatal(err) + } + got, err := seam.Sharing() + if err != nil || got != tc.want { + t.Fatalf("engine paired=%v preference=%s: sharing=%v err=%v", tc.paired, tc.choice, got, err) + } + } +} diff --git a/cmd/codeaf/chatv3_pair.go b/cmd/codeaf/chatv3_pair.go index f0ab4cd5b6..c67b1e49c7 100644 --- a/cmd/codeaf/chatv3_pair.go +++ b/cmd/codeaf/chatv3_pair.go @@ -12,6 +12,7 @@ import ( "context" "errors" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" @@ -110,3 +111,6 @@ type chatOfferUI struct { func (u chatOfferUI) Show(code *pair.Code, _ string) { u.OfferUI.Show(code, pair.ChatCardLines(code, u.service)) } + +// HasTeams checks this computer's profile even while a hosted chat is open. +func (chatPairDoor) HasTeams() bool { return pair.HasTeams(config.ProfileDir()) } diff --git a/cmd/codeaf/chatv3_pair_test.go b/cmd/codeaf/chatv3_pair_test.go index fa1453eebe..b591a851a2 100644 --- a/cmd/codeaf/chatv3_pair_test.go +++ b/cmd/codeaf/chatv3_pair_test.go @@ -5,10 +5,19 @@ package main // same fact points at the /pair keystroke the person is already sitting at. import ( + "bytes" "context" "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/teams" + "io" + "os" + "path/filepath" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/pair" ) @@ -32,6 +41,7 @@ func TestChatJoinRefusalNamesTheInChatReplace(t *testing.T) { // The replacing errand reaches pair.Join with replace set: the interface keeps // the two doors from disagreeing about what a replace is. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestChatPairDoorImplementsBothJoins(t *testing.T) { var _ interface { Join(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) @@ -39,3 +49,169 @@ func TestChatPairDoorImplementsBothJoins(t *testing.T) { HasOwnChats() bool } = chatPairDoor{} } + +// F3 includes teams in terminal replacement confirmation even without an identity or chats. +func TestTerminalPairReplaceWithOnlyTeamsAsksBeforeRelay(t *testing.T) { + for _, link := range []bool{false, true} { + t.Run(fmt.Sprintf("link=%v", link), func(t *testing.T) { + dir := t.TempDir() + if err := teams.Update(dir, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(teams.Path(dir)) + out := &bytes.Buffer{} + d := newPairDoor(strings.NewReader("n\n"), out) + d.asksName = true + d.joining = func(replace bool) pair.Joining { return pair.Joining{Home: dir, Replace: replace} } + d.mailbox = func(string) (pair.Mailbox, error) { + t.Fatal("declined replacement reached relay") + return pair.Mailbox{}, nil + } + var err error + if link { + err = d.askToJoin(t.Context(), "", true) + } else { + err = d.join(t.Context(), "", "71-869-571", true) + } + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), pair.ReplaceAskLine) { + t.Fatal("terminal omitted teams confirmation", out.String()) + } + after, _ := os.ReadFile(teams.Path(dir)) + if !bytes.Equal(before, after) { + t.Fatal("declining changed teams") + } + }) + } +} + +// F1 uses the same quiet pairing gate for the production session and surface callbacks. +func TestTeamPairedFollowsPairingAndSyncOff(t *testing.T) { + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + t.Setenv(syncsetup.URLVar, "") + if teamPaired() { + t.Fatal("missing identity was paired") + } + if _, err := identity.EnsureSolo(root); err != nil { + t.Fatal(err) + } + if teamPaired() { + t.Fatal("solo identity was paired") + } + if err := identity.EndSolo(root); err != nil { + t.Fatal(err) + } + if !teamPaired() { + t.Fatal("shared identity was unpaired") + } + t.Setenv(syncsetup.URLVar, "off") + if teamPaired() { + t.Fatal("sync off kept sharing effective") + } +} + +// R3/F3 preserves non-terminal --replace for existing chats and teams without reading a silent pipe. +func TestTerminalPairReplaceWithoutTerminalNeverWaitsForInput(t *testing.T) { + for _, ownChats := range []bool{false, true} { + for _, link := range []bool{false, true} { + t.Run(fmt.Sprintf("ownChats=%v/link=%v", ownChats, link), func(t *testing.T) { + dir := t.TempDir() + if ownChats { + if _, err := identity.Ensure(dir); err != nil { + t.Fatal(err) + } + } + if err := teams.Update(dir, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil { + t.Fatal(err) + } + in, writer := io.Pipe() + defer in.Close() + defer writer.Close() + out := &pairScreenText{} + d := newPairDoor(in, out) + d.joining = func(replace bool) pair.Joining { return pair.Joining{Home: dir, Replace: replace} } + reached := errors.New("reached pairing without input") + d.mailbox = func(string) (pair.Mailbox, error) { return pair.Mailbox{}, reached } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + if link { + done <- d.askToJoin(ctx, "", true) + } else { + done <- d.join(ctx, "", "71-869-571", true) + } + }() + select { + case err := <-done: + if !errors.Is(err, reached) { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("non-terminal replacement waited for input") + } + if strings.Contains(out.String(), pair.ReplaceAskLine) { + t.Fatal("non-terminal replacement asked a question") + } + }) + } + } +} + +// R3/F3 completes non-terminal teams-only replacement and prints the preserved team's actual path. +func TestTerminalTeamsOnlyReplacePrintsAsideReceiptWithoutInput(t *testing.T) { + rig := newPairRig(t) + if err := teams.Update(rig.homeB, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(teams.Path(rig.homeB)) + if err != nil { + t.Fatal(err) + } + shown := rig.show(t) + code := shown.code(t) + in, writer := io.Pipe() + defer in.Close() + defer writer.Close() + out := &pairScreenText{} + d := rig.door(rig.homeB, in, out) + joining := d.joining + d.joining = func(replace bool) pair.Joining { + j := joining(replace) + j.TeamsPaired = func() bool { return true } + return j + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- d.run(ctx, []string{code, "--replace"}) }() + shown.out.waitFor(t, `y / n `) + if _, err := io.WriteString(shown.typed, "yes\n"); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("teams-only replacement waited for terminal input") + } + if err := shown.end(t); err != nil { + t.Fatal(err) + } + paths, err := filepath.Glob(teams.Path(rig.homeB) + ".unreadable-*") + if err != nil || len(paths) != 1 { + t.Fatal("replacement did not set teams aside", paths, err, out.String()) + } + if !strings.Contains(out.String(), pair.TeamsAsideLine(paths[0])) || strings.Contains(out.String(), pair.ReplaceAskLine) { + t.Fatal("missing non-terminal receipt", out.String()) + } + got, err := os.ReadFile(paths[0]) + if err != nil || !bytes.Equal(got, before) { + t.Fatal("receipt's file did not preserve teams", string(got), err) + } +} diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index d3c4c7f858..51929978e2 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -11,6 +11,7 @@ import ( internalenv "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" codeupdate "github.com/Agent-Field/codeaf/internal/update" ) @@ -48,6 +49,12 @@ 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.Host == "" { + if options.Notices == nil { + options.Notices = surfaceNotices + } + defer teams.WatchSharingErrors(options.ProfileDir, options.Notices.SayOnce)() + } if options.SyncService == "" && options.Host == "" { options.SyncService = chatSyncService() } @@ -100,6 +107,7 @@ func runSurface(ctx context.Context, options tui3.Options) error { options.Restart = restart } options.CellsUnavailable = cell.UnavailableReason() + options.TeamsPaired = teamPaired if options.TaskComputers == nil && options.Host == "" { options.TaskComputers = v3TaskComputers(options.ProfileDir) } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 6f65a4e031..e90cef3dd9 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1170,6 +1170,7 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote. proc.warmModels("engine/models", agent, launch.Model) return &remote.Engine{ + TeamsPaired: teamPaired, SyncService: chatSyncService(), ReleaseOnLeave: func(current remote.WrappedAgent) bool { built, ok := current.(*session.Agent) diff --git a/cmd/codeaf/pair.go b/cmd/codeaf/pair.go index 618e46cf0d..7ea5d64ec0 100644 --- a/cmd/codeaf/pair.go +++ b/cmd/codeaf/pair.go @@ -224,6 +224,9 @@ func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) e if _, err := pair.ReadJoinCode(typed); err != nil { return err } + if !d.confirmReplacement(ctx, replace) { + return nil + } route, err := d.mailbox(relay) if err != nil { return err @@ -233,6 +236,9 @@ func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) e } joined, err := pair.Join(ctx, route, d.joining(replace), typed, joinScreen{d.term}) if err != nil { + if line := pair.TeamsAsideLine(joined.TeamsAside); line != "" { + d.term.say(line) + } return d.endedByPerson(ctx, err) } if f, err := pullFleet(d.joining(replace).Home, route)(ctx); err == nil { @@ -269,3 +275,26 @@ func asksForHelp(args []string) bool { } return false } + +// confirmReplacement asks before either terminal joining flow changes local state. +func (d pairDoor) confirmReplacement(ctx context.Context, replace bool) bool { + // A non-terminal --replace already authorizes replacement, as it did + // before the interactive question included teams. + if !replace || !d.asksName { + return true + } + j := d.joining(true) + profile := j.ProfileDir + if profile == "" { + profile = j.Home + } + _, err := identity.Load(j.Home) + if err != nil && !pair.HasTeams(profile) { + return true + } + if d.term.confirm(ctx, pair.ReplaceAskLine) { + return true + } + d.term.say("nothing was replaced: this computer's chats stay.") + return false +} diff --git a/cmd/codeaf/pairlink.go b/cmd/codeaf/pairlink.go index 6fdb293eea..444b1921b2 100644 --- a/cmd/codeaf/pairlink.go +++ b/cmd/codeaf/pairlink.go @@ -114,6 +114,9 @@ func (s inviteScreen) Invited(in pair.Invite) { s.say(pair.InviteLines(in)) } // askToJoin is the new device: a link on screen until a device that is in says yes. func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) error { + if !d.confirmReplacement(ctx, replace) { + return nil + } route, err := d.mailbox(relay) if err != nil { return err @@ -122,6 +125,11 @@ func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) err return err } joined, err := pair.JoinByLink(ctx, d.requests(route), d.linking(route, replace), inviteScreen{d.term}) + if err != nil { + if line := pair.TeamsAsideLine(joined.TeamsAside); line != "" { + d.term.say(line) + } + } if err != nil && !errors.Is(err, pair.ErrPairedNotRead) { return d.endedByPerson(ctx, err) } @@ -130,7 +138,8 @@ func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) err return nil } d.recordOwnDeviceOrSay(ctx, route) - d.term.say(pair.PairedWithLine(joined.Fleet.With)) + joined.Joined.With = joined.Fleet.With + d.term.say(joined.Joined.Sentence()) return nil } diff --git a/cmd/codeaf/pairroute.go b/cmd/codeaf/pairroute.go index 2c3c62ce43..4a099b5201 100644 --- a/cmd/codeaf/pairroute.go +++ b/cmd/codeaf/pairroute.go @@ -92,6 +92,7 @@ func pairJoiningAt(dir string) func(replace bool) pair.Joining { return func(replace bool) pair.Joining { return pair.Joining{ Home: dir, + TeamsPaired: func() bool { return syncsetup.MayTalk(dir) }, ProfileDir: config.ProfileDir(), Label: devname.Name(dir), Replace: replace, diff --git a/cmd/codeaf/team_resume.go b/cmd/codeaf/team_resume.go index 1abd2411c1..12d71ea36f 100644 --- a/cmd/codeaf/team_resume.go +++ b/cmd/codeaf/team_resume.go @@ -90,3 +90,6 @@ func teamComputers(ctx context.Context, members []teams.Member) (map[string]stri } return s.TeamComputers(ctx, members) } + +// teamPaired supplies the shared quiet gate to packages below relay setup. +func teamPaired() bool { return syncsetup.MayTalk(home.Dir()) } diff --git a/internal/remote/server.go b/internal/remote/server.go index 953d122c50..a94e8d0103 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -169,6 +169,8 @@ type Engine struct { // carried in Welcome so a linked-local surface writes every local row back // to the profile the running conversation actually reads. A remote surface // does not use the path for local writes. + // TeamsPaired supplies the engine computer's current pairing gate. + TeamsPaired func() bool ProfileDir string UnreadProfileKeys []string // Workspace is the directory the engine resolved and works in — the answer diff --git a/internal/remote/teams.go b/internal/remote/teams.go index 8f490615c4..a0b529509e 100644 --- a/internal/remote/teams.go +++ b/internal/remote/teams.go @@ -41,7 +41,7 @@ func (s *server) teamsCall(call Frame) (json.RawMessage, bool, error) { } sess := s.session sess.mu.Lock() - dir := sess.engine.ProfileDir + dir, paired := sess.engine.ProfileDir, sess.engine.TeamsPaired sess.mu.Unlock() switch call.Method { @@ -50,7 +50,7 @@ func (s *server) teamsCall(call Frame) (json.RawMessage, bool, error) { if err != nil { return nil, true, err } - reading, err := teamsRead(dir, args) + reading, err := teamsRead(dir, args, paired) if err != nil { return nil, true, err } @@ -79,7 +79,7 @@ func (s *server) teamsCall(call Frame) (json.RawMessage, bool, error) { limit = teamsPage } if args.History { - entries, cursor, mirror, reset, err := teamsWatch.History(dir, args.Team, args.After, args.Mirror, limit) + entries, cursor, mirror, reset, err := teamsWatch.History(dir, args.Team, args.After, args.Mirror, limit, config.TeamsSharingAt(dir, paired)) if err != nil { return nil, true, err } @@ -104,12 +104,13 @@ func (s *server) teamsCall(call Frame) (json.RawMessage, bool, error) { // hand back an older list with a newer stamp, and the window would be told // "same" about a change it never saw. A colour repair the load writes back // moves the stamp the same way, and costs the same one extra read. -func teamsRead(dir string, args TeamsReadArgs) (TeamsReading, error) { +func teamsRead(dir string, args TeamsReadArgs, paired func() bool) (TeamsReading, error) { + sharing := config.TeamsSharingAt(dir, paired) stamp := teamstore.Stamp(dir) if args.Stamp != "" && args.Stamp == stamp { - return TeamsReading{Stamp: stamp, Same: true}, nil + return TeamsReading{Sharing: &sharing, Stamp: stamp, Same: true}, nil } - if args.Stamp != "" && config.TeamsShareAt(dir) { + if args.Stamp != "" && sharing { // Refresh is observational. Persisted repairs remain at the // explicit opening and write boundaries, even over a host connection. f, err := teamstore.Snapshot(dir) @@ -117,7 +118,7 @@ func teamsRead(dir string, args TeamsReadArgs) (TeamsReading, error) { return TeamsReading{}, err } f.Colour(args.Reserved) - return TeamsReading{Stamp: stamp, Teams: f.Teams}, nil + return TeamsReading{Sharing: &sharing, Stamp: stamp, Teams: f.Teams}, nil } f, err := teamstore.LoadHued(dir, args.Reserved) if err != nil { @@ -129,9 +130,9 @@ func teamsRead(dir string, args TeamsReadArgs) (TeamsReading, error) { if _, aside := teamstore.SetAside(dir); aside != nil { return TeamsReading{}, err } - return TeamsReading{Stamp: teamstore.Stamp(dir)}, nil + return TeamsReading{Sharing: &sharing, Stamp: teamstore.Stamp(dir)}, nil } - return TeamsReading{Stamp: stamp, Teams: f.Teams}, nil + return TeamsReading{Sharing: &sharing, Stamp: stamp, Teams: f.Teams}, nil } // teamsUpdate is [MethodTeamsUpdate]: the list written whole, under the store's diff --git a/internal/remote/teams_history_test.go b/internal/remote/teams_history_test.go index 1acc1f7f6f..15c65d6346 100644 --- a/internal/remote/teams_history_test.go +++ b/internal/remote/teams_history_test.go @@ -1,6 +1,7 @@ package remote import ( + "encoding/json" "os" "testing" "time" @@ -11,8 +12,9 @@ import ( // A hosted window uses its engine's mirror and retains the engine's local // cursor. The ordinary Traffic method remains the local-only compatibility door. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestHostedTrafficHistoryKeepsMirrorSeparateAndCursorLocal(t *testing.T) { - loop, dir := teamsLoop(t) + loop, dir := pairedTeamsLoop(t) if err := teams.AppendTraffic(dir, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "local"}); err != nil { t.Fatal(err) } @@ -33,8 +35,9 @@ func TestHostedTrafficHistoryKeepsMirrorSeparateAndCursorLocal(t *testing.T) { } } +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestHostedTeamRefreshDoesNotPersistRepairs(t *testing.T) { - loop, dir := teamsLoop(t) + loop, dir := pairedTeamsLoop(t) row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTeamsShare) if err := row.Apply("on"); err != nil { t.Fatal(err) @@ -56,9 +59,12 @@ func TestHostedTeamRefreshDoesNotPersistRepairs(t *testing.T) { } } +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestHostedWindowsKeepIndependentMirrorVersions(t *testing.T) { - first, dir := teamsLoop(t) - second, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: &fakeAgent{model: "m"}, ProfileDir: dir}, nil }}) + first, dir := pairedTeamsLoop(t) + second, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: &fakeAgent{model: "m"}, ProfileDir: dir, TeamsPaired: func() bool { return true }}, nil + }}) if err != nil { t.Fatal(err) } @@ -85,3 +91,106 @@ func TestHostedWindowsKeepIndependentMirrorVersions(t *testing.T) { } } } + +func pairedTeamsLoop(t *testing.T) (*Loop, string) { + t.Helper() + dir := t.TempDir() + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: &fakeAgent{model: "m"}, ProfileDir: dir, TeamsPaired: func() bool { return true }}, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + return loop, dir +} + +// F1 keeps repairing reads for an unset preference on an unpaired engine. +func TestUnpairedHostedTeamRefreshRetainsRepairingReads(t *testing.T) { + loop, dir := teamsLoop(t) + first, err := loop.Client.TeamsRead("", nil) + if err != nil { + t.Fatal(err) + } + raw := []byte(`{"version":2,"teams":[{"id":"alpha","name":"alpha","members":[{"key":"local","word":"worker"}]}]}`) + if err := os.WriteFile(teams.Path(dir), raw, 0600); err != nil { + t.Fatal(err) + } + got, err := loop.Client.TeamsRead(first.Stamp, nil) + if err != nil || len(got.Teams) != 1 || got.Teams[0].Members[0].Handle == "" { + t.Fatal("unpaired engine skipped repair", got, err) + } + after, _ := os.ReadFile(teams.Path(dir)) + if string(after) == string(raw) { + t.Fatal("unpaired engine did not persist repair") + } +} + +// F7 hides retained history over the engine boundary when off or unpaired, then restores it on re-enable. +func TestHostedTeamHistoryStopsWithEffectiveSharing(t *testing.T) { + dir := t.TempDir() + paired := true + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: &fakeAgent{model: "m"}, ProfileDir: dir, TeamsPaired: func() bool { return paired }}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + if err := teams.AppendTraffic(dir, "alpha", teams.Entry{Kind: teams.KindNote, From: teams.FromYou, To: teams.ToRoom, Text: "local"}); err != nil { + t.Fatal(err) + } + if err := teams.WriteTrafficMirror(dir, "alpha", []teams.Entry{{ID: "other/1", Kind: teams.KindNote, Text: "foreign"}}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(teams.TrafficMirrorPath(dir, "alpha")) + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTeamsShare) + cursor, mirror := "", "" + for _, tc := range []struct { + paired bool + choice string + want int + }{{true, "on", 2}, {true, "off", 1}, {false, "on", 1}, {true, "on", 2}} { + paired = tc.paired + if err := row.Apply(tc.choice); err != nil { + t.Fatal(err) + } + // R4 carries the engine's live sharing even when its team file is unchanged. + first, err := loop.Client.TeamsRead("", nil) + if err != nil { + t.Fatal(err) + } + reading, err := loop.Client.TeamsRead(first.Stamp, nil) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(reading) + if err != nil { + t.Fatal(err) + } + var state struct { + Sharing *bool `json:"sharing"` + } + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if state.Sharing == nil || *state.Sharing != (tc.want == 2) { + t.Fatalf("engine omitted effective sharing: %s", raw) + } + got, err := loop.Client.TeamsHistory("alpha", cursor, mirror, 100) + if err != nil { + t.Fatal(err) + } + if !got.Reset { + got, err = loop.Client.TeamsHistory("alpha", "", "", 100) + } + if err != nil || len(got.Entries) != tc.want { + t.Fatalf("paired=%v choice=%s history=%+v err=%v", tc.paired, tc.choice, got, err) + } + cursor, mirror = got.Cursor, got.Mirror + } + after, _ := os.ReadFile(teams.TrafficMirrorPath(dir, "alpha")) + if string(before) != string(after) { + t.Fatal("off deleted retained mirror") + } +} diff --git a/internal/remote/wire_teams.go b/internal/remote/wire_teams.go index 1dda899868..ca17a93d1d 100644 --- a/internal/remote/wire_teams.go +++ b/internal/remote/wire_teams.go @@ -59,10 +59,13 @@ type TeamsReadArgs struct { // [MethodTeamsUpdate]: the file moved since the base, nothing was written, and // Stamp is where it is now. type TeamsReading struct { - Stamp string `json:"stamp"` - Same bool `json:"same,omitempty"` - Stale bool `json:"stale,omitempty"` - Teams []teamstore.Team `json:"teams,omitempty"` + // Sharing is the engine's effective gate, including unchanged-file replies. + // Absence identifies engines that predate this fact. + Sharing *bool `json:"sharing,omitempty"` + Stamp string `json:"stamp"` + Same bool `json:"same,omitempty"` + Stale bool `json:"stale,omitempty"` + Teams []teamstore.Team `json:"teams,omitempty"` } // TeamsUpdateArgs is the whole list the window wants written, and the stamp diff --git a/internal/session/agent.go b/internal/session/agent.go index a5e3b9c231..65054f11b6 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -3278,7 +3278,9 @@ func (a *Agent) drainSteering(hub *eventHub) int { if news := a.teamBoundary(); news != "" { a.enqueueNote(userText(news)) } + sharing := a.config.teamProfile() != "" && a.teamsSharing() a.mu.Lock() + a.adoptTeamDigestSharingLocked(sharing) opening := !a.running || len(a.messages) == a.turnFloor // AND THE VOLATILE NOTE LANDS HERE, ahead of the steering, for the reason the // drain itself is here: this seam runs immediately before the next request diff --git a/internal/session/loop.go b/internal/session/loop.go index 48332d388c..5ca88e46aa 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -518,6 +518,10 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // task node start no reading at all. var team *sidecar[struct{}] if a.config.teamProfile() != "" { + sharing := a.teamsSharing() + a.mu.Lock() + a.adoptTeamDigestSharingLocked(sharing) + a.mu.Unlock() team = readBeside(ctx, func(read context.Context) struct{} { a.refreshTeamDigest(read) return struct{}{} diff --git a/internal/session/mention.go b/internal/session/mention.go index 356ec2fd89..a54f0c0eb8 100644 --- a/internal/session/mention.go +++ b/internal/session/mention.go @@ -42,7 +42,7 @@ func (a *Agent) mentionNote(text string) string { continue } seenTeam[slug] = true - if block := mentionTeamBlock(profile, self, slug, now); block != "" { + if block := mentionTeamBlock(profile, self, slug, now, a.teamsSharing()); block != "" { blocks = append(blocks, block) } } @@ -59,7 +59,7 @@ func (a *Agent) mentionNote(text string) string { return strings.Join(blocks, "\n\n") } -func mentionTeamBlock(profile, self, slug string, now time.Time) string { +func mentionTeamBlock(profile, self, slug string, now time.Time, sharing bool) string { if profile == "" { return "" } @@ -78,7 +78,7 @@ func mentionTeamBlock(profile, self, slug string, now time.Time) string { if !found { return "" } - log, _ := teams.ReadHistory(profile, team.ID, 40) + log, _ := teams.ReadHistory(profile, team.ID, 40, sharing) states := map[string]teams.MemberState{} for _, m := range team.Members { if m.Key == self || m.File == self { diff --git a/internal/session/session.go b/internal/session/session.go index 527bee129c..fd0bd59e6a 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1608,6 +1608,8 @@ type Config struct { // holders once for the complete recipient batch before any team Traffic is // written; an unpaired home stays quiet. TeamComputers func(context.Context, []teams.Member) (map[string]string, error) + // TeamsPaired checks the live pairing gate without importing relay setup. + TeamsPaired func() bool // failures is the tally the piece of work this agent belongs to keeps of its // own failures (internal/taxonomy). A worker built for a task node carries @@ -3036,7 +3038,10 @@ type Agent struct { // team this conversation manages, "" for one that manages none. It sits // under mu and rides its own note at the tail, like elsewhereText, so an // unchanged team lands no second note. - teamDigestText string + teamDigestText string + teamDigestSharing bool + teamDigestKnown bool + teamDigestGeneration uint64 // teamRoleText is what this conversation is in its teams (team.go's // [teamRoleBlock]), set at every step boundary before the request and // landed in a note of its own ahead of the others. diff --git a/internal/session/team.go b/internal/session/team.go index 6e491bb3ab..0ae92ef500 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -997,7 +997,7 @@ func (a *Agent) readTeamCursorsLocked() { // number lines this computer never wrote, so every team starts again from // [Agent.firstTeamCursor], exactly as for a team met for the first time; // that keeps the directive a rouse opened this conversation for. - sharing := config.TeamsShareAt(a.config.teamProfile()) + sharing := a.teamsSharing() if sharing && stored.Device != "" && stored.Device != teamCursorDevice() { return } @@ -1039,7 +1039,7 @@ func (a *Agent) saveTeamCursorsLocked() { read[id] = cursor } device := "" - if config.TeamsShareAt(a.config.teamProfile()) { + if a.teamsSharing() { device = teamCursorDevice() } raw, err := json.Marshal(struct { @@ -1101,19 +1101,49 @@ func (a *Agent) refreshTeamDigest(ctx context.Context) { if profile == "" || ctx.Err() != nil { return } - block := a.teamDigest(profile) + sharing := a.teamsSharing() + a.mu.Lock() + a.adoptTeamDigestSharingLocked(sharing) + generation := a.teamDigestGeneration + a.mu.Unlock() + block := a.teamDigestWithSharing(profile, sharing) + current := a.teamsSharing() a.mu.Lock() defer a.mu.Unlock() - if a.closed { + if a.closed || ctx.Err() != nil || generation != a.teamDigestGeneration { + return + } + a.adoptTeamDigestSharingLocked(current) + if current != sharing { return } a.teamDigestText = block } +// adoptTeamDigestSharingLocked retires every scratch digest made under a +// different sharing state. Keep its message slot so turn and checkpoint indices +// stay valid; these notes are request context and are never journalled. +func (a *Agent) adoptTeamDigestSharingLocked(sharing bool) { + if a.teamDigestKnown && a.teamDigestSharing != sharing { + a.teamDigestGeneration++ + a.teamDigestText = "" + for i, message := range a.messages { + if message.Role == "user" && strings.HasPrefix(messageContentText(message), teamNoteOpening+"\n\n") { + a.messages[i] = textMessage("user", teamNoteOpening+"\n\nThe previous team history reading is no longer current.") + } + } + } + a.teamDigestSharing, a.teamDigestKnown = sharing, true +} + // teamDigest is one digest per managed team, "" for a conversation that // manages none. Who the manager is and what it does is not here: that is the // role note's, which cannot arrive late ([teamRoleBlock]). func (a *Agent) teamDigest(profile string) string { + return a.teamDigestWithSharing(profile, a.teamsSharing()) +} + +func (a *Agent) teamDigestWithSharing(profile string, sharing bool) string { a.team.mu.Lock() roles := a.teamRolesLocked(profile) keys := append([]string(nil), a.teamKeysLocked()...) @@ -1141,7 +1171,7 @@ func (a *Agent) teamDigest(profile string) string { continue } team = managedView(file, team) - log, _ := teams.ReadHistory(profile, role.id, teamStateLook) + log, _ := teams.ReadHistory(profile, role.id, teamStateLook, sharing) states := memberStates(team, keys, now, log, &a.team.journals) markShared(file, team, states) parts = append(parts, teams.Digest(team, states, recentOf(log), teamDigestBudget)) diff --git a/internal/session/team_locality.go b/internal/session/team_locality.go index 7f8a60412f..5abcd06779 100644 --- a/internal/session/team_locality.go +++ b/internal/session/team_locality.go @@ -16,7 +16,7 @@ type teamUnreached struct { // Only remote identities or a successful ownership reading can refuse local // work. Failed discovery leaves the existing local delivery path available. func (a *Agent) teamDeliveryTargets(ctx context.Context, targets []teams.Member) ([]teams.Member, []teamUnreached) { - if !config.TeamsShareAt(a.config.teamProfile()) { + if !a.teamsSharing() { return targets, nil } names := map[string]string{} @@ -79,3 +79,8 @@ func (a *Agent) teamSendPreflight(ctx context.Context, targets []teams.Member) ( } return "", "", local } + +// Pairing is checked at use time so ending it restores every local-only path. +func (a *Agent) teamsSharing() bool { + return config.TeamsSharingAt(a.config.teamProfile(), a.config.TeamsPaired) +} diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index 75157ed739..ef668a8e8c 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -42,7 +42,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/Agent-Field/codeaf/internal/config" "strconv" "strings" "time" @@ -697,7 +696,7 @@ func readableBelow(profile string, team teams.Team, words string) (teams.Member, // to, marked with the sub-team it came from, and that manager roused. func (a *Agent) teamPostUp(team teams.Team, role teamRole, entry teams.Entry) (string, bool, error) { profile := a.config.teamProfile() - if config.TeamsShareAt(profile) { + if a.teamsSharing() { if file, _, _, _ := a.teamSnapshot(profile); file != nil { if boss, ok := file.Team(role.boss); ok { if m, ok := boss.Member(boss.Manager); ok { diff --git a/internal/session/team_questions.go b/internal/session/team_questions.go index 263fb49439..aa641704c5 100644 --- a/internal/session/team_questions.go +++ b/internal/session/team_questions.go @@ -43,7 +43,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/Agent-Field/codeaf/internal/config" "strconv" "strings" @@ -304,7 +303,7 @@ func (a *Agent) teamDecideTool(ctx context.Context, args json.RawMessage) (strin case teams.PacketCap, teams.PacketClosing: return fmt.Sprintf("A %s is the person's to decide, never a manager's. Nothing was done.", p.Kind), true, nil } - if config.TeamsShareAt(a.config.teamProfile()) { + if a.teamsSharing() { file, _, _, _ := a.teamSnapshot(a.config.teamProfile()) if refusal := a.teamDeliveryRefusal(ctx, teamPacketRecipients(file, p)); refusal != "" { return refusal, true, nil diff --git a/internal/session/team_relay_test.go b/internal/session/team_relay_test.go index 35cb87b214..59583193b0 100644 --- a/internal/session/team_relay_test.go +++ b/internal/session/team_relay_test.go @@ -3,13 +3,18 @@ package session import ( "context" "encoding/json" + "fmt" + "github.com/Agent-Field/agentfield/sdk/go/ai" "os" "path/filepath" "strings" + "sync" + "sync/atomic" "testing" "time" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/teams" ) @@ -20,6 +25,7 @@ func manualRelayTeamClock(c *Config) { c.teamWatchManual = true } // Exercise the boundary, idle watcher and running-turn stop watcher, all with // the same mirror. None of these readers may ever consume display history. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestTrafficMirrorNeverDeliversStartsQuestionsWakesOrStops(t *testing.T) { f := newWakingTeamFixture(t) enableSessionTeamSharing(t, f.profile) @@ -39,7 +45,7 @@ func TestTrafficMirrorNeverDeliversStartsQuestionsWakesOrStops(t *testing.T) { if err := teams.WriteTrafficMirror(f.profile, f.teamID, lines); err != nil { t.Fatal(err) } - visible, err := teams.ReadHistory(f.profile, f.teamID, 0) + visible, err := teams.ReadHistory(f.profile, f.teamID, 0, true) if err != nil || len(visible) != 4 { t.Fatal(visible, err) } @@ -201,19 +207,51 @@ func TestRemoteTeamSendsStartsPostsAndRousesRefuseBeforeTraffic(t *testing.T) { func enableSessionTeamSharing(t *testing.T, profile string) { t.Helper() + if _, err := identity.Load(home.Dir()); err != nil { + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + if _, err := identity.Ensure(root); err != nil { + t.Fatal(err) + } + } row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: profile}).Row(config.KeyTeamsShare) if err := row.Apply("on"); err != nil { t.Fatal(err) } } +// C9 pins off explicitly so legacy cursors and ownership checks keep their sharing-off contract. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestSharingOffKeepsLegacyCursorBytesAndSkipsEveryOwnershipLookup(t *testing.T) { + checkSharingDisabledLegacyCursors(t, true) +} + +// F1 leaves legacy cursor bytes and delivery unchanged on an unpaired computer with an unset preference. +func TestUnpairedSharingUnsetKeepsLegacyCursorBytesAndSkipsEveryOwnershipLookup(t *testing.T) { + checkSharingDisabledLegacyCursors(t, false) +} + +func checkSharingDisabledLegacyCursors(t *testing.T, explicitOff bool) { root := t.TempDir() t.Setenv("CODEAF_HOME", root) - if _, err := identity.Ensure(root); err != nil { + ensure := identity.EnsureSolo + if explicitOff { + ensure = identity.Ensure + } + if _, err := ensure(root); err != nil { t.Fatal(err) } f := newWakingTeamFixture(t) + if explicitOff { + sharing, _ := config.NewSettings(config.SettingsOptions{ProfileDir: f.profile}).Row(config.KeyTeamsShare) + if err := sharing.Apply("off"); err != nil { + t.Fatal(err) + } + } else { + if _, err := identity.EnsureSolo(root); err != nil { + t.Fatal(err) + } + } a := teamAgent(t, f, f.web, nil, manualRelayTeamClock) a.config.TeamComputers = func(context.Context, []teams.Member) (map[string]string, error) { t.Fatal("sharing off called ownership lookup") @@ -245,3 +283,133 @@ func TestSharingOffKeepsLegacyCursorBytesAndSkipsEveryOwnershipLookup(t *testing } } } + +// F7 removes retained mirror history from status, prompt digests and team mentions while off or unpaired. +func TestSessionTeamHistoryStopsWithEffectiveSharing(t *testing.T) { + f := newWakingTeamFixture(t) + enableSessionTeamSharing(t, f.profile) + a := teamAgent(t, f, f.manager, nil, manualRelayTeamClock) + if err := teams.WriteTrafficMirror(f.profile, f.teamID, []teams.Entry{{ID: "other/1", At: time.Now(), Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "foreign history"}}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(teams.TrafficMirrorPath(f.profile, f.teamID)) + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: f.profile}).Row(config.KeyTeamsShare) + for _, tc := range []struct { + paired bool + choice string + want bool + }{{true, "on", true}, {true, "off", false}, {false, "on", false}, {true, "on", true}} { + a.config.TeamsPaired = func() bool { return tc.paired } + if err := row.Apply(tc.choice); err != nil { + t.Fatal(err) + } + status, failed, err := a.teamStatusTool(t.Context(), json.RawMessage(`{}`)) + if err != nil || failed { + t.Fatal(status, failed, err) + } + for _, got := range []string{status, a.teamDigest(f.profile), mentionTeamBlock(f.profile, f.manager, "harbor", time.Now(), a.teamsSharing())} { + if strings.Contains(got, "foreign history") != tc.want { + t.Fatalf("paired=%v choice=%s history=%s", tc.paired, tc.choice, got) + } + } + } + after, _ := os.ReadFile(teams.TrafficMirrorPath(f.profile, f.teamID)) + if string(before) != string(after) { + t.Fatal("session readers changed retained mirror") + } +} + +// R7/F7 removes cached mirror digests from the outgoing request after off or unpairing. +func TestOutgoingTeamDigestStopsWithEffectiveSharing(t *testing.T) { + for _, unpair := range []bool{false, true} { + t.Run(fmt.Sprintf("unpair=%v", unpair), func(t *testing.T) { + f := newWakingTeamFixture(t) + enableSessionTeamSharing(t, f.profile) + var paired atomic.Bool + paired.Store(true) + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("noted"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("noted"), nil }, + }} + a := teamAgent(t, f, f.manager, completer, func(c *Config) { manualRelayTeamClock(c); c.TeamsPaired = func() bool { return paired.Load() } }) + if err := teams.WriteTrafficMirror(f.profile, f.teamID, []teams.Entry{{ID: "other/1", At: time.Now(), Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "foreign digest marker"}}); err != nil { + t.Fatal(err) + } + a.refreshTeamDigest(t.Context()) + lane, err := a.Submit(t.Context(), "how is the team?") + if err != nil { + t.Fatal(err) + } + collect(t, lane) + if !strings.Contains(userTextIn(completer.request(0)), "foreign digest marker") { + t.Fatal("enabled request lacked mirror digest") + } + if unpair { + paired.Store(false) + } else { + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: f.profile}).Row(config.KeyTeamsShare) + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + } + lane, err = a.Submit(t.Context(), "and now?") + if err != nil { + t.Fatal(err) + } + collect(t, lane) + if got := userTextIn(completer.request(1)); strings.Contains(got, "foreign digest marker") { + t.Fatalf("outgoing request retained mirror digest: %s", got) + } + }) + } +} + +// R7/F7 rejects a background digest after off and on were adopted while its result was in flight. +func TestTeamDigestDropsBackgroundResultAcrossSharingChanges(t *testing.T) { + f := newWakingTeamFixture(t) + enableSessionTeamSharing(t, f.profile) + var paired atomic.Bool + paired.Store(true) + var calls atomic.Int32 + computed, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) + a := teamAgent(t, f, f.manager, nil, func(c *Config) { + manualRelayTeamClock(c) + c.TeamsPaired = func() bool { + state := paired.Load() + if calls.Add(1) == 2 { + close(computed) + <-release + } + return state + } + }) + if err := teams.WriteTrafficMirror(f.profile, f.teamID, []teams.Entry{{ID: "other/1", Kind: teams.KindNote, Text: "stale mirror digest"}}); err != nil { + t.Fatal(err) + } + go func() { defer close(done); a.refreshTeamDigest(t.Context()) }() + var releaseOnce sync.Once + defer func() { releaseOnce.Do(func() { close(release) }); <-done }() + select { + case <-computed: + case <-time.After(5 * time.Second): + t.Fatal("digest did not finish computing") + } + for _, state := range []bool{false, true} { + paired.Store(state) + a.drainSteering(nil) + } + a.mu.Lock() + if a.teamDigestText != "" { + t.Error("changed sharing kept cached digest") + } + a.mu.Unlock() + // The held callback returns its original on reading, so only the adopted + // generation can reject this result after the off/on transition. + releaseOnce.Do(func() { close(release) }) + <-done + a.mu.Lock() + defer a.mu.Unlock() + if a.teamDigestText != "" { + t.Fatal("late background result revived stale digest") + } +} diff --git a/internal/session/team_test.go b/internal/session/team_test.go index 1dedca8396..1ebc220b92 100644 --- a/internal/session/team_test.go +++ b/internal/session/team_test.go @@ -18,6 +18,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/manual" "github.com/Agent-Field/codeaf/internal/teams" ) @@ -124,6 +125,10 @@ func teamAgent(t *testing.T, fixture teamFixture, transcript string, completer C } agent, _ := newTestAgent(t, completer, func(config *Config) { config.ProfileDir = fixture.profile + config.TeamsPaired = func() bool { + _, err := identity.Load(home.Dir()) + return err == nil && !identity.Solo(home.Dir()) + } config.SessionFile = transcript config.Place = Place{Dir: filepath.Dir(transcript)} if mutate != nil { diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 0aff201efc..3da552d663 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -267,7 +267,7 @@ func (a *Agent) teamStatusTool(ctx context.Context, args json.RawMessage) (strin a.team.mu.Lock() keys := append([]string(nil), a.teamKeysLocked()...) a.team.mu.Unlock() - log, _ := teams.ReadHistory(profile, team.ID, teamStateLook) + log, _ := teams.ReadHistory(profile, team.ID, teamStateLook, a.teamsSharing()) file, _, _, _ := a.teamSnapshot(profile) states := memberStates(team, keys, time.Now(), log, &a.team.journals) markShared(file, team, states) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index b959115329..35698a5d3a 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1295,7 +1295,10 @@ type app struct { timestamps string workMode string // teamsShare is adopted with profile preferences, keeping config reads off the loop. - teamsShare bool + teamsShare bool + teamsPaired func() bool + teamsShareGeneration uint64 + teamsSharingCmd tea.Cmd // Icon repertoire is independent of colour; detection is a boot fact and // the person can override it through the live Display setting. actionAuto tokens.GlyphSet @@ -3103,6 +3106,7 @@ func newApp(ctx context.Context, opts Options) *app { machines: opts.Machines, taker: opts.Takeover, pairing: opts.Pairing, + teamsPaired: opts.TeamsPaired, syncService: opts.SyncService, cellsUnavailable: opts.CellsUnavailable, approvals: opts.Approvals, @@ -3309,7 +3313,9 @@ func newApp(ctx context.Context, opts Options) *app { a.mouse = config.MouseEnabledAt(a.profileDir) a.timestamps = config.TimestampsAt(a.profileDir) a.workMode = config.WorkAt(a.profileDir) - a.teamsShare = config.TeamsShareAt(a.profileDir) + if a.teamsDisk.door.Sharing == nil { + a.adoptTeamsSharing(config.TeamsSharingAt(a.profileDir, a.teamsPaired)) + } a.actionAuto, _ = tokens.DetectGlyphSet(env) a.adoptIcons() // AND THE COLUMN'S POSTURE IS READ HERE AND NOWHERE ELSE — at boot, never at @@ -3652,7 +3658,7 @@ func (a *app) Init() tea.Cmd { a.news.waitRing(), a.leaving.waitRing(), a.landedBell.waitRing(), a.noticeBell.waitRing(), // AND THE TEAMS' FIRST READ, when the seam held nothing to load above // (teamseam.go); nil on every local launch. - a.teamsWrite()} + a.teamsWrite(), a.refreshTeamsSharing()} if a.warmEmptyProviders != nil { warm := a.warmEmptyProviders standing = append(standing, func() tea.Msg { @@ -3721,6 +3727,7 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } model, cmd := a.update(msg) + cmd = tea.Batch(cmd, a.takeTeamsSharingCmd()) // A USAGE NOTICE THE LAST FRAME DREW IS RECORDED HERE, on the loop and once: // the frame may only note that it drew it (view.go), because the door's // record is a write to disk. @@ -4319,6 +4326,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case wallTickMsg: return a, a.wallTick() + case teamsSharingMsg: + a.takeTeamsSharing(msg) + return a, nil case teamsRefreshTickMsg: return a, a.teamsRefreshTick() @@ -6024,7 +6034,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case linkPingMsg: a.linkPingBack(msg) - return a, tea.Batch(a.linkPingTick(), a.linkLatencyExpiry()) + var sharing tea.Cmd + if a.teamsDisk.door.Sharing != nil { + sharing = a.refreshTeamsSharing() + } + return a, tea.Batch(a.linkPingTick(), a.linkLatencyExpiry(), sharing) case linkLatencyExpiredMsg: if msg.at == a.linkLatencyAt { a.touch() @@ -7082,7 +7096,7 @@ func (a *app) settle() tea.Cmd { } a.stick = false } - return tea.Batch(a.probeGit(), fadeTicks()) + return tea.Batch(a.probeGit(), fadeTicks(), a.takeTeamsSharingCmd()) } // dropForming resolves every call that was still ARRIVING when the turn ended. diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index d2f4ef303e..3ec0d11112 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -1423,7 +1423,7 @@ func (a *app) tabPress(x, y int) (tea.Cmd, bool) { // refusals said in the same words, because one gesture with two spellings of // "that folder is gone" is two features to keep in step. func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { - if cmd, remote := a.teamsRemoteGo(teamMember{Key: tab.key, File: tab.file, Word: tab.word}, func() tea.Cmd { return a.tabGo(tab) }); remote { + if cmd, remote := a.teamsRemoteGo(teamMember{Key: tab.key, File: tab.file, Word: tab.word}); remote { return cmd } defer func() { diff --git a/internal/tui3/conversationdelete_test.go b/internal/tui3/conversationdelete_test.go index ad2dfcacf6..45a405558a 100644 --- a/internal/tui3/conversationdelete_test.go +++ b/internal/tui3/conversationdelete_test.go @@ -297,11 +297,12 @@ func TestHomeDeleteLastSavedRowThroughItsOptions(t *testing.T) { } } +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestConversationDeleteBlockedUntilEveryActiveManagerChangedInTeams(t *testing.T) { a, parent, child := menuApp(t) a.width, a.height = 120, 35 a.profileDir = t.TempDir() - a.teamsDisk.door = localTeams(a.profileDir, &a.teamsDisk.watch) + a.teamsDisk.door = localTeams(a.profileDir, &a.teamsDisk.watch, nil) old := a.frontTabKey() for i := range a.wall.teams { if a.wall.teams[i].ID == parent || a.wall.teams[i].ID == child { @@ -359,11 +360,12 @@ func TestConversationDeleteBlockedUntilEveryActiveManagerChangedInTeams(t *testi } } +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestTeamsChooseManagerExistingMembersOnlyAndPersists(t *testing.T) { a, parent, other := menuApp(t) a.width, a.height = 120, 35 a.profileDir = t.TempDir() - a.teamsDisk.door = localTeams(a.profileDir, &a.teamsDisk.watch) + a.teamsDisk.door = localTeams(a.profileDir, &a.teamsDisk.watch, nil) old := a.frontTabKey() if err := a.teamEdit(func(f *teamstore.File) error { return f.SetManager(parent, old) }); err != nil { t.Fatal(err) diff --git a/internal/tui3/homepanel_machines.go b/internal/tui3/homepanel_machines.go index 1d846da95d..92e578a441 100644 --- a/internal/tui3/homepanel_machines.go +++ b/internal/tui3/homepanel_machines.go @@ -61,6 +61,7 @@ type machineReading struct { // homeMachinesMsg is one listing, coming BACK from the source. type homeMachinesMsg struct { + sharing *teamsSharingMsg readWithin time.Duration rows []chatlist.Row version uint64 @@ -115,11 +116,15 @@ func (a *app) askChats() tea.Cmd { seq := a.machinesAskSeq a.machineRead.host = a.relayHost() src, file := a.machines, a.file + sharingRead := a.readTeamsSharing() read := func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), machinesAskTimeout) defer cancel() rows, version, err := chatlist.RowsAt(ctx, src) msg := homeMachinesMsg{rows: rows, version: version, err: err, readWithin: machinesAskTimeout} + if sharing, ok := sharingRead().(teamsSharingMsg); ok { + msg.sharing = &sharing + } if local, ok := src.(sendLeaseSource); ok && file != "" { msg.holdCell = filepath.Base(session.DirOf(file)) _, msg.holdFence, _ = local.SendLease(msg.holdCell) @@ -153,6 +158,9 @@ func (a *app) recordMachines(msg homeMachinesMsg) tea.Cmd { a.homeAccountNotice = "" } } + if msg.sharing != nil { + a.takeTeamsSharing(*msg.sharing) + } if msg.err == nil { a.rememberSendHolds(msg) } diff --git a/internal/tui3/pair.go b/internal/tui3/pair.go index f9e6c256db..842c87c716 100644 --- a/internal/tui3/pair.go +++ b/internal/tui3/pair.go @@ -62,7 +62,7 @@ const ( // 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 = "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." + replaceAskLine = pair.ReplaceAskLine // replaceDeclinedLine is the word when the answer was no. replaceDeclinedLine = "nothing was replaced: this computer's chats stay." ) @@ -399,7 +399,7 @@ func joinWork(door Pairing, typed string, replace bool) func(*pairRun) { // anything to lose: a home with no identity of its own is joined as if // it were plain. A no — typed or by closing the panel — leaves // everything exactly as it was. - if replace && door.HasOwnChats() && !ui.Confirm(run.ctx, replaceAskLine) { + if replace && (door.HasOwnChats() || pairingHasTeams(door)) && !ui.Confirm(run.ctx, replaceAskLine) { run.send(pairEnded{line: replaceDeclinedLine, ok: true}) return } @@ -441,6 +441,9 @@ func sharedLine(label string, err error) string { // already gets that fact's own sentence and not a success it did not earn. func joinedLine(joined pair.Joined, err error) string { if err != nil { + if line := pair.TeamsAsideLine(joined.TeamsAside); line != "" { + return err.Error() + " " + line + } return err.Error() } return joined.Sentence() @@ -455,9 +458,12 @@ func (a *app) tookPair(msg pairMsg) tea.Cmd { msg.event.apply(&a.pair) a.touch() if a.pair.done { - return nil + if a.pair.ok && a.pair.result != replaceDeclinedLine { + a.machineRead.computerRemoved = false + } + return a.refreshTeamsSharing() } - return msg.run.wait() + return tea.Batch(msg.run.wait(), a.refreshTeamsSharing()) } // pairKey is the panel's whole claim on the keyboard. While a person is asked, @@ -526,3 +532,9 @@ func (a *app) pairLinkKey(msg tea.KeyPressMsg) tea.Cmd { } return nil } + +// Older pairing doors may expose chats alone; the local door also detects teams. +func pairingHasTeams(door Pairing) bool { + state, ok := door.(interface{ HasTeams() bool }) + return ok && state.HasTeams() +} diff --git a/internal/tui3/pair_test.go b/internal/tui3/pair_test.go index d858dae7cd..3ddeb215f8 100644 --- a/internal/tui3/pair_test.go +++ b/internal/tui3/pair_test.go @@ -21,6 +21,7 @@ type fakePairing struct { join func(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) replace func(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) hasChats bool + hasTeams bool } func (f *fakePairing) Offer(ctx context.Context, ui pair.OfferUI) (string, error) { @@ -36,6 +37,7 @@ func (f *fakePairing) JoinReplacing(ctx context.Context, typed string, ui pair.J } func (f *fakePairing) HasOwnChats() bool { return f.hasChats } +func (f *fakePairing) HasTeams() bool { return f.hasTeams } // pairRig runs the commands a pairing returns the way the program would: each // on its own goroutine, its message fed back through Update, and whatever diff --git a/internal/tui3/pairdrag_test.go b/internal/tui3/pairdrag_test.go index b49d818e42..5aadbe6694 100644 --- a/internal/tui3/pairdrag_test.go +++ b/internal/tui3/pairdrag_test.go @@ -8,6 +8,7 @@ package tui3 import ( "context" + "fmt" "strings" "testing" "unicode/utf8" @@ -99,12 +100,13 @@ func replaceApp(t *testing.T, hasChats bool) (*app, *pairRig, *int) { return a, newPairRig(t, a), &joins } +// F3 preserves the described replacement behavior and recovery receipts. func TestPairReplaceAsksAndOnlyYesJoins(t *testing.T) { a, r, joins := replaceApp(t, true) 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{"Pair with your other devices?", "existing chats stay here", "shared memories and settings are cleared"} { + for _, want := range []string{"Pair with your other devices?", "existing chats stay here", "shared memories and settings are cleared", "teams are set aside"} { if !strings.Contains(shown, want) { t.Fatalf("no replace confirmation (lacks %q):\n%s", want, shown) } @@ -140,6 +142,7 @@ func TestPairReplaceYesJoins(t *testing.T) { } // A HOME WITH NOTHING TO LOSE IS JOINED WITHOUT THE QUESTION. +// F3 preserves the described replacement behavior and recovery receipts. func TestPairReplaceWithoutOwnChatsSkipsTheAsk(t *testing.T) { a, r, joins := replaceApp(t, false) r.slash("/pair 71-869-571 --replace") @@ -151,3 +154,27 @@ func TestPairReplaceWithoutOwnChatsSkipsTheAsk(t *testing.T) { t.Fatal("a home with no own chats was asked anyway") } } + +// F3 asks before replacement when teams are the only local state. +func TestPairReplaceWithTeamsAndNoChatsStillAsks(t *testing.T) { + a, r, joins := replaceApp(t, false) + a.pairing.(*fakePairing).hasTeams = true + r.slash("/pair 71-869-571 --replace") + r.until("the teams replacement question", func() bool { return a.pair.ask != nil }) + if !strings.Contains(a.pair.ask.raw, "teams are set aside") { + t.Fatal("teams missing from confirmation", a.pair.ask.raw) + } + r.press("n") + r.until("the decline", func() bool { return a.pair.done }) + if *joins != 0 { + t.Fatal("declining replaced local teams") + } +} + +// F3 keeps the recovery path in a receipt even if a later pairing step fails. +func TestPairReplacementFailureStillNamesPreservedTeamsFile(t *testing.T) { + line := joinedLine(pair.Joined{TeamsAside: "/fixture/teams.json.unreadable-123"}, fmt.Errorf("saving the relay failed")) + if !strings.Contains(line, "saving the relay failed") || !strings.Contains(line, "This computer's teams were set aside to /fixture/teams.json.unreadable-123.") { + t.Fatal("failed pairing hid recovery receipt", line) + } +} diff --git a/internal/tui3/removed_computer.go b/internal/tui3/removed_computer.go index df5e21ce02..7cda1f9ce0 100644 --- a/internal/tui3/removed_computer.go +++ b/internal/tui3/removed_computer.go @@ -4,6 +4,12 @@ const removedPairNotice = "removed by another of your devices — pair again to func (a *app) removedComputer() { a.machineRead.computerRemoved = true + a.teamsShareGeneration++ + if a.teamsDisk.door.Sharing == nil { + a.adoptTeamsSharing(false) + } else { + a.teamsSharingCmd = a.refreshTeamsSharing() + } a.leftOff.row = nil a.continueGeneration++ a.dropHomeAsk() diff --git a/internal/tui3/settings_live.go b/internal/tui3/settings_live.go index 8f5feb55e7..b7fa64360f 100644 --- a/internal/tui3/settings_live.go +++ b/internal/tui3/settings_live.go @@ -1,6 +1,9 @@ package tui3 -import "github.com/Agent-Field/codeaf/internal/config" +import ( + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" +) // refreshProfileUI adopts presentation preferences after a panel save or a chat // turn. Keep temporary layout choices (such as the task column) and the engine's @@ -10,10 +13,88 @@ func (a *app) refreshProfileUI() { a.mouse = config.MouseEnabledAt(a.profileDir) a.timestamps = config.TimestampsAt(a.profileDir) a.workMode = config.WorkAt(a.profileDir) - a.teamsShare = config.TeamsShareAt(a.profileDir) + a.teamsSharingCmd = a.refreshTeamsSharing() a.adoptIcons() a.hopQuick = config.QuickSwitchAt(a.profileDir) a.askWait = a.consentWait() a.notices.enabled = config.HintsAt(a.profileDir) a.touch() } + +// adoptTeamsSharing also retires a clock armed before pairing ended. +func (a *app) adoptTeamsSharing(sharing bool) { + sharing = sharing && (!a.machineRead.computerRemoved || a.teamsDisk.door.Sharing != nil) + changed := a.teamsShare != sharing + a.teamsShare = sharing + if !a.teamsShare { + a.teamsDisk.refreshing = false + // Unpaired adoption stays as cheap as explicit off. Only leaving + // effective sharing can have cached mirror rows to retire. + if changed { + a.traffic.mirror = nil + for id, entries := range a.traffic.rows { + kept := entries[:0:0] + for _, entry := range entries { + if entry.Origin == "" { + kept = append(kept, entry) + } + } + if len(kept) != len(entries) { + a.traffic.rows[id] = kept + a.traffic.version++ + } + } + } + } + if changed { + if a.comp.open { + a.fillMentions() + a.comp.rank() + } + if a.home.comp.open { + a.fillHomeMentions() + a.home.comp.rank() + } + } +} + +type teamsSharingMsg struct { + generation uint64 + enabled bool +} + +func (a *app) takeTeamsSharing(msg teamsSharingMsg) { + if msg.generation == a.teamsShareGeneration { + a.adoptTeamsSharing(msg.enabled) + a.touch() + } +} + +// Pairing events refresh the gate beside the surface loop, just like disk reads. +func (a *app) refreshTeamsSharing() tea.Cmd { + a.teamsShareGeneration++ + return a.readTeamsSharing() +} + +// readTeamsSharing captures the source and generation before leaving the loop. +func (a *app) readTeamsSharing() tea.Cmd { + dir, paired, generation := a.profileDir, a.teamsPaired, a.teamsShareGeneration + remote := a.teamsDisk.door.Sharing + return func() tea.Msg { + if remote != nil { + enabled, err := remote() + if err != nil { + return nil + } + return teamsSharingMsg{generation, enabled} + } + return teamsSharingMsg{generation, config.TeamsSharingAt(dir, paired)} + } +} + +// takeTeamsSharingCmd hands a Settings save's deferred read to the event pump. +func (a *app) takeTeamsSharingCmd() tea.Cmd { + cmd := a.teamsSharingCmd + a.teamsSharingCmd = nil + return cmd +} diff --git a/internal/tui3/settingsteams_host_test.go b/internal/tui3/settingsteams_host_test.go index 79aa9fe1a3..9b811b214d 100644 --- a/internal/tui3/settingsteams_host_test.go +++ b/internal/tui3/settingsteams_host_test.go @@ -11,12 +11,13 @@ import ( // AN EDIT OVER A HOST SEAM LANDS IN THE FAR PROFILE and nowhere on this // machine. The row says where the value came from in the same words the team // card uses for a value inherited from these defaults. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestTeamsSettingsOverAHostSeamLandInTheFarProfile(t *testing.T) { a := placeApp(t) laptop := a.profileDir far := t.TempDir() a.host = "tower" - a.teamsDisk.door = localTeams(far, &a.teamsDisk.watch) + a.teamsDisk.door = localTeams(far, &a.teamsDisk.watch, nil) before := teamstore.DefaultsAt(laptop) drive(t, a, key(placeChord(pageSettings))) openTeamsTab(t, a) @@ -55,11 +56,12 @@ func TestTeamsSettingsOverAHostSeamLandInTheFarProfile(t *testing.T) { // AN OLDER ENGINE KEEPS THE TAB READ-ONLY and says so in one line. The far // profile is not written, and neither is this machine's. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestTeamsSettingsOverAnOlderEngineStayReadOnly(t *testing.T) { a := placeApp(t) laptop := a.profileDir far := t.TempDir() - door := localTeams(far, &a.teamsDisk.watch) + door := localTeams(far, &a.teamsDisk.watch, nil) door.ApplyDefault = nil a.host = "tower" a.teamsDisk.door = door @@ -92,11 +94,12 @@ func TestTeamsSettingsOverAnOlderEngineStayReadOnly(t *testing.T) { // THE NOTE FOLLOWS THE TAB. A local tab says the rows are this machine's. The // Teams tab, over a seam that can write, says they are saved on the far // machine and does not repeat the local claim. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestSettingsNoteFollowsTheTabOverAHostSeam(t *testing.T) { a := placeApp(t) far := t.TempDir() a.host = "tower" - a.teamsDisk.door = localTeams(far, &a.teamsDisk.watch) + a.teamsDisk.door = localTeams(far, &a.teamsDisk.watch, nil) drive(t, a, key(placeChord(pageSettings))) local := lastNote(t, a) if local != settingsLocalWord { diff --git a/internal/tui3/teamsacts.go b/internal/tui3/teamsacts.go index 758188f9b6..f8fec05a46 100644 --- a/internal/tui3/teamsacts.go +++ b/internal/tui3/teamsacts.go @@ -196,7 +196,7 @@ func (a *app) teamsMemberGo(id, key string) tea.Cmd { // teamsResumeBehind opens one member's conversation behind the one in front, // off the loop, and holds it as a tab of its own. func (a *app) teamsResumeBehind(m teamMember) tea.Cmd { - if cmd, remote := a.teamsRemoteGo(m, func() tea.Cmd { return a.teamsResumeBehind(m) }); remote { + if cmd, remote := a.teamsRemoteGo(m); remote { return cmd } name := m.Word diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 9b2abebd1a..1c400c6f5f 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -40,6 +40,9 @@ import ( // runs on, as functions (the reason [LinkSeam] gives). The zero value is // this machine's own profile, which is every local launch. type TeamsSeam struct { + // Sharing reads the engine's effective sharing off-loop when team data + // comes over remote. Local seams use the window's pairing gate instead. + Sharing func() (bool, error) // Load is the teams as held, coloured around reserved, and the file's stamp // (internal/teams' stamp.go). It is asked on the loop, at an opening, and // must NOT block. known false says nothing is held yet: the window asks @@ -144,7 +147,7 @@ func (s TeamsSeam) delegation() bool { // localTeams is the seam onto internal/teams in dir, the profile of this // machine. watch is the window's stat-before-read memory of the logs. -func localTeams(dir string, watch *teamstore.Watch) TeamsSeam { +func localTeams(dir string, watch *teamstore.Watch, paired func() bool) TeamsSeam { return TeamsSeam{ Load: func(reserved []float64) ([]teamstore.Team, string, bool) { stamp := teamstore.Stamp(dir) @@ -167,7 +170,7 @@ func localTeams(dir string, watch *teamstore.Watch) TeamsSeam { } var f *teamstore.File var err error - if config.TeamsShareAt(dir) { + if config.TeamsSharingAt(dir, paired) { f, err = teamstore.Snapshot(dir) } else { f, err = teamstore.LoadHued(dir, reserved) @@ -190,7 +193,7 @@ func localTeams(dir string, watch *teamstore.Watch) TeamsSeam { return entries, err }, DisplayTraffic: func(team, after, version string, limit int) ([]teamstore.Entry, string, string, bool, error) { - return watch.History(dir, team, after, version, limit) + return watch.History(dir, team, after, version, limit, config.TeamsSharingAt(dir, paired)) }, Defaults: func() (teamstore.Defaults, error) { return teamstore.DefaultsAt(dir), nil }, ApplyDefault: func(key, raw string) (teamstore.Defaults, error) { @@ -281,7 +284,7 @@ func (a *app) teamsSeam() TeamsSeam { if a.teamsDisk.door.present() { return a.teamsDisk.door } - return localTeams(a.profileDir, &a.teamsDisk.watch) + return localTeams(a.profileDir, &a.teamsDisk.watch, a.teamsPaired) } // THE FRAME ASKS WHICH DOORS THERE ARE, NEVER FOR THE SEAM. Binding the local diff --git a/internal/tui3/teamseam_test.go b/internal/tui3/teamseam_test.go index c1b3f43856..010c943bd3 100644 --- a/internal/tui3/teamseam_test.go +++ b/internal/tui3/teamseam_test.go @@ -51,7 +51,7 @@ type farTeams struct { } func (f *farTeams) seam() TeamsSeam { - local := localTeams(f.dir, &f.watch) + local := localTeams(f.dir, &f.watch, nil) return TeamsSeam{ Load: func(reserved []float64) ([]teamstore.Team, string, bool) { f.mu.Lock() @@ -75,10 +75,11 @@ func (f *farTeams) seam() TeamsSeam { // THE LOCAL SEAM IS THE STORE IN THE PROFILE. A load of no file is known and // empty; an update answers what it wrote and a stamp; a read at that stamp is // "same" and carries nothing; a quiet log after its cursor is nothing. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestTheLocalTeamsSeamIsTheStoreInTheProfile(t *testing.T) { dir := t.TempDir() var watch teamstore.Watch - seam := localTeams(dir, &watch) + seam := localTeams(dir, &watch, nil) teams, stamp, known := seam.Load(nil) if !known || len(teams) != 0 || stamp != teamstore.MissingStamp { t.Fatalf("a load of no file: %v, %q, %v", teams, stamp, known) @@ -274,11 +275,12 @@ func TestAHostedSeamThatHoldsNothingYetIsReadOffTheLoop(t *testing.T) { // handed without them says so. A packet raised through the local seam is read // back through it, a second read at the stamp is same, and the spend of a team // nobody has spent in is zero with a stamp that answers same. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestTheLocalTeamsSeamCarriesTheDelegationDoors(t *testing.T) { dir := t.TempDir() t.Setenv("CODEAF_HOME", t.TempDir()) var watch teamstore.Watch - seam := localTeams(dir, &watch) + seam := localTeams(dir, &watch, nil) if !seam.delegation() { t.Fatal("the local seam has no delegation doors") } diff --git a/internal/tui3/teamsoverview.go b/internal/tui3/teamsoverview.go index dda479690f..ef1fcc5b25 100644 --- a/internal/tui3/teamsoverview.go +++ b/internal/tui3/teamsoverview.go @@ -104,6 +104,9 @@ func (a *app) teamsTakeInteractions(id string, entries []teamstore.Entry) bool { byID[e.ID] = e } for _, e := range entries { + if e.Origin != "" && !a.teamsShare { + continue + } byID[e.ID] = e } list := make([]teamstore.Entry, 0, len(byID)) diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index c426317169..9284831e4e 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -432,6 +432,7 @@ func TestTeamsDisbandedSubteamHasNoReopenAction(t *testing.T) { } // Contract 6.1: A linked local team with a history door draws its closing report in the closed pane. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestLinkedLocalClosedTeamDrawsItsReport(t *testing.T) { a, harbor, _ := teamsPlaceLabIDs(t) p := teamstore.Packet{ID: "closing-report", Team: teamstore.Person, Origin: harbor, @@ -441,7 +442,7 @@ func TestLinkedLocalClosedTeamDrawsItsReport(t *testing.T) { if err := a.teamEdit(func(f *teamstore.File) error { return f.Close(harbor, a.now(), p.ID) }); err != nil { t.Fatal(err) } - door := localTeams(a.profileDir, &a.teamsDisk.watch) + door := localTeams(a.profileDir, &a.teamsDisk.watch, nil) door.History = func(team string) ([]teamstore.Packet, error) { if team == harbor { return []teamstore.Packet{p}, nil diff --git a/internal/tui3/teamsread_test.go b/internal/tui3/teamsread_test.go index 9df9f2961d..6568af06a3 100644 --- a/internal/tui3/teamsread_test.go +++ b/internal/tui3/teamsread_test.go @@ -150,12 +150,13 @@ func TestTeamsFirstFrameIsDrawnBeforeAnyReadAndTheReadMovesNothing(t *testing.T) // older engine over --host carries no History; the read used to index the // histories it never asked for, once per closed team, and the window died on a // press of a closed team's link. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestTeamsReadOfAClosedTeamWithNoHistoryDoorDoesNotPanic(t *testing.T) { a, harbor, _ := teamsPlaceLabIDs(t) if err := a.teamEdit(func(f *teamstore.File) error { return f.Close(harbor, a.now(), "") }); err != nil { t.Fatal(err) } - door := localTeams(a.profileDir, &a.teamsDisk.watch) + door := localTeams(a.profileDir, &a.teamsDisk.watch, nil) door.History = nil a.teamsDisk.door = door a.tp.reading = false diff --git a/internal/tui3/teamsrelay.go b/internal/tui3/teamsrelay.go index 64c7e1c1a8..ca424d179c 100644 --- a/internal/tui3/teamsrelay.go +++ b/internal/tui3/teamsrelay.go @@ -99,7 +99,7 @@ func (a *app) teamsContinue(row chatlist.Row) tea.Cmd { return tea.Batch(cmd, a.offerContinue(row, chatlist.Offer{})) } -func (a *app) teamsRemoteGo(m teamMember, local ...func() tea.Cmd) (tea.Cmd, bool) { +func (a *app) teamsRemoteGo(m teamMember) (tea.Cmd, bool) { _, sentinel := teamstore.RemoteCell(m.Key) if !a.teamsShare { if !sentinel && !teamstore.OnOtherComputer(m.Key) { @@ -168,9 +168,9 @@ func (a *app) teamsRemoteGo(m teamMember, local ...func() tea.Cmd) (tea.Cmd, boo return a.teamsContinue(row), true } } - // A stale local transcript can be the first thing opened after launch, - // before Home has read ownership. Wait for that reading as remote rows do. - if !remote && (a.machines == nil || !a.machineRead.at.IsZero()) { + // Home opens a local row without waiting for a directory reading. A team + // shortcut must take that same path unless cached ownership says otherwise. + if !remote { return nil, false } if a.machines != nil { @@ -193,12 +193,6 @@ func (a *app) teamsRemoteGo(m teamMember, local ...func() tea.Cmd) (tea.Cmd, boo break } } - if !remote { - if len(local) > 0 { - return local[0]() - } - return a.trafficGo(m.Key) - } a.tp.msg = "This chat is on " + a.teamsElsewhere(m) + " and is not available here. Open it there." a.note(a.tp.msg) return nil diff --git a/internal/tui3/teamsrelay_test.go b/internal/tui3/teamsrelay_test.go index 8e44553528..af90925010 100644 --- a/internal/tui3/teamsrelay_test.go +++ b/internal/tui3/teamsrelay_test.go @@ -2,6 +2,7 @@ package tui3 import ( tea "charm.land/bubbletea/v2" + "context" "fmt" "os" "reflect" @@ -12,6 +13,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell/celltest" "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -83,31 +85,26 @@ func TestImportedStartsAndStopsNeverActOnSurface(t *testing.T) { } } -func TestTeamMemberFetchesItsContinueRowWhenHomeHasNotReadItYet(t *testing.T) { +// F4 keeps ownership discovery for a remote member while local members take Home's local door. +func TestRemoteTeamMemberFetchesItsContinueRowWhenHomeHasNotReadItYet(t *testing.T) { celltest.Available(t) - for _, local := range []bool{false, true} { - t.Run(fmt.Sprintf("local=%v", local), func(t *testing.T) { - a := continueHome(t, &fakeTaker{}, continueRows, 140) - enableSurfaceTeamSharing(t, a) - row := continueRows[0] - a.machineRead.rows, a.machineRead.at = nil, time.Time{} - member := teamMember{Key: teamstore.RemoteKey(row.Cell), Word: row.Title, SyncID: "cell:" + row.Cell} - if local { - member.Key, member.File = "/old/local/transcript.jsonl", "/old/local/transcript.jsonl" - } - a.wall.loaded = true - a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{member}}} - spend(t, a, a.trafficGo(member.Key)) - ask, ok := a.homeAsking() - if !ok || ask.question.Head != continueAsk { - t.Fatal("uncached team member did not fetch its continuation row") - } - }) + a := continueHome(t, &fakeTaker{}, continueRows, 140) + enableSurfaceTeamSharing(t, a) + row := continueRows[0] + a.machineRead.rows, a.machineRead.at = nil, time.Time{} + member := teamMember{Key: teamstore.RemoteKey(row.Cell), Word: row.Title, SyncID: "cell:" + row.Cell} + a.wall.loaded = true + a.wall.teams = []team{{ID: "alpha", Name: "alpha", Members: []teamMember{member}}} + spend(t, a, a.trafficGo(member.Key)) + ask, ok := a.homeAsking() + if !ok || ask.question.Head != continueAsk { + t.Fatal("uncached remote team member did not fetch its continuation row") } } // Render the real frame after entering through Teams or a chat tab. Inspecting // home.ask alone would miss a question raised while Home was still hidden. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestMemberGesturesShowContinuationAndBothAnswersFromTeamsAndTabs(t *testing.T) { celltest.Available(t) for _, gesture := range []string{"teams", "tab"} { @@ -187,7 +184,8 @@ func TestMemberGesturesShowContinuationAndBothAnswersFromTeamsAndTabs(t *testing } } -func TestUnpairedRefreshClockReadsChangedFileWithoutRepairingOrWritingAnything(t *testing.T) { +// F1 preserves observational refresh only while paired sharing is effective. +func TestPairedRefreshClockReadsChangedFileWithoutRepairingOrWritingAnything(t *testing.T) { a := teamsPlaceLab(t) enableSurfaceTeamSharing(t, a) teamsFlush(t, a) @@ -198,13 +196,13 @@ func TestUnpairedRefreshClockReadsChangedFileWithoutRepairingOrWritingAnything(t spend(t, a, a.teamsRefreshTick()) after, _ := os.ReadFile(teamstore.Path(a.profileDir)) if string(after) != string(raw) { - t.Fatal("refresh repaired the unpaired file") + t.Fatal("refresh repaired the shared file") } if len(a.teamsDisk.queue) != 0 { t.Fatal("refresh queued a repair") } if len(a.wall.teams) != 2 || a.wall.teams[0].ID != "zz" { - t.Fatal("unpaired refresh changed file order") + t.Fatal("shared refresh changed file order") } } @@ -333,25 +331,39 @@ func TestTypingInATeamChatHeldElsewhereTakesTheSameDoorAsAnyChat(t *testing.T) { func enableSurfaceTeamSharing(t *testing.T, a *app) { t.Helper() + a.teamsPaired = func() bool { return true } row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) if err := row.Apply("on"); err != nil { t.Fatal(err) } + refreshSurfaceTeamSharing(t, a) +} + +// refreshSurfaceTeamSharing runs the preference read beside the simulated loop. +func refreshSurfaceTeamSharing(t *testing.T, a *app) { + t.Helper() a.refreshProfileUI() + drive(t, a, runCmd(a.takeTeamsSharingCmd())...) } // FIX4 F1: Update uses the adopted profile snapshot; a direct file write is // not a setting adoption and cannot arm the sharing clock on the surface loop. +// C9 pins off explicitly so this still tests profile adoption after the default changes. func TestTeamSharingClockUsesOnlyAdoptedProfileSettings(t *testing.T) { a, _ := sheetApp(t) - a.wall.loaded = true - if cmd := a.teamsRefreshArm(); cmd != nil || a.teamsDisk.refreshing { - t.Fatal("the default-off profile armed the sharing clock") - } + a.teamsPaired = func() bool { return true } row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) if !ok { t.Fatal("missing sharing setting") } + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + refreshSurfaceTeamSharing(t, a) + a.wall.loaded = true + if cmd := a.teamsRefreshArm(); cmd != nil || a.teamsDisk.refreshing { + t.Fatal("the disabled profile armed the sharing clock") + } if err := row.Apply("on"); err != nil { t.Fatal(err) } @@ -361,12 +373,17 @@ func TestTeamSharingClockUsesOnlyAdoptedProfileSettings(t *testing.T) { t.Fatal("the surface loop read an unadopted sharing setting") } } - a.refreshProfileUI() + refreshSurfaceTeamSharing(t, a) a.Update(struct{}{}) if !a.teamsDisk.refreshing { t.Fatal("profile adoption did not arm the sharing clock") } - reopened := newTestAppWithProfile(a.profileDir, &fakeAgent{}) + reopened := newApp(context.Background(), Options{ + Agent: &fakeAgent{}, Workspace: "/tmp/lab", UsageLedger: labLedger(), + ProfileDir: a.profileDir, TeamsPaired: a.teamsPaired, + BashBackgroundAfterSeconds: config.DefaultBashBackgroundAfter, + Env: envOf(map[string]string{"TERM": "xterm-256color"}), + }) reopened.wall.loaded = true if cmd := reopened.teamsRefreshArm(); cmd == nil || !reopened.teamsDisk.refreshing { t.Fatal("boot did not adopt the enabled sharing setting") @@ -377,15 +394,33 @@ func TestTeamSharingClockUsesOnlyAdoptedProfileSettings(t *testing.T) { if cmd := a.teamsRefreshTick(); cmd == nil || !a.teamsDisk.refreshing { t.Fatal("the sharing tick read an unadopted file change") } - a.settle() + spend(t, a, a.settle()) if cmd := a.teamsRefreshTick(); cmd != nil || a.teamsDisk.refreshing { t.Fatal("turn completion did not adopt the disabled sharing setting") } } +// C9 pins off explicitly so local shortcuts still exercise the sharing-off path. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestSharingOffStopsRefreshAndKeepsLocalChatShortcuts(t *testing.T) { celltest.Available(t) + checkSharingDisabledShortcuts(t, true) +} + +// F1 preserves the refresh clock and local shortcuts with an unset preference and no pairing. +func TestUnpairedSharingUnsetStopsRefreshAndKeepsLocalChatShortcuts(t *testing.T) { + checkSharingDisabledShortcuts(t, false) +} + +func checkSharingDisabledShortcuts(t *testing.T, explicitOff bool) { a := continueHome(t, &fakeTaker{}, continueRows, 140) + if explicitOff { + sharing, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := sharing.Apply("off"); err != nil { + t.Fatal(err) + } + } + refreshSurfaceTeamSharing(t, a) row := continueRows[0] member := teamMember{Key: a.frontTabKey(), File: a.file, Word: row.Title, SyncID: "cell:" + row.Cell} a.wall.loaded = true @@ -417,26 +452,34 @@ func TestSharingOffStopsRefreshAndKeepsLocalChatShortcuts(t *testing.T) { } } +// C1, C5 and C9 keep the visible default and explanation accurate in General. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestSettingsGeneralHasLocalTeamSharingChoiceAndSearch(t *testing.T) { a, _ := sheetApp(t) + a.teamsPaired = func() bool { return true } + refreshSurfaceTeamSharing(t, a) a.openSettings() cursorTo(t, a, config.KeyTeamsShare) item, ok := a.sheet.current() if !ok || item.row.Key != config.KeyTeamsShare || item.meta.tab != tabGeneral || item.meta.label != "share teams" || item.meta.widget != widgetCycle { t.Fatal("sharing row not on General", item) } - if item.row.Value() != "off" { - t.Fatal("sharing must start off", item.row.Value()) + if item.row.Value() != "on" { + t.Fatal("sharing must start on", item.row.Value()) } - for _, want := range []string{"teams", "members and settings", "recent Traffic as read-only history", "off until turned on", "both computers must turn it on", "pairing required"} { + for _, want := range []string{"teams", "members and settings", "recent Traffic as read-only history", "paired computers", "On by default", "turning it off on either computer stops sharing"} { if !strings.Contains(item.meta.about, want) { t.Fatal("sharing explanation omitted", want, item.meta.about) } } - if shown := strings.Join(settingsDetails(a, 90), " "); !strings.Contains(shown, "both computers") { + if shown := strings.Join(settingsDetails(a, 90), " "); !strings.Contains(shown, "either computer") { t.Fatal("sharing explanation hidden on General", shown) } a.wall.loaded = true + chooseSheetValue(t, a, "off") + if item.row.Value() != "off" || a.teamsRefreshTick() != nil || a.teamsDisk.refreshing { + t.Fatal("saving the General choice did not stop default sharing immediately") + } chooseSheetValue(t, a, "on") if item.row.Value() != "on" || !a.teamsDisk.refreshing { t.Fatal("saving the General choice did not enable sharing immediately") @@ -454,8 +497,26 @@ func TestSettingsGeneralHasLocalTeamSharingChoiceAndSearch(t *testing.T) { } } +// C9 pins off explicitly so this still checks the original local Traffic reader. +// F1 preserves the existing team surface contract with an explicit pairing gate. func TestSharingOffUsesOnlyTheOriginalTrafficReadDoor(t *testing.T) { + checkSharingDisabledTrafficDoor(t, true) +} + +// F1 keeps the original Traffic reader when the preference is unset on an unpaired computer. +func TestUnpairedSharingUnsetUsesOnlyTheOriginalTrafficReadDoor(t *testing.T) { + checkSharingDisabledTrafficDoor(t, false) +} + +func checkSharingDisabledTrafficDoor(t *testing.T, explicitOff bool) { a, id, _, _ := trafficApp(t) + if explicitOff { + sharing, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := sharing.Apply("off"); err != nil { + t.Fatal(err) + } + } + refreshSurfaceTeamSharing(t, a) a.tp.sel = id seam := a.teamsSeam() seam.DisplayTraffic = func(string, string, string, int) ([]teamstore.Entry, string, string, bool, error) { @@ -466,3 +527,253 @@ func TestSharingOffUsesOnlyTheOriginalTrafficReadDoor(t *testing.T) { trafficReadNow(t, a) spend(t, a, a.teamsRead(false)) } + +// F7 drops cached mirror rows on adoption and rejects a late pre-off history response. +func TestSharingStopsDropCachedAndInFlightMirrorHistory(t *testing.T) { + for _, unpair := range []bool{false, true} { + t.Run(fmt.Sprintf("unpair=%v", unpair), func(t *testing.T) { + a, id, _, _ := trafficApp(t) + enableSurfaceTeamSharing(t, a) + local := teamstore.Entry{ID: "000000000001", Kind: teamstore.KindNote, Text: "local"} + mirror := teamstore.Entry{ID: "other/1", Origin: "other/1", Kind: teamstore.KindNote, Text: "foreign"} + a.teamsTakeInteractions(id, []teamstore.Entry{local, mirror}) + if len(a.traffic.rows[id]) != 2 { + t.Fatal("fixture did not adopt history") + } + if unpair { + a.teamsPaired = func() bool { return false } + } else { + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + } + refreshSurfaceTeamSharing(t, a) + if got := a.traffic.rows[id]; len(got) != 1 || got[0].ID != local.ID { + t.Fatalf("retained cached mirror: %+v", got) + } + a.teamsTakeInteractions(id, []teamstore.Entry{mirror}) + if len(a.traffic.rows[id]) != 1 { + t.Fatal("late overview response revived mirror") + } + a.traffic.cursor = map[string]string{id: trafficFromStart} + a.trafficTake([]trafficGot{{trafficJob: trafficJob{id: id, after: trafficFromStart}, entries: []teamstore.Entry{mirror}, reset: true}}, nil, a.traffic.stamp, a.traffic.edits, false) + if got := a.traffic.rows[id]; len(got) != 1 || got[0].ID != local.ID { + t.Fatal("late history response erased local rows", got) + } + for _, e := range a.traffic.rows[id] { + if e.Origin != "" { + t.Fatal("late live response revived mirror") + } + } + }) + } +} + +type offlineTeamSource struct{ calls int } + +func (s *offlineTeamSource) Rows(context.Context) ([]chatlist.Row, error) { + s.calls++ + return nil, fmt.Errorf("relay offline") +} + +// F4 gives local members Home's immediate local opening path and retains known-remote refusals. +func TestLocalTeamMemberOpeningNeverAddsAnOwnershipRequest(t *testing.T) { + a, _, _, _ := trafficApp(t) + enableSurfaceTeamSharing(t, a) + src := &offlineTeamSource{} + a.machines = src + a.machineRead.rows = nil + a.machineRead.at = time.Time{} + member := teamMember{Key: a.frontTabKey(), File: a.file, SyncID: "cell:local"} + if cmd, handled := a.teamsRemoteGo(member); cmd != nil || handled { + t.Fatal("local member waited for ownership") + } + if src.calls != 0 { + t.Fatal("local member asked relay", src.calls) + } + a.machineRead.down = true + a.machineRead.rows = []chatlist.Row{{Cell: "local", Status: chatlist.Running, Device: "desktop", DeviceID: "remote"}} + if _, handled := a.teamsRemoteGo(member); !handled { + t.Fatal("known remote holder was opened locally") + } + remote := teamMember{Key: teamstore.RemoteKey("elsewhere"), Word: "remote"} + spend(t, a, a.trafficGo(remote.Key)) + // The direct remote boundary must also refuse an unavailable member. + cmd, handled := a.teamsRemoteGo(remote) + if !handled { + t.Fatal("remote member was treated as local") + } + spend(t, a, cmd) + if len(a.entries) == 0 || a.entries[len(a.entries)-1].text != a.chatNotice(a.machineRead.unavailableWord()) { + t.Fatal("remote member had no relay refusal") + } +} + +// F1 keeps @ completion identical to explicit off on an unpaired computer with an unset preference. +func TestUnpairedSharingUnsetKeepsTheOriginalCompletionCatalog(t *testing.T) { + a, _, _, _ := trafficApp(t) + baseline := a.mentionChatsExcept("") + a.wall.teams = append(a.wall.teams, team{ID: "foreign", Members: []teamMember{{Key: "team-only", Word: "only in teams"}}}) + refreshSurfaceTeamSharing(t, a) + if got := a.mentionChatsExcept(""); !reflect.DeepEqual(got, baseline) { + t.Fatal("unpaired default added team-only completion", got) + } + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + refreshSurfaceTeamSharing(t, a) + if got := a.mentionChatsExcept(""); !reflect.DeepEqual(got, baseline) { + t.Fatal("explicit off catalog changed", got) + } +} + +// F1 adopts pairing events off-loop and rejects a pre-off preference snapshot. +func TestTeamSharingAdoptsPairingChangesWithoutLoopReads(t *testing.T) { + a, _ := sheetApp(t) + paired := false + calls := 0 + a.teamsPaired = func() bool { calls++; return paired } + refreshSurfaceTeamSharing(t, a) + a.wall.loaded = true + if a.teamsShare || a.teamsRefreshArm() != nil { + t.Fatal("unpaired default armed sharing") + } + paired = true + run := newPairRun(a.ctx) + defer run.cancel() + a.pair.start(run) + before := calls + cmd := a.tookPair(pairMsg{run: run, event: pairEnded{ok: true}}) + if before != calls { + t.Fatal("pairing adoption read the gate on the loop") + } + drive(t, a, runCmd(cmd)...) + if !a.teamsShare { + t.Fatal("pairing did not adopt sharing") + } + old := runCmd(a.refreshTeamsSharing()) + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := row.Apply("off"); err != nil { + t.Fatal(err) + } + refreshSurfaceTeamSharing(t, a) + drive(t, a, old...) + if a.teamsShare { + t.Fatal("old snapshot re-enabled sharing") + } + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + refreshSurfaceTeamSharing(t, a) + if !a.teamsShare { + t.Fatal("fixture did not resume sharing") + } + a.teamsRefreshArm() + paired = false + drive(t, a, runCmd(a.refreshTeamsSharing())...) + if a.teamsShare || a.teamsDisk.refreshing { + t.Fatal("unpairing retained sharing") + } +} + +// F1 retains the original repairing read door with an unset preference and no pairing. +func TestUnpairedLocalTeamRefreshRetainsRepairingReads(t *testing.T) { + dir := t.TempDir() + watch := &teamstore.Watch{} + seam := localTeams(dir, watch, nil) + _, stamp, _ := seam.Load(nil) + raw := []byte(`{"version":2,"teams":[{"id":"alpha","name":"harbor","members":[{"key":"local","word":"worker"}]}]}`) + if err := os.WriteFile(teamstore.Path(dir), raw, 0600); err != nil { + t.Fatal(err) + } + got, _, same, err := seam.ReadSince(stamp, nil) + if err != nil || same || len(got) != 1 || got[0].Members[0].Handle == "" { + t.Fatal("unpaired read skipped repair", got, err) + } + after, _ := os.ReadFile(teamstore.Path(dir)) + if string(after) == string(raw) { + t.Fatal("unpaired read did not persist repair") + } +} + +// R6/F1 keeps pairing reads outside Update for both a Settings save and a completed turn. +func TestSettingsSaveAndTurnCompletionReadPairingOffLoop(t *testing.T) { + for _, turn := range []bool{false, true} { + t.Run(fmt.Sprintf("turn=%v", turn), func(t *testing.T) { + a, _ := sheetApp(t) + calls, updating := 0, false + a.teamsPaired = func() bool { + calls++ + if updating { + t.Error("pairing callback ran on the update loop") + } + return true + } + var cmd tea.Cmd + updating = true + if turn { + _, cmd = a.Update(streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventTurnDone}}) + } else { + a.openSettings() + cursorTo(t, a, config.KeyTeamsShare) + a.sheetKey(key("enter")) + _, cmd = a.Update(key("enter")) + } + updating = false + if calls != 0 { + t.Fatal("update read pairing") + } + drive(t, a, runCmd(cmd)...) + if calls == 0 { + t.Fatal("update never scheduled pairing adoption") + } + }) + } +} + +// R4/F1/F7 uses the engine's sharing for history, mirror adoption and the refresh clock over any remote seam. +func TestHostedTeamDisplayFollowsEngineSharing(t *testing.T) { + for _, viewerPaired := range []bool{false, true} { + t.Run(fmt.Sprintf("viewerPaired=%v", viewerPaired), func(t *testing.T) { + a, id, _, _ := trafficApp(t) + a.host = "engine" + a.teamsPaired = func() bool { t.Error("hosted display read viewer pairing"); return viewerPaired } + engine := t.TempDir() + enginePaired := !viewerPaired + if err := teamstore.Save(engine, a.wall.teams); err != nil { + t.Fatal(err) + } + if err := teamstore.WriteTrafficMirror(engine, id, []teamstore.Entry{{ID: "engine/1", Kind: teamstore.KindNote, Text: "engine mirror"}}); err != nil { + t.Fatal(err) + } + seam := localTeams(engine, &teamstore.Watch{}, func() bool { return enginePaired }) + seam.Sharing = func() (bool, error) { return config.TeamsSharingAt(engine, func() bool { return enginePaired }), nil } + displayReads, history := 0, seam.DisplayTraffic + seam.DisplayTraffic = func(team, after, version string, limit int) ([]teamstore.Entry, string, string, bool, error) { + displayReads++ + return history(team, after, version, limit) + } + a.teamsDisk.door = seam + for _, sharing := range []bool{!viewerPaired, viewerPaired, !viewerPaired} { + enginePaired = sharing + before := displayReads + spend(t, a, a.trafficRead()) + if (displayReads > before) != sharing { + t.Fatalf("engine sharing=%v history requests=%d", sharing, displayReads-before) + } + if a.teamsShare != sharing || a.teamsDisk.refreshing != sharing { + t.Fatalf("window did not follow engine sharing=%v: sharing=%v refreshing=%v", sharing, a.teamsShare, a.teamsDisk.refreshing) + } + found := false + for _, entry := range a.traffic.rows[id] { + found = found || entry.Text == "engine mirror" + } + if found != sharing { + t.Fatalf("engine sharing=%v mirror=%v", sharing, found) + } + } + }) + } +} diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index a360eaa565..9f5a75273f 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -280,7 +280,15 @@ func (a *app) trafficReadOf(ids []string, tick bool) tea.Cmd { } a.traffic.reading = true seam, reserved, stamp, edits, sharing := a.teamsSeam(), teamReservedHues(a.pal), a.traffic.stamp, a.traffic.edits, a.teamsShare + generation := a.teamsShareGeneration return a.besideLine(func() func(bool) tea.Cmd { + var engineSharing *teamsSharingMsg + if seam.Sharing != nil { + if enabled, err := seam.Sharing(); err == nil { + sharing = enabled + engineSharing = &teamsSharingMsg{generation, enabled} + } + } got := make([]trafficGot, len(jobs)) for i, j := range jobs { after, limit := j.after, trafficPage @@ -308,7 +316,12 @@ func (a *app) trafficReadOf(ids []string, tick bool) tea.Cmd { if err != nil || same { fresh, at = nil, stamp } - return func(bool) tea.Cmd { return a.trafficTake(got, fresh, at, edits, tick) } + return func(bool) tea.Cmd { + if engineSharing != nil { + a.takeTeamsSharing(*engineSharing) + } + return a.trafficTake(got, fresh, at, edits, tick) + } }) } @@ -337,6 +350,13 @@ func (a *app) trafficTake(got []trafficGot, fresh []team, at string, edits int, var acts []tea.Cmd needRoster := false for _, g := range got { + // A history response already in flight cannot revive rows after off. + if !a.teamsShare { + g.mirror = "" + if g.reset { + g.entries, g.reset = g.news, false + } + } if g.news == nil && !g.reset { g.news = g.entries } diff --git a/internal/tui3/teamwritesaid_test.go b/internal/tui3/teamwritesaid_test.go index 94a1304c80..e36daa90e0 100644 --- a/internal/tui3/teamwritesaid_test.go +++ b/internal/tui3/teamwritesaid_test.go @@ -12,7 +12,7 @@ import ( // that hands every change the file as fake holds it and answers err, or, when // err is nil, whatever the changes made of it. func refusingSeam(a *app, err error) TeamsSeam { - seam := localTeams(a.profileDir, &a.teamsDisk.watch) + seam := localTeams(a.profileDir, &a.teamsDisk.watch, nil) seam.Update = func(change func(*teamstore.File) error) ([]teamstore.Team, string, error) { if err != nil { return nil, "", err diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 9c47373b64..e694f16771 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -464,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 + // TeamsPaired supplies this computer's pairing gate at preference adoption. + TeamsPaired func() bool // 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 From 5c43803240f1d4110415a909ad75fbd3feacfba3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 11:34:07 -0400 Subject: [PATCH 4/6] manual: team sharing is on by default for paired computers The team pages, Settings and PERF.md say what the code now does: automatic sharing between paired computers, unpaired computers unaffected, turning it off on either computer, the first-sharing backup, duplicate names, former managers, spending caps counted per computer, two-minute Traffic, and what pairing replace does with teams. Co-Authored-By: Claude Opus 5.5 --- PERF.md | 13 ++- .../0000-teams-share-on-by-default.md | 49 +++++++++ .../manual/chat/conversations-and-teams.md | 99 ++++++++++++++++--- .../chat/reaching-this-machine-without-ssh.md | 6 +- internal/manual/chat/settings.md | 12 ++- internal/manual/chat/team-manager.md | 38 +++++-- .../chat/use-this-on-another-computer.md | 4 +- internal/manual/chat_test.go | 43 +++++++- 8 files changed, 226 insertions(+), 38 deletions(-) create mode 100644 docs/changes/unreleased/0000-teams-share-on-by-default.md diff --git a/PERF.md b/PERF.md index bd8f7edc47..afbc078232 100644 --- a/PERF.md +++ b/PERF.md @@ -121,8 +121,8 @@ same commit; derive repeated values from constants. ## Paired team history -These sharing budgets apply only when **share teams** is on in Settings → Devices. -Sharing is off by default on each computer; off rounds run no team carriers, +These sharing budgets apply whenever **share teams** is on in Settings → General, +which is the default for paired computers. Off rounds run no team carriers, identity discovery, mirror writes or ownership lookups and retain existing polling. `teams.TrafficSyncLines` limits each writing computer's window to 32 lines per @@ -130,7 +130,7 @@ team, and `teams.TrafficSyncBytes` limits encoded lines to 16 KiB per team. `teams.TrafficSyncTextBytes` shortens text beyond 2 KiB at a rune boundary. `teams.TrafficSyncDeviceBytes` caps each device's encoded Traffic share at 64 KiB across teams, newest local activity first. `teams.TrafficSyncEvery` permits a -publication at most once every 30 seconds; team record edits are not delayed. +publication at most once every 2 minutes; team record edits are not delayed. Capture reads the bounded backward tail across the current and rotated logs. Received windows replace a separate display mirror and are never republished. An unchanged round on 500 chats and 5 teams performs zero world scans and opens zero cell metadata files; @@ -140,8 +140,11 @@ compare deterministic values, and verify quiet rounds leave the vault unchanged. Mirror filtering makes zero directory requests per vault round, using only an already-held local roster, or every other writer when none is available. A send shares one ownership reading across its recipients, with a 3-second timeout and -a 5-second process cache (including failures). Cursor saves stat identity files -but decode them only after a stamp changes. Mirror refresh never acknowledges +a 5-second process cache (including failures). With effective sharing, cursor saves stat identity files +but decode them only after a stamp changes. Unpaired computers retain legacy +cursors, repairing reads and completion catalogs, with no sharing refresh clock +or mirror reads. The first team capture preserves the original file once as +`teams.before-sharing.json` before creating its ledger. Mirror refresh never acknowledges local news and each hosted window carries its own mirror version. ## Binary size diff --git a/docs/changes/unreleased/0000-teams-share-on-by-default.md b/docs/changes/unreleased/0000-teams-share-on-by-default.md new file mode 100644 index 0000000000..c2766a2728 --- /dev/null +++ b/docs/changes/unreleased/0000-teams-share-on-by-default.md @@ -0,0 +1,49 @@ +--- +kind: changed +title: team sharing on by default for paired computers +pr: 0000 +surface: [chat] +invalidates: + - >- + Share teams was off by default and both computers had to turn it on. It is + now on by default for paired computers, and turning it off on either + computer stops sharing. Unpaired computers are unchanged. + + - >- + The raw sharing preference selected refresh, cursor and history behavior. + Now only preference on plus pairing enables those paths; unpaired computers + retain their original clocks, completion, repairing reads and cursor bytes. + - >- + A damaged configuration could enable team sharing. Now missing or empty + choices read on, while unreadable files and invalid values read off. + - >- + A first merge could leave duplicate team names and discard appointments + without a copy. Now teams.before-sharing.json preserves the original once, + duplicate names stay unchanged, the older global manager stays global, + the other manages an ordinary team. Cleared appointments fill only an empty + or matching former-manager slot; existing former appointments stay. + - >- + Replacement could set teams aside without mentioning them or the saved path. + Now chat and interactive terminal confirmations include teams even without local chats, + non-terminal --replace proceeds as before, and the receipt names the preserved team file. + - >- + Opening a local team member could wait on or be refused by ownership discovery. + Now it takes the same local path as Home; positive remote ownership still refuses. + - >- + Traffic windows could publish every 30 seconds. Now they publish at most every + 2 minutes while team record edits remain undelayed. + - >- + Turning sharing off left retained mirror history in session readers and cached + screens. Now off or unpaired hides that history and rejects late mirror rows, + preserving the file and retiring cached provider digests; hosted windows follow + their engine's sharing. Previously exchanged teams and encrypted vault copies stay. + - >- + The manual did not explain the spending scope of a shared cap. It now states + that each computer meters spending separately, including continued members. + +--- + +Teams, their members and settings, and recent Traffic as read-only history now +share by default between paired computers. Settings → General → share teams +stays local to each computer. An explicit off stays off, stops sharing +on that computer and keeps teams already received, including encrypted vault copies. Unpaired computers are unchanged. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index d644bb2123..b1900f6e52 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -450,15 +450,16 @@ profile or changes the requesting chat's model. ## Do my teams show on my other computer? -Team sharing is off by default. On each paired computer, open -**Settings → General → share teams** and choose **on**. -Both paired computers must turn it on. This shares teams, members, handles, the +Team sharing is on by default for paired computers. The local switch is +**Settings → General → share teams**. Sharing includes teams, members, handles, the manager and team settings, plus recent Traffic as read-only history. Team changes show within a few seconds, even with no chat open. The Teams dropdown, Teams page and `@` list refresh without a restart. Both computers need to be running codeaf and able to reach the relay. An unpaired computer or a computer with sharing off keeps its teams to itself. -Turning sharing off stops sharing immediately and keeps teams already received. +Turning it off on either computer stops sharing between them: that computer +stops sending and receiving teams and Traffic. What was already exchanged stays, +including teams and encrypted copies in the vault. Unpaired computers are unaffected. Open a member whose chat is on another paired computer to see `Continue this chat here?` on Home. Choose **continue here** to move the chat @@ -467,20 +468,25 @@ A chat that is not shared names the computer that has it: open it there. Until its name is known, it says `the other computer`. Under `--host` or `--at`, a member on another computer shows that notice; open it on its computer. +A local member opens through the same path as its chat on Home, without an added +ownership lookup or relay refusal. A chat positively known to be elsewhere still +needs continuation. + A send to one member or manager whose chat is known to be on another computer is refused: it would not reach that chat. Continue the chat here first, then send again. A room post or send to several members still reaches local members and names anyone not reached, such as `Not reached: @web (on desktop).` If every -recipient is elsewhere, nothing is written. An unavailable relay never blocks -local messages. +recipient is elsewhere, nothing is written. If the relay is unavailable, local messages still work. A local message may +wait while this computer finishes sending an earlier change to the relay. ## Will deleting a team on my laptop delete it on my desktop? -Once both paired computers share teams (Settings → General → **share teams** set -to **on** on each), a deleted team stays deleted. Sharing is off by default. A desktop that was +Team sharing is on by default for paired computers. With **Settings → General → +share teams** enabled on both computers, a deleted team stays deleted. A desktop that was offline catches up when it reconnects. Renaming its old copy while offline does not bring the team back. Deleting a team does not delete its conversations. -An unpaired computer or one with sharing off keeps its teams to itself. +Turning it off on either computer stops sharing between them. An unpaired +computer keeps its teams to itself. Edits to different members or different parts of a team can both survive. If both computers change the same detail, the later change wins. Teams appear in @@ -489,10 +495,11 @@ consistently, and a removed member cannot remain the manager. ## Does Traffic sync between computers? -Traffic sharing is off by default. Once both paired computers turn on **share teams** -in Settings → General, Traffic from another computer shows its newest history, up to 32 lines per team and at most 16 KiB per team. Across all teams, each computer shares +Traffic sharing is on by default for paired computers through **Settings → General → +share teams**. Turning it off on either computer stops sharing between them. +Traffic from another computer shows its newest history, up to 32 lines per team and at most 16 KiB per team. Across all teams, each computer shares at most 64 KiB, with the most recently active teams first. It can take up to -about 30 seconds to appear. Text longer than 2 KiB is shortened, with ` …` at +about 2 minutes to appear. Text longer than 2 KiB is shortened, with ` …` at the end. Your original local lines keep their full text. This is history only: it cannot be answered or acted on here. It never delivers @@ -512,12 +519,72 @@ on the other computer's history. ## How do I turn on team sharing? -Team sharing is **off by default**. Pair your computers, then open -**Settings → General → share teams** and choose **on** on each computer. Both computers must -turn it on to exchange teams, members, manager choices and settings, and recent Traffic as read-only history. +Team sharing is on by default for paired computers. If it was turned off, open +**Settings → General → share teams** on that computer and choose **on** to resume. +Sharing exchanges teams, members, manager choices and settings, and recent Traffic as read-only history +while the switch stays enabled on both paired computers. This setting belongs to each computer and does not follow synced preferences. An unpaired computer or one with sharing off keeps its teams to itself. Once both computers share teams, team changes arrive within a few seconds while -codeaf runs and can reach the relay; recent Traffic can take about 30 seconds. +codeaf runs and can reach the relay; recent Traffic can take about 2 minutes. Turn **share teams** to **off** to stop all team sharing immediately. Teams already received stay as they are. + +## Stop sharing teams with my other computer + +Team sharing is on by default for paired computers. Open **Settings → General → +share teams** on either computer and choose **off**. Turning it off on either computer stops sharing +teams, members, manager choices and settings, and recent Traffic between them immediately. +That computer stops sending and receiving teams and Traffic. Teams already received +stay as they are, including encrypted copies in the vault. Local chats and Traffic +remain available. Retained history from the other computer is hidden until sharing resumes. +This choice stays on that computer and does not follow synced preferences. +An unpaired computer keeps its teams to itself. + +To resume sharing, choose **on** on any computer where you turned it off. Both +computers need to be running codeaf and able to reach the relay. Team changes +arrive within a few seconds; recent Traffic can take about 2 minutes. + +## First team sharing: backup, duplicate names and global managers + +Teams share automatically between paired computers while **Settings → General → +share teams** is on, its default. An unpaired computer is unaffected. Before the +first sharing round, this computer copies its original team file to +`teams.before-sharing.json` beside it, once, without replacing an existing copy. +If that copy cannot be saved, incoming team changes wait and a sync notice says +sharing will retry. + +If both computers had a team with the same name, both appear with their names +unchanged. Rename one in its Settings to tell them apart. + +If both computers had a global manager, the older global team stays global. The +other becomes an ordinary team under it and keeps its active manager. That +manager is not added to former-manager history just because the team changed. +When a conflicting appointment must be cleared, it is recorded in former-manager +history only if that slot is empty or already holds the same appointment. An +existing former appointment stays; the first-sharing backup preserves the +original file. Review manager choices in Teams. + +## Does a shared team's spending cap cover both computers? + +A team's spending cap counts spending on each computer separately. Sharing the +team and its cap does not combine the usage meters. A member continued on the +other computer is metered there, while members still running here count here. +For example, a $5 team cap uses separate local totals on the two computers. +Review spending on each computer when work is split between them. + +## Pairing with replace: what happens to my teams? + +With team sharing on, pairing with `--replace` sets this computer's team file +aside and clears its team-sharing ledgers. Teams from the newly joined computers +then supply the active list. Local chats and Traffic files stay here. The receipt +names the preserved file: `This computer's teams were set aside to .` +Its name is `teams.json.unreadable-` beside the original (or the legacy +file's corresponding name). Keep it if you need to recover the original teams. + +The chat and terminal ask before replacement even when this computer has teams +and no chats: `Pair with your other devices? This computer's existing chats stay +here but stop syncing; shared memories and settings are cleared; with share teams on, teams are set +aside while the other devices supply theirs. Provider keys saved on this computer +stay here.` Only an explicit yes proceeds. With share teams off, replacement +preserves this computer's team file and team-sharing ledgers. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index e41685ebb2..d9997d2363 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -448,7 +448,7 @@ And a relay that is turning connections away: refuses the upgrade, it names the full address it tried: `the sync service at https://service.example.com/wrong-prefix refused this connection (404) — check the address it was given`. -## The pairing code was right but approval failed — writing access on the serving computer +## The pairing code was right but approval failed — paired then device stopped, writing access on the serving computer **A pairing that says the code was wrong when you are sure it was right** has one other cause, and it is the machine's own disk. The machine writes a device into its list @@ -464,6 +464,10 @@ could not write down that pairing: ``` So if a code you read carefully keeps being refused, go and look at that machine. +A current serving computer saves the pairing before confirming it. If it said +paired and later says `this device has been stopped on that machine — pair it +again from there`, that machine no longer recognizes this device's permission. +Pair again from the serving computer. A name of the wrong shape is caught before anything is dialled: diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 265b534323..d51b358bd8 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -82,10 +82,14 @@ positive: on shows contextual tips, off hides them. Keyboard instructions and setting explanations stay visible either way. **Completed tool details** offers **collapsed** or **expanded** without changing the meaning of an existing profile. -**Settings → General → share teams** is off until turned on. Both paired computers -must turn it on to share teams, their members and settings, and recent Traffic as -read-only history. The choice stays on each computer. Turning it off stops sharing -and keeps teams already received. +**Settings → General → share teams** is on by default for paired computers. It +shares teams, their members and settings, and recent Traffic as read-only history. +The choice stays on each computer. Turning it off on either computer stops sharing +between them: that computer stops sending and receiving teams and Traffic. What +was already exchanged stays, including encrypted copies in the vault. Received +history is hidden while sharing is off; its file is kept for turning sharing back +on. Unpaired computers are unaffected. A missing or empty choice reads on; a +damaged configuration or invalid value reads off, as shown in Settings. ## AI teams settings versus an individual AI team diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 9ebeb6fca4..c5b7b99a63 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -566,10 +566,11 @@ while it was closed. The traffic itself is kept in the profile of the machine the conversations run on, in `teams//traffic.jsonl`, one line per message, only ever added to. It holds only -messages written on this computer. Team sharing is off by default: on each paired -computer, set **share teams** to **on** in **Settings → General**. An unpaired -computer or one with sharing off keeps teams and Traffic to itself. Turning it -off keeps teams already received. +messages written on this computer. Team sharing is on by default for paired +computers through **Settings → General → share teams**. Turning it off on either +computer stops teams moving between them: that computer stops sending and receiving. +What was already exchanged stays, including encrypted copies in the vault. History +from the other computer is hidden while sharing is off. Unpaired computers are unaffected. Once both computers share teams, Traffic also shows recent history from the other computer, kept beside it in `teams//elsewhere.jsonl`. That history @@ -614,5 +615,30 @@ in the current team; ambiguous or missing history is not guessed. One conversation may manage a team and descendants of that team, while remaining an ordinary member of other teams. It cannot manage unrelated teams. The global manager is separate from every ordinary team's manager. Choose AI manager or a team move refuses a -change that introduces conflicting responsibilities, naming the teams. Existing conflicts -are retained until the person explicitly chooses replacements in Teams. +change that introduces conflicting responsibilities, naming the teams. A sharing merge +clears conflicting appointments automatically. A cleared appointment goes into +former-manager history only when that slot is empty or already holds it. An existing +former appointment stays; the first-sharing backup preserves the original file. +Review manager choices in Teams. + +## Shared team spending caps and continued members + +Teams and their settings share automatically between paired computers by default; +an unpaired computer is unaffected. A shared team's spending cap counts spending +on each computer separately. A member continued on the other computer is metered +there. The cap does not combine ongoing spending across computers, so inspect +each computer's spending when work is split between them. + +## First sharing merge and two global managers + +Before the first team-sharing round, this computer copies its original team file +to `teams.before-sharing.json` beside it, once and without overwriting a copy. +If both computers had a team with the same name, both appear unchanged. Rename +one in its Settings to tell them apart. + +If both computers had a global manager, the older global team stays global; the +other becomes an ordinary team under it and keeps its active manager. Demotion +does not add that active manager to former-manager history. Cleared conflicting +appointments enter that history only if the slot is empty or already holds the +same appointment. An existing former appointment stays; the first-sharing backup +is the record of the original file. Review manager choices in Teams. diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 239cdffe7f..1c0ec7ae54 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -191,7 +191,9 @@ A computer whose conversations have no submitted turns can pair directly. If it 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: `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. +## Pairing with --replace: existing chats, teams and recovery + +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; with share teams on, teams are set aside while the other devices supply theirs. Provider keys saved on this computer stay here.` The chat and an interactive terminal ask, including when this computer has teams but no chats. Without a terminal, `--replace` proceeds without this question. With share teams on, replacement sets the team file aside and clears its sharing ledgers; the receipt names the preserved file: `This computer's teams were set aside to .` Local Traffic stays here. With share teams off, its team file and sharing ledgers are preserved. At a terminal, `y` or `yes` proceeds. In the chat, choose yes. A no, or closing the panel with esc, leaves everything as it was. If the other computer already has the same chats, it says: diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c23d1a3973..cd8f107d83 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4445,6 +4445,7 @@ func TestTheSupersededChatQuestionsReachTheAnswer(t *testing.T) { } } +// F8 keeps the shipped sharing explanation searchable and accurate. func TestTheDeviceQuestionsReachTheirPages(t *testing.T) { const add, cont, naming = "add-or-remove-a-computer", "continuing-a-chat-on-another-computer", "naming-a-device" for _, probe := range []struct{ asked, page, says string }{ @@ -4486,15 +4487,17 @@ func TestTheDeviceQuestionsReachTheirPages(t *testing.T) { } } -func TestTheTeamSharingQuestionExplainsTheLocalOptIn(t *testing.T) { +// C6 and C9 keep the sharing answer consistent with the default and local off switch. +// F8 keeps the shipped sharing explanation searchable and accurate. +func TestTheTeamSharingQuestionExplainsTheLocalDefaultOn(t *testing.T) { for _, section := range Chat().Search("do my teams show on my other computer?", DefaultResults) { if section.Page != "conversations-and-teams" { continue } complete := true for _, fact := range []string{ - "Settings → General → share teams", "off by default", - "Both paired computers must turn it on", "teams, members", + "Settings → General → share teams", "on by default", + "Turning it off on either computer stops sharing", "teams, members", "team settings", "recent Traffic as read-only history", } { complete = complete && strings.Contains(section.Body, fact) @@ -4503,15 +4506,18 @@ func TestTheTeamSharingQuestionExplainsTheLocalOptIn(t *testing.T) { return } } - t.Fatal("the team sharing question must reach one section explaining the local opt-in and what it shares") + t.Fatal("the team sharing question must reach one section explaining the local default-on choice and what it shares") } // C30.5 replaces the old chat-only memory promise with independent encrypted sync. +// C6 and C9 keep cross-computer answers consistent with default-on team sharing. +// F8 keeps the shipped sharing explanation searchable and accurate. func TestTheCrossComputerQuestionsReachTheFactThatAnswersThem(t *testing.T) { for _, probe := range []struct{ asked, page, says string }{ {"do my teams show on my other computer?", "conversations-and-teams", "within a few seconds"}, - {"how do I turn on team sharing", "conversations-and-teams", "off by default"}, + {"how do I turn on team sharing", "conversations-and-teams", "on by default"}, {"how do I turn on team sharing", "conversations-and-teams", "Settings → General → share teams"}, + {"stop sharing teams with my other computer", "conversations-and-teams", "Turning it off on either computer stops sharing"}, {"will deleting a team on my laptop delete it on my desktop?", "conversations-and-teams", "a deleted team stays deleted"}, {"does Traffic sync between computers?", "conversations-and-teams", "32 lines per team"}, {"do my crew settings follow me to my other computer?", "models-and-cost", "provider route and providers you turned off stay on each computer"}, @@ -4630,3 +4636,30 @@ func TestSyncOffManualQuotesExactSentences(t *testing.T) { }) } } + +// R1/R9 and F8 keep first-merge safety, local cap scope and replacement recovery reachable by ordinary questions. +func TestChatTeamSharingSafetyAnswers(t *testing.T) { + for _, tc := range []struct { + query string + facts []string + }{ + {"first team sharing backup duplicate names global managers", []string{"teams.before-sharing.json", "both appear", "Rename one in its Settings", "older global team stays global", "former-manager history", "Teams"}}, + {"shared team spending cap both computers", []string{"spending on each computer separately", "other computer is metered there"}}, + {"pairing with replace what happens to teams", []string{"teams are set aside", "Provider keys saved on this computer stay here", "This computer's teams were set aside to ."}}, + {"stop sharing teams with my other computer", []string{"stops sending and receiving", "encrypted copies in the vault", "history from the other computer is hidden"}}, + } { + t.Run(tc.query, func(t *testing.T) { + for _, section := range Chat().Search(tc.query, DefaultResults) { + text := strings.ToLower(strings.Join(strings.Fields(section.Body), " ")) + complete := true + for _, fact := range tc.facts { + complete = complete && strings.Contains(text, strings.ToLower(fact)) + } + if complete { + return + } + } + t.Fatalf("no self-contained answer to %q includes %v", tc.query, tc.facts) + }) + } +} From 61f6737cc056f5876d09ed3c2d5b0bcdd95a5268 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 11:34:51 -0400 Subject: [PATCH 5/6] changes: name the entry after #2095 Co-Authored-By: Claude Opus 5.5 --- ...s-share-on-by-default.md => 2095-teams-share-on-by-default.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{0000-teams-share-on-by-default.md => 2095-teams-share-on-by-default.md} (100%) diff --git a/docs/changes/unreleased/0000-teams-share-on-by-default.md b/docs/changes/unreleased/2095-teams-share-on-by-default.md similarity index 100% rename from docs/changes/unreleased/0000-teams-share-on-by-default.md rename to docs/changes/unreleased/2095-teams-share-on-by-default.md From 9acd06f036a76f98e6edeb0e48b3e399be0f04ff Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 31 Oct 2026 12:03:57 -0400 Subject: [PATCH 6/6] manual, settings: keep file and pairing questions on their own pages The new team-sharing sections said 'file' and 'share' often enough to pull 'how do I share a file with it' away from attaching files, and the hint's 'paired computers' pulled a Settings search for 'pair' onto General. Reword both without changing any fact, and give the change entry its PR number. Co-Authored-By: Claude Opus 5.5 --- .../2095-teams-share-on-by-default.md | 2 +- internal/config/settings.go | 2 +- .../manual/chat/conversations-and-teams.md | 33 ++++++++++--------- internal/manual/chat/settings.md | 2 +- internal/manual/chat/team-manager.md | 10 +++--- internal/tui3/teamsrelay_test.go | 2 +- 6 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/changes/unreleased/2095-teams-share-on-by-default.md b/docs/changes/unreleased/2095-teams-share-on-by-default.md index c2766a2728..aed6f3047a 100644 --- a/docs/changes/unreleased/2095-teams-share-on-by-default.md +++ b/docs/changes/unreleased/2095-teams-share-on-by-default.md @@ -1,7 +1,7 @@ --- kind: changed title: team sharing on by default for paired computers -pr: 0000 +pr: 2095 surface: [chat] invalidates: - >- diff --git a/internal/config/settings.go b/internal/config/settings.go index 055ab470ae..3294ab287e 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2291,7 +2291,7 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyTeamsShare, Category: CategoryInterface, Kind: SettingChoice, Label: "share teams", Choices: teamsShareChoices, - Hint: "Share teams, their members and settings, and recent Traffic as read-only history with your paired computers. On by default; turning it off on either computer stops sharing.", + Hint: "Share teams, their members and settings, and recent Traffic as read-only history with your other computers (pairing required). On by default; turning it off on either computer stops sharing.", read: func() string { if TeamsShareAt(dir) { return "on" diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index b1900f6e52..71779e8f45 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -547,12 +547,12 @@ arrive within a few seconds; recent Traffic can take about 2 minutes. ## First team sharing: backup, duplicate names and global managers -Teams share automatically between paired computers while **Settings → General → +Paired computers exchange teams automatically while **Settings → General → share teams** is on, its default. An unpaired computer is unaffected. Before the -first sharing round, this computer copies its original team file to -`teams.before-sharing.json` beside it, once, without replacing an existing copy. -If that copy cannot be saved, incoming team changes wait and a sync notice says -sharing will retry. +first exchange, this computer keeps a copy of its teams as +`teams.before-sharing.json` beside the original, once, never replacing an earlier +copy. If that copy cannot be saved, incoming team changes wait and a sync notice +says it will try again. If both computers had a team with the same name, both appear with their names unchanged. Rename one in its Settings to tell them apart. @@ -562,8 +562,8 @@ other becomes an ordinary team under it and keeps its active manager. That manager is not added to former-manager history just because the team changed. When a conflicting appointment must be cleared, it is recorded in former-manager history only if that slot is empty or already holds the same appointment. An -existing former appointment stays; the first-sharing backup preserves the -original file. Review manager choices in Teams. +existing former appointment stays; the copy made before the first exchange keeps +the original teams. Review manager choices in Teams. ## Does a shared team's spending cap cover both computers? @@ -575,16 +575,17 @@ Review spending on each computer when work is split between them. ## Pairing with replace: what happens to my teams? -With team sharing on, pairing with `--replace` sets this computer's team file -aside and clears its team-sharing ledgers. Teams from the newly joined computers -then supply the active list. Local chats and Traffic files stay here. The receipt -names the preserved file: `This computer's teams were set aside to .` -Its name is `teams.json.unreadable-` beside the original (or the legacy -file's corresponding name). Keep it if you need to recover the original teams. +With share teams on, pairing with `--replace` puts this computer's teams aside, +and the newly joined computers supply the team list. Local chats and Traffic stay +here. The receipt says where the teams went: +`This computer's teams were set aside to .` +That copy is named `teams.json.unreadable-` beside the original (or the +older layout's equivalent name). Keep it to recover the original teams. -The chat and terminal ask before replacement even when this computer has teams +The chat and a terminal ask before replacement, even when this computer has teams and no chats: `Pair with your other devices? This computer's existing chats stay here but stop syncing; shared memories and settings are cleared; with share teams on, teams are set aside while the other devices supply theirs. Provider keys saved on this computer -stay here.` Only an explicit yes proceeds. With share teams off, replacement -preserves this computer's team file and team-sharing ledgers. +stay here.` Only an explicit yes proceeds (`y` or `yes` in a terminal). Without a +terminal, `--replace` goes ahead without asking and prints the receipt. With share +teams off, replacement leaves this computer's teams as they are. diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index d51b358bd8..48aeb73136 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -87,7 +87,7 @@ shares teams, their members and settings, and recent Traffic as read-only histor The choice stays on each computer. Turning it off on either computer stops sharing between them: that computer stops sending and receiving teams and Traffic. What was already exchanged stays, including encrypted copies in the vault. Received -history is hidden while sharing is off; its file is kept for turning sharing back +history is hidden while sharing is off and is kept for when it is turned back on. Unpaired computers are unaffected. A missing or empty choice reads on; a damaged configuration or invalid value reads off, as shown in Settings. diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index c5b7b99a63..a1c59bef8e 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -618,7 +618,7 @@ separate from every ordinary team's manager. Choose AI manager or a team move re change that introduces conflicting responsibilities, naming the teams. A sharing merge clears conflicting appointments automatically. A cleared appointment goes into former-manager history only when that slot is empty or already holds it. An existing -former appointment stays; the first-sharing backup preserves the original file. +former appointment stays; the copy made before the first exchange keeps the original teams. Review manager choices in Teams. ## Shared team spending caps and continued members @@ -631,8 +631,8 @@ each computer's spending when work is split between them. ## First sharing merge and two global managers -Before the first team-sharing round, this computer copies its original team file -to `teams.before-sharing.json` beside it, once and without overwriting a copy. +Before the first exchange, this computer keeps a copy of its teams as +`teams.before-sharing.json` beside the original, once, never replacing an earlier copy. If both computers had a team with the same name, both appear unchanged. Rename one in its Settings to tell them apart. @@ -640,5 +640,5 @@ If both computers had a global manager, the older global team stays global; the other becomes an ordinary team under it and keeps its active manager. Demotion does not add that active manager to former-manager history. Cleared conflicting appointments enter that history only if the slot is empty or already holds the -same appointment. An existing former appointment stays; the first-sharing backup -is the record of the original file. Review manager choices in Teams. +same appointment. An existing former appointment stays; the copy made before the +first exchange keeps the original teams. Review manager choices in Teams. diff --git a/internal/tui3/teamsrelay_test.go b/internal/tui3/teamsrelay_test.go index af90925010..4fa2408174 100644 --- a/internal/tui3/teamsrelay_test.go +++ b/internal/tui3/teamsrelay_test.go @@ -467,7 +467,7 @@ func TestSettingsGeneralHasLocalTeamSharingChoiceAndSearch(t *testing.T) { if item.row.Value() != "on" { t.Fatal("sharing must start on", item.row.Value()) } - for _, want := range []string{"teams", "members and settings", "recent Traffic as read-only history", "paired computers", "On by default", "turning it off on either computer stops sharing"} { + for _, want := range []string{"teams", "members and settings", "recent Traffic as read-only history", "pairing required", "On by default", "turning it off on either computer stops sharing"} { if !strings.Contains(item.meta.about, want) { t.Fatal("sharing explanation omitted", want, item.meta.about) }