diff --git a/.github/workflows/release-downloads.yml b/.github/workflows/release-downloads.yml deleted file mode 100644 index 39e40c1101..0000000000 --- a/.github/workflows/release-downloads.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Release downloads - -# Reports release download counts to PostHog once a day. The counts come from -# public GitHub release data — the API publishes a cumulative download_count -# per asset — so this needs no code in the binary. One event per binary asset -# (codeaf--[.exe]); sidecars like checksums.txt are not reported. -on: - schedule: - # 06:00 UTC every day. - - cron: '0 6 * * *' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: release-downloads - cancel-in-progress: false - -jobs: - report: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Report release download counts - env: - GH_TOKEN: ${{ github.token }} - GITHUB_REPOSITORY: ${{ github.repository }} - CODEAF_POSTHOG_PROJECT_KEY: ${{ secrets.CODEAF_POSTHOG_PROJECT_KEY }} - # Repository variable; unset falls back to https://us.i.posthog.com. - POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} - run: | - python3 scripts/release_downloads.py diff --git a/PERF.md b/PERF.md index 1167c8f5af..d1b771b408 100644 --- a/PERF.md +++ b/PERF.md @@ -193,8 +193,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 only when **share teams** is on in Settings → General. +Sharing is on by 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 @@ -202,7 +202,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; @@ -212,8 +212,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/cmd/codeaf/app_devices_e14_test.go b/cmd/codeaf/app_devices_e14_test.go index 09e92ff60e..6fda417311 100644 --- a/cmd/codeaf/app_devices_e14_test.go +++ b/cmd/codeaf/app_devices_e14_test.go @@ -114,7 +114,7 @@ func e14PairedEnginesRetry(t *testing.T, restart bool) { t.Fatalf("A1: %d successful PUT attempts, want one per computer", puts.Load()) } renamed := machineCall[appwire.DeviceRenameResult](t, wa, "devices.rename", appwire.DeviceRenameParams{Name: "studio"}) - if renamed.Line != "This computer is now called studio." || puts.Load() != 3 { + if renamed.Line != "This computer is now called studio on all your devices." || puts.Load() != 3 { t.Fatalf("A1/A3: rename was not published immediately: %+v, PUTs=%d", renamed, puts.Load()) } appDeviceEvent(t, rb, func(v appwire.Devices) bool { return len(v.Devices) == 2 && v.Devices[1].Name == "studio" }) @@ -124,7 +124,7 @@ func e14PairedEnginesRetry(t *testing.T, restart bool) { } allow.Store(false) pending := machineCall[appwire.DeviceRenameResult](t, wa, "devices.rename", appwire.DeviceRenameParams{Name: "office"}) - if pending.Line != "This computer is now called office. Your other computers will see it once this computer is back online." { + if pending.Line != "This computer is now called office. Your other devices will show it after this one next connects." { t.Fatalf("A3: pending rename receipt: %q", pending.Line) } allow.Store(true) diff --git a/cmd/codeaf/app_pair_merge_test.go b/cmd/codeaf/app_pair_merge_test.go index 5e35bd4a88..24eceac737 100644 --- a/cmd/codeaf/app_pair_merge_test.go +++ b/cmd/codeaf/app_pair_merge_test.go @@ -71,7 +71,10 @@ func TestM1M2M5M9M10AppPairRealHomes(t *testing.T) { return c, []byte(history) } chatA, wantA := seed(dirA, "A chat") - chatB, wantB := seed(dirB, "B chat") + // A startup-only joining home is legal; its own chat is created after pairing. + if _, err := identity.EnsureSolo(dirB); err != nil { + t.Fatal(err) + } _, recA, wa := appDeviceBackend(t, dirA) oldB, recB, wb := appDeviceBackend(t, dirB) defer assertAppPairCopyM9(t, recA) @@ -93,6 +96,7 @@ func TestM1M2M5M9M10AppPairRealHomes(t *testing.T) { if err := oldB.Close(); err != nil { t.Fatal(err) } + chatB, wantB := seed(dirB, "B chat") down.Store(true) _, restarted, nextWire := appDeviceBackend(t, dirB) wb = nextWire diff --git a/cmd/codeaf/app_relay_test.go b/cmd/codeaf/app_relay_test.go index b7f86ce0c5..9436633946 100644 --- a/cmd/codeaf/app_relay_test.go +++ b/cmd/codeaf/app_relay_test.go @@ -267,7 +267,7 @@ func TestM1M4M8M9M10AppPair(t *testing.T) { func assertAppPairCopyM9(t *testing.T, rec *appRecorder) { t.Helper() - allowed := regexp.MustCompile(`^(Pairing shares your chats with another computer\.|On your other computer, choose Pair a computer, then Enter a code from the other computer, and type [0-9-]+ \(dashes optional\)\. This code lasts 10 minutes\.|Waiting for approval on your other computer\. It should show: .+\.|Paired with .+\. Your chats can continue on both computers\.|Paired\. Your chats are kept\. Both computers' chats will appear as sharing finishes\.|Cancelled\. Nothing was paired\.|Pair with .+\?|Pair only if your other computer shows the same (three words|check number): .+\.|Pair|Don't pair|Open this pairing link on a computer you already use\.|Compare the check number on both computers: .+\.|Waiting for approval\. This link lasts [0-9]+ minutes\.)$`) + allowed := regexp.MustCompile(`^(Paired with your other device\. Run codeaf to open your chats\.|Pairing shares your chats with another computer\.|On your other computer, choose Pair a computer, then Enter a code from the other computer, and type [0-9-]+ \(dashes optional\)\. This code lasts 10 minutes\.|Waiting for approval on your other computer\. It should show: .+\.|Paired with .+\. Your chats can continue on both computers\.|Paired\. Your chats are kept\. Both computers' chats will appear as sharing finishes\.|Cancelled\. Nothing was paired\.|Pair with .+\?|Pair only if your other computer shows the same (three words|check number): .+\.|Pair|Don't pair|Open this pairing link on a computer you already use\.|Compare the check number on both computers: .+\.|Waiting for approval\. This link lasts [0-9]+ minutes\.)$`) rec.mu.Lock() defer rec.mu.Unlock() for i, name := range rec.events { @@ -295,7 +295,7 @@ func assertAppPairCopyM9(t *testing.T, rec *appRecorder) { // The three check words are drawn at random from a word list that holds // "ledger" and "cello", so only the copy around them is held to this list. - copyOnly := strings.ToLower(strings.ReplaceAll(line, p.Words, "")) + copyOnly := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(line, "Paired with your other device. Run codeaf to open your chats.", ""), p.Words, "")) for _, banned := range []string{"codeaf pair", "--replace", "device", "identity", "cell", "lease", "fence", "ledger", "vault"} { if strings.Contains(copyOnly, banned) { t.Errorf("L3 terminal copy: %q", line) diff --git a/cmd/codeaf/app_take_recovery_test.go b/cmd/codeaf/app_take_recovery_test.go index 39a832c499..0c8b5ea98d 100644 --- a/cmd/codeaf/app_take_recovery_test.go +++ b/cmd/codeaf/app_take_recovery_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "net/http" "os" "path/filepath" @@ -14,6 +15,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cell/celltest" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/session" @@ -21,7 +23,7 @@ import ( ) // S5 uses L4's post-acquisition permission failure with a real open source -// conversation, then submits through the source app's ordinary send door. +// conversation. Dev requires fresh consent before that source may send again. func TestAppTakeInstallFailureS5(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("L4 injection needs ordinary file permissions") @@ -120,15 +122,35 @@ func TestAppTakeInstallFailureS5(t *testing.T) { t.Fatalf("S5 failed taker row: %+v", rows) } t.Setenv(home.EnvVar, dirA) - if err = drive.started().CheckLease(t.Context()); err != nil { - t.Fatal(err) - } var view appwire.Agent - source.locked(func() { view = source.agents[agent.ID].view }) + appSyncWait(t, "released take is available without restoring work", func() bool { + if err := drive.started().CheckLease(t.Context()); err != nil { + t.Fatal(err) + } + source.locked(func() { view = source.agents[agent.ID].view }) + return view.Location != nil && view.Location.Line == chatlist.Available + }) if view.Location != nil && strings.Contains(view.Location.Line, "continued") { t.Fatalf("S5 released chat claims a continuation: %+v", view.Location) } - turn, err := source.Send(agent.ID, "continue after the failed move", "s5-recover") + // A failed destination install releases the hold, not consent for this stale window. + if _, err := source.Send(agent.ID, "without fresh consent", "s5-refused"); err == nil { + t.Fatal("S5 stale source send was admitted without a new hold") + } else { + var failure *appwire.Error + if !errors.As(err, &failure) || failure.Code != appwire.CodeConflict { + t.Fatalf("S5 stale source refusal: %v", err) + } + } + current, err := source.ListChats() + if err != nil || len(current.Chats) != 1 || !current.Chats[0].CanTake { + t.Fatalf("S5 source continuation: %+v, %v", current, err) + } + resumed, err := source.TakeAgent(appwire.TakeParams{Cell: c.ID, Seen: current.Chats[0].Seen}) + if err != nil { + t.Fatalf("S5 explicit source continuation: %v", err) + } + turn, err := source.Send(resumed.ID, "continue after the failed move", "s5-recover") if err != nil { t.Fatalf("S5 source send: %v", err) } diff --git a/cmd/codeaf/apperrand.go b/cmd/codeaf/apperrand.go index 9ca57aadd4..948380142c 100644 --- a/cmd/codeaf/apperrand.go +++ b/cmd/codeaf/apperrand.go @@ -50,6 +50,11 @@ func (b *appBackend) Errand(ctx context.Context, id string, ask session.ErrandAs return session.ErrandAnswer{}, err } } + // Factory page calls and the brief writer borrow the agent for one errand. + // Ordinary calls on that same agent retain their refusal policy. + if session.IsFactoryAIPurpose(ask.Purpose) { + ask.DropRefusedEffort = true + } answer, err := door.RunErrand(ctx, ask) if err == nil || errors.Is(err, session.ErrErrandAsk) || errors.Is(err, session.ErrErrandRoute) { return answer, err diff --git a/cmd/codeaf/appfactory_memory_spend_test.go b/cmd/codeaf/appfactory_memory_spend_test.go index 962d828d50..dbb50d26a6 100644 --- a/cmd/codeaf/appfactory_memory_spend_test.go +++ b/cmd/codeaf/appfactory_memory_spend_test.go @@ -483,11 +483,14 @@ func TestMemorySpendAndLifecycle(t *testing.T) { failUpdates.Store(true) defer failUpdates.Store(false) before := calls.Load() - ctx, cancel := context.WithTimeout(context.Background(), 6500*time.Millisecond) + // Three-attempt rule: an unchanged failed input makes exactly three attempts, + // ordinaryBuildAttemptLimit in internal/knowledge/build.go. Failures wait 5, 10 + // and 20 seconds, so a fourth attempt would come at about 35 seconds. + ctx, cancel := context.WithTimeout(context.Background(), 38*time.Second) defer cancel() _ = v.Run(ctx, history, func() string { return "fixture" }) - if got := calls.Load() - before; got != 1 { - t.Errorf("unchanged failed input spent %d model calls, want 1", got) + if got := calls.Load() - before; got != 3 { + t.Errorf("unchanged failed input spent %d model calls, want exactly 3 and no further retries", got) } }) t.Run("unchanged refinement", func(t *testing.T) { diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 02c8da688b..c66b94b9bf 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1028,6 +1028,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, @@ -2006,7 +2007,7 @@ func v3Policy(workspace, profileDir string, yolo bool) (*approval.Policy, error) // ordinary launch — so the exceptions, the shell rules and the floor cannot // differ between them. func v3PolicyMode(workspace, profileDir, mode string) (*approval.Policy, error) { - rows, err := v3ApprovalRowsAt(workspace, profileDir, mode) + rows, err := v3ApprovalRowsAt(workspace, profileDir, mode, false) if err != nil { return nil, err } @@ -2018,15 +2019,32 @@ func v3PolicyMode(workspace, profileDir, mode string) (*approval.Policy, error) // configuration reader's file freshness rules. type v3ApprovalRows struct { mode, tools, bash string + profileReadError string } -func v3ApprovalRowsAt(workspace, profileDir, mode string) (v3ApprovalRows, error) { +func v3ApprovalRowsAt(workspace, profileDir, mode string, headless bool) (rows v3ApprovalRows, err error) { project, err := config.LoadProjectConfig(workspace) if err != nil { return v3ApprovalRows{}, err } + // Every profile read participates, including a read that fails after an + // earlier row succeeded. Returning an ordinary error would retain an old, + // potentially permissive live policy instead of asking the person. + defer func() { + var pathErr *os.PathError + if errors.As(err, &pathErr) && pathErr.Path == config.BudgetConfigPath(profileDir) { + rows, err = v3ApprovalRows{profileReadError: err.Error()}, nil + } + }() + if _, err := config.ReadProfileApprovalConfig(profileDir); err != nil { + return v3ApprovalRows{}, err + } if mode == "" { - mode, err = project.ResolveString(profileDir, config.KeyToolApprovalMode) + if headless { + mode, err = config.HeadlessToolApprovalModeAt(workspace, profileDir) + } else { + mode, err = project.ResolveString(profileDir, config.KeyToolApprovalMode) + } if err != nil { return v3ApprovalRows{}, err } @@ -2043,6 +2061,9 @@ func v3ApprovalRowsAt(workspace, profileDir, mode string) (v3ApprovalRows, error } func (rows v3ApprovalRows) build() (*approval.Policy, error) { + if rows.profileReadError != "" { + return &approval.Policy{ProfileReadError: rows.profileReadError}, nil + } raw := map[string]any{"default": rows.mode} // The repository's exceptions REPLACE the person's, whole (internal/config // states the merge law): a rule set assembled from two files is a rule set diff --git a/cmd/codeaf/chatv3_approval.go b/cmd/codeaf/chatv3_approval.go index a7756d29f3..7a30f87cba 100644 --- a/cmd/codeaf/chatv3_approval.go +++ b/cmd/codeaf/chatv3_approval.go @@ -80,17 +80,14 @@ func (g *v3ApprovalGate) Build(posture string) (*approval.Policy, bool, error) { case session.PostureDeny: mode, guardian = string(approval.ActionDeny), false } - if mode == "" && g.headless { - var err error - mode, err = config.HeadlessToolApprovalModeAt(g.workspace, g.profileDir) - if err != nil { - return nil, false, err - } - } - rows, err := v3ApprovalRowsAt(g.workspace, g.profileDir, mode) + rows, err := v3ApprovalRowsAt(g.workspace, g.profileDir, mode, g.headless) if err != nil { return nil, false, err } + if rows.profileReadError != "" { + guardian = false + } + g.mu.Lock() defer g.mu.Unlock() if g.policy != nil && g.rows == rows { diff --git a/cmd/codeaf/chatv3_approval_live_test.go b/cmd/codeaf/chatv3_approval_live_test.go index ac73486010..56cf10dd2e 100644 --- a/cmd/codeaf/chatv3_approval_live_test.go +++ b/cmd/codeaf/chatv3_approval_live_test.go @@ -37,11 +37,23 @@ func TestApprovalGateRejectsUnreadableProfiles(t *testing.T) { if err != nil { t.Fatal(err) } - if policy, _, err := gate.Build(posture); err == nil || policy != nil { - t.Fatalf("%s profile produced policy %+v, error %v; want a failed build", broken, policy, err) + policy, _, err := gate.Build(posture) + if broken == "malformed" { + if err == nil || policy != nil { + t.Fatalf("%s profile produced policy %+v, error %v; want a failed build", broken, policy, err) + } + } else { + if err != nil || policy == nil { + t.Fatalf("unreadable profile must keep interactive startup available: %v", err) + } + for _, tool := range []string{"write", "read", "manual", "use_service", "unknown"} { + if got := policy.Check(tool, nil); got.Action != approval.ActionPrompt { + t.Errorf("%s = %s; want prompt", tool, got.Action) + } + } } if first.Check("write", json.RawMessage(`{"path":"example.go"}`)).Action != approval.ActionDeny { - t.Fatal("failed rebuild changed the previous policy") + t.Fatal("rebuild changed the immutable previous policy") } }) } 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 c7acf07aea..a99b21822b 100644 --- a/cmd/codeaf/chatv3_pair.go +++ b/cmd/codeaf/chatv3_pair.go @@ -11,6 +11,7 @@ package main import ( "context" + "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" @@ -40,8 +41,7 @@ func (chatPairDoor) Offer(ctx context.Context, ui pair.OfferUI) (string, error) return label, explainSyncUnreachable(err, route.URL) } -// Join shares this solo computer's chats with the other computer, keeping -// its local history and saved keys. +// Join pairs this computer; submitted chats require explicit replacement. func (chatPairDoor) Join(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) { route, err := pairMailbox("") if err != nil { @@ -92,3 +92,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 2a536ea20e..2f75abaede 100644 --- a/cmd/codeaf/chatv3_pair_test.go +++ b/cmd/codeaf/chatv3_pair_test.go @@ -4,20 +4,28 @@ package main // behavior as the terminal command. import ( + "bytes" "context" + "errors" + "fmt" + "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/config" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/teams/teamstest" ) -// M8: ordinary refusals never steer a person into the destructive opt-in door. -func TestM8ChatPairRefusalDoesNotAdviseReplace(t *testing.T) { - if strings.Contains(pair.ErrDifferentChats.Error(), "--replace") || !strings.Contains(pair.ErrDifferentChats.Error(), "nothing was changed") { +// A used computer is told how to confirm replacement without changing its chats. +func TestChatPairRefusalExplainsReplacement(t *testing.T) { + if !strings.Contains(pair.ErrDifferentChats.Error(), "--replace") || !strings.Contains(pair.ErrDifferentChats.Error(), "nothing was changed") { t.Fatal(pair.ErrDifferentChats) } } @@ -41,15 +49,8 @@ func TestM1M8ChatPairDoorUsedSoloHome(t *testing.T) { _, err := (chatPairDoor{}).Join(t.Context(), code, joinScreen{newTerminal(strings.NewReader(""), io.Discard)}) done <- err }() - shown.out.waitFor(t, `wants your chats\. Same three words`) - if _, err := io.WriteString(shown.typed, "y\n"); err != nil { - t.Fatal(err) - } - if err := <-done; err != nil { - t.Fatal(err) - } - if err := shown.end(t); err != nil { - t.Fatal(err) + if err := <-done; !errors.Is(err, pair.ErrDifferentChats) { + t.Fatalf("used solo pairing = %v", err) } if got, err := os.ReadFile(chat); err != nil || string(got) != "history kept\n" { t.Fatalf("M1 /pair history: %q, %v", got, err) @@ -58,6 +59,7 @@ func TestM1M8ChatPairDoorUsedSoloHome(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) @@ -65,3 +67,176 @@ 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) + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: rig.homeB}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + 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 := teamstest.JSON(t, rig.homeB) + 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-*[0-9]") + 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()) + } + kept := t.TempDir() + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.Rename(paths[0]+suffix, teams.Path(kept)+suffix); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } + got := teamstest.JSON(t, kept) + if !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/copied_pingpong_test.go b/cmd/codeaf/copied_pingpong_test.go index ff6e5a3048..32ca54152a 100644 --- a/cmd/codeaf/copied_pingpong_test.go +++ b/cmd/codeaf/copied_pingpong_test.go @@ -29,6 +29,7 @@ type copiedPingPong struct { initialRoot string door string replies []string + realBuckets bool } type copiedTakeResult struct { @@ -174,8 +175,11 @@ func (h *copiedPingPong) takeB() (cell.Cell, error) { } return h.initialRoot } - c := h.b.Continuer(h.eb, syncsetup.TakeOptions{DeviceName: "computer B", RootFor: rootFor, OnPhase: h.trace, - BucketFor: func(string) string { return filepath.Join(h.b.Home, "v3", "projects", "copy--project") }}) + bucket := func(string) string { return filepath.Join(h.b.Home, "v3", "projects", "copy--project") } + if h.realBuckets { + bucket = takeCopyBucket + } + c := h.b.Continuer(h.eb, syncsetup.TakeOptions{DeviceName: "computer B", RootFor: rootFor, OnPhase: h.trace, BucketFor: bucket}) door := h.door if h.copyRoot == "" { door = "home" @@ -194,7 +198,11 @@ func (h *copiedPingPong) takeB() (cell.Cell, error) { func (h *copiedPingPong) takeA() cell.Cell { h.t.Helper() h.t.Setenv(home.EnvVar, h.a.Home) - c := h.a.Continuer(h.ea, syncsetup.TakeOptions{DeviceName: "computer A", RootFor: func(string) string { return h.c.Root }, OnPhase: h.trace}) + options := syncsetup.TakeOptions{DeviceName: "computer A", RootFor: func(string) string { return h.c.Root }, OnPhase: h.trace} + if h.realBuckets { + options.BucketFor = takeCopyBucket + } + c := h.a.Continuer(h.ea, options) got, err := h.surfaceTake(h.a, takeover{c: c}, h.c.Root, "home", "computer B") if err != nil { h.t.Fatal(err) @@ -202,6 +210,39 @@ func (h *copiedPingPong) takeA() cell.Cell { return got } +func TestCopiedChatFromDeepWorkspaceMovesAndReturns(t *testing.T) { + rig := newHostedMoveRig(t) + rig.work = filepath.Join(t.TempDir(), strings.Repeat("nested-project/", 24), "worktree") + if err := os.MkdirAll(rig.work, 0700); err != nil { + t.Fatal(err) + } + rig.ea.Workspace = rig.work + rig.ea.DataRoot = filepath.Join(rig.a.Home, "v3", "stores") + rig.eb.DataRoot = filepath.Join(rig.b.Home, "v3", "stores") + if err := session.SaveMeta(rig.c.Root, session.Meta{ID: rig.c.ID, Workspace: rig.work}); err != nil { + t.Fatal(err) + } + machine, err := preflight.OpenMachine(rig.c.Root, rig.work) + if err != nil { + t.Fatal(err) + } + machine.Observer().Refresh() + h := &copiedPingPong{hostedMoveRig: rig, initialRoot: filepath.Join(rig.b.Home, "v3", "projects", "copies", rig.c.ID), realBuckets: true, door: "home"} + for round := 1; round <= 3; round++ { + fromA := fmt.Sprintf("deep workspace A %d", round) + h.publishTurn(h.a, h.ea, h.c, h.work, "computer A", fromA) + b, err := h.takeB() + if err != nil { + t.Fatalf("B take %d: %v", round, err) + } + h.content(b, filepath.Join(b.Root, "work"), fromA) + fromB := fmt.Sprintf("deep workspace B %d", round) + h.publishTurn(h.b, h.eb, b, filepath.Join(b.Root, "work"), "computer B", fromB) + a := h.takeA() + h.content(a, h.work, fromB) + } +} + func (h *copiedPingPong) content(c cell.Cell, work, text string) { h.t.Helper() if body, err := os.ReadFile(filepath.Join(work, "latest.txt")); err != nil || string(body) != text { diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 5e126b436c..911d7dd483 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1243,6 +1243,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string 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/factory_effort_doors_test.go b/cmd/codeaf/factory_effort_doors_test.go new file mode 100644 index 0000000000..8ee86427ad --- /dev/null +++ b/cmd/codeaf/factory_effort_doors_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/session" +) + +// refusingFactoryEffort answers only requests without thinking controls. +func refusingFactoryEffort(t *testing.T) (*httptest.Server, func() []bool) { + t.Helper() + var mu sync.Mutex + var requests []bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + w.WriteHeader(400) + return + } + knobs := body["reasoning"] != nil || body["reasoning_effort"] != nil || body["thinking"] != nil + mu.Lock() + requests = append(requests, knobs) + mu.Unlock() + if knobs { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":{"message":"This model does not support reasoning","code":400}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, `data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n") + })) + t.Cleanup(server.Close) + return server, func() []bool { mu.Lock(); defer mu.Unlock(); return append([]bool(nil), requests...) } +} + +func TestFactoryLiveManagerRecoversEffortOnlyForItsTurn(t *testing.T) { + server, requests := refusingFactoryEffort(t) + agent, err := session.New(session.Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), Model: "fixture/manager", APIKey: "fixture", BaseURL: server.URL, Effort: effort.High, OneModel: true, DeterministicNames: true}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + // This is the live road: the Factory runner borrows an existing agent. + manager := liveManagerTurn{agent: agent, door: runDoor(newRunRig(t).st, t.TempDir(), t.TempDir())} + events, err := manager.SubmitRunnerNote(ctx, "Report done.") + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatalf("live manager refused: %v", event.Err) + } + } + if err := manager.WaitIdle(ctx); err != nil { + t.Fatal(err) + } + events, err = agent.Submit(ctx, "An ordinary question.") + if err != nil { + t.Fatal(err) + } + refused := false + for event := range events { + if event.Kind == session.EventError { + refused = true + } + } + got := requests() + if !refused || len(got) != 3 || !got[0] || got[1] || !got[2] { + t.Fatalf("ordinary refusal=%v requests=%v; want true [true false true]", refused, got) + } +} + +func TestFactoryPageErrandsRecoverEffortOnExistingAgent(t *testing.T) { + for _, path := range []struct{ purpose, role string }{ + {"repo_ask", "insight_ask"}, {"repo_notes", "insight_notes"}, + {"repo_themes", "insight_themes"}, {"repo_eval", "insight"}, + {"pipeline_ai", "insight_pipeline"}, {"view_topic", "insight_views"}, + {"step_ai", "insight_pipeline"}, {"my_pipeline_ai", "insight_pipeline"}, + {"factory_brief", "brief"}, + } { + t.Run(path.purpose, func(t *testing.T) { + s := newLocalSetup(t) + server, requests := refusingFactoryEffort(t) + t.Setenv("OPENROUTER_API_KEY", "fixture") + t.Setenv("CODEAF_BASE_URL", server.URL) + s.start() + s.backend.open = func(ask appOpenAsk) (*appConn, error) { + agent, err := session.New(session.Config{Workspace: ask.Workspace, SessionFile: ask.Transcript, ProfileDir: t.TempDir(), Model: "fixture/page", APIKey: "fixture", BaseURL: server.URL, OneModel: true, DeterministicNames: true, SupportsParameter: func(string, string) (bool, bool) { return true, true }}) + if err != nil { + return nil, err + } + return &appConn{Conv: agent, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() { _ = agent.Close() }}, nil + } + s.backend.mint = appMint + var created struct{ Agent appwire.Agent } + s.must("agents.create", map[string]any{"workspace": t.TempDir()}, &created) + if _, err := s.backend.OpenAgent(created.Agent.ID); err != nil { + t.Fatalf("open page agent: %v", err) + } + params := map[string]any{"agent": created.Agent.ID, "purpose": path.purpose, "role": path.role, "effort": "high", "messages": []appwire.ErrandMessage{{Role: "user", Text: "Answer done."}}, "max_tokens": 100} + var answer appwire.Errand + deadline := time.Now().Add(5 * time.Second) + for { + answer = appwire.Errand{} + s.must("agents.errand", params, &answer) + if answer.Reason != appwire.ErrandNotOpen { + break + } + if time.Now().After(deadline) { + t.Fatal("agent did not open") + } + if _, err := s.backend.OpenAgent(created.Agent.ID); err != nil { + t.Fatalf("open page agent: %v", err) + } + time.Sleep(10 * time.Millisecond) + } + if answer.Reason != "" || answer.Text != "done" { + t.Fatalf("Factory errand: %+v", answer) + } + // The same model, role and agent still refuse outside Factory work. + params["purpose"] = "ordinary" + s.must("agents.errand", params, &answer) + got := requests() + if answer.Reason != session.ErrandModelFailed || len(got) != 3 || !got[0] || got[1] || !got[2] { + t.Fatalf("ordinary reason=%s requests=%v; want model_failed [true false true]", answer.Reason, got) + } + }) + } +} diff --git a/cmd/codeaf/factory_personal_effort_test.go b/cmd/codeaf/factory_personal_effort_test.go new file mode 100644 index 0000000000..367e0ae7f2 --- /dev/null +++ b/cmd/codeaf/factory_personal_effort_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestPersonalFactoryManagerRecoversEffort(t *testing.T) { + for _, door := range []string{"factory.job_send", "manager agents.send", "completed stage", "previous stage round", "ordinary"} { + t.Run(door, func(t *testing.T) { + s := newLocalSetup(t) + st := s.start() + floor := s.backend.local.host.floor + server, requests := refusingFactoryEffort(t) + var opened *session.Agent + s.backend.open = func(ask appOpenAsk) (*appConn, error) { + a, err := session.New(session.Config{Workspace: ask.Workspace, SessionFile: ask.Transcript, ProfileDir: t.TempDir(), Model: "fixture/manager", APIKey: "fixture", BaseURL: server.URL, Effort: effort.High, OneModel: true, DeterministicNames: true, JobChat: jobChatDoorOf(floor), SupportsParameter: func(string, string) (bool, bool) { return true, true }}) + if err != nil { + return nil, err + } + opened = a + return &appConn{Conv: a, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() { _ = a.Close() }}, nil + } + it, err := floor.Create(factory.Item{Repo: "acme/api", Num: 72, Kind: factory.KindIssue, Origin: factory.OriginForge, State: factory.StateNew, Effort: "high"}) + if err != nil { + t.Fatal(err) + } + if door == "factory.job_send" { + var sent appwire.FactoryJobSent + s.must("factory.job_send", map[string]any{"factory": st.Factory, "job": it.ID, "text": "Please check the ledger"}, &sent) + } else { + var id string + switch door { + case "manager agents.send": + makeJobManager(t, s, it.ID) + var chats appwire.FactoryJobChats + s.must("factory.job_chats", map[string]any{"factory": st.Factory, "job": it.ID}, &chats) + id = chats.Agent + if err := s.backend.MoveToClosed([]string{id}); err != nil { + t.Fatal(err) + } + case "completed stage", "previous stage round": + chat, stageID := jobConversation(t) + id = stageID + phase := factory.Phase{Name: "review", Kind: factory.StageChat, State: factory.PhaseDone, Round: 2, Chat: chat} + if door == "previous stage round" { + phase.Chat, _ = jobConversation(t) + phase.Rounds = []factory.RoundLine{{Round: 1, Chat: chat}, {Round: 2, Chat: phase.Chat}} + } + if err := floor.Update(it.ID, func(it *factory.Item) error { + it.State = factory.StateShipped + it.Stream = &factory.Stream{Phases: []factory.Phase{phase}} + return nil + }); err != nil { + t.Fatal(err) + } + case "ordinary": + _, id = jobConversation(t) + } + s.must("agents.send", map[string]any{"id": id, "text": "Please check the ledger"}, nil) + } + deadline := time.Now().Add(5 * time.Second) + for len(requests()) == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if opened == nil { + t.Fatal("conversation not opened") + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if err := opened.WaitIdle(ctx); err != nil { + t.Fatal(err) + } + got := requests() + if door == "ordinary" { + if len(got) != 1 || !got[0] { + t.Fatalf("ordinary request knobs=%v; want [true]", got) + } + return + } + if len(got) != 2 || !got[0] || got[1] { + t.Fatalf("Factory request knobs=%v; want [true false]", got) + } + found := false + for _, entry := range opened.Transcript() { + if entry.Text == "done" { + found = true + } + } + if !found { + t.Fatal("Factory manager did not answer done") + } + }) + } +} diff --git a/cmd/codeaf/factory_scout.go b/cmd/codeaf/factory_scout.go index 991a2a113d..702df5c551 100644 --- a/cmd/codeaf/factory_scout.go +++ b/cmd/codeaf/factory_scout.go @@ -62,7 +62,9 @@ func factoryScoutChat(load func() (config.Config, error)) scout.Chat { if model == "" { return scout.Reply{}, errors.New("no model for the small-work seat") } - client, err := provider.NewClient(settings.ClientConfig(model)) + clientConfig := settings.ClientConfig(model) + clientConfig.DropRefusedEffort = true + client, err := provider.NewClient(clientConfig) if err != nil { return scout.Reply{}, err } diff --git a/cmd/codeaf/factory_shape.go b/cmd/codeaf/factory_shape.go index 352aebcb12..bd498d8a79 100644 --- a/cmd/codeaf/factory_shape.go +++ b/cmd/codeaf/factory_shape.go @@ -290,6 +290,7 @@ func openManagerTurn(parent session.Config, workspace, profileDir string, it fac cfg.AskConsent = false cfg.Factory, cfg.Recipe, cfg.FactoryItem, cfg.Floor, cfg.Stage = nil, nil, nil, nil, nil cfg.FactoryRun = door + cfg.DropRefusedEffort = true if cheap { cfg.Effort = effort.Low } diff --git a/cmd/codeaf/factory_stage_test.go b/cmd/codeaf/factory_stage_test.go index 891f3b19e7..c3ac7065f7 100644 --- a/cmd/codeaf/factory_stage_test.go +++ b/cmd/codeaf/factory_stage_test.go @@ -49,6 +49,10 @@ func newStageModel(t *testing.T) *stageModel { } func newScriptedStageModel(t *testing.T, script ...stageCall) *stageModel { + return newStageModelFixture(t, false, script...) +} + +func newStageModelFixture(t *testing.T, refuseEffort bool, script ...stageCall) *stageModel { t.Helper() m := &stageModel{} m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -57,6 +61,17 @@ func newScriptedStageModel(t *testing.T, script ...stageCall) *stageModel { return } raw, _ := io.ReadAll(r.Body) + var knobs map[string]json.RawMessage + if err := json.Unmarshal(raw, &knobs); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if refuseEffort && (knobs["reasoning"] != nil || knobs["reasoning_effort"] != nil || knobs["thinking"] != nil) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":{"message":"This model does not support reasoning","code":400}}`) + return + } var req struct { recordedRequest Model string `json:"model"` @@ -403,3 +418,62 @@ func TestTheStagePosturePinIsRegisteredAndRead(t *testing.T) { } } } + +func TestFactoryStageRecoversUnsupportedEffort(t *testing.T) { + root := resolvedTempDir(t) + t.Setenv("HOME", filepath.Join(root, "home")) + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + t.Setenv(config.ProfileDirEnv, "") + t.Setenv("OPENROUTER_API_KEY", "test-key") + model := newStageModelFixture(t, true, stageCall{"stage_result", stageModelReport}) + t.Setenv("CODEAF_BASE_URL", model.server.URL) + workspace := filepath.Join(root, "work") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + t.Chdir(workspace) + freshEngineProcess(t) + stageSettle = 20 * time.Millisecond + t.Cleanup(func() { stageSettle = unattendedRunSettleTick }) + + proc, err := openEngineProcess() + if err != nil { + t.Fatal(err) + } + launch, err := openV3Launch(proc, engineLaunchOptions(remote.Hello{Version: remote.Version, Workspace: workspace}, workspace, "")) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(root, "factory")) + if err != nil { + t.Fatal(err) + } + id, err := st.Add(context.Background(), factory.Item{ + Title: "tidy the ledger", Repo: "work", Tier: factory.TierOwner, Model: "openai/gpt-4.1", Effort: "high", + Stages: []factory.Stage{{Name: "review", Kind: factory.StageChat, Ask: "look it over", Until: "done", On: true}}, + }) + if err != nil { + t.Fatal(err) + } + if err := st.Update(id, func(it *factory.Item) error { it.Stream = &factory.Stream{}; return nil }); err != nil { + t.Fatal(err) + } + it, _ := st.Get(id) + + parent := launch.Config + parent.Model = "profile/default" + parent.OneModel = true + parent.SupportsParameter = nil + parent.ReasoningProfile = nil + maker := factoryStageMaker(st, workspace, "", parent) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conv, err := maker.Open(ctx, factoryrun.ConversationSpec{Item: it, Name: "review", Dir: workspace, Model: it.Model, Effort: "high", Brief: "Look it over."}) + if err != nil { + t.Fatal(err) + } + defer conv.Close() + if err := conv.Wait(ctx); err != nil { + t.Fatalf("Factory stage conversation failed on unsupported effort: %v", err) + } +} diff --git a/cmd/codeaf/factory_triage.go b/cmd/codeaf/factory_triage.go index a92208ccc7..bc09cb72ed 100644 --- a/cmd/codeaf/factory_triage.go +++ b/cmd/codeaf/factory_triage.go @@ -155,7 +155,9 @@ func factoryTriageCallNoting(load func() (config.Config, error), noted func(usd if model == "" { return "", errors.New("no model for the small-work seat") } - client, err := provider.NewClient(settings.ClientConfig(model)) + clientConfig := settings.ClientConfig(model) + clientConfig.DropRefusedEffort = true + client, err := provider.NewClient(clientConfig) if err != nil { return "", err } diff --git a/cmd/codeaf/homesharing_test.go b/cmd/codeaf/homesharing_test.go index ddf5765992..8e1dd71b2f 100644 --- a/cmd/codeaf/homesharing_test.go +++ b/cmd/codeaf/homesharing_test.go @@ -290,8 +290,8 @@ func TestTheKeysLineGoesWhenTheKeysAreShared(t *testing.T) { } t.Setenv("CODEAF_HOME", rig.homeB) t.Setenv(syncsetup.URLVar, relay.URL) - keptKey(t, rig.homeB) r1PairHomes(t, rig) + keptKey(t, rig.homeB) refused.Store(r1SavedChat(t, rig.homeB, "a chat the relay refuses").ID) vaultDown.Store(true) b := &appBackend{left: make(chan struct{}), emit: newAppRecorder()} @@ -358,8 +358,8 @@ func TestBetweenChatsTheKeysAndTheCountMoveOn(t *testing.T) { } t.Setenv("CODEAF_HOME", rig.homeB) t.Setenv(syncsetup.URLVar, relay.URL) - keptKey(t, rig.homeB) r1PairHomes(t, rig) + keptKey(t, rig.homeB) id, err := identity.Load(rig.homeB) if err != nil { t.Fatal(err) diff --git a/cmd/codeaf/hosted_takeback_test.go b/cmd/codeaf/hosted_takeback_test.go index 7bb099b31e..d2fafaab8f 100644 --- a/cmd/codeaf/hosted_takeback_test.go +++ b/cmd/codeaf/hosted_takeback_test.go @@ -16,7 +16,6 @@ import ( "os/exec" "path/filepath" "runtime" - "slices" "strings" "sync" "sync/atomic" @@ -343,18 +342,8 @@ func TestHostedTakeBackReachesAcquireWithoutSharedUploadsOrReleaseDelay(t *testi h.wait("hosted take-back arrival", func() bool { return strings.Contains(screen.String(), "Moved from other computer") }) h.mu.Lock() defer h.mu.Unlock() - if len(h.acquires) == 0 { - t.Fatal("take-back made no acquire") - } - // The reopened writer resumes the hold the take acquired (F1, #2009): it - // renews that fence rather than acquiring another. - resumed := slices.IndexFunc(h.resumes, func(at time.Time) bool { return !at.Before(h.acquires[0]) }) - if resumed < 0 { - t.Fatal("the reopened hosted writer never resumed the taken hold") - } - t.Logf("relay resume after first acquire: %s", h.resumes[resumed].Sub(h.acquires[0])) - if gap := h.resumes[resumed].Sub(h.acquires[0]); gap >= 5*time.Second { - t.Errorf("resume gap=%s", gap) + if len(h.acquires) < 2 { + t.Fatalf("take and reopened writer made %d acquires; want both", len(h.acquires)) } t.Logf("relay first acquire after key: %s", h.acquires[0].Sub(started)) if elapsed := h.acquires[0].Sub(started); elapsed > time.Second { diff --git a/cmd/codeaf/pair.go b/cmd/codeaf/pair.go index b883b3521b..ab6d21a545 100644 --- a/cmd/codeaf/pair.go +++ b/cmd/codeaf/pair.go @@ -252,6 +252,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 @@ -265,6 +268,9 @@ func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) e } joined, err := pair.Join(ctx, route, joining, typed, joinScreen{d.term}) if err != nil { + if line := pair.TeamsAsideLine(joined.TeamsAside); line != "" { + d.term.say(line) + } return d.endedByPerson(ctx, err) } if !joined.Already && d.name != "" { @@ -279,14 +285,9 @@ func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) e d.recordOwnDeviceOrSay(ctx, route) } d.term.say(joinedSentence(joined)) - if !joined.Already { - d.term.say(terminalSharingLine) - } return nil } -const terminalSharingLine = "Sharing finishes the next time codeaf runs." - // joinedSentence is how a finished join reads. func joinedSentence(joined pair.Joined) string { return joined.Sentence() } @@ -313,3 +314,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/pair_test.go b/cmd/codeaf/pair_test.go index 618e729d15..79eea0d413 100644 --- a/cmd/codeaf/pair_test.go +++ b/cmd/codeaf/pair_test.go @@ -197,11 +197,10 @@ func (r *pairRig) joinWith(t *testing.T, code string, replace bool) (*pairScreen // L4-M12b: code approval uses the same completion sentence as link approval. func TestM1M8PairBothDoorsOnATerminal(t *testing.T) { rig := newPairRig(t) - if _, err := identity.EnsureSolo(rig.homeB); err != nil { - t.Fatal(err) - } - chat := filepath.Join(rig.homeB, "own-chat") - if err := os.WriteFile(chat, []byte("first question\nfirst answer\n"), 0600); err != nil { + // Dev permits startup preferences, but refuses submitted chats and unknown files. + kept := filepath.Join(rig.homeB, "config.json") + const preferences = `{"model.talk":"fixture/saved"}` + if err := os.WriteFile(kept, []byte(preferences), 0600); err != nil { t.Fatal(err) } @@ -231,14 +230,14 @@ func TestM1M8PairBothDoorsOnATerminal(t *testing.T) { } wanted := pair.WaitingChatsLine(asked[1]) - if got, want := joiner.String(), wanted+"\n"+pair.PairedWithLine("laptop-a")+"\n"+terminalSharingLine+"\n"; got != want { - t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s", got, want) + if got := joiner.String(); got != wanted+"\n"+"Paired with laptop-a. Run codeaf to open your chats."+"\n" { + t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s\n%s", got, wanted, "Paired with laptop-a. Run codeaf to open your chats.") } if !strings.HasSuffix(shown.out.String(), pair.PairedChatsLine("laptop")+"\n") { t.Fatalf("the sharing terminal did not end on the paired line:\n%s", shown.out.String()) } - if got, err := os.ReadFile(chat); err != nil || string(got) != "first question\nfirst answer\n" { - t.Fatalf("M1 terminal history: %q, %v", got, err) + if got, err := os.ReadFile(kept); err != nil || string(got) != preferences { + t.Fatalf("startup preferences changed: %q, %v", got, err) } first, _ := identity.Load(rig.homeA) second, err := identity.Load(rig.homeB) diff --git a/cmd/codeaf/pairlink.go b/cmd/codeaf/pairlink.go index db523cab0e..bc607ca583 100644 --- a/cmd/codeaf/pairlink.go +++ b/cmd/codeaf/pairlink.go @@ -120,6 +120,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 @@ -132,6 +135,11 @@ func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) err joining.Label = d.name } joined, err := pair.JoinByLink(ctx, d.requests(route), joining, 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) } @@ -148,8 +156,8 @@ func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) err d.term.say(joined.Sentence()) } else { d.recordOwnDeviceOrSay(ctx, route) - d.term.say(pair.PairedWithLine(joined.Fleet.With)) - d.term.say(terminalSharingLine) + joined.Joined.With = joined.Fleet.With + d.term.say(joined.Joined.Sentence()) } return nil } diff --git a/cmd/codeaf/pairlink_test.go b/cmd/codeaf/pairlink_test.go index 0df9516bbe..8d4eb8e468 100644 --- a/cmd/codeaf/pairlink_test.go +++ b/cmd/codeaf/pairlink_test.go @@ -59,8 +59,8 @@ func TestPairByLinkEndToEnd(t *testing.T) { t.Fatal(err) } screen.waitFor(t, `Paired with`) - if !strings.Contains(screen.String(), terminalSharingLine+"\n") { - t.Fatalf("link join omitted deferred sharing: %s", screen.String()) + if !strings.Contains(screen.String(), ". Run codeaf to open your chats.\n") { + t.Fatalf("link join omitted dev completion copy: %s", screen.String()) } first, _ := identity.Load(rig.homeA) second, err := identity.Load(rig.homeB) diff --git a/cmd/codeaf/pairroute.go b/cmd/codeaf/pairroute.go index 39862f202b..8ebace0b1e 100644 --- a/cmd/codeaf/pairroute.go +++ b/cmd/codeaf/pairroute.go @@ -101,6 +101,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), Platform: runtime.GOOS, diff --git a/cmd/codeaf/profile_startup_test.go b/cmd/codeaf/profile_startup_test.go new file mode 100644 index 0000000000..ed0854db53 --- /dev/null +++ b/cmd/codeaf/profile_startup_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestUnreadableProfileOrdinaryStartupPrompts(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read mode 000 files") + } + t.Setenv("CODEAF_DAILY_BUDGET", "0") + profile := t.TempDir() + path := filepath.Join(profile, "config.json") + if err := os.WriteFile(path, []byte(`{"tools.approvalMode":"allow","tools.approval":"edit:allow","tools.bashPatterns":"allow custom-command"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + defer os.Chmod(path, 0600) + if limit, err := config.DailyBudgetUSDAt(profile); err != nil || limit != 0 { + t.Fatalf("explicit budget pin: %v %v", limit, err) + } + cfg, err := applyV3Governance(session.Config{Workspace: t.TempDir()}, profile, false, true) + if err != nil { + t.Fatalf("ordinary interactive startup failed independently of spending authority: %v", err) + } + if got := cfg.ApprovalPolicy.Check("edit", json.RawMessage(`{"path":"x"}`)); got.Action != approval.ActionPrompt { + t.Fatalf("unreadable policy edit action=%v; want prompt", got.Action) + } + if got := cfg.ApprovalPolicy.Check("bash", json.RawMessage(`{"command":"custom-command"}`)); got.Action != approval.ActionPrompt { + t.Fatalf("unreadable policy bash action=%v; want prompt", got.Action) + } +} diff --git a/cmd/codeaf/profile_unreadable_allow_test.go b/cmd/codeaf/profile_unreadable_allow_test.go new file mode 100644 index 0000000000..04f94446ac --- /dev/null +++ b/cmd/codeaf/profile_unreadable_allow_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestReviewUnreadableAllowMustKeepDeny(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("mode 000 requires nonroot") + } + for _, door := range []string{"live allow", "yolo launch"} { + t.Run(door, func(t *testing.T) { + profile := v3Profile(t, map[string]any{config.KeyToolApprovalMode: "prompt", config.KeyToolApprovals: "write:deny", config.KeyBashApprovals: "deny git status"}) + workspace := t.TempDir() + gate := &v3ApprovalGate{workspace: workspace, profileDir: profile} + first, _, err := gate.Build(session.PostureAllow) + if err != nil { + t.Fatal(err) + } + args := json.RawMessage(`{"path":"example.go"}`) + if first.Check("write", args).Action != approval.ActionDeny { + t.Fatal("fixture did not deny write") + } + path := filepath.Join(profile, "config.json") + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + defer os.Chmod(path, 0600) + var policy *approval.Policy + if door == "live allow" { + policy, _, err = gate.Build(session.PostureAllow) + } else { + policy, err = v3Policy(workspace, profile, true) + } + if err != nil { + t.Fatalf("interactive policy must open: %v", err) + } + for _, call := range []struct { + name string + args json.RawMessage + }{{"write", args}, {"bash", json.RawMessage(`{"command":"git status"}`)}} { + decision := policy.Check(call.name, call.args) + t.Logf("%s with unreadable saved deny: %s", call.name, decision.Action) + if decision.Action != approval.ActionPrompt { + t.Errorf("unreadable saved deny: %s = %s; want prompt", call.name, decision.Action) + } + if !strings.Contains(decision.Rule, path) || !strings.Contains(decision.Rule, "could not be read") { + t.Errorf("prompt does not explain failed file read: %s", decision.Rule) + } + } + for _, posture := range []string{"", session.PostureAllow, session.PostureGuardian, session.PostureDeny} { + gate.headless = true + p, guardian, err := gate.Build(posture) + if err != nil || p == nil || guardian { + t.Fatalf("headless %q: policy=%+v guardian=%v err=%v", posture, p, guardian, err) + } + if got := p.Check("write", args); got.Action != approval.ActionPrompt || !strings.Contains(got.Rule, path) { + t.Errorf("headless %q: %s; want prompt naming file", posture, got) + } + } + }) + } +} diff --git a/cmd/codeaf/review_fixes_test.go b/cmd/codeaf/review_fixes_test.go index 74db581edb..681077a2bc 100644 --- a/cmd/codeaf/review_fixes_test.go +++ b/cmd/codeaf/review_fixes_test.go @@ -16,8 +16,8 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cell/celltest" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/identity" - "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" @@ -123,11 +123,8 @@ func TestR1_7PendingMergeResumesThenGoesIdle(t *testing.T) { } t.Setenv("CODEAF_HOME", rig.homeB) t.Setenv(syncsetup.URLVar, relay.URL) - v, err := keys.Open(rig.homeB) - if err != nil { - t.Fatal(err) - } - if err := v.Put("kept", keys.Entry{Name: "KEY", Value: "kept"}); err != nil { + // Preferences are legal startup-only state under dev's pairing policy. + if err := config.WriteChatModel(rig.homeB, "fixture/saved"); err != nil { t.Fatal(err) } r1PairHomes(t, rig) @@ -432,10 +429,10 @@ func TestR1_4TerminalExplainsAndResumesDeferredSharing(t *testing.T) { t.Setenv("CODEAF_HOME", rig.homeB) t.Setenv(syncsetup.URLVar, rig.url) t.Cleanup(cellstore.StopSpawned) - c := r1SavedChat(t, rig.homeB, "brought chat") out := r1PairHomes(t, rig) - if !strings.Contains(out.String(), "Sharing finishes the next time codeaf runs.\n") { - t.Fatalf("terminal omitted when sharing finishes: %s", out.String()) + c := r1SavedChat(t, rig.homeB, "brought chat") + if !strings.Contains(out.String(), ". Run codeaf to open your chats.\n") { + t.Fatalf("terminal omitted dev completion copy: %s", out.String()) } ctx, cancel := context.WithCancel(t.Context()) done := make(chan struct{}) diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index edd3d22089..384dbc0f69 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "log" @@ -382,7 +384,12 @@ func takeRootForAt(dir, id string) string { // takeCopyBucket files each copy under the folder it came from, apart from // conversations that work in this computer's own folder of the same name. func takeCopyBucket(origin string) string { - return home.Join("v3", "projects", "copy-"+encodeWorkspace(origin)) + name := "copy-" + encodeWorkspace(origin) + if len(name) > 255 { + digest := sha256.Sum256([]byte(filepath.Clean(origin))) + name = "copy-" + hex.EncodeToString(digest[:]) + } + return home.Join("v3", "projects", name) } // deviceName is what this machine is called in a sentence about it: the name a 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/docs/README.md b/docs/README.md index 7472cde3ba..3e750969fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,12 +11,6 @@ contributor rules and historical records serve different readers. | Find the exact behavior codeaf explains about itself | [Shipped manual map](../internal/manual/README.md) — separate chat and resident corpora | | Learn the current chat's features | [Chat manual](../internal/manual/chat/what-i-can-do.md), [getting started](../internal/manual/chat/getting-started.md) | | Understand Memory and knowledge from saved chats | [Memory](../internal/manual/chat/what-i-remember.md), [knowledge from saved chats](../internal/manual/chat/places.md#knowledge-from-saved-chats-inside-memory) | -| **Drive it from a script, CI, or a benchmark** | [HEADLESS.md](HEADLESS.md) — `codeaf do` and `codeaf exec`, exit codes, the `--json` objects, environment, and the rules for measuring it | -| **Know what it can do, feature by feature** | [FEATURES.md](FEATURES.md) — the master catalog, one entry per capability | -| **Understand how it is built** | [ARCHITECTURE.md](ARCHITECTURE.md) — current chat and resident implementation owners | -| **Know what it may spend** | [LIMITS.md](LIMITS.md) — every money rail, its default, what fires when it is reached, and where `0` means no limit | -| **Profile a slow run (developers)** | [DIAGNOSTIC-PROFILE.md](DIAGNOSTIC-PROFILE.md) — opt-in bounded CPU and heap capture of any command | -| **Keep it fast** | [../PERF.md](../PERF.md) — the size ratchet, the allocation laws, the launch-path pins, and why none of them is a stopwatch | The manual is product knowledge compiled into the binary. Repository development notes and users' learned chat knowledge are separate; see [documentation ownership](DOCUMENTATION.md). @@ -34,7 +28,8 @@ explains task folders, cooperating layouts, multi-root transfer, and result appl | Spending and reporting | [Limits](LIMITS.md), [model usage accounting](model-usage-accounting.md), [telemetry disclosure](TELEMETRY.md) | | Operation and verification | [Run a relay](relay.md), [test moving chats](testing-anywhere.md), [remote session test notes](remote-access-testing.md) — check the historical warning | | Design rationale | [Visual design language](DESIGN-LANGUAGE.md), [home layout](HOME-BRIDGE.md), [thread UX](THREAD-UX.md), [background jobs](JOBS.md), [services](SERVICES.md), [multimodal](MULTIMODAL.md), [stored subharness design](SUBHARNESS.md) | -| Performance | [Performance rules and size budget](../PERF.md) | +| Feature catalog | [Capabilities](FEATURES.md) | +| Performance | [Performance rules and size budget](../PERF.md), [diagnostic CPU and heap profiles](DIAGNOSTIC-PROFILE.md) | A contract describes an interface that other code relies on. Design rationale explains a decision and may include proposals or earlier revisions. Check scope diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 2721d3f419..7a8f136047 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -151,22 +151,6 @@ judge at all). `codeaf pool status` says how many rows wait to leave for the pool; this page describes both streams, so "what is collected" is answered for everything the binary sends. -## Download counts - -The download numbers this repository reports come from public GitHub release -data: the GitHub API publishes a cumulative download count for every asset on -a release, and a daily scheduled workflow reads those counts and sends one -event per binary asset to PostHog (`codeaf:release_downloads`). No code in -the binary is involved, nothing is collected from the person downloading, and -the only facts in the event are the release tag, the asset's platform, and -the count GitHub already shows on the release page. Sidecar files such as -`checksums.txt` are not counted. The workflow lives in -`.github/workflows/release-downloads.yml`; the script behind it is -`scripts/release_downloads.py`, and running it with `--dry-run` prints the -batch it would send. - -The release-download workflow fails visibly when its production PostHog key or GitHub token is missing; dry-run still works without a PostHog key. - ## Estimates and recovered receipts See [Model usage accounting](model-usage-accounting.md) for the estimator, diff --git a/docs/changes/unreleased/1886-tasks-at-once-manual-category.md b/docs/changes/unreleased/1886-tasks-at-once-manual-category.md new file mode 100644 index 0000000000..2ef329c0d5 --- /dev/null +++ b/docs/changes/unreleased/1886-tasks-at-once-manual-category.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: the tasks manual points task.parallel at Tasks, not Spending +pr: 1886 +surface: [docs] +invalidates: + - "The tasks manual located `task.parallel` under the settings panel's category \"spending\", so someone following the page searched the wrong section. `/settings` draws the row under Tasks as `tasks at once` (`CategoryTasks` in the registry); the page now names the section and the row exactly as drawn, and manual probes for the setting reach the tasks page." +--- diff --git a/docs/changes/unreleased/2095-teams-share-on-by-default.md b/docs/changes/unreleased/2095-teams-share-on-by-default.md new file mode 100644 index 0000000000..aed6f3047a --- /dev/null +++ b/docs/changes/unreleased/2095-teams-share-on-by-default.md @@ -0,0 +1,49 @@ +--- +kind: changed +title: team sharing on by default for paired computers +pr: 2095 +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/docs/changes/unreleased/2162-hosted-relay-authority-fastpath.md b/docs/changes/unreleased/2162-hosted-relay-authority-fastpath.md new file mode 100644 index 0000000000..7fa296e9d5 --- /dev/null +++ b/docs/changes/unreleased/2162-hosted-relay-authority-fastpath.md @@ -0,0 +1,26 @@ +--- +kind: fixed +title: Reuse confirmed hosted authority and prepare new identity peers asynchronously +pr: 2162 +surface: [remote, chat] +invalidates: + - Hosted reads previously repeated quorum work and synchronous presence updates; they now reuse confirmed authority while failing closed when it expires. + - New empty identities previously waited for three voting replicas; their first change now commits on one voter before background joint-consensus formation. +--- + +Directory mutations remain consensus commits. Committed revocations close device +watches and held store polls immediately. Authorization is checked once before +blob work and repeated after I/O only when the applied index changed. Advisory +presence is coalesced outside the request path. + +Internal quorum reads preserve an in-progress idle transition; ordinary reads +still wake the group. Link approvals validate certificates and grants before +reserving a pairing request. Exact retries of a committed decision remain valid +after its original pending deadline; expired undecided requests remain refused. + +Existing identities retain their voting configuration. New singleton births +persist immutable provenance that older relay code cannot serve; upgrades must +preserve compatible recovery rather than restore a local directory writer. + +Copied chats use bounded destination names when a source workspace path exceeds +the filesystem component limit, including paths nested by earlier device moves. diff --git a/docs/changes/unreleased/2193-release-downloads-workflow.md b/docs/changes/unreleased/2193-release-downloads-workflow.md new file mode 100644 index 0000000000..b860dbb096 --- /dev/null +++ b/docs/changes/unreleased/2193-release-downloads-workflow.md @@ -0,0 +1,11 @@ +--- +kind: removed +title: The daily Release downloads workflow and its PostHog script are gone +pr: 2193 +surface: [build, docs] +invalidates: + - "A daily workflow, .github/workflows/release-downloads.yml, sent GitHub release download counts to PostHog as codeaf:release_downloads; that workflow and scripts/release_downloads.py no longer exist and nothing reports download counts." +--- + +It failed on every run because its PostHog project key was never set, and the +counts are already visible on the GitHub release pages. diff --git a/docs/changes/unreleased/2214-relay-prod-caf-limits.md b/docs/changes/unreleased/2214-relay-prod-caf-limits.md new file mode 100644 index 0000000000..92f0f41832 --- /dev/null +++ b/docs/changes/unreleased/2214-relay-prod-caf-limits.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: A CI deploy of the production relay keeps its global identity and storage caps +pr: 2214 +surface: [build, remote] +invalidates: + - "scripts/relay-wrangler-config.sh production wrote no CAF_LIMITS, so a CI deploy would have lifted production's caps of 10000 new identities a day and 1 TiB of stored frames; the production config now carries exactly those caps." +--- diff --git a/docs/changes/unreleased/2273-furrow-socket-marker-snapshots.md b/docs/changes/unreleased/2273-furrow-socket-marker-snapshots.md new file mode 100644 index 0000000000..91281156c0 --- /dev/null +++ b/docs/changes/unreleased/2273-furrow-socket-marker-snapshots.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: A chat whose folder runs Git's fsmonitor moves to another computer +pr: 2273 +surface: [engine] +invalidates: + - "Moving a chat whose folder held a Unix socket (Git's fsmonitor socket, .git/fsmonitor--daemon.ipc) failed on the receiving computer with 'workspace path changed during rewind'; the embedded storage engine is now furrow 0.3.6, which leaves the socket out and restores the rest." +--- +The embedded storage engine is furrow 0.3.6. A Unix socket is never copied +between computers. furrow 0.3.5 then read the socket's absence as a +concurrent edit and refused to restore the folder, so a chat whose folder ran +Git's fsmonitor could not be continued elsewhere. furrow 0.3.6 accepts that +absence, and an unchanged received folder shows no pending restore. diff --git a/docs/changes/unreleased/2291-dev-merge-ancestry.md b/docs/changes/unreleased/2291-dev-merge-ancestry.md new file mode 100644 index 0000000000..d138cedbbe --- /dev/null +++ b/docs/changes/unreleased/2291-dev-merge-ancestry.md @@ -0,0 +1,60 @@ +--- +kind: fixed +title: Record dev ancestry in app/v3 with a true merge +pr: 2291 +surface: [build, app, engine, docs] +invalidates: + - "Removed computers previously entered replacement cleanup when rejoining their own identity; app re-pairing now retains their local state." +--- + +The merge records dev ancestry and retains non-conflicting app/v3 work. Under +the owner rule, dev wins behavioral conflicts outside Factory. Factory, billing +and ledger dependencies keep app/v3 behavior, including SQLite team storage, +lazy Factory briefs and complete first-sharing backups. + +Team sharing is on by default for paired computers. Missing or empty +`teams.share` reads on; explicit off stays off, and damaged configuration or +invalid values read off. The live pairing gate still prevents unpaired sharing. +Solo computers with submitted chats refuse ordinary pairing and explain +`--replace`; replacement retains confirmation and preserved-team receipts. +Unsupported-effort repair is local to opted-in task and Factory clients. Job-owned personal manager turns, reopened completed stages and earlier rounds, +stage conversations, their helpers, manager turns, scouts and triage retain recovery, +including after model or account switches. Live manager turns, brief writing and +Factory page errands (including Step AI and My pipeline AI) opt in per call; the same agent retains ordinary refusal +for its other turns and errands. Ordinary clients return the refusal without a +repair. Pairing acceptance tests use legal startup homes instead of assuming +submitted chats can merge without replacement. Both terminal pairing doors use +dev's completion sentence, asking the person to run codeaf to open their chats. + +These choices supersede the earlier merge's opt-in sharing default, keep-and-share +pairing policy and process-wide reasoning repair. Later app/v3 work remains merged. + +Chat ownership follows dev too: reopening acquires a new fence, an expired hold +cannot admit tools or desktop sends, and stale move confirmation refuses a +changed fence even when holder and saved head match. Typed desktop refusals and +saved-head metadata remain available. + +Explicit replacement clears live chats and shared preferences even when rejoining +the same account. Durable cleanup progress and the destination device distinguish +an unfinished reset from an installed replacement, preserving later writes. + +Ordinary file-memo readers retain their missing-file fallback. Profile writes, +daily spending authority and preference/key sharing use strict reads: unreadable +existing configuration cannot be overwritten or treated as an absent budget or +shared value. Ordinary startup still opens when profile access fails. Policy +construction carries the read failure explicitly: every call requires a fresh +human answer, even under an allow posture or `--yolo`, because saved denials +cannot be known. Prompts explain the failed read and name the file; headless calls +refuse with an error naming it. Parse errors still propagate. Preference edit-clock reads retain their ordinary fallback. +Missing profiles still use their normal defaults. + +A failed chat installation leaves the source read-only until a fresh Continue +here action. A released takeover updates the source window to available instead +of retaining a stale continuation notice; release never restores its old fence. + +A removed computer re-pairing from the app is not choosing replacement. Code +and link pairing check the approved identity, preserve local chats, memories +and settings when it is the same, and install a fresh device key. An unremoved +computer keeps its key and reports no change. Used solo computers still refuse +without confirmed replacement; explicit same-identity replacement still runs +cleanup. Removal no longer implies consent to replace local state. diff --git a/docs/changes/unreleased/2291-memory-automatic-retry.md b/docs/changes/unreleased/2291-memory-automatic-retry.md new file mode 100644 index 0000000000..41971109fd --- /dev/null +++ b/docs/changes/unreleased/2291-memory-automatic-retry.md @@ -0,0 +1,23 @@ +--- +kind: fixed +title: Bound automatic retries of ordinary memory builds +pr: 2291 +surface: [chat, docs] +invalidates: + - "Ordinary memory builds previously retried unchanged failed input without a limit. They now stop after three compilation attempts in total, with the count retained across restarts." +--- + +Ordinary memory builds again report `Completed progress is retained; retrying +automatically.` while attempts remain. After three failed compilation attempts +in total, they stop and say to use Build or add new sources. Changed input or +Build starts a fresh count; success clears it. Attempts survive restarts, and +each can make several paid requests. Old databases with `failed_input` still +load. Memory off still prevents compilation. +An empty memory-on home does not persist retry metadata, so a first start +can still pair. Real memory sources still prevent joining another home. +Factory sources remain excluded from independent background compilation, with +failures, ownership and usage accounting handled by the owning job. + +Documentation follows dev wording while preserving guidance for capabilities +retained in this tree, including desktop reply suggestions, question Hold, +conversation commands, service cleanup and local saving recovery. diff --git a/docs/knowledge.md b/docs/knowledge.md index 41d5f34d29..cb806678bb 100644 --- a/docs/knowledge.md +++ b/docs/knowledge.md @@ -75,9 +75,14 @@ ceiling are preserved. The process worker does not launch another refinement pass merely because the previous pass left editorial work pending. New source input or an explicit build can advance that work. The projection records the Build request it consumed, so a forced flush alone cannot buy another refinement -pass on unchanged sources. Failed input is attempted once; -new sources or an explicit Build permit another attempt. Historical unrecorded costs -are not backfilled. +pass on unchanged sources. Ordinary failed input permits three compilation +attempts in total, including the first. The worker persists each attempt before +compilation, so restarts retain the limit. Its input identity includes the latest +ordinary source rowid, source count, latest arrival, Build request and projection +version. Changed input or an explicit Build starts a new count; a successful +compilation clears it. Each attempt can make several paid requests. Owned job +compilation retains its separate retry and accounting rules. Historical +unrecorded costs are not backfilled. A successful stage result is retained even when deferred memory spending reaches its cap. The runner records the finished phase before checking admission for the diff --git a/internal/approval/approval.go b/internal/approval/approval.go index cb0de6e237..f6e20169fc 100644 --- a/internal/approval/approval.go +++ b/internal/approval/approval.go @@ -174,6 +174,9 @@ type Rule struct { // The zero Policy prompts for everything. That is the intended reading of "no // policy configured" — a session with no settings should ask, not run. type Policy struct { + // ProfileReadError requires a fresh human answer for every call because + // saved denials could not be read. It overrides every automatic allowance. + ProfileReadError string // Default applies to any tool with no rule of its own. Default Action // Tools is keyed by tool name (read, edit, write, bash, …). A tool rule @@ -190,6 +193,8 @@ type Policy struct { // because every surface that renders a consent prompt needs the same sentence // and none of them should be re-deriving it from the Policy. type Decision struct { + // ProfileReadError prevents other consent sources from answering for a person. + ProfileReadError string // Scope is the explicit rule matched, absent for default and inferred allows. Scope string Action Action @@ -202,6 +207,10 @@ func (d Decision) String() string { return d.Rule + " → " + string(d.Action) } // Check answers for one tool call. args is the raw JSON the model emitted; it // is read only for bash, and only for its "command" field. func (p Policy) Check(tool string, args json.RawMessage) Decision { + if p.ProfileReadError != "" { + return Decision{Action: ActionPrompt, ProfileReadError: p.ProfileReadError, + Rule: "saved approval rules could not be read; every call needs your approval: " + p.ProfileReadError} + } tool = strings.TrimSpace(tool) base := p.base(tool, args) if tool != ToolBash { @@ -225,6 +234,9 @@ func (p Policy) Check(tool string, args json.RawMessage) Decision { // string — a slash command, a queued shell action, a settings preview that // wants to show what a pattern would do. func (p Policy) CheckBash(command string) Decision { + if p.ProfileReadError != "" { + return p.Check(ToolBash, nil) + } return p.checkBash(command, p.base(ToolBash, nil)) } diff --git a/internal/clusterstate/apply.go b/internal/clusterstate/apply.go index 18b1389543..e6a11a6563 100644 --- a/internal/clusterstate/apply.go +++ b/internal/clusterstate/apply.go @@ -150,7 +150,7 @@ func Sign(c Command, key ed25519.PrivateKey) (Command, error) { // immutable records from unread records. func Dependencies(c Command) []RecordKey { keys := []RecordKey{QuiescenceKey(), IdentityKey(), DeviceKey(c.Actor), AuthorityKey(c.Actor), SessionKey(c.Actor, c.Session), SessionsKey()} - if c.Change.InitMembership != nil { + if c.Change.InitMembership != nil || c.Change.PrepareQuiescence != nil { keys = append(keys, LegacyImportKey()) } m := c.Change @@ -360,11 +360,13 @@ func evaluateCommand(c Command, meta ApplicationMeta, read ReadRecord, paged boo // Old imported groups retain their exact manifest and rows. Their FIRST // registry command may use only a manifest-provisioned anchor key; it logs // the authority installation instead of rewriting a frozen import snapshot. - if rows[AuthorityKey(c.Actor)] == nil && c.Change.InitMembership != nil { + // Before that installation, the initial singleton anchor may only prepare + // a quorum-confirmed idle marker for its actual one-voter configuration. + if rows[AuthorityKey(c.Actor)] == nil && (c.Change.InitMembership != nil || c.Change.PrepareQuiescence != nil && len(meta.Configuration.Voters) == 1 && !meta.Configuration.Joint()) { var manifest LegacyManifest if raw := rows[LegacyImportKey()]; raw != nil && Decode(raw, &manifest) == nil && manifest.Validate() == nil && manifest.Config.Cluster == c.Cluster && bytes.Equal(manifest.Config.SigningKey, identity.SigningKey) { for i, node := range manifest.Config.Anchors { - if c.Actor == AnchorActor(node) && i < len(manifest.Config.AnchorKeys) { + if c.Actor == AnchorActor(node) && i < len(manifest.Config.AnchorKeys) && (c.Change.InitMembership != nil || i == 0 && meta.Configuration.Voters[0] == node) { auth = Authority{Cluster: c.Cluster, Key: manifest.Config.AnchorKeys[i], Anchor: true} } } @@ -419,7 +421,7 @@ func evaluateCommand(c Command, meta ApplicationMeta, read ReadRecord, paged boo return out, nil } } - service := c.Change.RenewHolds != nil || c.Change.RecordUpload != nil || c.Change.AdmitJoin != nil || c.Change.CompleteJoin != nil || auth.Effects && (c.Change.WorkerBeatBatch != nil || c.Change.DeviceSeen != nil) + service := c.Change.RenewHolds != nil || c.Change.RecordUpload != nil || c.Change.AdmitJoin != nil || c.Change.CompleteJoin != nil || auth.Effects && (c.Change.WorkerBeatBatch != nil || c.Change.DeviceSeen != nil || c.Change.AdvanceTime != nil) if service != auth.Effects || auth.Personal && auth.Effects || auth.Anchor && (auth.Personal || auth.Effects || (c.Change.EnrollMember == nil || c.Change.EnrollMember.Admission == nil) && (c.Change.RemoveMember == nil || c.Change.RemoveMember.Admission == nil) && c.Change.InitMembership == nil && c.Change.PrepareQuiescence == nil && c.Change.SettleMembership == nil && c.Change.SettleLeadership == nil && c.Change.CompleteLeadership == nil && c.Change.RestoreAnchors == nil && c.Change.CloseSession == nil) { return out, nil } @@ -521,7 +523,10 @@ func evaluateCommand(c Command, meta ApplicationMeta, read ReadRecord, paged boo } } } else if service { - code = applyEffect(c, out.Now, rows, put) + // A clock witness advances logical time without stamping presence. + if c.Change.AdvanceTime == nil { + code = applyEffect(c, out.Now, rows, put) + } } else { if assignmentMutation(c.Change) { code = applyAssignment(c, out.Now, identity, rows, put, &out.Result) diff --git a/internal/clusterstate/effects.go b/internal/clusterstate/effects.go index 7c038af468..324dc0bcfb 100644 --- a/internal/clusterstate/effects.go +++ b/internal/clusterstate/effects.go @@ -147,7 +147,7 @@ func applyEffect(c Command, now int64, rows map[RecordKey][]byte, put func(Recor return OK case c.Change.AdmitJoin != nil: v := c.Change.AdmitJoin - if v.Code == "" || len(v.PublicKey) != ed25519.PublicKeySize || v.Expires <= now || v.Expires > int64(MaxSafeInteger) { + if v.Code == "" || len(v.PublicKey) != ed25519.PublicKeySize || v.Expires > int64(MaxSafeInteger) { return BadRequest } k := JoinKey(v.Code) @@ -156,11 +156,19 @@ func applyEffect(c Command, now int64, rows map[RecordKey][]byte, put func(Recor if Decode(raw, &r) != nil { return BadRequest } - if bytes.Equal(r.PublicKey, v.PublicKey) && r.Expires == v.Expires && r.Platform == PlatformOf(v.Platform) { + sameAdmission := bytes.Equal(r.PublicKey, v.PublicKey) && r.Expires == v.Expires && r.Platform == PlatformOf(v.Platform) + // Pending expiry cannot invalidate an already committed decision replay. + if v.Expires <= now && (!sameAdmission || r.Decision == nil) { + return BadRequest + } + if sameAdmission { return OK } return Conflict } + if v.Expires <= now { + return BadRequest + } put(k, JoinRequest{Device: DeviceIDOfKey(v.PublicKey), PublicKey: v.PublicKey, Expires: v.Expires, Platform: PlatformOf(v.Platform)}) return OK case c.Change.CompleteJoin != nil: diff --git a/internal/clusterstate/effects_test.go b/internal/clusterstate/effects_test.go index ab89e08840..dc15939889 100644 --- a/internal/clusterstate/effects_test.go +++ b/internal/clusterstate/effects_test.go @@ -129,6 +129,63 @@ func TestR16R17R21EffectsAuthorizationCampaign(t *testing.T) { }) } } +func TestR16DecidedJoinAdmissionSurvivesPendingExpiry(t *testing.T) { + public := actorKey("joiner").Public().(ed25519.PublicKey) + admission := cs.AdmitJoin{Code: "request", PublicKey: public, Expires: 1002} + decision := cs.JoinDecision{Code: "request", Device: cs.DeviceIDOfKey(public), Cert: joinCert(t, "joiner"), Grant: "c2VhbGVk", Record: cs.Device{V: 1, Name: "c2VhbGVk"}} + t.Run("decided replay preserves the original decision", func(t *testing.T) { + a := newApplication(t) + if r := service(a, 1000, cs.Mutation{AdmitJoin: &admission}); r.Code != cs.OK { + t.Fatal(r) + } + if r := a.run("a", 1001, cs.Mutation{DecideJoin: &decision}); r.Code != cs.OK { + t.Fatal(r) + } + if r := service(a, 1003, cs.Mutation{AdmitJoin: &admission}); r.Code != cs.OK { + t.Fatal("pending expiry blocked an existing decision", r) + } + if r := a.run("a", 1004, cs.Mutation{DecideJoin: &decision}); r.Code != cs.OK { + t.Fatal("the original decision was not idempotent", r) + } + if r := a.run("a", 1005, cs.Mutation{DecideJoin: &cs.JoinDecision{Code: "request", Deny: true}}); r.Code != cs.AlreadyDecided { + t.Fatal("a contradictory decision changed the outcome", r) + } + extended := admission + extended.Expires = 2000 + if r := service(a, 1006, cs.Mutation{AdmitJoin: &extended}); r.Code != cs.Conflict { + t.Fatal("re-admission changed a decided request's lifetime", r) + } + mismatched := admission + mismatched.PublicKey = actorKey("different").Public().(ed25519.PublicKey) + if r := service(a, 1007, cs.Mutation{AdmitJoin: &mismatched}); r.Code != cs.BadRequest { + t.Fatal("expired re-admission changed the immutable requester", r) + } + }) + t.Run("expired pending admission cannot be revived", func(t *testing.T) { + a := newApplication(t) + if r := service(a, 1000, cs.Mutation{AdmitJoin: &admission}); r.Code != cs.OK { + t.Fatal(r) + } + if r := service(a, 1003, cs.Mutation{AdmitJoin: &admission}); r.Code != cs.BadRequest { + t.Fatal("an expired pending request was admitted again", r) + } + extended := admission + extended.Expires = 2000 + if r := service(a, 1004, cs.Mutation{AdmitJoin: &extended}); r.Code != cs.Conflict { + t.Fatal("re-admission extended the pending lifetime", r) + } + if r := a.run("a", 1005, cs.Mutation{DecideJoin: &decision}); r.Code != cs.RequestGone { + t.Fatal("an expired pending request acquired a decision", r) + } + }) + t.Run("unseen expired admission is refused", func(t *testing.T) { + a := newApplication(t) + if r := service(a, 1003, cs.Mutation{AdmitJoin: &admission}); r.Code != cs.BadRequest { + t.Fatal("an unseen expired request was admitted", r) + } + }) +} + func TestR16PairingCertificateAndStickyRevocation(t *testing.T) { a := newApplication(t) pub := actorKey("joiner").Public().(ed25519.PublicKey) diff --git a/internal/clusterstate/records.go b/internal/clusterstate/records.go index 0c7174551d..461447a7e8 100644 --- a/internal/clusterstate/records.go +++ b/internal/clusterstate/records.go @@ -171,11 +171,11 @@ type LeaseExpectation struct { Head string `json:"head,omitempty"` } -// Matches allows a newer fence only with evidence of the same holder and -// saved head. Older callers without a head retain the exact-fence contract. +// Matches requires the observed holder and fence. A supplied saved head +// adds a content check without relaxing ownership. func (e LeaseExpectation) Matches(c Cell) bool { if c.Lease.Device != e.Device || e.Head != "" && c.Head != e.Head { return false } - return c.Lease.Fence == e.Fence || e.Head != "" && c.Lease.Fence > e.Fence + return c.Lease.Fence == e.Fence } diff --git a/internal/clusterstate/rules_test.go b/internal/clusterstate/rules_test.go index 3608c58772..e743998360 100644 --- a/internal/clusterstate/rules_test.go +++ b/internal/clusterstate/rules_test.go @@ -106,3 +106,13 @@ func TestR16DeviceStampCannotUndoRevocationOrTrustClientTimes(t *testing.T) { t.Fatalf("self revoke: %v", err) } } + +// A saved-head match cannot authorize a take after ownership changed. +func TestConditionalAcquireRejectsNewerFenceWithSameHead(t *testing.T) { + c := Cell{Head: "saved", Lease: Lease{Device: "a", Fence: 8, Expires: 100}} + seen := &LeaseExpectation{Device: "a", Fence: 7, Head: "saved"} + got, err := Acquire(c, "b", 101, true, seen) + if err != ErrLeaseHeld || !reflect.DeepEqual(got, c) { + t.Fatalf("stale take = %+v, %v", got, err) + } +} diff --git a/internal/config/budget.go b/internal/config/budget.go index 368d9cc407..8d9460b7a9 100644 --- a/internal/config/budget.go +++ b/internal/config/budget.go @@ -56,7 +56,7 @@ var profileConfigMemo = filememo.Stamped(SettingsGeneration, return values, nil }) -// readProfileConfig is the single reader of the profile's config.json. +// readProfileConfig is the ordinary reader of the profile's config.json. // // EVERY CALLER GETS THE SAME MAP AND NOBODY MAY WRITE IT. A memo hands back the // value it holds rather than a copy, so the eight readers in this package treat @@ -73,6 +73,12 @@ func readProfileConfig(profileDir string) (map[string]json.RawMessage, error) { return profileConfigMemo.Read(BudgetConfigPath(profileDir)) } +// readProfileConfigStrict preserves read errors at spending, privacy and write boundaries. +// Absence still means a new profile; an unreadable existing file does not. +func readProfileConfigStrict(profileDir string) (map[string]json.RawMessage, error) { + return profileConfigMemo.ReadStrict(BudgetConfigPath(profileDir)) +} + // writeProfileValue is the single writer every persisted setting goes through: // read, replace one key, write a temporary file, rename. Unrelated keys — the // other settings and anything a later version adds — survive untouched. @@ -209,7 +215,7 @@ func editProfile(profileDir, key string, decide func(held map[string]json.RawMes } defer profileLock.Close() - held, err := readProfileConfig(profileDir) + held, err := readProfileConfigStrict(profileDir) if err != nil { return fmt.Errorf("write config: preserve existing file: %w", err) } diff --git a/internal/config/dailylimit.go b/internal/config/dailylimit.go index 52088fbf8f..53b13eb150 100644 --- a/internal/config/dailylimit.go +++ b/internal/config/dailylimit.go @@ -159,7 +159,7 @@ func DailyBudgetBaseUSDAt(profileDir string) (float64, error) { if raw := strings.TrimSpace(env.Get(dailyBudgetEnv)); raw != "" { return parseDailyBudget(raw, dailyBudgetEnv) } - held, err := readProfileConfig(profileDir) + held, err := readProfileConfigStrict(profileDir) if err != nil { return 0, err } @@ -181,7 +181,7 @@ func DailyBudgetUSDAtTime(profileDir string, now time.Time) (float64, error) { if raw := strings.TrimSpace(env.Get(dailyBudgetEnv)); raw != "" { return parseDailyBudget(raw, dailyBudgetEnv) } - held, err := readProfileConfig(profileDir) + held, err := readProfileConfigStrict(profileDir) if err != nil { return 0, err } @@ -210,7 +210,7 @@ func DailyBudgetUSDAtTime(profileDir string, now time.Time) (float64, error) { // DailyBudgetAuthorityEstablished says whether the limit's authority marker is // written, so legacy values are over. func DailyBudgetAuthorityEstablished(profileDir string) (bool, error) { - held, err := readProfileConfig(profileDir) + held, err := readProfileConfigStrict(profileDir) if err != nil { return false, err } @@ -271,7 +271,7 @@ func RaiseDailyBudgetToday(profileDir string, usd float64, now time.Time) error return fmt.Errorf("the daily limit is set by %s", name) } for range dailyWriteTries { - held, err := readProfileConfig(profileDir) + held, err := readProfileConfigStrict(profileDir) if err != nil { return err } @@ -344,7 +344,7 @@ func AdoptLegacyDailyLimit(profileDir string, legacy LegacyDailyLimit, now time. } } for range dailyWriteTries { - held, err := readProfileConfig(profileDir) + held, err := readProfileConfigStrict(profileDir) if err != nil { return err } diff --git a/internal/config/preferences.go b/internal/config/preferences.go index ee49ee0765..cf3826ecb9 100644 --- a/internal/config/preferences.go +++ b/internal/config/preferences.go @@ -32,7 +32,7 @@ func PreferenceKey(key string) bool { // ReadPreferences reads stored values alone: operator environment pins and // project settings never travel to another computer (C30.3). func ReadPreferences(dir string) (map[string]string, error) { - held, err := readProfileConfig(dir) + held, err := readProfileConfigStrict(dir) if err != nil { return nil, err } diff --git a/internal/config/profile_policy_fallback_test.go b/internal/config/profile_policy_fallback_test.go new file mode 100644 index 0000000000..a5bbb5143d --- /dev/null +++ b/internal/config/profile_policy_fallback_test.go @@ -0,0 +1,50 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestUnreadableProfilePolicyReadReportsFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read mode 000 files") + } + for _, warm := range []bool{false, true} { + name := "cold" + if warm { + name = "cached" + } + t.Run(name, func(t *testing.T) { + profile := t.TempDir() + path := BudgetConfigPath(profile) + if err := os.WriteFile(path, []byte(`{"tools.approvalMode":"allow","tools.approval":"edit:allow","tools.bashPatterns":"allow custom-command"}`), 0600); err != nil { + t.Fatal(err) + } + project, err := LoadProjectConfig(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if warm { + if got, err := project.ResolveString(profile, KeyToolApprovalMode); err != nil || got != "allow" { + t.Fatalf("warm policy: %q, %v", got, err) + } + } + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + defer os.Chmod(path, 0600) + for _, key := range []string{KeyToolApprovalMode, KeyToolApprovals, KeyBashApprovals} { + if _, err := project.ResolveString(profile, key); err == nil || !strings.Contains(err.Error(), path) { + t.Errorf("%s: %v; want read failure naming %s", key, err, path) + } + } + if got := ToolApprovalModeAt(profile); got != "prompt" { + t.Errorf("ordinary mode = %q; want prompt", got) + } + if got, err := HeadlessToolApprovalModeAt(t.TempDir(), profile); err == nil || !strings.Contains(err.Error(), path) { + t.Errorf("headless mode = %q, %v; want read failure naming %s", got, err, path) + } + }) + } +} diff --git a/internal/config/profileconfig_memo_test.go b/internal/config/profileconfig_memo_test.go index 9a6ca69a67..e0d8c866f6 100644 --- a/internal/config/profileconfig_memo_test.go +++ b/internal/config/profileconfig_memo_test.go @@ -1,7 +1,9 @@ package config import ( + "bytes" "os" + "strings" "testing" "time" ) @@ -127,3 +129,90 @@ func replaceKeepingLength(raw []byte, from, to string) []byte { } panic("the fixture does not contain " + from) } + +func TestUnreadableProfileRefusesSpendingAndSettingsWrites(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read mode 000 files") + } + t.Setenv(dailyBudgetEnv, "") + profile := t.TempDir() + path := BudgetConfigPath(profile) + original := []byte(`{"model.talk":"keep/model","daily_budget_usd":1}`) + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0600) }) + if limit, err := DailyBudgetUSDAt(profile); err == nil { + t.Errorf("unreadable saved spending authority returned %v without an error", limit) + } + if err := WriteChatModel(profile, "new/model"); err == nil || !strings.Contains(err.Error(), "preserve existing file") { + t.Errorf("settings write must refuse to replace unreadable config: %v", err) + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("settings write changed unreadable config: %s", got) + } +} + +func TestUnreadableProfileAuthoritiesDoNotUseOrdinaryFallback(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read mode 000 files") + } + t.Setenv(dailyBudgetEnv, "") + for _, warm := range []bool{false, true} { + name := "cold" + if warm { + name = "cached" + } + t.Run(name, func(t *testing.T) { + profile := t.TempDir() + path := BudgetConfigPath(profile) + original := []byte(`{"model.talk":"keep/model","daily_budget_usd":1,"tool_approval_mode":"prompt"}`) + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + if warm { + if got, err := DailyBudgetBaseUSDAt(profile); err != nil || got != 1 { + t.Fatalf("warm budget: %v, %v", got, err) + } + } + if err := os.Chmod(path, 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(path, 0600) + if _, err := DailyBudgetBaseUSDAt(profile); err == nil { + t.Error("base budget hid read failure") + } + if _, err := DailyBudgetAuthorityEstablished(profile); err == nil { + t.Error("budget authority hid read failure") + } + if _, err := ReadPreferences(profile); err == nil { + t.Error("sync preferences hid read failure") + } + if _, err := ReadProviderKeys(profile); err == nil { + t.Error("sync provider keys hid read failure") + } + if got := ToolApprovalModeAt(profile); got != "prompt" { + t.Errorf("tool policy = %q; want prompt", got) + } + if got, err := HeadlessToolApprovalModeAt(t.TempDir(), profile); err == nil || !strings.Contains(err.Error(), path) { + t.Errorf("headless tool policy = %q, %v; want read failure naming %s", got, err, path) + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + if got, err := DailyBudgetUSDAt(profile); err != nil || got != 1 { + t.Fatalf("restored budget: %v, %v", got, err) + } + }) + } +} diff --git a/internal/config/projectconfig.go b/internal/config/projectconfig.go index c976adef5e..cbbae0cf2b 100644 --- a/internal/config/projectconfig.go +++ b/internal/config/projectconfig.go @@ -147,10 +147,10 @@ func LoadProjectConfig(cwd string) (ProjectConfig, error) { if path == "" { return ProjectConfig{}, nil } - project, err := projectConfigMemo.Read(path) + project, err := projectConfigMemo.ReadStrict(path) if os.IsNotExist(err) { legacy := filepath.Join(strings.TrimSpace(cwd), legacyProjectConfigDir, ProjectConfigFile) - project, err = projectConfigMemo.Read(legacy) + project, err = projectConfigMemo.ReadStrict(legacy) if os.IsNotExist(err) { return ProjectConfig{path: path}, nil } @@ -335,10 +335,14 @@ func (p ProjectConfig) ResolveString(profileDir, key string) (string, error) { return "", fmt.Errorf("%q is not a text row", key) } -// profileApprovalString distinguishes an absent preference from a failed read. -// Live gates must keep their last policy when the profile cannot be read. +// ReadProfileApprovalConfig preserves a failed profile read so policy builders +// can require a person even when a caller or project supplies an allow mode. +func ReadProfileApprovalConfig(profileDir string) (map[string]json.RawMessage, error) { + return readProfileConfigStrict(profileDir) +} + func profileApprovalString(profileDir, key string) (string, error) { - values, err := readProfileConfig(profileDir) + values, err := ReadProfileApprovalConfig(profileDir) if err != nil { return "", err } diff --git a/internal/config/providerkeys.go b/internal/config/providerkeys.go index 4853ab111f..92b17f4e03 100644 --- a/internal/config/providerkeys.go +++ b/internal/config/providerkeys.go @@ -39,7 +39,7 @@ func secretRowKeys() []string { // config.json that cannot be read is an error, never an empty answer, so a // damaged file is not mistaken for a person who removed their keys. func ReadProviderKeys(profileDir string) (ProviderKeys, error) { - values, err := readProfileConfig(profileDir) + values, err := readProfileConfigStrict(profileDir) if err != nil { return ProviderKeys{}, err } diff --git a/internal/config/settings.go b/internal/config/settings.go index 65470ae8c1..9074f87026 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2311,7 +2311,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 other computers (pairing required). On by default; turning it off on either computer stops sharing.", read: func() string { if TeamsShareAt(dir) { return "on" @@ -3670,7 +3670,10 @@ func knownDocumentEngine(engine string) bool { // absent setting uses YOLO; an unrecognised persisted value still asks, so a // garbled setting does not widen a previously selected posture. func ToolApprovalModeAt(profileDir string) string { - values, _ := readProfileConfig(profileDir) + values, err := ReadProfileApprovalConfig(profileDir) + if err != nil { + return "prompt" + } return toolApprovalMode(values, DefaultToolApprovalMode) } @@ -3700,7 +3703,7 @@ func HeadlessToolApprovalModeAt(workspace, profileDir string) (string, error) { } else if found { return project.ResolveString(profileDir, KeyToolApprovalMode) } - values, err := readProfileConfig(profileDir) + values, err := ReadProfileApprovalConfig(profileDir) if err != nil { return "", err } 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 a83dc736a9..f60ef6abb3 100644 --- a/internal/filememo/filememo.go +++ b/internal/filememo/filememo.go @@ -94,6 +94,7 @@ type reading[T any] struct { modTime time.Time missing bool stamp uint64 + mode os.FileMode } // New binds a memo to its derivation. @@ -119,17 +120,29 @@ 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 and invalidates cached values when +// permissions change. Ordinary readers retain the missing-file fallback. +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) { m.mu.Lock() defer m.mu.Unlock() now, statErr := os.Stat(path) - if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + if (strict || m.source != nil) && statErr != nil && !errors.Is(statErr, os.ErrNotExist) { var zero T return zero, statErr } var size int64 var modTime time.Time + var mode os.FileMode if now != nil { size, modTime = now.Size(), now.ModTime() + mode = now.Mode() } absent := now == nil stamp := uint64(0) @@ -138,7 +151,7 @@ func (m *Memo[T]) Read(path string) (T, error) { } held, known := m.held[path] m.tick++ - if known && held.fresh(size, modTime, absent, stamp) && (absent || os.SameFile(held.info, now)) { + if known && held.fresh(size, modTime, absent, stamp) && (absent || os.SameFile(held.info, now)) && (!strict || held.mode == mode) { held.used = m.tick m.held[path] = held return held.value, held.err @@ -149,7 +162,7 @@ func (m *Memo[T]) Read(path string) (T, error) { } value, err := m.source(path, now) if err == nil { - m.keep(path, reading[T]{value: value, info: now, size: size, modTime: modTime, missing: absent, stamp: stamp}) + m.keep(path, reading[T]{value: value, info: now, size: size, modTime: modTime, missing: absent, stamp: stamp, mode: mode}) } return value, err } @@ -158,8 +171,9 @@ func (m *Memo[T]) Read(path string) (T, error) { m.reads++ bytes, err := os.ReadFile(path) if err != nil { - // A failed read is never content, even when its stat succeeded. - if errors.Is(err, os.ErrNotExist) { + // Failed reads are not cached. Ordinary callers retain their + // missing-file default; privacy preferences use ReadStrict. + if !strict { return m.derive(path, nil, true) } var zero T @@ -173,7 +187,7 @@ func (m *Memo[T]) Read(path string) (T, error) { } m.keep(path, reading[T]{ value: value, err: err, - info: now, size: size, modTime: modTime, missing: absent, stamp: stamp, + info: now, 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) + } +} diff --git a/internal/filememo/stamps_test.go b/internal/filememo/stamps_test.go index f538c743c1..6680df10ef 100644 --- a/internal/filememo/stamps_test.go +++ b/internal/filememo/stamps_test.go @@ -68,7 +68,7 @@ func TestMemoBoundsRetainedPaths(t *testing.T) { } } -func TestMemoReadErrorIsNeitherMissingNorCachedContent(t *testing.T) { +func TestMemoStrictReadErrorIsNeitherMissingNorCachedContent(t *testing.T) { path := t.TempDir() calls := 0 memo := New(func(_ string, data []byte, missing bool) (string, error) { @@ -76,7 +76,7 @@ func TestMemoReadErrorIsNeitherMissingNorCachedContent(t *testing.T) { return string(data), nil }) for range 2 { - if _, err := memo.Read(path); err == nil || os.IsNotExist(err) { + if _, err := memo.ReadStrict(path); err == nil || os.IsNotExist(err) { t.Fatalf("directory read error became absence: %v", err) } } @@ -84,3 +84,19 @@ func TestMemoReadErrorIsNeitherMissingNorCachedContent(t *testing.T) { t.Fatalf("failed reads derived=%d held=%d reads=%d", calls, len(memo.held), memo.Reads()) } } + +// Source reads crew routing history, so a failed stat cannot mean empty data. +func TestSourcePreservesStatErrors(t *testing.T) { + parent := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(parent, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + calls := 0 + memo := Source(func(_ string, _ os.FileInfo) (string, error) { calls++; return "empty", nil }) + if _, err := memo.Read(filepath.Join(parent, "child")); err == nil { + t.Fatal("source stat error became empty data") + } + if calls != 0 { + t.Fatal("source derived a value after a failed stat") + } +} diff --git a/internal/furrowbin/pin.json b/internal/furrowbin/pin.json index dbf0bb7ac7..e51f178a14 100644 --- a/internal/furrowbin/pin.json +++ b/internal/furrowbin/pin.json @@ -1,31 +1,31 @@ { "repository": "Agent-Field/furrow", - "tag": "v0.3.5", - "version": "0.3.5", + "tag": "v0.3.6", + "version": "0.3.6", "artifacts": { "darwin-amd64": { "asset": "furrow-darwin-amd64", - "sha256": "afd6c5627909185f6ea7c284526937d1c709ee9b7ab3d6e8fc12b807c487af66" + "sha256": "f7d59faf0ebbe45fbe4abd82157a20d4277cec93b5a15bdb06b066edab7a3ce0" }, "darwin-arm64": { "asset": "furrow-darwin-arm64", - "sha256": "18a6afdf75aac9ae738ea5a75c3ecc7e6096fa915b72c2916fbdefd0f45a3396" + "sha256": "6c3b2361dcd75afce024a3c1eee8674e0b8495443f0fa2d4e4887daefdcae1cd" }, "linux-amd64": { "asset": "furrow-linux-amd64", - "sha256": "647c57a521637eb6930f77c1c29797bf15c34f598751756c70a19cea90d91827" + "sha256": "e16936f996dfc564a3d3a686da56ad2a21489fff9108da792293848e326831bb" }, "linux-arm64": { "asset": "furrow-linux-arm64", - "sha256": "da87e1067bbb5eee27dbd034a5274d6ae8c48b938d05c791ea0e5503932c78d8" + "sha256": "dabf64ebcf10cfaf0e83493cc8d9ad0bf3ac7c893db0fbabb1b14772a6701917" }, "windows-amd64": { "asset": "furrow-windows-amd64.exe", - "sha256": "bf4c60bacaea42b1a0cdb3c97279562fbadc73fea5bf69ca04d55f048d1a2cf1" + "sha256": "61f0e81ee9764e07a30403e804cfc15ea62d7ea0152163f423c92c56e9702ccc" }, "windows-arm64": { "asset": "furrow-windows-arm64.exe", - "sha256": "fedfab37475ab17bb3be4c8bdadaffcf77756aa3679815068d57ff3dd9e30861" + "sha256": "bba0bd08ff4777e5f8bc1415692da6adfccac5f6aac35c06c72fe5af5261b3b6" } } } diff --git a/internal/handoff/contract_regression_test.go b/internal/handoff/contract_regression_test.go index 8fcb080e64..552028b8bf 100644 --- a/internal/handoff/contract_regression_test.go +++ b/internal/handoff/contract_regression_test.go @@ -69,12 +69,10 @@ func TestTakeF2SameHolderSameHeadFenceChange(t *testing.T) { } } _, err := b.taker().TakeSeen(context.Background(), chatID, seen) - if change == "fence" && err != nil { - t.Fatalf("F2 harmless fence bump refused: %v", err) - } - if change != "fence" && !errors.Is(err, directory.ErrLeaseHeld) { - t.Fatalf("F2 %s must conflict: %v", change, err) + if !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("stale %s must conflict: %v", change, err) } + }) } } diff --git a/internal/identity/l4fix2_startup_test.go b/internal/identity/l4fix2_startup_test.go index 8b0399bb9b..37a2bf8f9f 100644 --- a/internal/identity/l4fix2_startup_test.go +++ b/internal/identity/l4fix2_startup_test.go @@ -225,4 +225,10 @@ func TestL4Fix2MemoryOnFirstStartCanJoin(t *testing.T) { if ok, err := identity.Joinable(home); err != nil || !ok { t.Fatalf("a first start with memory on cannot join: %v %v", ok, err) } + if err := vault.Capture(knowledge.Source{ID: "work", Session: "chat", Role: "user", TS: time.Now().UTC().Format(time.RFC3339Nano), Text: "Cedar queues jobs."}); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); err != nil || ok { + t.Fatalf("a home with a real memory source can join: %v %v", ok, err) + } } diff --git a/internal/knowledge/build.go b/internal/knowledge/build.go index 4c0bfa6bad..2bfdd48516 100644 --- a/internal/knowledge/build.go +++ b/internal/knowledge/build.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "os" "path/filepath" "time" @@ -12,6 +13,8 @@ import ( "github.com/Agent-Field/codeaf/internal/filelock" ) +const ordinaryBuildAttemptLimit = 3 + type BuildPhase string const ( @@ -139,7 +142,7 @@ func (v *Vault) Run(ctx context.Context, history History, key func() string) err return loadErr } job["phase"] = string(BuildFailed) - job["error"] = "Knowledge build paused: " + err.Error() + ". Completed progress is retained; use Build to retry or add new sources." + job["error"] = "Knowledge build paused: " + err.Error() + ". " + buildRetryNotice(number(job["build_attempts"])) saveErr := saveState(db, "job", job) db.Close() if saveErr != nil { @@ -154,6 +157,13 @@ func (v *Vault) Run(ctx context.Context, history History, key func() string) err } } } +func buildRetryNotice(attempts float64) string { + if attempts >= ordinaryBuildAttemptLimit { + return fmt.Sprintf("Completed progress is retained; automatic retries stopped after %d attempts; use Build or add new sources.", ordinaryBuildAttemptLimit) + } + return "Completed progress is retained; retrying automatically." +} + func waitBuild(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() @@ -340,9 +350,21 @@ func (v *Vault) runPass(ctx context.Context, history History, key func() string, if err = db.QueryRow("SELECT coalesce(max(rowid),0),count(*),coalesce(max(arrived),0) FROM events WHERE partition='profile' AND coalesce(json_extract(payload,'$.job'),0)=0").Scan(&latest, &admitted, &arrived); err != nil { return err } - attemptInput := identity([]any{latest, admitted, arrived, job["request"], knowledgeProjectionVersion}) - if job["failed_input"] == attemptInput { - return nil + // An empty vault has no build input or retry budget to persist. + if total > 0 { + attemptInput := identity([]any{latest, admitted, arrived, job["request"], knowledgeProjectionVersion}) + if job["build_input"] != attemptInput { + job["build_input"] = attemptInput + job["build_attempts"] = 0 + } + if number(job["build_attempts"]) >= ordinaryBuildAttemptLimit { + if job["phase"] != string(BuildFailed) { + job["phase"] = string(BuildFailed) + job["error"] = "Knowledge build interrupted. " + buildRetryNotice(number(job["build_attempts"])) + return saveState(db, "job", job) + } + return nil + } } job["problems"] = problems job["sources"] = total @@ -369,6 +391,8 @@ func (v *Vault) runPass(ctx context.Context, history History, key func() string, } } job["phase"] = string(BuildRunning) + // Reserve the attempt durably before compilation can spend money. + job["build_attempts"] = number(job["build_attempts"]) + 1 if err = saveState(db, "job", job); err != nil { return err } @@ -376,10 +400,10 @@ func (v *Vault) runPass(ctx context.Context, history History, key func() string, if errors.Is(err, ErrDisabled) { return nil } - job["failed_input"] = attemptInput - if saveErr := saveState(db, "job", job); saveErr != nil { - return saveErr - } + return err + } + job["build_attempts"] = 0 + if err = saveState(db, "job", job); err != nil { return err } var remaining int diff --git a/internal/knowledge/runtime_test.go b/internal/knowledge/runtime_test.go index 536e54172b..fc686e287a 100644 --- a/internal/knowledge/runtime_test.go +++ b/internal/knowledge/runtime_test.go @@ -2,16 +2,403 @@ package knowledge_test import ( "context" + "database/sql" "errors" "fmt" + "io" "net/http" "path/filepath" + "strings" + "sync/atomic" "testing" "time" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/knowledge" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/sqliteuri" ) +func TestKnowledgeOrdinaryBuildRetriesFailedInputAutomatically(t *testing.T) { + v := knowledge.Open(t.TempDir()) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + var attempts atomic.Int32 + withKnowledgeModel(t, func(payload map[string]any) map[string]any { + if payload["messages"] == nil { + return map[string]any{"subjects": []any{}} + } + return map[string]any{"claims": []any{map[string]any{"local_id": "c", "text": "Cedar queues jobs.", "topic": "Queues", "scope": "Cedar", "kind": "fact", "evidence": []any{map[string]any{"source": "good", "quote": "Cedar queues jobs."}}}}} + }) + respond := http.DefaultTransport + http.DefaultTransport = knowledgeTransport(func(r *http.Request) (*http.Response, error) { + if attempts.Add(1) == 1 { + raw := fmt.Sprintf(`{"model":%q,"choices":[{"finish_reason":"length","message":{"content":"{"}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"cost":0.125}}`, knowledge.Model) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(raw))}, nil + } + return respond.RoundTrip(r) + }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- v.Run(ctx, runtimeHistory{journals: []knowledge.Journal{{ID: "good", Session: "chat", Size: 1}}}, func() string { return "fixture" }) + }() + defer func() { + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Errorf("worker cancellation: %v", err) + } + }() + deadline := time.Now().Add(9 * time.Second) + failedMessage := "" + for time.Now().Before(deadline) { + state, err := v.Snapshot() + if err != nil { + t.Fatal(err) + } + if state.Build.Phase == knowledge.BuildFailed { + failedMessage = state.Build.Error + } + if state.Build.Phase == knowledge.BuildReady { + if !strings.Contains(failedMessage, "Completed progress is retained; retrying automatically.") { + t.Errorf("automatic retry notice: %q", failedMessage) + } + hits, err := v.Search("queues", 5) + if err != nil || len(hits) != 1 || state.Pending != 0 { + t.Fatalf("retried source was not published: hits=%+v pending=%d err=%v", hits, state.Pending, err) + } + t.Logf("automatic retry recovered: paid calls=%d", attempts.Load()) + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("unchanged failed input did not retry automatically: attempts=%d notice=%q", attempts.Load(), failedMessage) +} + +func TestKnowledgeOrdinaryBuildStopsAfterThreePaidFailures(t *testing.T) { + v := knowledge.Open(t.TempDir()) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + failKnowledgeRequests(t, &calls) + ctx, cancel := context.WithTimeout(context.Background(), 38*time.Second) + defer cancel() + if err := v.Run(ctx, runtimeHistory{journals: []knowledge.Journal{{ID: "good", Session: "chat", Size: 1}}}, func() string { return "fixture" }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + state, err := v.Snapshot() + t.Logf("paid calls=%d; error=%q", calls.Load(), state.Build.Error) + if err != nil || calls.Load() != 3 || state.Build.Phase != knowledge.BuildFailed || !strings.Contains(state.Build.Error, "use Build or add new sources") { + t.Fatalf("unchanged failed input must stop after 3 paid calls: state=%+v calls=%d err=%v", state.Build, calls.Load(), err) + } +} + +func TestKnowledgeOrdinaryBuildRetryCountSurvivesRestart(t *testing.T) { + profile := t.TempDir() + v := knowledge.Open(profile) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + captureRuntimeSource(t, v, "first") + var calls atomic.Int32 + failKnowledgeRequests(t, &calls) + for want := int32(1); want <= 3; want++ { + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, want, want == 3) + } + assertKnowledgeBuildIdle(t, knowledge.Open(profile), &calls, 3) +} + +func TestKnowledgeOrdinaryBuildInterruptedFinalAttemptStopsAfterRestart(t *testing.T) { + profile := t.TempDir() + v := knowledge.Open(profile) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + captureRuntimeSource(t, v, "first") + var calls atomic.Int32 + failKnowledgeRequests(t, &calls) + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, 1, false) + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, 2, false) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + failing := http.DefaultTransport + http.DefaultTransport = knowledgeTransport(func(r *http.Request) (*http.Response, error) { + response, err := failing.RoundTrip(r) + cancel() + return response, err + }) + if err := v.Run(ctx, runtimeHistory{}, func() string { return "fixture" }); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + assertKnowledgeBuildIdle(t, knowledge.Open(profile), &calls, 3) +} + +func TestKnowledgeOrdinaryBuildRetryCountRestarts(t *testing.T) { + for _, action := range []string{"new source", "Build"} { + t.Run(action, func(t *testing.T) { + profile := t.TempDir() + v := knowledge.Open(profile) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + captureRuntimeSource(t, v, "first") + var calls atomic.Int32 + failKnowledgeRequests(t, &calls) + for want := int32(1); want <= 3; want++ { + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, want, want == 3) + } + if action == "Build" { + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + } else { + captureRuntimeSource(t, v, "second") + } + for want := int32(4); want <= 6; want++ { + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, want, want == 6) + } + assertKnowledgeBuildIdle(t, knowledge.Open(profile), &calls, 6) + }) + } +} + +func TestKnowledgeOrdinaryBuildSuccessClearsRetryCount(t *testing.T) { + profile := t.TempDir() + v := knowledge.Open(profile) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + // A successful 64-source batch must clear the two preceding failures, + // allowing three attempts for the last source without changing the input. + for i := 0; i < 65; i++ { + captureRuntimeSource(t, v, fmt.Sprintf("source-%02d", i)) + } + var calls atomic.Int32 + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + http.DefaultTransport = knowledgeTransport(func(*http.Request) (*http.Response, error) { + finish, result := "length", "{" + if calls.Add(1) == 3 { + finish, result = "stop", `{"claims":[]}` + } + raw := fmt.Sprintf(`{"model":%q,"choices":[{"finish_reason":%q,"message":{"content":%q}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"cost":0.125}}`, knowledge.Model, finish, result) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(raw))}, nil + }) + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, 1, false) + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, 2, false) + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, 4, false) + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, 5, false) + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, 6, true) + assertKnowledgeBuildIdle(t, knowledge.Open(profile), &calls, 6) + state, err := v.Snapshot() + if err != nil || state.Pending != 1 { + t.Fatalf("successful batch progress was lost: pending=%d err=%v", state.Pending, err) + } +} + +func TestKnowledgeOrdinaryBuildLoadsLegacyFailedInput(t *testing.T) { + profile := t.TempDir() + v := knowledge.Open(profile) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + captureRuntimeSource(t, v, "first") + // Seed an old database; all assertions use the worker and its public status. + uri := sqliteuri.File(filepath.Join(v.Directory(), "knowledge.sqlite")) + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`UPDATE state SET payload=json_set(payload,'$.failed_input','old-input') WHERE key='job'`) + closeErr := db.Close() + if err != nil || closeErr != nil { + t.Fatalf("legacy fixture: %v, %v", err, closeErr) + } + var calls atomic.Int32 + failKnowledgeRequests(t, &calls) + for want := int32(1); want <= 3; want++ { + runToKnowledgeFailure(t, knowledge.Open(profile), &calls, want, want == 3) + } + assertKnowledgeBuildIdle(t, knowledge.Open(profile), &calls, 3) +} + +func captureRuntimeSource(t *testing.T, v *knowledge.Vault, id string) { + t.Helper() + if err := v.Capture(knowledge.Source{ID: id, Session: "chat", Role: "user", TS: "2026-10-25T12:00:00Z", Text: "Cedar queues jobs."}); err != nil { + t.Fatal(err) + } +} + +func runToKnowledgeFailure(t *testing.T, v *knowledge.Vault, calls *atomic.Int32, want int32, stopped bool) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- v.Run(ctx, runtimeHistory{}, func() string { return "fixture" }) }() + defer func() { + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("worker cancellation: %v", err) + } + case <-time.After(3 * time.Second): + t.Error("worker did not stop") + } + }() + notice := "retrying automatically" + if stopped { + notice = "use Build or add new sources" + } + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + got := calls.Load() + state, err := v.Snapshot() + if err != nil { + t.Fatal(err) + } + // Read the call count before the status so an old failure cannot satisfy a new attempt. + if got == want && state.Build.Phase == knowledge.BuildFailed && strings.Contains(state.Build.Error, notice) { + t.Logf("paid calls=%d; error=%q", want, state.Build.Error) + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("worker did not persist failure at paid calls=%d (got %d)", want, calls.Load()) +} + +func assertKnowledgeBuildIdle(t *testing.T, v *knowledge.Vault, calls *atomic.Int32, want int32) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2200*time.Millisecond) + defer cancel() + if err := v.Run(ctx, runtimeHistory{}, func() string { return "fixture" }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + state, err := v.Snapshot() + t.Logf("after restart and idle checks: paid calls=%d; error=%q", calls.Load(), state.Build.Error) + if err != nil || calls.Load() != want || state.Build.Phase != knowledge.BuildFailed || !strings.Contains(state.Build.Error, "use Build or add new sources") { + t.Fatalf("stopped build changed: state=%+v calls=%d want=%d err=%v", state.Build, calls.Load(), want, err) + } +} + +func failKnowledgeRequests(t *testing.T, calls *atomic.Int32) { + t.Helper() + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + http.DefaultTransport = knowledgeTransport(func(*http.Request) (*http.Response, error) { + calls.Add(1) + raw := fmt.Sprintf(`{"model":%q,"choices":[{"finish_reason":"length","message":{"content":"{"}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"cost":0.125}}`, knowledge.Model) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(raw))}, nil + }) +} + +func TestKnowledgeMemoryOffDoesNotBuild(t *testing.T) { + for _, journals := range [][]knowledge.Journal{nil, {{ID: "saved", Session: "chat", Size: 1}}} { + t.Run(fmt.Sprintf("journals=%d", len(journals)), func(t *testing.T) { + profile := t.TempDir() + if err := config.WriteMemoryEnabled(profile, false); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + v := knowledge.Open(profile).WithCalls(func(ctx context.Context) (context.Context, error) { + calls.Add(1) + return nil, errors.New("memory off must not compile") + }) + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + http.DefaultTransport = knowledgeTransport(func(*http.Request) (*http.Response, error) { + calls.Add(1) + return nil, errors.New("memory off must not call a provider") + }) + ctx, cancel := context.WithTimeout(context.Background(), 2200*time.Millisecond) + defer cancel() + if err := v.Run(ctx, runtimeHistory{journals: journals}, func() string { return "fixture" }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + t.Logf("memory off: paid calls=%d", calls.Load()) + if err := v.Flush(context.Background(), "fixture", true); !errors.Is(err, knowledge.ErrDisabled) { + t.Fatalf("explicit compilation while off: %v", err) + } + state, err := v.Snapshot() + if err != nil || state.Enabled || state.Pending != 0 || len(state.Pages) != 0 || state.Build.Phase != knowledge.BuildUnbuilt || calls.Load() != 0 { + t.Fatalf("memory off built or opted in: state=%+v calls=%d err=%v", state, calls.Load(), err) + } + }) + } +} + +func TestKnowledgeFactoryBuildRetainsOwnedFailureAndAccounting(t *testing.T) { + v := knowledge.Open(t.TempDir()) + if err := v.StartBuild(); err != nil { + t.Fatal(err) + } + for _, session := range []string{"stage", "other-job"} { + if err := v.Capture(knowledge.Source{ID: session, Session: session, Job: true, Role: "user", TS: "2026-10-25T12:00:00Z", Text: "Cedar queues jobs."}); err != nil { + t.Fatal(err) + } + } + var calls atomic.Int32 + var billed []provider.Billed + var unowned atomic.Int32 + knowledge.SetUsageRecorder(func(context.Context, provider.Billed) { unowned.Add(1) }) + t.Cleanup(func() { knowledge.SetUsageRecorder(nil) }) + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + http.DefaultTransport = knowledgeTransport(func(r *http.Request) (*http.Response, error) { + if provider.SessionFrom(r.Context()) != "stage" || provider.BillingSinkFrom(r.Context()) == nil { + return nil, errors.New("Factory call lost its owner or billing sink") + } + result := `{"subjects":[]}` + finish := "stop" + switch calls.Add(1) { + case 1: + finish, result = "length", `{` + case 2: + result = `{"claims":[{"local_id":"c","text":"Cedar queues jobs.","topic":"Queues","scope":"Cedar","kind":"fact","evidence":[{"source":"stage","quote":"Cedar queues jobs."}]}]}` + } + raw := fmt.Sprintf(`{"model":%q,"choices":[{"finish_reason":%q,"message":{"content":%q}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"cost":0.125}}`, knowledge.Model, finish, result) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(raw))}, nil + }) + owned := v.WithCalls(func(ctx context.Context) (context.Context, error) { + ctx = provider.WithSession(ctx, "stage") + return provider.WithBilling(ctx, func(b provider.Billed) { billed = append(billed, b) }), nil + }) + ctx := knowledge.WithOwnerSessions(context.Background(), []string{"stage"}) + if err := owned.Flush(ctx, "fixture", true); err == nil || !strings.Contains(err.Error(), "length") { + t.Fatalf("Factory failure must return to its caller: %v", err) + } + state, err := v.Snapshot() + if err != nil || state.Pending != 2 || len(state.Pages) != 0 { + t.Fatalf("failed Factory build lost pending sources: %+v %v", state, err) + } + background, cancel := context.WithTimeout(context.Background(), 2200*time.Millisecond) + defer cancel() + if err := v.Run(background, runtimeHistory{}, func() string { return "fixture" }); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatalf("ordinary worker retried Factory work: calls=%d", calls.Load()) + } + if err := owned.Flush(ctx, "fixture", true); err != nil { + t.Fatalf("owner could not retry failed compilation: %v", err) + } + state, err = v.Snapshot() + hits, searchErr := v.Search("queues", 5) + if err != nil || searchErr != nil || state.Pending != 1 || len(hits) != 1 || len(hits[0].Page.Evidence) != 1 || hits[0].Page.Evidence[0].Source != "stage" { + t.Fatalf("Factory retry crossed job ownership: state=%+v hits=%+v errors=%v, %v", state, hits, err, searchErr) + } + if len(billed) != int(calls.Load()) || len(billed) != 3 || unowned.Load() != 0 { + t.Fatalf("Factory attempts lost accounting: billed=%+v calls=%d unowned=%d", billed, calls.Load(), unowned.Load()) + } + t.Logf("Factory: paid calls=%d; owned receipts=%d; unowned=%d", calls.Load(), len(billed), unowned.Load()) + for _, bill := range billed { + if bill.Cost != 0.125 || bill.PromptTokens != 10 || bill.CompletionTokens != 2 { + t.Fatalf("Factory receipt changed: %+v", bill) + } + } +} + func TestKnowledgeConcurrentBuildWaitsAndCanBeCancelled(t *testing.T) { v := knowledge.Open(t.TempDir()) if err := v.StartBuild(); err != nil { diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 2e14d47e01..05d7f6843e 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -6,12 +6,11 @@ Moving chats and pairing are available by default on Linux, macOS and native Win Older chats move through an independent copy. The original files stay untouched and remain usable in older codeaf builds. -Pairing a computer that has never shared keeps and shares its existing chats, -full history, saved keys, memory and preferences. It asks no replacement question. -The chat list updates as background sharing finishes. Each computer's own chats -stay here; Continue here moves a chat from the other one. Reopen codeaf after an -interruption to finish pairing and retry sharing. A used computer already -sharing another set refuses and changes nothing. +A computer with submitted chats and its own pairing keys refuses ordinary +pairing without changing its chats. Use `--replace` to join the other set after +confirming: existing chats stay here but stop syncing, shared memories and +settings clear, and local provider keys stay here. A startup-only computer can +pair directly. Your chats and your work can follow you to a second device. You add it in two steps. There is no account to make and no form to fill. @@ -239,7 +238,11 @@ this device was removed by another of your devices, so this chat stays here only ## Can a removed computer rejoin — /devices, pairing again and retained local chats -`/devices` on a removed computer says the same line in place of the list. A terminal refusal uses `this device was removed by another of your devices, so this chat stays here only — run codeaf pair --replace to bring it back`. Home says `removed by another of your devices — pair again to sync`. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. It appears the moment the other device removes this one, even if codeaf has been open for days, in the open chat and on the home screen alike, and once only. Your chats on this device are safe and stay on it. They no longer follow your other devices. To bring the device back, run `codeaf pair --replace` here, then approve the link it shows from a device that is still in (the steps above); plain `codeaf pair` does not bring a removed device back. The desktop app's Pair a computer does this on its own. Rejoining keeps the same identity, so only the device key is replaced: existing chats stay here and sync again, and shared memories and settings are not cleared. Approve the new link from a computer still in your devices. The exception for a fresh computer does not apply here: a computer that has only opened codeaf, even with a provider connected, and whose chats contain only startup headers needs no `--replace` when it joins your devices for the first time, but a removed computer already holds your identity, so it always needs `--replace`; saved knowledge, automations, tracked work and unknown local files require the explicit replacement road in either case. An active, already-paired computer is refused with the names of its other devices; `codeaf devices` lists them. +`/devices` on a removed computer says the removal notice in place of the list. A terminal refusal uses `this device was removed by another of your devices, so this chat stays here only — run codeaf pair --replace to bring it back`. Home says `removed by another of your devices — pair again to sync`. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. Removal is shown in an open window and after restarting. Your local chats stay usable while this computer is removed. + +In the desktop app, choose Pair a computer and enter a current device's code or ask to join by link. After approval from a device still in your devices, this computer keeps the same identity, its local chats, shared memories and settings, and receives a fresh device key. Its chats can sync again. No replacement confirmation is needed. A computer that was never removed reports that nothing changed. + +The terminal command still requires `codeaf pair --replace` on a removed computer. This is explicit replacement, even with the same identity: existing chats are moved aside and stop syncing, shared memories and settings are cleared, and locally saved provider keys remain. Approve the link from a device still in your devices. An active, already-paired computer is refused with the names of its other devices; `codeaf devices` lists them. ## Pair again in the chat — replace confirmation keeps local chats diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 65475a3312..b96ef5cbaf 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -1,6 +1,6 @@ # Attaching files -## Pasting a lot of text +## How do I share a file with it — attach files or paste text A paste of **3 or more lines** becomes one compact `[paste 1 · 42 lines]` chip in the message box. One- and two-line pastes stay ordinary text. A paste made only of real file diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 6469e069f0..669c50ca31 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -338,8 +338,8 @@ command). | Command | Aliases | Argument | Effect | |---|---|---|---| | `/pair` | `/sync`, `/link`, `/laptop` | — | shows a code like `42-715-302`, valid 10 minutes, that gives your chats to another computer, then asks `y / n` when that computer types it | -| `/pair` | `/sync`, `/link`, `/laptop` | `` | on the other computer: uses a code from `/pair` there, shows three words to compare, and keeps and shares this solo computer's existing chats | -| `/pair` | `/sync`, `/link`, `/laptop` | ` --replace` | explicit clean slate: asks first and gives up this computer's shared state for the other computer's; ordinary pairing keeps and shares a solo computer's chats | +| `/pair` | `/sync`, `/link`, `/laptop` | `` | on the other computer: uses a code from `/pair` there, and shows three words to compare while it waits | +| `/pair` | `/sync`, `/link`, `/laptop` | ` --replace` | on the other computer: like ``, but a computer with chats of its own is asked first, and only an explicit `y` joins the shared chats; existing local chats stay here but stop syncing, shared memories and settings clear, and local provider keys stay here | | `/pair` | `/sync`, `/link`, `/laptop` | `` | on a device that is already paired: opens a new device's request from the link it shows, with its name, system, time and a check number to compare, then `a` approves and `d` declines | | `/devices` | — | — | lists your devices with `●` online and `○` away; `n` renames this device, `r` asks before removing the computer under the cursor; only `y` removes it and any other key keeps it, and a removed device can only return as a new request you approve (`/settings` → `Devices` holds the same list, including this computer and `Add another device`) | diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index dd964d028c..ee607d89d2 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -430,7 +430,7 @@ raises that question without running a turn or making a branch. A saved hold from an earlier process cannot bypass the question. A window whose writer still holds the chat sends with one Enter without asking the service first. -A new chat can send its first message normally. A sleeping computer can also resume sending when nobody else has taken the chat and its local saved turn is still current. That safe return happens without another question. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. +A new chat can send its first message normally. A sleeping computer whose ownership check finds an expired hold must use Continue here before sending again. Reopening the chat acquires a new ownership fence. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. ## A move that failed partway — are my typed words safe, can the old copy still send diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index c0654c65f2..57370deb1f 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -452,15 +452,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 @@ -469,20 +470,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 @@ -491,10 +497,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 @@ -514,12 +521,73 @@ 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 + +Paired computers exchange teams automatically while **Settings → General → +share teams** is on, its default. An unpaired computer is unaffected. 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 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. + +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 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? + +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 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.db.unreadable-` beside the original (or the +older layout's equivalent name). Keep it to recover the original 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 (`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/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index dcf7a9408e..64d7263df4 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -100,6 +100,17 @@ An open Home or compatibility events viewer pings every 10 seconds. The platform Connections naming held chats share a renewal cycle every 30 seconds on both relays while fresh hold evidence is available. Each cycle uses bounded batches; the 10-second presence pings between cycles do not commit another renewal. Presence alone never renews a chat. +## First connection and hosted read authority + +A new, empty identity can save its first change before the hosted relay finishes +preparing its other copies. Machine coordination can briefly ask you to retry +until that preparation finishes. Existing identities keep their voting group. + +Ordinary hosted reads reuse recently confirmed authority. Once that confirmation +expires, an unavailable majority makes reads fail rather than use stale device +permissions. Revocation and rotation remain committed changes; device activity +is advisory and can appear after a short delay. + ## Hosted relay memory capacity and retry After every voter has completed the group's idle barrier, the hosted relay frees its working memory for other households and keeps the saved directory in persistent storage. The next request reloads those copies and obtains a fresh majority response. Busy groups keep their memory. When busy groups fill the relay's memory allowance, another activation receives HTTP 503 with retry guidance; retrying after a group sleeps can succeed. diff --git a/internal/manual/chat/desktop-sync.md b/internal/manual/chat/desktop-sync.md index f508adb9f7..441e1bbf83 100644 --- a/internal/manual/chat/desktop-sync.md +++ b/internal/manual/chat/desktop-sync.md @@ -91,14 +91,14 @@ computers share a name. Cancel takes back the waiting code or ends the request. A wrong code shows the engine's sentence and does not add a computer. -Pairing a computer that has never shared keeps its chats, full history, saved -keys, memory and preferences without a replacement question. Existing chats -appear on both computers as sharing finishes in the background. Each computer's -own chats stay here; use Continue here to move another computer's chat. A failed +A computer with submitted chats and its own pairing keys refuses ordinary +pairing and explains replacement. Confirm `--replace` to join the other set: +local chats stay here but stop syncing, shared memories and settings clear, +and local provider keys stay here. Use Continue here to move a shared chat. A failed upload stays saved locally and is retried while codeaf is open or on the next start. Sync status says `Sharing this computer's chats.` during the pass and `Some chats could not be shared yet. They are still saved here; codeaf will try -again.` on failure, including for unopened chats. A used computer already sharing another set refuses and changes nothing. +again.` on failure, including for unopened chats. A refused pairing changes nothing. Finish any live work before joining another computer. After joining, the desktop restarts its engine and reads setup, devices, sync and the shared chat list again. These actions appear only when the engine advertises pairing support. @@ -150,8 +150,8 @@ refresh the row and choose again. There is no automatic retry. Unsent drafts and attachments remain yours. Changes arrive as engine events without desktop polling. An older chat that works in the home folder stays on that computer. -Continue here still works after a reconnect when the same computer holds the -same saved turn. A different holder or a newer saved turn needs a fresh choice. +Continue here requires the holder and ownership fence shown when you chose it. +A changed fence, holder or saved turn needs a fresh choice. Completed turns are shared while the chat stays open, including after a move. ## Does an idle desktop chat keep its hold on the relay @@ -179,9 +179,9 @@ sync or repeat settled preferences. While the relay is unreachable, an open chat continues to run and save on this computer. Unpublished turns retry automatically with waits capped at 60 seconds, without -another send, quit or restart. A lapsed hold that still belongs to this computer -is renewed when the relay returns; its saved work is then shared. The chat stays -writable and never names this computer as having continued it elsewhere. +another send, quit or restart. Once an ownership check finds the hold expired, +the window stops admitting work. Use Continue here to acquire a new hold. +Reopening a chat also acquires a new ownership fence. If another computer continued the chat meanwhile, this window becomes read-only and names that computer. Work saved here during the outage is kept through the diff --git a/internal/manual/chat/factory.md b/internal/manual/chat/factory.md index 69dc00cbf9..9b14f2ca33 100644 --- a/internal/manual/chat/factory.md +++ b/internal/manual/chat/factory.md @@ -395,6 +395,18 @@ The `factory_item` card's thinking (`think strong`, `think cheap`, `default` for After your yes, the answer names the thinking the item was saved with, step by step (`thinking write strong, test strong`; `default` resets the changed steps to usual), and the steps that did not take it: `left as they were: plan cheap`. A budget or note alone says nothing about thinking. If another field changes but no step needs the requested thinking, the answer says `thinking unchanged`. A later turn's opening note lists thinking per running step when they differ (`thinking plan cheap, write strong`), one word when they agree, and nothing when none was set. A level other than cheap, strong or default is refused before any card, as `Invalid arguments: effort is one of cheap, strong, default, or left out.` +## models that refuse thinking or reasoning effort + +If a Factory model explicitly rejects its thinking setting, codeaf retries once +without that setting. This covers stages and their helpers, personal messages to +job managers, reopened completed stages and their earlier rounds, runner manager +turns on an already-open conversation, brief writing, and the repository, pipeline +and topic page AI, including Step AI and My pipeline AI. Switching models or +connected accounts keeps recovery available. Recovery follows actual job ownership +or a Factory call: an ordinary conversation that merely offers Factory tools still +returns the model's refusal. An ordinary conversation borrowed for a Factory call +retains its usual refusal on later ordinary turns and errands. + ## which model a cheap or strong step runs on — small work, checks model, effort models A factory step marked `cheap` opens on the **small work** model, and a step marked `strong` on the **checks** model: the same two rows the settings sheet already shows, so changing either changes the factory's cheap or strong steps from their next run. A model picked for the step, or for the whole job, wins over its effort. A step with no effort word runs on the job's model or the factory's default. With one model for every task, every step runs on that one model, whatever its effort. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 9f11d7b53b..49ba485e55 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1327,8 +1327,6 @@ meanings. ## What is the chip above the message box — the thinking chip, `alt+e`, `/effort`, and how to make this one chat think harder -To make this one chat think harder, choose a higher effort with `alt+e` or `/effort`. - The line above the message box — the legend — names how hard the model will think about your next turn, immediately after the model that will be doing the thinking: The effort follows a colon with no space: `model:effort`. The model stays bold and @@ -3849,6 +3847,7 @@ Its usage counts toward the conversation and any owning factory job budget. It stops at the budget limit or when the conversation closes, and falls back quietly if generation fails or the connected engine lacks this capability. This is a desktop composer feature; terminal Tab behavior is unchanged. + ## Quitting before local saving finishes — no upload receipt yet The successful `saved here` receipt requires the chat engine to acknowledge that its local transcript and captured work finished saving. A network error alone, or an unanswered closing call, does not prove that. If local closing still has no receipt after about three seconds, it says `still saving on this computer — ctrl+c leaves the written turns here`. If you leave before local saving is confirmed, codeaf says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. A confirmed local save that is still uploading uses the successful sync-service receipt instead. A failed local write keeps the `codeaf could not save this chat` sentence. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 263e33e312..908186e104 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1689,16 +1689,16 @@ provider translates whichever one it was given for a model that reads the other. endpoint that cannot be given a number at all falls back to `high`, which is the honest answer: it cannot think harder than its own ceiling. -**None of this can fail a turn.** +**Budget repairs are automatic; unsupported thinking can still fail an ordinary turn.** - A model whose endpoint refuses the thinking budget has the budget dropped and the refusal remembered, so it costs one rejected request for that model and never a failed reply. - A model whose catalog row says it takes no reasoning knob at all is sent nothing about thinking. - If an unlisted endpoint says its model does not support thinking or reasoning, - codeaf retries the request once without the knob and remembers that capability - for the model until this process exits. That capability refusal is permanent; - it does not schedule the same failed turn again later. + only opted-in task clients retry once without the knob and remember the refusal + in that client. Ordinary clients return the refusal and keep the requested + effort; they do not inherit a task client's memo. - On a small context window the budget shrinks to the room the conversation leaves beside an answer. When less than 1,024 tokens of thinking would fit, the budget is dropped and the rung travels as `high`, so `xhigh` on a 32,000-token window is never refused for the diff --git a/internal/manual/chat/naming-a-device.md b/internal/manual/chat/naming-a-device.md index 48a4379628..6456c201ca 100644 --- a/internal/manual/chat/naming-a-device.md +++ b/internal/manual/chat/naming-a-device.md @@ -43,13 +43,13 @@ Edit it and press enter to save, ctrl+u to clear it, esc to leave it as it was. After a rename it says: ``` -This computer is now called atlas. +This computer is now called atlas on all your devices. ``` Your other devices show the new name the next time they list devices or draw a home row. If the sync service cannot be reached at that moment, the device keeps the name and says: ``` -This computer is now called atlas. Your other computers will see it once this computer is back online. +This computer is now called atlas. Your other devices will show it after this one next connects. ``` You do not type it again. A running paired desktop engine retries automatically, diff --git a/internal/manual/chat/permissions.md b/internal/manual/chat/permissions.md index fee469102c..6ef8e1a50a 100644 --- a/internal/manual/chat/permissions.md +++ b/internal/manual/chat/permissions.md @@ -601,9 +601,22 @@ stopping work, answering an existing consent question, or closing the session. A question already waiting for your answer keeps waiting. Project rules, conversation postures, `--yolo` and dangerous-command floors keep their -precedence. An unreadable rule set leaves the last working gate in force until -the settings can be read again. Over `--host`, change the engine machine's -settings; a local profile cannot replace a remote machine's rules. +precedence when the profile can be read. Over `--host`, change the engine +machine's settings; a local profile cannot replace a remote machine's rules. + +## Unreadable profile config.json — why every call asks for approval + +If the profile's `config.json` cannot be read, the conversation still opens, but every call asks you, including reads, +whatever posture or `--yolo` you selected. The prompt names the file and explains +that saved approval rules could not be read. Remembered approvals and the guardian +cannot answer these questions for you. A headless call with nobody to ask returns +an error naming the file. Once the file is readable, its rules apply again. +Malformed JSON still fails policy construction. + +Two kinds of work do not get this protection yet. A default Factory stage runs on +its own allow posture and does not read your saved rules, whether the file is +readable or not. A task child started by a conversation does not carry the failed +read over from its parent, so it can run a call your saved rules would deny. ## Default YOLO in interactive conversations and headless runs @@ -680,6 +693,10 @@ goes through the ordinary shell-command rules, including prompts and denials. ## `--yolo` — how do I let it run things without asking +If the profile's `config.json` cannot be read, `--yolo` still asks for every call +because saved denials cannot be known. Prompts explain the failed read and name +the file; headless calls refuse with an error naming it. + `codeaf chat --yolo` and `codeaf resume --yolo` stop the asking about ordinary work. The flag's own help reads: "run every tool without asking: the approval default becomes allow". diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 521fdd7ade..07aba4575d 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -791,17 +791,24 @@ characters. Search runs against the database index. Idle background checks do not launch refinement without pending sources. Failed ordinary-chat requests retain pending -work. The same failed input does not spend again automatically; choose Build -to retry or add new saved sources. Unreadable chats do not prevent other chats -from being processed; their incomplete status remains visible. The Knowledge -view over `--host` is not supported. +work and completed progress. For unchanged input, the background build allows +three compilation attempts in total, including the first attempt. The count +survives quitting and restarting. While attempts remain, the failure says +`Completed progress is retained; retrying automatically.` After three failed +attempts, automatic retries stop; use **Build** or add new sources to start a new +count. A successful compilation clears the count. One compilation attempt can +make several model requests, so this is an attempt limit rather than a +three-request spending cap. Jobs keep their own retry and accounting rules. + +Unreadable chats do not prevent other chats from being processed; their +incomplete status remains visible. The Knowledge view over `--host` is not +supported. Settings → Models → **knowledge** (`knowledge.model`) selects the OpenRouter model for the next compilation. The default is `deepseek/deepseek-v4.1-flash`. The compiler checks the returned model ID and rejects substitutions. -Stable source IDs prevent duplicate records during an explicit retry or -journal recovery. A failed or interrupted provider request can still incur charges; +Stable source IDs prevent duplicate records during retries and journal recovery. A failed or interrupted provider request can still incur charges; retry safety does not mean every network request is billed only once. ## Updating existing knowledge pages and sections diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 33adb6a85c..902c64793b 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -28,7 +28,7 @@ A question with more behind it than the block draws **opens out into a page of its own** — `O` opens it, `esc` closes it, and the sections below say what you can do in there. -## The squiggly line at the start of a card — the squiggle, tick and amber mark +## The squiggle (a squiggly line), the tick and the amber mark at the start of a card Every question opens with **one cell**, and it says whether the card in front of you is waiting for you at all: 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/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 16b413faf4..472dcafc56 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -665,7 +665,7 @@ by default on Linux and macOS on every build. Native Windows is supported too; a **`codeaf pair approve `** compares the four-digit check number and asks `y / n`: only `y` or `yes` approves, and `d`, Esc, Enter or anything else decline. Code pairing keeps its `y / n` question. -**`codeaf pair`** on a new computer asks to join your devices and shows a link to approve from a computer that is already in (`codeaf pair approve `); **`codeaf pair --code`** shows a code that gives your chats to another computer. **`codeaf pair `** uses a code that another computer showed, for example `codeaf pair 42-715-302`. `CODEAF_SYNC_URL=` or `--via ` keeps the invitation on that service's own pairing page and includes `--via` in the token command; it never prints the hosted page link for a self-hosted invitation. `--via ` names the sync address to go through, and ordinary pairing keeps and shares a solo computer's own chats, history, saved keys, memory and preferences. Terminal pairing prints “Sharing finishes the next time codeaf runs.” Start codeaf again to share existing chats, saved keys, memory and preferences in the background; reopen after an interruption to resume. A used computer already sharing another set refuses and changes nothing. Only explicit `--replace` chooses a clean slate, with its existing confirmation. The page *Pairing your chats with a second computer* has the whole story. +**`codeaf pair`** on a new computer asks to join your devices and shows a link to approve from a computer that is already in (`codeaf pair approve `); **`codeaf pair --code`** shows a code that gives your chats to another computer. **`codeaf pair `** uses a code that another computer showed, for example `codeaf pair 42-715-302`. `CODEAF_SYNC_URL=` or `--via ` keeps the invitation on that service's own pairing page and includes `--via` in the token command; it never prints the hosted page link for a self-hosted invitation. `--via ` names the sync address to go through, and a solo computer with submitted chats refuses ordinary pairing and explains `--replace`. Terminal pairing prints “Paired with . Run codeaf to open your chats.” Start codeaf again to share existing chats, saved keys, memory and preferences in the background; reopen after an interruption to resume. A computer with local work refuses and changes nothing. Explicit `--replace` confirms replacement: local chats stay here but stop syncing, shared memories and settings clear, and local provider keys stay here. The page *Pairing your chats with a second computer* has the whole story. ## The architecture map — codeaf atlas [map], /atlas [map], what talks to what, how two computers pair and continue a chat diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index 36cd13ac1e..c4780263d3 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -1,7 +1,5 @@ # Providers and services — the places models come from -## Services and connections — what older builds called providers - Older builds called these **services** (or connections, or model services); this page and the surfaces it describes now say **provider**. The word `codeaf services` in a shell is something else again: long-running background processes, covered by their own page. diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index e11ef4a708..ea35044ab1 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 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. ## AI teams settings versus an individual AI team @@ -199,3 +203,19 @@ A compilation already running finishes with its original model. The compiler verifies the model reported by the provider and rejects substitutions; it never falls back to a different model. Existing notes stay intact when you change this setting. Turn automatic learning on with the shared Memory setting. + +## Unreadable profile settings and saved spending limits + +If an existing `config.json` cannot be read, changing a setting refuses with +`write config: preserve existing file:` followed by the read error. The file is +left unchanged. Daily spending-limit reads return the read error instead of +substituting the default limit. Restore read access to resume these operations. +A missing file still uses new-profile defaults. + +Preference and saved-provider-key sharing also refuses unreadable profile data. +An unreadable profile does not by itself block interactive startup: tool approvals +fall back to prompting, with saved profile exceptions withheld. Headless approval +resolution also falls back to prompting. A missing profile keeps its usual defaults; +malformed JSON still reports an error. Spending-limit reads and settings writes +continue to refuse unreadable files unless an explicit daily-limit override supplies +the spending authority. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 9ef429daa4..f7b971954d 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4276,13 +4276,13 @@ The plan store allows up to 256 tasks in one added batch and 1024 tasks in one r A composite worker can wake at most four times to integrate its children. The node tree's 3/20 bounds do not apply to that run. -## How many tasks run at once — can I have it do two things at the same time, can you work on several parts of my answer at once +## How many tasks run at once, where is task.parallel, where is the tasks at once setting, can I have it do two things at the same time, can you work on several parts of my answer at once **There is no limit by default.** codeaf does not cap the number of tasks running at the same time. The setting `task.parallel` exists for anyone who wants a number anyway — settings panel -(`ctrl+,` or `/settings`), category "spending". Blank means no limit. A cap is a queue and +(`ctrl+,` or `/settings`), under Tasks as `tasks at once`. Blank means no limit. A cap is a queue and never a refusal: work past the cap waits and starts when a slot frees, and while it waits its roster row says `waiting · slot` on its hint line, and it sits under `Queued`, which starts folded to its heading. diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index f9cb9187d3..a896f0cb8e 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 copy made before the first exchange keeps the original teams. +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 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. + +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 copy made before the +first exchange keeps the original teams. 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 7902ed80fa..17bc38c991 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -32,6 +32,11 @@ Dashes and spaces in the code do not matter. `42-715-302`, `42 715 302` and `427 The code is good for 10 minutes. It works once. +## Share my conversation with my other computer + +Pair the computers with `/pair` or `codeaf pair`. A computer with submitted +chats requires `--replace`. Continue here moves a conversation to the other computer. + ## Sync my chats — how to sync between paired computers Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. @@ -102,11 +107,11 @@ laptop is now paired. Your chats can continue there. and the other computer says: ``` -Paired with laptop-a. Your chats are kept. Both computers' chats will appear as sharing finishes. +Paired with laptop-a. Run codeaf to open your chats. ``` When the other computer's name cannot be read yet, it says -`Paired. Your chats are kept. Both computers' chats will appear as sharing finishes.` +`Paired with your other device. Run codeaf to open your chats.` The code is now dead. It cannot pair a third computer. @@ -194,12 +199,13 @@ It is not a view of your chats. It is a copy of you. Pair only a computer you ow Pairing shares your chats. It does not share remote access. A paired computer is not a key to the tools on this machine. That is a different code, from `codeaf serve`. Read *Reaching a machine with a pairing code* for that door. The two doors are compared in a table on that page. -## Pairing a computer that already has chats — merge, keeping history, sharing progress and interrupted pairing +## Pairing a computer that already has chats — refusal and replacement -Pairing a computer that has never shared its chats keeps its chats, full history, -saved keys, memory and preferences. It asks no replacement question. Each -computer's own chats stay here; the other computer's chats appear with Continue -here. Continuing a chat moves it, just as before. +A computer whose conversations have no submitted turns can pair directly. If it already has its own saved pairing keys and chats with submitted turns, the refusal counts them and explains `--replace`. Chats made with `codeaf chat --once` before any pairing keys exist are kept on first pairing, and reach the newly joined computers the next time codeaf's window opens here. For three chats on a computer with its own keys: + +``` +this computer already has 3 chats of its own, so nothing was changed. To pair it anyway, run this again with --replace: those 3 chats stay on this computer but no longer sync; shared memories and settings are cleared +``` After approval, codeaf saves the pairing and keeps the saved keys readable before it says Paired. Existing chats are shared in the background. The chat list updates @@ -210,19 +216,18 @@ Keep codeaf open to retry. Failed chats retry with pauses growing from five seconds to one minute; completed chats are left alone. In the app, sharing saved keys, memory and preferences after pairing stops after its first successful pass. A home with no unfinished pairing makes no periodic requests for that pass. -Terminal pairing says `Sharing finishes the next time codeaf runs.` If pairing -or sharing is interrupted, reopen codeaf: it finishes the saved pairing and +Terminal pairing says `Paired with . Run codeaf to open your chats.` +If pairing or sharing is interrupted, reopen codeaf: it finishes the saved pairing and retries sharing the same chats without making duplicates. Older chats still use the independent-copy policy described above. -A used computer already sharing a different set refuses with -`This computer already shares chats with another set of computers; nothing was changed.` Its -chats stay readable where they are. +A computer with local work refuses without changing it. When there are +submitted chats, the refusal counts them and explains `--replace`. Only an explicit `codeaf pair --replace` or `/pair --replace` asks to give up this computer's shared state for the other computer's. The existing confirmation remains; a no or Esc leaves everything as it was. This clean-slate -option is never needed for a solo computer to bring its chats along. Old chat +option is required for a solo computer with submitted chats. Old chat folders are set aside outside the chat list and are not shared into the new set. ## Interrupted explicit replacement — clean slate and saved keys @@ -257,6 +262,10 @@ progress cannot be read, it says: `Pairing could not finish reading its saved progress; your chats are still on this computer.` +## 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. + ## Already paired — pairing the same computers again If the other computer already has the same chats, it says: @@ -267,7 +276,10 @@ already paired; nothing changed To pair a removed computer again, run `codeaf pair --replace` there and approve its link from a computer still in the set. It keeps the shared set and installs -a new device; the removed device stays listed as removed. +a new device; the removed device stays listed as removed. Replacement still sets +aside this computer's old chats and clears shared preferences when the account +is unchanged. If replacement is interrupted, reopening finishes it and preserves +writes made after the replacement was installed. ## I got a new laptop — move my chats to a new machine @@ -540,7 +552,7 @@ field. Use `codeaf pair approve ` there instead. ## I opened codeaf once without sending a turn — can this computer pair? -Yes. A computer that has only opened codeaf and quit without sending a turn can pair without `--replace`, including when a provider such as Ollama is connected. Its fetched model lists do not count as local work. Startup logs, locks, the empty chat header, and empty knowledge and automation stores do not count as your work. The name you enter for pairing is kept. +Yes. A computer that has only opened codeaf and quit without sending a turn can pair without `--replace`, including when a provider such as Ollama is connected. Its fetched model lists do not count as local work. Startup logs, locks, the empty chat header, and empty knowledge and automation stores do not count as your work. Turning memory on also leaves a home joinable while it has no sources to compile. Saved knowledge sources count as local work. The name you enter for pairing is kept. Once this computer has its own saved pairing keys, a sent turn, tracked work, a chat memory, an automation, a saved knowledge page or a stored conversation makes the computer used. Unknown files or database tables also keep the conservative local-work refusal. Existing personal memories in the graph keep their normal merge behavior. A computer with an unrelated shared account is never silently merged; use the explicit replacement road when appropriate. diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index 0490697a4f..d14ea82a42 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -646,7 +646,7 @@ one line on a failed row is the whole of what it says. Person and project memory follow your paired computers through the encrypted vault within one sync round, including hand-edited lines in the Memories place and lines written by older chats. No chat needs to be continued first. Concurrent edits of different lines both survive; for the same line the later edit wins, including forgetting a line. The service receives ciphertext only. -Machine-scoped memories describe one computer and stay there. An unpaired home sends nothing. Pairing a solo computer keeps its chats and saved keys and merges its memory and preferences without a replacement question. Only explicit terminal `--replace` chooses a clean slate for shared memory and preferences. Chat-written memory still rides in each chat's own saved ledger as well. +Machine-scoped memories describe one computer and stay there. An unpaired home sends nothing. A solo computer with submitted chats requires confirmed replacement to pair. A startup-only computer can pair directly and merge its memory and preferences. Explicit `--replace` clears shared memory and preferences. Chat-written memory still rides in each chat's own saved ledger as well. ## Memory in older local conversations and new moving chats diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 0cf2d48cdb..cde2eace88 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -948,6 +948,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"my cursor jumped to another task while I was reading", "tasks"}, {"why is the finished piece at the bottom of the family", "tasks"}, {"can codeaf parallelize my task", "tasks"}, + {"where is the tasks at once setting", "tasks"}, + {"what section is task.parallel under", "tasks"}, {"how do I open my tasks on a phone", "tasks"}, {"how do I get back from a task on my phone", "tasks"}, {"do tasks touch my working copy", "how-tasks-run"}, @@ -3575,6 +3577,29 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { } } +// #1454: task.parallel is on the Tasks tab as `tasks at once`, not under +// Spending. This checks the retrieved section's answer rather than only its +// page, so restoring the former category cannot leave the retrieval gate green. +func TestTaskParallelQuestionReachesItsCorrectedSettingsLocation(t *testing.T) { + const ( + question = "where is task.parallel" + title = "How many tasks run at once" + ) + for _, section := range Chat().Search(question, DefaultResults) { + if section.Page != "tasks" || !strings.Contains(section.Title, title) { + continue + } + if !strings.Contains(section.Body, "under Tasks as `tasks at once`") { + t.Errorf("task.parallel answer omits its visible settings location: %s", section.Body) + } + if strings.Contains(section.Body, `category "spending"`) { + t.Errorf("task.parallel answer still names the former category: %s", section.Body) + } + return + } + t.Fatalf("%q did not reach tasks · %q", question, title) +} + // #1469: home listed waiting standing orders without their due time, and a person // asking when one goes off there had to be sent to the standing place for it. // Standing orders are automations now (automations.md), and home's `automations` @@ -4611,6 +4636,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 }{ @@ -4652,15 +4678,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) @@ -4669,15 +4697,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"}, @@ -4812,3 +4843,30 @@ func TestFactoryBriefLaterRequestsReachInputs(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) + }) + } +} diff --git a/internal/pair/errors.go b/internal/pair/errors.go index f03c97070e..00ddbb0e86 100644 --- a/internal/pair/errors.go +++ b/internal/pair/errors.go @@ -148,8 +148,8 @@ var ( ErrRelayBusy = errors.New("sync is full right now; try again in a few minutes") // ErrRelayTooOld is a relay that does not know pairing yet. ErrRelayTooOld = errors.New("that sync service is too old for pairing") - // ErrDifferentChats is a used computer already sharing another set. - ErrDifferentChats = errors.New("This computer already shares chats with another set of computers; nothing was changed.") + // ErrDifferentChats is a computer that already has chats of its own. + ErrDifferentChats = errors.New("this computer has local work, so nothing was changed. To pair it anyway, run this again with --replace: local chats stay on this computer but no longer sync; shared memories and settings are cleared") // ErrAlreadyPaired is a computer that already holds the chats being offered. ErrAlreadyPaired = errors.New(AlreadyPairedLine) // ErrCannotReach is a relay that does not answer, with its host after it. diff --git a/internal/pair/join.go b/internal/pair/join.go index c968555766..74ce7cf0c3 100644 --- a/internal/pair/join.go +++ b/internal/pair/join.go @@ -10,6 +10,7 @@ import ( "context" "crypto/rand" "errors" + "fmt" "slices" "github.com/Agent-Field/codeaf/internal/identity" @@ -35,10 +36,12 @@ type Joining struct { // Replace lets a computer that already has different chats give them up. Replace bool // Revoked says this computer's own device was removed. Rejoining the same - // identity then mints a new device key, as Replace does; nothing is given up. + // identity then mints a new device key without replacing local state. Revoked 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 @@ -49,6 +52,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 @@ -66,6 +71,15 @@ func Join(ctx context.Context, route Mailbox, j Joining, typed string, ui JoinUI if err != nil { return Joined{}, err } + // A solo identity cannot be the grant's identity, so known local work can + // refuse before the approver spends an approval or hears a success receipt. + if !j.Replace { + if _, err := identity.Load(j.Home); err == nil && identity.Solo(j.Home) { + if can, err := identity.Joinable(j.Home); err != nil || !can { + return Joined{}, differentChats(j.Home) + } + } + } if err := checkRelay(ctx, route); err != nil { return Joined{}, err } @@ -127,34 +141,41 @@ func joinOffer(label string, platform ...string) ([]byte, error) { return append([]byte(name), nonce...), nil } -// adopt takes the grant into this home, keeping a solo computer's chats and -// shared state. Their local objects stay in place; sync exports them under the -// shared keys and a new publication ledger after pairing. +// adopt takes the grant into this home. Submitted chats require explicit +// replacement; a startup-only home retains its state through the durable switch. func adopt(j Joining, grant Grant) (Joined, error) { return adoptWithDevice(j, grant, nil) } func adoptWithDevice(j Joining, grant Grant, dev *identity.Dev) (Joined, error) { - if err := Resume(j.Home, j.SaveSyncURL); err != nil { + recovered, err := j.resumeApproved() + if err != nil { + if recovered != nil { + return *recovered, err + } return Joined{}, err } + if recovered != nil { + held, err := identity.Load(j.Home) + if err != nil { + return *recovered, err + } + if held.ID() == grant.Identity.ID() { + return *recovered, nil + } + } // No-op and refusal paths must not even create a lock file. Recheck the // destination under the lock below before making any actual switch. held, err := identity.Load(j.Home) if err != nil && !errors.Is(err, identity.ErrNone) { return Joined{}, err } - // A removed computer holds the identity under a key the relay refuses, so - // "already joined" would leave it removed. Only a new device key returns it. - if err == nil && j.Revoked && held.ID() == grant.Identity.ID() { - j.Replace = true - } - if err == nil && !j.Replace && held.ID() == grant.Identity.ID() { + if err == nil && !j.Replace && !j.Revoked && held.ID() == grant.Identity.ID() { return Joined{Already: true}, nil } - if err == nil && !j.Replace && !identity.Solo(j.Home) && !slices.Contains(grant.Replaces, held.ID()) { + if err == nil && !j.Replace && held.ID() != grant.Identity.ID() && !slices.Contains(grant.Replaces, held.ID()) { if ok, err := identity.Joinable(j.Home); err != nil || !ok { - return Joined{}, ErrDifferentChats + return Joined{}, differentChats(j.Home) } } unlock, err := keys.LockHome(j.Home) @@ -162,13 +183,18 @@ func adoptWithDevice(j Joining, grant Grant, dev *identity.Dev) (Joined, error) return Joined{}, err } defer unlock() - if err := j.resume(); err != nil { + if _, err := j.resume(); err != nil { return Joined{}, err } held, err = identity.Load(j.Home) switch { case err == nil && held.ID() == grant.Identity.ID(): if !j.Replace { + // A removed device needs a fresh key, while its identity and local + // state stay in place. Only explicit replacement runs cleanup. + if j.Revoked { + return j.merge(grant, dev, held.CellKey()) + } return Joined{Already: true}, nil } // Explicit replacement after approval renews this computer's key even @@ -180,22 +206,20 @@ func adoptWithDevice(j Joining, grant Grant, dev *identity.Dev) (Joined, error) if err != nil && !errors.Is(err, identity.ErrNone) { return Joined{}, err } + joined := Joined{} if j.Replace { - return Joined{}, j.merge(grant, dev, nil) + return j.merge(grant, dev, nil) } merge := false - if err == nil && !j.Replace { - merge = identity.Solo(j.Home) - if !merge { - var checkErr error - merge, checkErr = identity.Joinable(j.Home) - if checkErr != nil { - return Joined{}, ErrDifferentChats - } + if err == nil { + var checkErr error + merge, checkErr = identity.Joinable(j.Home) + if checkErr != nil || !merge { + return Joined{}, differentChats(j.Home) } } if merge && err == nil { - return Joined{}, j.merge(grant, dev, held.CellKey()) + return j.merge(grant, dev, held.CellKey()) } if _, err := identity.Adopt(j.Home, grant.Identity, j.Replace || merge); err != nil { return Joined{}, adoptFailure(err) @@ -204,9 +228,9 @@ func adoptWithDevice(j Joining, grant Grant, dev *identity.Dev) (Joined, error) 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 @@ -235,3 +259,23 @@ func adoptFailure(err error) error { } return err } + +func differentChats(home string) error { + n, err := identity.ChatCount(home) + if err != nil { + return ErrDifferentChats + } + if n == 0 { + return ErrDifferentChats + } + count, kept := fmt.Sprintf("%d chats", n), fmt.Sprintf("those %d chats stay on this computer but no longer sync", n) + if n == 1 { + count, kept = "1 chat", "that chat stays on this computer but no longer syncs" + } + return differentChatError{"this computer already has " + count + " of its own, so nothing was changed. To pair it anyway, run this again with --replace: " + kept + "; shared memories and settings are cleared"} +} + +type differentChatError struct{ line string } + +func (e differentChatError) Error() string { return e.line } +func (e differentChatError) Unwrap() error { return ErrDifferentChats } diff --git a/internal/pair/l4_join_test.go b/internal/pair/l4_join_test.go index 51698d3cab..4b255a8812 100644 --- a/internal/pair/l4_join_test.go +++ b/internal/pair/l4_join_test.go @@ -1,6 +1,7 @@ package pair import ( + "errors" "os" "path/filepath" "strings" @@ -49,37 +50,49 @@ func TestL4LookedAroundCanPair(t *testing.T) { } } -// L4-H2d: the approver's startup instruction has no key-management vocabulary. -func TestL4ApprovalNeedsOneStart(t *testing.T) { - if ErrNotJoined.Error() != "open codeaf on this computer once, then approve again" { - t.Fatal(ErrNotJoined) +// L4-H2b: the refusal counts submitted chats and explains the replacement truthfully. +func TestL4PairCountsChats(t *testing.T) { + h := t.TempDir() + if _, err := identity.EnsureSolo(h); err != nil { + t.Fatal(err) } -} - -// A solo computer with chats of its own may join: app/v3 merges its chats into -// the shared set instead of refusing or replacing them (1c4ec8a25, #2031), and -// the joined line says so. Dev's count-and-refuse wording (#2087) does not -// apply on this branch. -func TestL4PairKeepsAComputersOwnChats(t *testing.T) { - for _, chats := range []int{1, 3} { - h := t.TempDir() - if _, err := identity.EnsureSolo(h); err != nil { + for _, name := range []string{"a", "b", "c"} { + p := filepath.Join(h, "v3/projects/project", name, ".cell") + if err := os.MkdirAll(p, 0700); err != nil { t.Fatal(err) } - for i := 0; i < chats; i++ { - p := filepath.Join(h, "v3/projects/project", string(rune('a'+i)), ".cell") - if err := os.MkdirAll(p, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(p, "transcript.jsonl"), []byte("{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}\n"), 0600); err != nil { - t.Fatal(err) - } - } - if _, err := (LinkJoining{Joining: Joining{Home: h}}).free(); err != nil { - t.Fatalf("%d chats: a computer with its own chats was refused: %v", chats, err) - } - if !strings.Contains(JoinedLine, "Your chats are kept") { - t.Fatal(JoinedLine) + if err := os.WriteFile(filepath.Join(p, "transcript.jsonl"), []byte("{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}\n"), 0600); err != nil { + t.Fatal(err) } } + _, err := (LinkJoining{Joining: Joining{Home: h}}).free() + if !errors.Is(err, ErrDifferentChats) || !strings.Contains(err.Error(), "already has 3 chats of its own") || !strings.Contains(err.Error(), "--replace") || !strings.Contains(err.Error(), "stay on this computer") { + t.Fatalf("%v", err) + } +} + +// L4-H2b: one chat is counted in the singular, as the hand walk read it. +func TestL4PairCountsOneChat(t *testing.T) { + h := t.TempDir() + if _, err := identity.EnsureSolo(h); err != nil { + t.Fatal(err) + } + p := filepath.Join(h, "v3/projects/project", "a", ".cell") + if err := os.MkdirAll(p, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(p, "transcript.jsonl"), []byte("{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}\n"), 0600); err != nil { + t.Fatal(err) + } + _, err := (LinkJoining{Joining: Joining{Home: h}}).free() + if !errors.Is(err, ErrDifferentChats) || !strings.Contains(err.Error(), "already has 1 chat of its own") || !strings.Contains(err.Error(), "that chat stays on this computer but no longer syncs") || strings.Contains(err.Error(), "those 1") { + t.Fatalf("%v", err) + } +} + +// L4-H2d: the approver's startup instruction has no key-management vocabulary. +func TestL4ApprovalNeedsOneStart(t *testing.T) { + if ErrNotJoined.Error() != "open codeaf on this computer once, then approve again" { + t.Fatal(ErrNotJoined) + } } diff --git a/internal/pair/l4_words_test.go b/internal/pair/l4_words_test.go index 3a0b02e40e..8916d229da 100644 --- a/internal/pair/l4_words_test.go +++ b/internal/pair/l4_words_test.go @@ -39,7 +39,7 @@ func TestL4LinkServiceMismatch(t *testing.T) { // L4-M12a through L4-M12c: both roads name the peer and end with the same // kept-chats sentence. func TestL4PairCompletionVoice(t *testing.T) { - if got := (Joined{With: "laptop-a"}).Sentence(); got != "Paired with laptop-a. Your chats are kept. Both computers' chats will appear as sharing finishes." { + if got := (Joined{With: "laptop-a"}).Sentence(); got != "Paired with laptop-a. Run codeaf to open your chats." { t.Fatal(got) } if got := PairedChatsLine("desk-b"); got != "desk-b is now paired. Your chats can continue there." { diff --git a/internal/pair/l4fix4_preflight_test.go b/internal/pair/l4fix4_preflight_test.go index 869cb7b47d..b98cb7fd39 100644 --- a/internal/pair/l4fix4_preflight_test.go +++ b/internal/pair/l4fix4_preflight_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/Agent-Field/codeaf/internal/identity" @@ -34,10 +35,9 @@ func preflightChat(t *testing.T, home string) string { return path } -// A solo home with chats of its own keeps and shares them, so it is never -// refused before the mailbox: every road reaches it, and nothing local changes -// until the other device has said yes. -func TestSoloJoinWithChatsReachesTheMailboxAndChangesNothing(t *testing.T) { +// L4-C: A used solo home refuses before even checking the mailbox. Replacement, +// startup-only homes and previously paired homes retain their existing roads. +func TestL4Fix4SoloJoinRefusesBeforeContactingMailbox(t *testing.T) { for _, tc := range []struct { name string used, paired, replace bool @@ -70,8 +70,16 @@ func TestSoloJoinWithChatsReachesTheMailboxAndChangesNothing(t *testing.T) { before := filesIn(t, home) box := &preflightMailbox{err: errors.New("mailbox reached")} _, err = Join(t.Context(), Mailbox{Box: box}, Joining{Home: home, Replace: tc.replace}, "42-715-302", newScreen()) - if !errors.Is(err, box.err) || box.calls != 1 { - t.Fatalf("join = %v; mailbox calls = %d", err, box.calls) + refuse := (tc.used || tc.name == "local work") && !tc.paired && !tc.replace + if refuse { + if !errors.Is(err, ErrDifferentChats) || box.calls != 0 { + t.Fatalf("join = %v; mailbox calls = %d", err, box.calls) + } + if tc.used && !strings.Contains(err.Error(), "already has 1 chat of its own") { + t.Fatal(err) + } + } else if !errors.Is(err, box.err) || box.calls != 1 { + t.Fatalf("existing road = %v; mailbox calls = %d", err, box.calls) } same(t, before, filesIn(t, home)) if held, err := identity.Load(home); err != nil || held.ID() != original.ID() { diff --git a/internal/pair/lines.go b/internal/pair/lines.go index 0b9beabbb9..df087c5dc5 100644 --- a/internal/pair/lines.go +++ b/internal/pair/lines.go @@ -39,12 +39,20 @@ func WaitingChatsLine(words string) string { return "Waiting for approval on your other device; it should show: " + words } -// JoinedLine is what the joining device says when it worked. -// keptChatsLine is the shared tail of every successful join: a solo computer's -// own chats stay, and the other computers' chats arrive as sharing finishes. -const keptChatsLine = ". Your chats are kept. Both computers' chats will appear as sharing finishes." +// 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 + "." +} -const JoinedLine = "Paired" + keptChatsLine +// 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 // AlreadyPairedLine is the no-change result on every pairing surface. const AlreadyPairedLine = "already paired; nothing changed" @@ -63,7 +71,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. @@ -174,14 +186,14 @@ func RenamedAloneLine(name string) string { // RenamedLine is what a computer in a fleet says after it was named and the // sync service has the new name, so every other device shows it. func RenamedLine(name string) string { - return RenamedAloneLine(name) + return fmt.Sprintf("This computer is now called %s on all your devices.", name) } // RenamedHereLine is what it says when the new name is kept here but the sync // service could not be reached: the next time this computer connects it tells // the others, so nothing needs to be typed again. func RenamedHereLine(name string) string { - return fmt.Sprintf("This computer is now called %s. Your other computers will see it once this computer is back online.", name) + return fmt.Sprintf("This computer is now called %s. Your other devices will show it after this one next connects.", name) } // NamePrompt is the question a person is asked while a computer joins: the name @@ -201,5 +213,5 @@ func PairedWithLine(name string) string { if name == "" { return JoinedLine } - return "Paired with " + name + keptChatsLine + return "Paired with " + name + openChatsLine } diff --git a/internal/pair/linkjoin.go b/internal/pair/linkjoin.go index 7ca2a6e07c..5d164966b1 100644 --- a/internal/pair/linkjoin.go +++ b/internal/pair/linkjoin.go @@ -75,14 +75,27 @@ func JoinByLink(ctx context.Context, reqs directory.Requests, j LinkJoining, ui return me.install(ctx, j, grant) } -// free checks that this home's pairing can be read. Whether the grant is the -// same set, its successor, or a different set is decided only once it arrives. +// free refuses a join that would overwrite chats, before any approval is spent. +// A computer that has only been started holds nothing to lose, so it joins +// without being told to replace; one that is used keeps its chats unless told. func (j LinkJoining) free() (LinkJoining, error) { if j.Replace { return j, nil } - if _, err := identity.Load(j.Home); err != nil && !errors.Is(err, identity.ErrNone) { - return j, err + if _, err := identity.Load(j.Home); err != nil { + return j, nil + } + // A removed paired computer may be returning to its own identity. The + // approved grant is checked by adoptWithDevice before any local switch. + if j.Revoked && !identity.Solo(j.Home) { + return j, nil + } + fresh, err := identity.Joinable(j.Home) + if err != nil { + return j, ErrDifferentChats + } + if !fresh { + return j, differentChats(j.Home) } return j, nil } @@ -189,13 +202,13 @@ func (a *asker) install(ctx context.Context, j LinkJoining, g LinkGrant) (LinkJo } joined, err := adoptWithDevice(j.Joining, g.Grant, &dev) if err != nil { - return LinkJoined{}, err + return LinkJoined{Joined: joined}, err } // Explicit re-pairing installs the approved device even in the same set, // so a removed device can return without changing the shared identity. if !joined.Already || j.Replace { if err := identity.Install(j.Home, g.Identity, dev); err != nil { - return LinkJoined{}, err + return LinkJoined{Joined: joined}, err } joined.Already = false } diff --git a/internal/pair/linkjoin_test.go b/internal/pair/linkjoin_test.go index 931c8afa06..37e02e7d9a 100644 --- a/internal/pair/linkjoin_test.go +++ b/internal/pair/linkjoin_test.go @@ -209,25 +209,18 @@ func TestJoinByLinkExpires(t *testing.T) { // M7b: after seeing the grant a used computer in another set refuses it, // retaining every local byte. Approval cannot grant permission to discard chats. -func TestM7JoinByLinkKeepsOwnChats(t *testing.T) { +func TestJoinByLinkKeepsOwnChats(t *testing.T) { r := newLinkRig(t) if _, err := identity.Ensure(r.new); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(r.new, "own-chat"), []byte("kept"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(r.new, "vault.enc"), []byte("x"), 0o600); err != nil { t.Fatal(err) } before := filesIn(t, r.new) - ui, done := r.start(t, t.Context()) - asking, err := r.approver.Look(t.Context(), waitInvite(t, ui).Ref) - if err != nil { - t.Fatal(err) - } - if err := r.approver.Approve(t.Context(), asking); err != nil { - t.Fatal(err) - } - if res := waitResult(t, done); !errors.Is(res.err, ErrDifferentChats) { - t.Fatalf("M7 refusal: %v", res.err) + _, err := JoinByLink(context.Background(), goneRequests{}, r.joining(false), &invites{got: make(chan Invite, 1)}) + if !errors.Is(err, ErrDifferentChats) { + t.Fatalf("joining over own chats ended with %v", err) } same(t, before, filesIn(t, r.new)) } @@ -310,7 +303,7 @@ func TestJoinByLinkAdoptsAnUnusedIdentity(t *testing.T) { // Anything a person made here (a vault, a chat store) makes the computer used, // so it keeps its set when an unrelated grant arrives. -func TestM7JoinByLinkKeepsAUsedIdentity(t *testing.T) { +func TestJoinByLinkKeepsAUsedIdentity(t *testing.T) { for _, name := range []string{"vault.enc", "graph.db", "v3", "something-new"} { r := newLinkRig(t) own, err := identity.Ensure(r.new) @@ -320,15 +313,7 @@ func TestM7JoinByLinkKeepsAUsedIdentity(t *testing.T) { if err := os.WriteFile(filepath.Join(r.new, name), []byte("x"), 0o600); err != nil { t.Fatal(err) } - ui, done := r.start(t, t.Context()) - asking, err := r.approver.Look(t.Context(), waitInvite(t, ui).Ref) - if err != nil { - t.Fatal(err) - } - if err := r.approver.Approve(t.Context(), asking); err != nil { - t.Fatal(err) - } - err = waitResult(t, done).err + _, err = JoinByLink(context.Background(), goneRequests{}, r.joining(false), &invites{got: make(chan Invite, 1)}) if !errors.Is(err, ErrDifferentChats) { t.Fatalf("%s: joining over a used computer ended with %v", name, err) } diff --git a/internal/pair/merge.go b/internal/pair/merge.go index 8dce6984e8..6102d411a2 100644 --- a/internal/pair/merge.go +++ b/internal/pair/merge.go @@ -48,20 +48,31 @@ type mergeRecord struct { Replace bool `json:"replace,omitempty"` ProfileDir string `json:"profile_dir,omitempty"` Phase string `json:"phase,omitempty"` + TeamsAside string `json:"teams_aside,omitempty"` + // Nil identifies older journals; new replacements explicitly record false before cleanup. + CleanupComplete *bool `json:"cleanup_complete,omitempty"` } // Resume completes an approved local merge before sync opens its clients. // The live chats and their engine store are never moved or rewritten. func Resume(home string, saveSyncURL func(string) error) error { - if _, err := os.Stat(filepath.Join(home, mergeFile)); errors.Is(err, os.ErrNotExist) { - return nil + _, err := (Joining{Home: home, SaveSyncURL: saveSyncURL}).resumeApproved() + return err +} + +// resumeApproved returns the recovered receipt when approved work was pending. +// The home lock covers both the saved progress and its installation. +func (j Joining) resumeApproved() (*Joined, error) { + if _, err := os.Stat(filepath.Join(j.Home, mergeFile)); errors.Is(err, os.ErrNotExist) { + return nil, nil } - unlock, err := keys.LockHome(home) + unlock, err := keys.LockHome(j.Home) if err != nil { - return err + return nil, err } defer unlock() - return (Joining{Home: home, SaveSyncURL: saveSyncURL}).resume() + joined, err := j.resume() + return &joined, err } func (j Joining) after(step string) error { @@ -71,28 +82,32 @@ func (j Joining) after(step string) error { return nil } -func (j Joining) merge(g Grant, dev *identity.Dev, oldKey []byte) error { +func (j Joining) merge(g Grant, dev *identity.Dev, oldKey []byte) (Joined, error) { if dev == nil { made, err := identity.NewDevice(g.Identity) if err != nil { - return err + return Joined{}, err } dev = &made } idDoc, err := g.Identity.Marshal() if err != nil { - return err + return Joined{}, err } devDoc, err := dev.Marshal() if err != nil { - return err + return Joined{}, err } rec := mergeRecord{OldKey: oldKey, Identity: idDoc, Device: devDoc, SyncURL: g.SyncURL, Replace: j.Replace, ProfileDir: j.ProfileDir} + if rec.Replace { + done := false + rec.CleanupComplete = &done + } if err := rec.save(j.Home); err != nil { - return err + return Joined{}, err } if err := j.after("recorded"); err != nil { - return err + return Joined{}, err } return j.resume() } @@ -117,90 +132,108 @@ func (rec mergeRecord) save(home string) error { return fsreplace.Rename(f.Name(), filepath.Join(home, mergeFile)) } -func (j Joining) resume() error { +func (j Joining) resume() (Joined, error) { + joined := Joined{} raw, err := os.ReadFile(filepath.Join(j.Home, mergeFile)) if errors.Is(err, os.ErrNotExist) { - return nil + return joined, nil } if err != nil { - return err + return joined, err } var rec mergeRecord if err := json.Unmarshal(raw, &rec); err != nil { - return errors.New("Pairing could not finish reading its saved progress; your chats are still on this computer.") + return joined, errors.New("Pairing could not finish reading its saved progress; your chats are still on this computer.") } + joined.TeamsAside = rec.TeamsAside id, err := identity.Unmarshal(rec.Identity) if err != nil { - return err + return joined, err } dev, err := identity.UnmarshalDev(rec.Device) if err != nil { - return err + return joined, err } if rec.Replace { if rec.Phase != "installed" { - // Preparation can precede a failed install or new writes under the - // old key. An installed destination proves the reset already ran, - // even if its process stopped before saving progress. - current, err := identity.Load(j.Home) - if err != nil && !errors.Is(err, identity.ErrNone) { - return err + // A prepared replacement can still receive old-state writes before + // installation. Only the journal's exact destination device proves + // installation, including when the account itself never changed. + current, err := identity.LoadDevice(j.Home) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return joined, err } - if current.ID() != id.ID() { - if err := replaceState(j.Home, rec.ProfileDir); err != nil { - return err + installed := err == nil && current.ID() == dev.ID() + // Older journals did not record cleanup. Their destination device + // still proves a completed installation, preserving admitted writes. + cleaned := rec.CleanupComplete == nil && installed || rec.CleanupComplete != nil && *rec.CleanupComplete + if !cleaned || !installed { + aside, replaceErr := replaceState(j.Home, rec.ProfileDir, func() bool { return true }) + if aside != "" { + rec.TeamsAside = aside + joined.TeamsAside = aside + } + if replaceErr == nil { + done := true + rec.CleanupComplete = &done + } + if err := rec.save(j.Home); err != nil { + return joined, errors.Join(replaceErr, err) + } + if replaceErr != nil { + return joined, replaceErr } } } } else { if _, err := keys.StageReseal(j.Home, rec.OldKey, id.CellKey()); err != nil { - return err + return joined, err } } if rec.Phase == "" { rec.Phase = "prepared" if err := rec.save(j.Home); err != nil { - return err + return joined, err } } if err := j.after("staged"); err != nil { - return err + return joined, err } if err := identity.Install(j.Home, id, dev); err != nil { - return err + return joined, err } rec.Phase = "installed" if err := rec.save(j.Home); err != nil { - return err + return joined, err } if err := j.after("installed"); err != nil { - return err + return joined, err } if err := keys.CommitReseal(j.Home); err != nil { - return err + return joined, err } if err := j.after("keys"); err != nil { - return err + return joined, err } if err := identity.EndSolo(j.Home); err != nil { - return err + return joined, err } if err := j.after("shared"); err != nil { - return err + return joined, err } if rec.SyncURL != "" && j.SaveSyncURL != nil { if err := j.SaveSyncURL(rec.SyncURL); err != nil { - return err + return joined, err } } if err := j.after("relay"); err != nil { - return err + return joined, err } // Save the background obligation before discarding local switch progress. if err := markSharing(j.Home, id.ID()); err != nil { - return err + return joined, err } - return os.Remove(filepath.Join(j.Home, mergeFile)) + return joined, os.Remove(filepath.Join(j.Home, mergeFile)) } func markSharing(home, id string) error { diff --git a/internal/pair/merge_test.go b/internal/pair/merge_test.go index 1eb70dd377..31a504e9b1 100644 --- a/internal/pair/merge_test.go +++ b/internal/pair/merge_test.go @@ -1,7 +1,6 @@ package pair import ( - "bytes" "errors" "os" "path/filepath" @@ -11,21 +10,13 @@ import ( "github.com/Agent-Field/codeaf/internal/keys" ) -// M1/M8: a used solo home joins without replacement, retaining readable chats -// and saved keys. M4: adoption writes nothing to the offering home. -func TestM1M4M8AdoptUsedSoloHome(t *testing.T) { +// A used solo home refuses adoption without changing chats or saved keys. +func TestAdoptUsedSoloHomeRequiresReplacement(t *testing.T) { r := newChatRig(t) if _, err := identity.EnsureSolo(r.homeB); err != nil { t.Fatal(err) } - chat := filepath.Join(r.homeB, "v3", "projects", "local", "saved", "transcript.jsonl") - want := []byte("first question\nfirst answer\nsecond question\nsecond answer\n") - if err := os.MkdirAll(filepath.Dir(chat), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(chat, want, 0600); err != nil { - t.Fatal(err) - } + preflightChat(t, r.homeB) v, err := keys.Open(r.homeB) if err != nil { t.Fatal(err) @@ -33,22 +24,12 @@ func TestM1M4M8AdoptUsedSoloHome(t *testing.T) { if err := v.Put("p/KEY", keys.Entry{Name: "KEY", Value: "kept", Scope: "p"}); err != nil { t.Fatal(err) } + before := filesIn(t, r.homeB) beforeA := filesIn(t, r.homeA) - joined, _, err := pairOnce(t, r, joiningAt(r.homeB)) - if err != nil || joined.Already || joined.Successor { - t.Fatalf("M1 join: %+v, %v", joined, err) - } - got, err := os.ReadFile(chat) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("M1 history changed: %q, %v", got, err) - } - v, err = keys.Open(r.homeB) - if err != nil { - t.Fatal(err) - } - if got, err := v.Get("p/KEY"); err != nil || got.Value != "kept" { - t.Fatalf("M1 saved key: %+v, %v", got, err) + if _, err := adopt(joiningAt(r.homeB), Grant{Identity: r.a}); !errors.Is(err, ErrDifferentChats) { + t.Fatalf("adoption = %v", err) } + same(t, before, filesIn(t, r.homeB)) same(t, beforeA, filesIn(t, r.homeA)) } @@ -69,24 +50,25 @@ func TestM7AdoptAnotherSetChangesNothing(t *testing.T) { same(t, before, filesIn(t, r.homeB)) } -func TestM1M6LinkFreeAcceptsUsedSoloAndDefersGrantChecks(t *testing.T) { +func TestLinkFreeRefusesUsedIdentity(t *testing.T) { home := t.TempDir() if _, err := identity.EnsureSolo(home); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(home, "own-chat"), []byte("kept"), 0600); err != nil { - t.Fatal(err) - } - if _, err := (LinkJoining{Joining: Joining{Home: home}}).free(); err != nil { - t.Fatalf("M1 link preflight: %v", err) + preflightChat(t, home) + before := filesIn(t, home) + if _, err := (LinkJoining{Joining: Joining{Home: home}}).free(); !errors.Is(err, ErrDifferentChats) { + t.Fatalf("solo preflight = %v", err) } - // Until the grant arrives this could also be the same set or a successor. + same(t, before, filesIn(t, home)) if err := identity.EndSolo(home); err != nil { t.Fatal(err) } - if _, err := (LinkJoining{Joining: Joining{Home: home}}).free(); err != nil { - t.Fatalf("M6 link preflight: %v", err) + before = filesIn(t, home) + if _, err := (LinkJoining{Joining: Joining{Home: home}}).free(); !errors.Is(err, ErrDifferentChats) { + t.Fatalf("paired preflight = %v", err) } + same(t, before, filesIn(t, home)) } // M5: an interrupted local switch is completed on startup, and retries retain @@ -99,13 +81,6 @@ func TestM5InterruptedMergeResumes(t *testing.T) { if err != nil { t.Fatal(err) } - v, err := keys.Open(home) - if err != nil { - t.Fatal(err) - } - if err := v.Put("p/KEY", keys.Entry{Name: "KEY", Value: "kept", Scope: "p"}); err != nil { - t.Fatal(err) - } other, _ := identity.Mint() stopped := errors.New("process stopped") var saved string @@ -120,13 +95,6 @@ func TestM5InterruptedMergeResumes(t *testing.T) { if _, err := adopt(j, grant); !errors.Is(err, stopped) { t.Fatalf("M5 stop at %s: %v", step, err) } - v, err = keys.Open(home) - if err != nil { - t.Fatal(err) - } - if got, err := v.Get("p/KEY"); err != nil || got.Value != "kept" { - t.Fatalf("M5 unreadable while interrupted: %+v, %v", got, err) - } if err := Resume(home, j.SaveSyncURL); err != nil { t.Fatal(err) } @@ -155,7 +123,7 @@ func TestM5InterruptedMergeResumes(t *testing.T) { } func TestM8InterruptedExplicitReplaceStaysClean(t *testing.T) { - for _, step := range []string{"recorded", "staged", "installed"} { + for _, step := range []string{"recorded", "staged", "installed", "keys", "shared", "relay"} { t.Run(step, func(t *testing.T) { home := t.TempDir() if _, err := identity.EnsureSolo(home); err != nil { @@ -172,6 +140,13 @@ func TestM8InterruptedExplicitReplaceStaysClean(t *testing.T) { if err != nil { t.Fatal(err) } + v, err := keys.Open(home) + if err != nil { + t.Fatal(err) + } + if err := v.Put("old", keys.Entry{Name: "OLD", Value: "old"}); err != nil { + t.Fatal(err) + } stopped := errors.New("process stopped") _, err = adopt(Joining{Home: home, Replace: true, After: func(at string) error { if at == step { @@ -182,12 +157,38 @@ func TestM8InterruptedExplicitReplaceStaysClean(t *testing.T) { if !errors.Is(err, stopped) { t.Fatalf("M8 interruption: %v", err) } + v, err = keys.Open(home) + if err != nil { + t.Fatal(err) + } + if step != "recorded" { + if _, err := v.Get("old"); err == nil { + t.Fatal("replacement retained old key") + } + } + if step == "installed" || step == "keys" || step == "shared" || step == "relay" { + if err := v.Put("new", keys.Entry{Name: "NEW", Value: "kept"}); err != nil { + t.Fatal(err) + } + } if err := Resume(home, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(chat); !os.IsNotExist(err) { t.Fatal("M8 old chat visible after recovery") } + v, err = keys.Open(home) + if err != nil { + t.Fatal(err) + } + if _, err := v.Get("old"); err == nil { + t.Fatal("recovery retained old key") + } + if step == "installed" || step == "keys" || step == "shared" || step == "relay" { + if got, err := v.Get("new"); err != nil || got.Value != "kept" { + t.Fatalf("recovery lost new key: %+v, %v", got, err) + } + } id, err := identity.Load(home) if err != nil || id.ID() != other.ID() { t.Fatal("M8 replacement did not finish") @@ -196,9 +197,8 @@ func TestM8InterruptedExplicitReplaceStaysClean(t *testing.T) { } } -// M1/M4/M6 exercise the real link approval service for used solo homes and -// a computer already in the same set, preserving its device certificate. -func TestM1M4M6JoinByLinkPreservesLocalState(t *testing.T) { +// A submitted chat refuses before a link is offered, even in the same set. +func TestJoinByLinkWithSubmittedChatsRequiresReplacement(t *testing.T) { for _, already := range []bool{false, true} { t.Run(map[bool]string{false: "used-solo", true: "already"}[already], func(t *testing.T) { r := newLinkRig(t) @@ -209,45 +209,14 @@ func TestM1M4M6JoinByLinkPreservesLocalState(t *testing.T) { } else if _, err := identity.EnsureSolo(r.new); err != nil { t.Fatal(err) } - chat := filepath.Join(r.new, "own-chat") - want := []byte("question 1\nanswer 1\nquestion 2\nanswer 2\n") - if err := os.WriteFile(chat, want, 0600); err != nil { - t.Fatal(err) - } - v, err := keys.Open(r.new) - if err != nil { - t.Fatal(err) - } - if err := v.Put("p/KEY", keys.Entry{Name: "KEY", Value: "kept", Scope: "p"}); err != nil { - t.Fatal(err) - } + preflightChat(t, r.new) before := filesIn(t, r.new) beforeA := filesIn(t, r.old) - ui, done := r.start(t, t.Context()) - asking, err := r.approver.Look(t.Context(), waitInvite(t, ui).Ref) - if err != nil { - t.Fatal(err) - } - if err := r.approver.Approve(t.Context(), asking); err != nil { - t.Fatal(err) - } - res := waitResult(t, done) - if res.err != nil || res.joined.Already != already { - t.Fatalf("M1/M6 join: %+v, %v", res.joined, res.err) - } - if got, _ := os.ReadFile(chat); !bytes.Equal(got, want) { - t.Fatal("M1 history changed") - } - v, err = keys.Open(r.new) - if err != nil { - t.Fatal(err) - } - if got, err := v.Get("p/KEY"); err != nil || got.Value != "kept" { - t.Fatalf("M1 saved key: %+v, %v", got, err) - } - if already { - same(t, before, filesIn(t, r.new)) + _, done := r.start(t, t.Context()) + if res := waitResult(t, done); !errors.Is(res.err, ErrDifferentChats) { + t.Fatalf("join=%v", res.err) } + same(t, before, filesIn(t, r.new)) same(t, beforeA, filesIn(t, r.old)) }) } diff --git a/internal/pair/review_fixes_test.go b/internal/pair/review_fixes_test.go index 65087d8615..955fd474a5 100644 --- a/internal/pair/review_fixes_test.go +++ b/internal/pair/review_fixes_test.go @@ -13,9 +13,9 @@ import ( "github.com/Agent-Field/codeaf/internal/keys" ) -// M1/M5: a writer opened before pairing must keep both saved keys when it -// overlaps the staged switch, and must remain usable after the switch. -func TestR1_1SavedKeyWriterOverlapsSwitch(t *testing.T) { +// A writer opened before confirmed replacement remains usable after the +// switch. Old keys are cleared; writes admitted after the switch survive. +func TestSavedKeyWriterOverlapsConfirmedReplacement(t *testing.T) { home := t.TempDir() v, err := keys.Open(home) if err != nil { @@ -29,7 +29,7 @@ func TestR1_1SavedKeyWriterOverlapsSwitch(t *testing.T) { t.Fatal(err) } written := make(chan error, 1) - j := Joining{Home: home, After: func(step string) error { + j := Joining{Home: home, Replace: true, After: func(step string) error { if step == "staged" { go func() { written <- v.Put("during", keys.Entry{Name: "DURING", Value: "kept"}) }() select { @@ -53,7 +53,10 @@ func TestR1_1SavedKeyWriterOverlapsSwitch(t *testing.T) { if err != nil { t.Fatal(err) } - for _, id := range []string{"before", "during", "after"} { + if _, err := again.Get("before"); err == nil { + t.Fatal("replacement retained the old saved key") + } + for _, id := range []string{"during", "after"} { if got, err := again.Get(id); err != nil || got.Value != "kept" { t.Errorf("saved key %s after switch = %+v, %v", id, got, err) } diff --git a/internal/pair/same_identity_replace_test.go b/internal/pair/same_identity_replace_test.go new file mode 100644 index 0000000000..0035b7969d --- /dev/null +++ b/internal/pair/same_identity_replace_test.go @@ -0,0 +1,113 @@ +package pair + +import ( + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/identity" + "os" + "path/filepath" + "testing" +) + +func TestSameIdentityReplacementClearsOldState(t *testing.T) { + home := t.TempDir() + id, err := identity.EnsureSolo(home) + if err != nil { + t.Fatal(err) + } + chat := filepath.Join(home, "v3", "projects", "local", "old-chat", "transcript.jsonl") + if err := os.MkdirAll(filepath.Dir(chat), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(chat, []byte("old chat"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "config.json"), []byte(`{"model.talk":"stub/old"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err := adopt(Joining{Home: home, Replace: true}, Grant{Identity: id}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(chat); !os.IsNotExist(err) { + t.Errorf("old chat still in live tree: %v", err) + } + raw, err := os.ReadFile(filepath.Join(home, "config.json")) + if err != nil { + t.Fatal(err) + } + var preferences map[string]json.RawMessage + if err := json.Unmarshal(raw, &preferences); err != nil { + t.Fatal(err) + } + if _, ok := preferences["model.talk"]; ok { + t.Errorf("old model.talk survived replacement: %s", raw) + } +} + +func TestSameIdentityReplacementRecoveryPreservesInstalledWrites(t *testing.T) { + for _, stop := range []string{"staged", "installed", "installed-before-progress"} { + t.Run(stop, func(t *testing.T) { + home := t.TempDir() + id, err := identity.EnsureSolo(home) + if err != nil { + t.Fatal(err) + } + stopped := errors.New("process stopped") + _, err = adopt(Joining{Home: home, Replace: true, After: func(step string) error { + if step == stop || stop == "installed-before-progress" && step == "installed" { + return stopped + } + return nil + }}, Grant{Identity: id}) + if !errors.Is(err, stopped) { + t.Fatalf("interruption: %v", err) + } + if stop == "installed-before-progress" { + // Simulate installation succeeding before the phase write reaches disk. + raw, err := os.ReadFile(filepath.Join(home, mergeFile)) + if err != nil { + t.Fatal(err) + } + var rec mergeRecord + if err := json.Unmarshal(raw, &rec); err != nil { + t.Fatal(err) + } + rec.Phase = "prepared" + if err := rec.save(home); err != nil { + t.Fatal(err) + } + } + chat := filepath.Join(home, "v3", "projects", "local", "during", "transcript.jsonl") + if err := os.MkdirAll(filepath.Dir(chat), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(chat, []byte("during"), 0600); err != nil { + t.Fatal(err) + } + config := filepath.Join(home, "config.json") + if err := os.WriteFile(config, []byte(`{"model.talk":"during/model"}`), 0600); err != nil { + t.Fatal(err) + } + if err := Resume(home, nil); err != nil { + t.Fatal(err) + } + _, chatErr := os.Stat(chat) + raw, err := os.ReadFile(config) + if err != nil { + t.Fatal(err) + } + var preferences map[string]json.RawMessage + if err := json.Unmarshal(raw, &preferences); err != nil { + t.Fatal(err) + } + _, hasModel := preferences["model.talk"] + if stop == "staged" { + if !os.IsNotExist(chatErr) || hasModel { + t.Fatalf("pre-install writes survived: chat=%v config=%s", chatErr, raw) + } + } else if chatErr != nil || string(preferences["model.talk"]) != `"during/model"` { + t.Fatalf("post-install writes lost: chat=%v config=%s", chatErr, raw) + } + }) + } +} diff --git a/internal/pair/state.go b/internal/pair/state.go index b05463bd39..5139dda8bf 100644 --- a/internal/pair/state.go +++ b/internal/pair/state.go @@ -10,7 +10,8 @@ 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 := "" // A clean slate must not offer the old chats to the new set on startup. // Set their folders aside rather than deleting locally readable history. for _, name := range []string{"projects", "cells", "sync"} { @@ -18,14 +19,14 @@ func replaceState(home, profile string) error { if _, err := os.Stat(from); os.IsNotExist(err) { continue } else if err != nil { - return err + return aside, err } kept, err := os.MkdirTemp(home, "replaced-chats-") if err != nil { - return err + return aside, err } if err := os.Rename(from, filepath.Join(kept, name)); err != nil { - return err + return aside, err } } if profile == "" { @@ -33,37 +34,51 @@ func replaceState(home, profile string) error { } 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", "vault.enc.next"} - if config.TeamsShareAt(profile) { - if _, err := teams.MoveAside(profile); err != nil && !os.IsNotExist(err) { - return err + if config.TeamsSharingAt(profile, paired) { + path, err := teams.MoveAside(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/state_test.go b/internal/pair/state_test.go index 726b35eb5e..a436602041 100644 --- a/internal/pair/state_test.go +++ b/internal/pair/state_test.go @@ -14,20 +14,13 @@ import ( // TestC30PairingMergesMemoryUnlessReplaceWasExplicit proves C30.4 at both // adoption doors: hand-edited personal lines survive a join; --replace clears // shared memory/preferences while preserving machine-owned lines and paths. -func TestM1M6M8PairingMergesMemoryUnlessReplaceWasExplicit(t *testing.T) { +func TestC30PairingMergesMemoryUnlessReplaceWasExplicit(t *testing.T) { for _, replace := range []bool{false, true} { t.Run(map[bool]string{false: "merge", true: "replace"}[replace], func(t *testing.T) { home := t.TempDir() if _, err := identity.EnsureSolo(home); err != nil { t.Fatal(err) } - chat := filepath.Join(home, "v3", "projects", "local", "own-chat") - if err := os.MkdirAll(chat, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(chat, "transcript.jsonl"), []byte("kept history\n"), 0600); err != nil { - t.Fatal(err) - } if err := os.WriteFile(filepath.Join(home, "config.json"), []byte(`{"model.talk":"stub/local","machine.path":"/keep"}`), 0600); err != nil { t.Fatal(err) } @@ -51,10 +44,6 @@ func TestM1M6M8PairingMergesMemoryUnlessReplaceWasExplicit(t *testing.T) { if _, err := adopt(Joining{Home: home, Replace: replace}, Grant{Identity: other}); err != nil { t.Fatal(err) } - _, chatErr := os.Stat(chat) - if replace != os.IsNotExist(chatErr) { - t.Fatalf("M8 clean slate=%v, old chat visible=%v", replace, chatErr == nil) - } if replace { if err := brain.Rebuild(); err != nil { t.Fatal(err) diff --git a/internal/pair/teams_relay_test.go b/internal/pair/teams_relay_test.go index 6913de5990..e7b6bba985 100644 --- a/internal/pair/teams_relay_test.go +++ b/internal/pair/teams_relay_test.go @@ -134,6 +134,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 { @@ -246,6 +250,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) @@ -272,7 +277,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) } @@ -456,6 +461,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) @@ -469,7 +475,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) } @@ -600,6 +606,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) @@ -623,7 +630,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) } @@ -692,6 +699,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") @@ -753,6 +762,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, @@ -793,7 +804,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 33fc4f5e69..ae425fe9d6 100644 --- a/internal/pair/teams_replace_test.go +++ b/internal/pair/teams_replace_test.go @@ -2,14 +2,18 @@ package pair import ( "bytes" + "errors" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/teams/teamstest" "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) @@ -30,7 +34,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-*[0-9]") @@ -60,8 +64,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) } @@ -79,7 +89,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 { @@ -89,3 +99,71 @@ 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() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: home}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if err := teams.Update(home, func(f *teams.File) error { f.Teams = []teams.Team{{ID: "local", Name: "harbor"}}; return nil }); err != nil { + t.Fatal(err) + } + before := teamstest.JSON(t, 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) + } + keptProfile := t.TempDir() + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.Rename(joined.TeamsAside+suffix, teams.Path(keptProfile)+suffix); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } + kept := teamstest.JSON(t, keptProfile) + if !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()) + } +} + +func TestInterruptedReplacementRetainsTeamsReceiptOnResume(t *testing.T) { + home := t.TempDir() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: home}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + if err := teams.Save(home, []teams.Team{{ID: "local", Name: "harbor"}}); err != nil { + t.Fatal(err) + } + other, err := identity.Ensure(t.TempDir()) + if err != nil { + t.Fatal(err) + } + interrupted := errors.New("interrupted after cleanup") + j := Joining{Home: home, Replace: true, TeamsPaired: func() bool { return true }, After: func(step string) error { + if step == "staged" { + return interrupted + } + return nil + }} + joined, err := adopt(j, Grant{Identity: other}) + if !errors.Is(err, interrupted) || joined.TeamsAside == "" { + t.Fatalf("interruption lost receipt: %+v, %v", joined, err) + } + j.After = nil + resumed, err := adopt(j, Grant{Identity: other}) + if err != nil || resumed.TeamsAside != joined.TeamsAside { + t.Fatalf("resume lost receipt: %+v, %v", resumed, err) + } + if _, err := os.Stat(resumed.TeamsAside); err != nil { + t.Fatal(err) + } +} diff --git a/internal/provider/client.go b/internal/provider/client.go index 6e113ef3f6..2127a847cf 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -50,7 +50,7 @@ type Config struct { // explicitly accepts an empty key. The ordinary keyless client remains the // first-run state and refuses before the wire. KeyOptional bool - // DropRefusedEffort lets a task child repair an explicit thinking refusal + // DropRefusedEffort lets a task or Factory client repair an explicit thinking refusal // without teaching other clients or processes on this home. DropRefusedEffort bool // BillingDoor is the person-facing name of a bound road. Empty is a service @@ -823,7 +823,7 @@ func (c *Client) sendRepaired(ctx context.Context, request *ai.Request, knobs ca // The compatible usage option earns only its own repair. Peeking at an // unrelated refusal must not admit it to the older learning road. repairable := response.StatusCode == http.StatusBadRequest && c.repairable(model, knobs) - dropEffort := c.config.DropRefusedEffort && c.refusedEffortRepairable(model, knobs, response.StatusCode) + dropEffort := (c.config.DropRefusedEffort || knobs.effort.dropRefused) && c.refusedEffortRepairable(model, knobs, response.StatusCode) if !foreign && !repairable && !dropEffort && !(streamUsage && usageRefusalStatus) { return response, nil } @@ -924,7 +924,7 @@ func (c *Client) resend(ctx context.Context, request *ai.Request, knobs callKnob // adapter knows how to answer. It is asked before the error body is touched, so // a 400 that could not be ours costs no extra read. func (c *Client) repairable(model string, knobs callKnobs) bool { - return c.resolveEffort(model, knobs.effort) != EffortNone || + return c.resolveEffort(model, knobs.effort) == EffortOff || c.resolveReasoningBudget(model, knobs.effort) > 0 || c.dialectFor(model) == cacheDialectBreakpoints || (len(knobs.reasoning) > 0 && !reasoningReplayRefused(model)) @@ -937,12 +937,6 @@ func (c *Client) repairable(model string, knobs callKnobs) bool { // first match winning, because a single 400 can name more than one field. func (c *Client) learn(model string, knobs callKnobs, payload []byte) []string { var learned []string - // Only an effort that was on the wire can have been refused. A request the - // endpoint ladder had already stripped teaches nothing about the model. - if !knobs.relaxed.has(relaxReasoning) && c.resolveEffort(model, knobs.effort) != EffortNone && refusesReasoning(payload) { - quirks.record(func(q *quirksStore) map[string]time.Time { return q.noReasoning }, model, time.Now()) - learned = append(learned, learnedReasoningUnsupported) - } if c.resolveEffort(model, knobs.effort) == EffortOff && refusesDisabledReasoning(payload) { noteReasoningMandatory(model) learned = append(learned, learnedReasoningMandatory) diff --git a/internal/provider/hints.go b/internal/provider/hints.go index 94d31c74b2..a2744df151 100644 --- a/internal/provider/hints.go +++ b/internal/provider/hints.go @@ -91,6 +91,8 @@ func CacheKeyFrom(ctx context.Context) string { // whose catalog entry does not confirm reasoning support, because an optional // harness default that 400s an unknown model would be a self-inflicted outage. type effortRequest struct { + dropRefused bool + effort Effort // budget is the thinking allowance in tokens that the two ladder rungs @@ -157,8 +159,17 @@ func ReasoningEffortFrom(ctx context.Context) Effort { return request.effort } +// WithRefusedEffortRecovery lets this call retry a model's explicit reasoning +// refusal without changing other calls through the same client. +func WithRefusedEffortRecovery(ctx context.Context) context.Context { + return context.WithValue(ctx, refusedEffortRecoveryKey{}, true) +} + +type refusedEffortRecoveryKey struct{} + func effortFrom(ctx context.Context) effortRequest { request, _ := ctx.Value(effortContextKey{}).(effortRequest) + request.dropRefused, _ = ctx.Value(refusedEffortRecoveryKey{}).(bool) if request.wall > 0 { request.lane = askedFor(ctx) } diff --git a/internal/provider/quirks.go b/internal/provider/quirks.go index fe316b0940..474814e213 100644 --- a/internal/provider/quirks.go +++ b/internal/provider/quirks.go @@ -66,8 +66,6 @@ type quirksStore struct { // fields. It is per model because one incompatible endpoint must not erase // continuity for every reasoning model routed through this process. noReasoningReplay map[string]time.Time - // noReasoning is process-local evidence that the model accepts no knob. - noReasoning map[string]time.Time // answerCut is the sixth learned fact and the only one that is a NUMBER // rather than a date: how many completion tokens a model spent before its // structured answer was cut off, keyed by model and by the lane that asked. @@ -101,7 +99,6 @@ type quirksStore struct { } var quirks = &quirksStore{ - noReasoning: map[string]time.Time{}, mandatory: map[string]time.Time{}, disableIgnored: map[string]time.Time{}, noCacheControl: map[string]time.Time{}, @@ -615,7 +612,6 @@ func (q *quirksStore) forget() { q.noCacheControl = map[string]time.Time{} q.noReasoningBudget = map[string]time.Time{} q.noReasoningReplay = map[string]time.Time{} - q.noReasoning = map[string]time.Time{} q.answerCut = map[string]int{} q.servedWindow = map[string]int{} q.contextLimits = map[string]ContextLimit{} diff --git a/internal/provider/quirks_test.go b/internal/provider/quirks_test.go index fad39c4fb6..e7ffdfd7c3 100644 --- a/internal/provider/quirks_test.go +++ b/internal/provider/quirks_test.go @@ -224,7 +224,6 @@ func quirksAt(t *testing.T, models ...string) string { delete(quirks.noCacheControl, normalizeModel(model)) delete(quirks.noReasoningBudget, normalizeModel(model)) delete(quirks.noReasoningReplay, normalizeModel(model)) - delete(quirks.noReasoning, normalizeModel(model)) } quirks.path = "" }) diff --git a/internal/provider/reasoning_sync_test.go b/internal/provider/reasoning_sync_test.go index ee84affd72..1789f23901 100644 --- a/internal/provider/reasoning_sync_test.go +++ b/internal/provider/reasoning_sync_test.go @@ -9,7 +9,7 @@ import ( "github.com/Agent-Field/codeaf/internal/effort" ) -func TestReasoningRefusalS2(t *testing.T) { +func TestOrdinaryReasoningRefusalIsNotRepairedS2(t *testing.T) { for _, message := range []string{`\"llama3.2:latest\" does not support thinking`, `This model does not support reasoning`} { t.Run(message, func(t *testing.T) { quirksAt(t, "ollama/llama3.2:latest") @@ -31,15 +31,15 @@ func TestReasoningRefusalS2(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + if _, err = client.CompleteWithMessages(ctx, userMessages("hello")); err == nil { t.Fatalf("S2 call %d: %v", i, err) } - if got := recorded.count(); got != i+2 { - t.Fatalf("S2 sent %d requests, want %d", got, i+2) + if got := recorded.count(); got != i+1 { + t.Fatalf("S2 sent %d requests, want %d", got, i+1) } } - if recorded.body(0)["reasoning"] == nil || recorded.body(1)["reasoning"] != nil || recorded.body(2)["reasoning"] != nil { - t.Fatal("S2 must remove reasoning from repair and future clients") + if recorded.body(0)["reasoning"] == nil || recorded.body(1)["reasoning"] == nil { + t.Fatal("ordinary clients must keep their requested reasoning") } }) } @@ -75,7 +75,7 @@ func TestReasoningRefusalIsPermanentS2(t *testing.T) { if !errors.As(err, &failure) || !failure.ReasoningUnsupported || !failure.OurRequest() { t.Fatalf("S2 refusal lost its permanent classification: %v", err) } - if recorded.count() != 2 { - t.Fatalf("S2 sent %d requests, want one refusal and one repair", recorded.count()) + if recorded.count() != 1 { + t.Fatalf("S2 sent %d requests, want one refusal without repair", recorded.count()) } } diff --git a/internal/provider/refused_effort_test.go b/internal/provider/refused_effort_test.go index 5b25f1d847..7b68a425e0 100644 --- a/internal/provider/refused_effort_test.go +++ b/internal/provider/refused_effort_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "sync" "sync/atomic" "testing" @@ -40,21 +41,16 @@ func TestATaskChildResendsWithoutEffortWhenTheModelSaysItCannotThink(t *testing. if _, err := os.Stat(filepath.Join(fixture.profile, quirksFile)); !os.IsNotExist(err) { t.Fatalf("the refusal escaped the client into the quirks file: %v", err) } - // The task client's memo stays its own: another client sends effort - // again. app/v3 then repairs an ordinary client's precise refusal too - // and remembers it for the process (f9c0f1a1c, landed in #2009), so - // the ordinary client costs one refusal and its repair, and a later - // task client sends without effort at once. fresh := fixture.client(t, false) - if _, err := fresh.CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil { - t.Fatalf("an ordinary client's refusal was not repaired: %v", err) - } - if bodies := fixture.requests(); len(bodies) != 5 || bodies[3]["reasoning"] == nil { + if _, err := fresh.CompleteWithMessages(ctx, userMessages("write KIWI")); err == nil { t.Fatal("another client inherited the task client's memo") } + if len(fixture.requests()) != 4 { + t.Fatal("the other client retried a refusal without opting in") + } freshTask := fixture.client(t, true) if _, err := freshTask.CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil || len(fixture.requests()) != 6 { - t.Fatalf("a later task client did not use the remembered refusal: %v", err) + t.Fatalf("another task client did not discover its own refusal: %v", err) } if _, err := client.CompleteWithMessages(ctx, userMessages("write KIWI"), ai.WithModel("another-model")); err != nil || len(fixture.requests()) != 8 { t.Fatalf("one model's memo leaked into another model: %v", err) @@ -105,21 +101,19 @@ func TestTaskEffortRepairMatchesOnlyTheExplicit400(t *testing.T) { } } -// An ordinary client also repairs the model's precise thinking refusal once: -// app/v3 treats that 400 as permanent (f9c0f1a1c, landed in #2009). The memo -// stays in the process and never reaches the quirks file. -func TestWithoutTheFlagARefusedEffortIsRepairedOnceForTheProcess(t *testing.T) { +// R10's stability rule keeps ordinary clients' explicit effort and 400 error. +func TestWithoutTheFlagARefusedEffortIsReturnedAsBefore(t *testing.T) { fixture := noThinkingEndpoint(t) ctx := WithConfiguredReasoningEffort(context.Background(), EffortLow) - if _, err := fixture.client(t, false).CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil { - t.Fatalf("ordinary refusal was not repaired: %v", err) + _, err := fixture.client(t, false).CompleteWithMessages(ctx, userMessages("write KIWI")) + if err == nil || !strings.Contains(err.Error(), `API error (400): "llama3.2:latest" does not support thinking`) { + t.Fatalf("ordinary refusal changed: %v", err) } - bodies := fixture.requests() - if len(bodies) != 2 || bodies[0]["reasoning"] == nil || bodies[1]["reasoning"] != nil { - t.Fatalf("want the refused request and one repair without effort: %+v", bodies) + if bodies := fixture.requests(); len(bodies) != 1 || bodies[0]["reasoning"] == nil { + t.Fatalf("ordinary call changed or retried: %+v", bodies) } if _, err := os.Stat(filepath.Join(fixture.profile, quirksFile)); !os.IsNotExist(err) { - t.Fatalf("the refusal escaped the process into the quirks file: %v", err) + t.Fatalf("ordinary refusal wrote a quirks file: %v", err) } } diff --git a/internal/provider/wire.go b/internal/provider/wire.go index 29c58b78b7..e03dafc9d7 100644 --- a/internal/provider/wire.go +++ b/internal/provider/wire.go @@ -502,10 +502,7 @@ func (c *Client) effortAsked(requested effortRequest) effortRequest { // resolveEffort decides whether the knob may travel, and in what shape. func (c *Client) resolveEffort(model string, requested effortRequest) Effort { - if quirks.recorded(func(q *quirksStore) map[string]time.Time { return q.noReasoning }, model) { - return EffortNone - } - if c.config.DropRefusedEffort { + if c.config.DropRefusedEffort || requested.dropRefused { if _, refused := c.refusedEffort.Load(normalizeModel(model)); refused { return EffortNone } diff --git a/internal/raftabi/abi.go b/internal/raftabi/abi.go index a1e6bbd23c..6e7a286230 100644 --- a/internal/raftabi/abi.go +++ b/internal/raftabi/abi.go @@ -206,6 +206,8 @@ func (r *Runtime) call(op uint32, d *decoder) ([]byte, error) { return v1Commands(d) case 41, 42, 43, 44, 45: return r.pagedApplication(op, d) + case 49: + return r.formation(d) case 39: return hostedForward(d) case 36: diff --git a/internal/raftabi/formation.go b/internal/raftabi/formation.go new file mode 100644 index 0000000000..3943dc8277 --- /dev/null +++ b/internal/raftabi/formation.go @@ -0,0 +1,27 @@ +package raftabi + +import ( + cs "github.com/Agent-Field/codeaf/internal/clusterstate" +) + +func (r *Runtime) formation(d *decoder) ([]byte, error) { + node := r.nodes[d.u32()] + if node == nil { + return nil, errFrame + } + var manifest cs.LegacyManifest + raw, birth, registry := d.blob(), d.blob(), d.blob() + if d.end() != nil || cs.Decode(raw, &manifest) != nil || !validSingleton(manifest, birth) || len(registry) > 0 { + return nil, errFrame + } + plan, err := node.PlanFormation(manifest.Config.Anchors) + if err != nil { + return nil, err + } + e := encoder{} + e.blob(plan.Change) + e.flag(plan.Checkpoint) + e.flag(plan.Settle) + e.flag(plan.Configure) + return e.b, nil +} diff --git a/internal/raftabi/hosted.go b/internal/raftabi/hosted.go index 9902a5ee36..0f523b3da6 100644 --- a/internal/raftabi/hosted.go +++ b/internal/raftabi/hosted.go @@ -22,7 +22,7 @@ func hostedEffect(d *decoder) ([]byte, error) { if cs.Decode(raw, &change) != nil && (j.Decode(&change) != nil || j.Decode(new(any)) != io.EOF) { return nil, errFrame } - if change.DeviceSeen == nil && change.RenewHolds == nil && change.AdmitJoin == nil && change.CompleteJoin == nil && change.RecordUpload == nil { + if change.AdvanceTime == nil && change.DeviceSeen == nil && change.RenewHolds == nil && change.AdmitJoin == nil && change.CompleteJoin == nil && change.RecordUpload == nil { return nil, errFrame } key := ed25519.NewKeyFromSeed(seed) diff --git a/internal/raftabi/legacy.go b/internal/raftabi/legacy.go index 7e5c28e916..a8a141db99 100644 --- a/internal/raftabi/legacy.go +++ b/internal/raftabi/legacy.go @@ -4,6 +4,7 @@ import ( "github.com/Agent-Field/codeaf/internal/clusterstate" pb "go.etcd.io/raft/v3/raftpb" "google.golang.org/protobuf/proto" + "strconv" ) // The hosted source feeds original SQL JSON to this bounded conversion call. @@ -36,6 +37,10 @@ func legacyCodec(d *decoder) ([]byte, error) { func legacyGenesis(d *decoder) ([]byte, error) { var m clusterstate.LegacyManifest raw := d.blob() + var birth []byte + if len(d.b) > 0 { + birth = d.blob() + } if e := d.end(); e != nil { return nil, e } @@ -45,7 +50,14 @@ func legacyGenesis(d *decoder) ([]byte, error) { e := encoder{} e.message(&pb.Entry{Index: proto.Uint64(1), Term: proto.Uint64(1)}) e.message(&pb.HardState{Term: proto.Uint64(1), Commit: proto.Uint64(1)}) - e.message(&pb.ConfState{Voters: m.Config.Anchors}) + voters := m.Config.Anchors + if len(birth) > 0 { + if !validSingleton(m, birth) { + return nil, clusterstate.ErrMigration + } + voters = voters[:1] + } + e.message(&pb.ConfState{Voters: voters}) return e.b, nil } @@ -73,3 +85,16 @@ func legacyAuthorities(d *decoder) ([]byte, error) { } return out.b, nil } + +func validSingleton(m clusterstate.LegacyManifest, raw []byte) bool { + var birth struct { + Cluster string `json:"cluster"` + Digest string `json:"digest"` + Voters []string `json:"voters"` + } + if clusterstate.Decode(raw, &birth) != nil || m.Validate() != nil || m.Version != 0 || len(m.Config.Anchors) != 3 || birth.Cluster != m.Config.Cluster || birth.Digest != m.Digest || len(birth.Voters) != 1 || birth.Voters[0] != strconv.FormatUint(m.Config.Anchors[0], 10) { + return false + } + rows, err := clusterstate.LegacyAuthorities(m.Config) + return err == nil && m.Rows == uint64(len(rows)+1) +} diff --git a/internal/raftabi/membership.go b/internal/raftabi/membership.go index 8a56e153d8..06dbab82d5 100644 --- a/internal/raftabi/membership.go +++ b/internal/raftabi/membership.go @@ -48,6 +48,16 @@ func learnerReady(d *decoder) ([]byte, error) { func anchorRegistry(d *decoder) ([]byte, error) { raw := d.blob() + var conf *pb.ConfState + if len(d.b) > 0 { + conf = new(pb.ConfState) + d.message(conf) + var manifest clusterstate.LegacyManifest + manifestRaw, birth := d.blob(), d.blob() + if clusterstate.Decode(manifestRaw, &manifest) != nil || !validSingleton(manifest, birth) { + return nil, errFrame + } + } if err := d.end(); err != nil { return nil, err } @@ -62,6 +72,23 @@ func anchorRegistry(d *decoder) ([]byte, error) { if err != nil { return nil, err } + if conf != nil { + for _, node := range append(append(append([]uint64{}, conf.GetVoters()...), conf.GetVotersOutgoing()...), conf.GetLearners()...) { + found := false + for _, peer := range peers { + found = found || peer.Node == node + } + if !found { + return nil, errFrame + } + } + for i := range peers { + peers[i].Voter = false + for _, node := range append(append([]uint64{}, conf.GetVoters()...), conf.GetVotersOutgoing()...) { + peers[i].Voter = peers[i].Voter || peers[i].Node == node + } + } + } b, err := clusterstate.Encode(peers) if err != nil { return nil, err diff --git a/internal/raftcore/core.go b/internal/raftcore/core.go index aae0f9bdb4..cc22031eef 100644 --- a/internal/raftcore/core.go +++ b/internal/raftcore/core.go @@ -258,15 +258,10 @@ func (c *Core) Step(input Input) (effects Effects, err error) { effects.SnapshotNeeded = c.SnapshotNeeded() } }() + idleRead := false if idle, ok := input.(IdleRead); ok { - active, sleeping := c.schedule.active, c.schedule.sleeping + idleRead = true input = Read{RequestID: idle.RequestID} - defer func() { - if err == nil && !sleeping { - c.schedule.active = active - c.schedule.restart = false - } - }() } if c.stopped { return effects, ErrStopped @@ -316,7 +311,11 @@ func (c *Core) Step(input Input) (effects Effects, err error) { return effects, ErrBusy } switch input.(type) { - case Read, Propose, Configure, Transfer: + case Read: + if !idleRead || c.schedule.sleeping { + c.wakeScheduling() + } + case Propose, Configure, Transfer: c.wakeScheduling() case PeerMessage: if c.schedule.sleeping { diff --git a/internal/raftcore/formation.go b/internal/raftcore/formation.go new file mode 100644 index 0000000000..cf49ef01c0 --- /dev/null +++ b/internal/raftcore/formation.go @@ -0,0 +1,119 @@ +package raftcore + +import ( + "slices" + + pb "go.etcd.io/raft/v3/raftpb" + "google.golang.org/protobuf/proto" +) + +type FormationPlan struct { + Change []byte + Configure bool + Checkpoint bool + Settle bool +} + +// PlanFormation expands only an immutable singleton birth configuration. It +// cannot restore or shrink a formed cluster; the durable ConfState governs +// every retry, including recovery after the joint entry has committed. +func (c *Core) PlanFormation(anchors []uint64) (FormationPlan, error) { + var p FormationPlan + if c.stopped { + return p, ErrStopped + } + if !c.leader() { + return p, ErrLeader + } + if c.pending != nil { + return p, ErrBusy + } + if len(anchors) != 3 || !slices.Equal(c.cfg.Anchors, anchors) { + return p, ErrConfig + } + conf := c.store.conf + voters, learners, outgoing := conf.GetVoters(), conf.GetLearners(), conf.GetVotersOutgoing() + // A queued configuration must be decided before another can be planned. + last, err := c.store.LastIndex() + if err != nil { + return p, err + } + if last > c.applied { + entries, err := c.store.Entries(c.applied+1, last+1, ^uint64(0)) + if err != nil { + return p, err + } + for _, entry := range entries { + if entry.GetType() != pb.EntryNormal { + return p, ErrBusy + } + } + } + change := &pb.ConfChangeV2{} + if len(outgoing) > 0 { + if !sameIDs(voters, anchors) || !sameIDs(outgoing, anchors[:1]) || len(learners) > 0 || len(conf.GetLearnersNext()) > 0 || conf.GetAutoLeave() { + return p, ErrConfig + } + // An empty explicit change leaves joint consensus using both quorums. + } else if sameIDs(voters, anchors) { + if len(learners) > 0 || len(conf.GetLearnersNext()) > 0 || conf.GetAutoLeave() { + return p, ErrConfig + } + p.Settle = true + return p, nil + } else { + if !sameIDs(voters, anchors[:1]) || len(conf.GetLearnersNext()) > 0 || conf.GetAutoLeave() { + return p, ErrConfig + } + for _, node := range learners { + if !slices.Contains(anchors[1:], node) { + return p, ErrConfig + } + } + for _, node := range anchors[1:] { + if !slices.Contains(learners, node) { + change.Changes = []*pb.ConfChangeSingle{{Type: pb.ConfChangeAddLearnerNode.Enum(), NodeId: proto.Uint64(node)}} + p.Configure = true + p.Checkpoint = true + p.Change, err = proto.MarshalOptions{Deterministic: true}.Marshal(change) + return p, err + } + } + snapshot, err := c.store.Snapshot() + if err != nil { + return p, err + } + for _, node := range anchors[1:] { + if !slices.Contains(snapshot.GetMetadata().GetConfState().GetLearners(), node) { + p.Checkpoint = true + return p, nil + } + } + progress := c.node.Status().Progress + for _, node := range anchors[1:] { + peer, ok := progress[node] + if !ok || peer.Match < c.applied { + return p, nil + } + } + change.Transition = pb.ConfChangeTransitionJointExplicit.Enum() + for _, node := range anchors[1:] { + change.Changes = append(change.Changes, &pb.ConfChangeSingle{Type: pb.ConfChangeAddNode.Enum(), NodeId: proto.Uint64(node)}) + } + } + p.Configure = true + p.Change, err = proto.MarshalOptions{Deterministic: true}.Marshal(change) + return p, err +} + +func sameIDs(a, b []uint64) bool { + if len(a) != len(b) { + return false + } + for _, node := range a { + if !slices.Contains(b, node) { + return false + } + } + return true +} diff --git a/internal/raftcore/idle_read_quiescence_test.go b/internal/raftcore/idle_read_quiescence_test.go new file mode 100644 index 0000000000..6b8237a82d --- /dev/null +++ b/internal/raftcore/idle_read_quiescence_test.go @@ -0,0 +1,89 @@ +package raftcore + +import ( + "testing" + + cs "github.com/Agent-Field/codeaf/internal/clusterstate" +) + +func TestIdleReadAfterCommittedMarkerPreservesQuiescence(t *testing.T) { + for _, public := range []bool{false, true} { + name := "maintenance" + if public { + name = "public" + } + t.Run(name, func(t *testing.T) { + g := newGroup(t) + l := g.elect() + c := g.nodes[l-1] + s := c.status() + marker := cs.Quiescence{Index: s.Applied, Term: s.Term, Barrier: s.Applied - 1, Voters: []uint64{1, 2, 3}} + acks := []SleepAck{{1, s.Applied, s.Term}, {2, s.Applied, s.Term}, {3, s.Applied, s.Term}} + if _, err := c.Schedule(ScheduleInput{Now: 0}); err != nil { + t.Fatal(err) + } + if _, err := c.Schedule(ScheduleInput{Now: 60000, Marker: marker, Acks: acks[:2]}); err != nil { + t.Fatal(err) + } + if public { + g.call(l, Read{RequestID: "public"}) + } else { + g.call(l, IdleRead{RequestID: "maintenance"}) + } + g.pump() + plan, err := c.Schedule(ScheduleInput{Now: 60000, Marker: marker, Acks: acks}) + if err != nil { + t.Fatal(err) + } + if plan.Sleeping == public { + t.Fatalf("completed %s read: sleeping=%t, want %t", name, plan.Sleeping, !public) + } + }) + } +} + +func TestIdleReadWakesSleepingQuiescence(t *testing.T) { + g := newGroup(t) + l := g.elect() + c := g.nodes[l-1] + s := c.status() + marker := cs.Quiescence{Index: s.Applied, Term: s.Term, Barrier: s.Applied - 1, Voters: []uint64{1, 2, 3}} + acks := []SleepAck{{1, s.Applied, s.Term}, {2, s.Applied, s.Term}, {3, s.Applied, s.Term}} + if _, err := c.Schedule(ScheduleInput{Now: 0}); err != nil { + t.Fatal(err) + } + plan, err := c.Schedule(ScheduleInput{Now: 60000, Marker: marker, Acks: acks}) + if err != nil || !plan.Sleeping { + t.Fatal(plan, err) + } + g.call(l, IdleRead{RequestID: "wake-internal"}) + g.pump() + plan, err = c.Schedule(ScheduleInput{Now: 60001, Marker: marker, Acks: acks}) + if err != nil || plan.Sleeping || len(plan.Wake) != 3 { + t.Fatal("internal read reused sleeping certificate", plan, err) + } +} + +func TestUnconfirmedIdleReadCannotReuseQuiescence(t *testing.T) { + g := newGroup(t) + l := g.elect() + c := g.nodes[l-1] + s := c.status() + marker := cs.Quiescence{Index: s.Applied, Term: s.Term, Barrier: s.Applied - 1, Voters: []uint64{1, 2, 3}} + acks := []SleepAck{{1, s.Applied, s.Term}, {2, s.Applied, s.Term}, {3, s.Applied, s.Term}} + if _, err := c.Schedule(ScheduleInput{Now: 0}); err != nil { + t.Fatal(err) + } + g.drop = func(from, to uint64) bool { return from == l || to == l } + g.call(l, IdleRead{RequestID: "no-quorum"}) + g.pump() + for _, read := range g.reads { + if read.RequestID == "no-quorum" { + t.Fatal("internal read completed without quorum") + } + } + plan, err := c.Schedule(ScheduleInput{Now: 60000, Marker: marker, Acks: acks}) + if err != nil || plan.Sleeping { + t.Fatal("unconfirmed read reused sleeping certificate", plan, err) + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 5a341d8c64..ec223d499d 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -177,6 +177,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 0a3f0be912..ba6cf73cc0 100644 --- a/internal/remote/teams_history_test.go +++ b/internal/remote/teams_history_test.go @@ -1,6 +1,8 @@ package remote import ( + "encoding/json" + "os" "testing" "time" @@ -11,8 +13,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 +36,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 +60,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 +92,109 @@ func TestHostedWindowsKeepIndependentMirrorVersions(t *testing.T) { } } } + +func pairedTeamsLoop(t *testing.T) (*Loop, string) { + t.Helper() + dir := t.TempDir() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: dir}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + 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"}]}]}`) + teamstest.Write(t, dir, raw) + before := teamstest.JSON(t, dir) + 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 := teamstest.JSON(t, dir) + if string(after) == string(before) { + 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/teams_test.go b/internal/remote/teams_test.go index 7fcd25ea42..17c22205e2 100644 --- a/internal/remote/teams_test.go +++ b/internal/remote/teams_test.go @@ -53,8 +53,8 @@ func TestTheTeamsDoorsCrossFromTheEnginesProfile(t *testing.T) { } // Device and inode widths vary by filesystem; only the stamp and flag cross. stamp, _ := json.Marshal(wrote.Stamp) - if raw, _ := json.Marshal(same); string(raw) != `{"stamp":`+string(stamp)+`,"same":true}` { - t.Fatalf("an unchanged answer must carry only the held stamp and same: %s", raw) + if raw, _ := json.Marshal(same); string(raw) != `{"sharing":false,"stamp":`+string(stamp)+`,"same":true}` { + t.Fatalf("an unchanged answer must carry sharing, the held stamp and same: %s", raw) } if err := teamstore.AppendTraffic(dir, "0a0a0a0a0a0a", teamstore.Entry{Kind: teamstore.KindNote, 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 98ec6fc4ca..87db8bf4d8 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -3700,7 +3700,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/clientdoor.go b/internal/session/clientdoor.go index c3af1cc6c7..de08894a3c 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -745,6 +745,11 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, if err := factoryCallRefusal(ctx); err != nil { return nil, model, err } + // Job ownership also covers personal manager turns and reopened stages. + // Keep recovery on the call so shared clients retain ordinary refusals. + if a.jobBudgetOf() != nil { + ctx = provider.WithRefusedEffortRecovery(ctx) + } if purpose == purposeTurn { messages = a.jobAutonomyMessages(messages) } @@ -883,6 +888,9 @@ func (c Config) clientConfig(model string, timeout time.Duration) provider.Confi configured.Model = c.workerWireModel } configured.Timeout = timeout + // Factory stages, their children and manager turns keep local effort repair. + // Merely offering Factory tools does not opt an ordinary conversation in. + configured.DropRefusedEffort = c.DropRefusedEffort || c.Stage != nil || c.JobBudget != nil // The catalog gate on optional knobs, and the two answers about the model's // reasoning profile and list price, are seams the surface resolves. A caller // that hands over none of them keeps today's behaviour exactly — knobs travel diff --git a/internal/session/consent.go b/internal/session/consent.go index cdd790dd7d..012963f2e1 100644 --- a/internal/session/consent.go +++ b/internal/session/consent.go @@ -195,7 +195,7 @@ func (a *Agent) capabilitySays(tool string, args json.RawMessage, decision appro } switch a.connect.CapabilityState(service, capability) { case connect.StateYes: - if decision.Action == approval.ActionPrompt { + if decision.Action == approval.ActionPrompt && decision.ProfileReadError == "" { return approval.Decision{ Action: approval.ActionAllow, Rule: "you said yes to " + strconv.Quote(phrase), @@ -231,7 +231,7 @@ func (a *Agent) approve(ctx context.Context, hub *eventHub, call ai.ToolCall) (t if governed && decision.Action == approval.ActionAllow && decision.Scope != "" && hub != nil { hub.send(Event{Kind: EventConsentAllowed, Tool: call.Function.Name, Args: argsText(call), CallID: call.ID, Hint: a.gloss(call), Rule: decision.Scope}) } - if !governed || decision.Action == approval.ActionAllow || setupGranted(ctx, call, decision) { + if !governed || decision.Action == approval.ActionAllow || (decision.ProfileReadError == "" && setupGranted(ctx, call, decision)) { return toolResult{}, true } if decision.Action == approval.ActionDeny { @@ -251,7 +251,7 @@ func (a *Agent) approve(ctx context.Context, hub *eventHub, call ai.ToolCall) (t // ran silently for the rest of the session. The memo is somebody saying // they are done being asked about ordinary work. It is not somebody // saying they have read a message that has not been written yet. - if !approval.AlwaysAsks(call.Function.Name, json.RawMessage(call.Function.Arguments)) { + if decision.ProfileReadError == "" && !approval.AlwaysAsks(call.Function.Name, json.RawMessage(call.Function.Arguments)) { if remembered, known := a.rememberedConsent(call.Function.Name); known { if remembered { return toolResult{}, true @@ -270,7 +270,7 @@ func (a *Agent) approve(ctx context.Context, hub *eventHub, call ai.ToolCall) (t // refusal, which is the whole point of having said so in advance; with it off // — the default — this line does nothing and a headless run refuses exactly as // it always did. - if a.guardianAllows(ctx, hub, call, decision) { + if decision.ProfileReadError == "" && a.guardianAllows(ctx, hub, call, decision) { return toolResult{}, true } diff --git a/internal/session/daily_spend.go b/internal/session/daily_spend.go index 3345aa8f56..93fc47fc33 100644 --- a/internal/session/daily_spend.go +++ b/internal/session/daily_spend.go @@ -83,6 +83,12 @@ var factoryAIRoles = map[string]bool{ "view_topic": true, "factory_brief": true, } +// IsFactoryAIPurpose identifies Factory-owned AI calls for billing and per-call +// provider recovery. Ordinary errands on the same agent remain ordinary. +func IsFactoryAIPurpose(purpose string) bool { + return factoryAIRoles[purpose] +} + type spendTally struct { day string file os.FileInfo @@ -122,7 +128,7 @@ func (t *spendTally) read(file *os.File, info os.FileInfo) error { return fmt.Errorf("spending ledger contains an invalid charge") } usd += line.USD - if factoryAIRoles[line.Role] && line.FactoryJob == "" { + if IsFactoryAIPurpose(line.Role) && line.FactoryJob == "" { factoryAI += line.USD } if math.IsInf(usd, 0) { diff --git a/internal/session/errand.go b/internal/session/errand.go index f9c90a2630..9d24d21496 100644 --- a/internal/session/errand.go +++ b/internal/session/errand.go @@ -92,6 +92,10 @@ type ErrandMessage struct { // ErrandAsk is one errand. It travels as JSON to a remote owner unchanged. type ErrandAsk struct { + // DropRefusedEffort scopes explicit reasoning-refusal recovery to this errand, + // including when the ask travels to a remote owner. + DropRefusedEffort bool `json:"drop_refused_effort,omitempty"` + // Purpose labels the receipt and keys the one-in-flight slot. Purpose string `json:"purpose"` // Role is any role this build has a tier for; empty is the naming role. @@ -271,6 +275,9 @@ func (ask ErrandAsk) cacheKey() [32]byte { // answering — closed, stopped, over budget, busy, deadline, a failed or // unusable answer — is an empty text with a Reason. func (a *Agent) RunErrand(ctx context.Context, ask ErrandAsk) (ErrandAnswer, error) { + if ask.DropRefusedEffort { + ctx = provider.WithRefusedEffortRecovery(ctx) + } ask, err := ask.Normalize() if err != nil { return ErrandAnswer{}, err diff --git a/internal/session/factory_effort_recovery_test.go b/internal/session/factory_effort_recovery_test.go new file mode 100644 index 0000000000..500c26c14b --- /dev/null +++ b/internal/session/factory_effort_recovery_test.go @@ -0,0 +1,139 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +func TestFactoryEffortRecoverySurvivesModelAndAccountSwitch(t *testing.T) { + t.Run("stage", func(t *testing.T) { checkFactoryEffortSwitches(t, false) }) + t.Run("manager", func(t *testing.T) { checkFactoryEffortSwitches(t, true) }) +} + +func checkFactoryEffortSwitches(t *testing.T, manager bool) { + t.Helper() + var mu sync.Mutex + requests := make(map[string][]bool) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !answersChatOnly(w, r) { + return + } + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + w.WriteHeader(400) + return + } + var model string + _ = json.Unmarshal(body["model"], &model) + knobs := body["reasoning"] != nil || body["reasoning_effort"] != nil || body["thinking"] != nil + key := model + "/" + r.Header.Get("Authorization") + mu.Lock() + requests[key] = append(requests[key], knobs) + mu.Unlock() + if knobs { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":{"message":"This model does not support reasoning","code":400}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, `data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n") + })) + defer server.Close() + sources := func(key string) modelsource.Set { + return modelsource.NewSet(modelsource.Connected{Source: modelsource.DefaultSource(server.URL), Address: server.URL, Key: key}) + } + cfg := Config{Workspace: t.TempDir(), Model: "fixture/first", Sources: sources("first"), Effort: effort.High} + if manager { + cfg.DropRefusedEffort = true + cfg.FactoryRun = &fakeRunDoor{} + } else { + cfg.Stage = &fakeStageDoor{} + } + agent, err := New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + submit := func() { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + events, err := agent.Submit(ctx, "answer done") + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == EventError { + t.Fatalf("Factory turn: %v", event.Err) + } + } + } + submit() + agent.SetModel("fixture/second") + submit() + agent.SetSources(sources("second")) + submit() + mu.Lock() + defer mu.Unlock() + for _, key := range []string{"fixture/first/Bearer first", "fixture/second/Bearer first", "fixture/second/Bearer second"} { + got := requests[key] + if len(got) != 2 || !got[0] || got[1] { + t.Errorf("requests for %s = %v; want [true false]", key, got) + } + } +} + +func TestOrdinaryConversationWithFactoryToolsKeepsEffortRefusal(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !answersChatOnly(w, r) { + return + } + requests.Add(1) + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + w.Header().Set("Content-Type", "application/json") + if body["reasoning"] != nil || body["reasoning_effort"] != nil || body["thinking"] != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":{"message":"This model does not support reasoning","code":400}}`) + return + } + fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`) + })) + defer server.Close() + agent, err := New(Config{Workspace: t.TempDir(), Model: "fixture/ordinary", APIKey: "test", BaseURL: server.URL, FactoryRun: &fakeRunDoor{}, Effort: effort.High}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + events, err := agent.Submit(ctx, "answer done") + if err != nil { + t.Fatal(err) + } + refused := false + for event := range events { + if event.Kind == EventError { + refused = true + } + } + if !refused || requests.Load() != 1 { + t.Fatalf("ordinary conversation: refused=%v requests=%d; want refusal on one request", refused, requests.Load()) + } +} diff --git a/internal/session/loop.go b/internal/session/loop.go index edd507ca0e..79f26d812e 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -557,6 +557,10 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f // 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 1c4ddda3af..37b5266f30 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) } } @@ -77,7 +77,7 @@ func mentionTeam(profile string, match func(teams.Team) bool) (teams.Team, bool) return teams.Team{}, false } -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 "" } @@ -85,7 +85,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/profile_approval_test.go b/internal/session/profile_approval_test.go new file mode 100644 index 0000000000..0d715e341f --- /dev/null +++ b/internal/session/profile_approval_test.go @@ -0,0 +1,46 @@ +package session + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/approval" +) + +func TestUnreadableProfileApprovalRequiresPersonEveryTime(t *testing.T) { + const file = "/profile/config.json" + policy := &approval.Policy{ProfileReadError: "open " + file + ": permission denied"} + agent, runs := consentAgent(t, &scriptedCompleter{steps: toolCallTurn("touch", "touch")}, policy, true) + agent.rememberConsent("touch", true) + events := drainAnswering(t, mustSubmit(t, agent, "touch twice"), func(request Event) { + if !strings.Contains(request.Rule, file) { + t.Errorf("prompt omits failed file: %s", request.Rule) + } + agent.ResolveConsentRemember(request.ID, true, ConsentToolSession) + }) + if got := countKind(events, EventConsentRequest); got != 2 { + t.Fatalf("asked %d times; want 2", got) + } + if len(runs) != 2 { + t.Fatalf("ran %d times; want 2 human-approved calls", len(runs)) + } + for _, tool := range []string{"touch", "read", "bash", "use_service"} { + agent.rememberConsent(tool, true) + result, allowed := agent.approve(context.Background(), nil, gateCall(tool, `{"command":"git status"}`)) + if allowed || !result.isError || !strings.Contains(result.text, file) { + t.Errorf("%s without person: allowed=%v result=%+v", tool, allowed, result) + } + } +} + +func TestUnreadableProfileApprovalUserBashRefusesWithoutPerson(t *testing.T) { + const file = "/profile/config.json" + agent := gateAgent(t, &approval.Policy{ProfileReadError: "open " + file + ": permission denied"}) + for _, args := range []string{`{"command":"git status"}`, `{"command":"rm -rf /"}`} { + result, allowed := agent.approveUserBash(context.Background(), nil, gateCall("bash", args)) + if allowed || !result.isError || !strings.Contains(result.text, file) { + t.Errorf("user bash %s: allowed=%v result=%+v; want refusal naming file", args, allowed, result) + } + } +} diff --git a/internal/session/session.go b/internal/session/session.go index f7e5f8d724..5a8ad2811b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1825,6 +1825,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 @@ -2188,6 +2190,10 @@ type Config struct { // piece of work, which is every conversation a person opens themselves. Effort effort.Rung + // DropRefusedEffort opts a Factory manager turn into client-local effort recovery. + // Available Factory tools alone do not establish that this is Factory work. + DropRefusedEffort bool + // EffortRole is what this session is FOR, and it is the rung of last resort // before the install's default: an errand asks for nothing at all, and a // task worker on the bash belt starts low. THE ZERO VALUE IS NOT A ROLE and @@ -3431,7 +3437,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 35ce4313b0..49418fb9fd 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -1051,7 +1051,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 } @@ -1093,7 +1093,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 { @@ -1155,19 +1155,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()...) @@ -1195,7 +1225,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 fe13a98bba..28c4cbecd3 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" @@ -704,7 +703,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 cf0812ec0a..f3616e3024 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" @@ -306,7 +305,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 033e60b12c..b9cc62be0b 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 ce96e04dc6..ce54a5982a 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" "github.com/Agent-Field/codeaf/internal/teams/teamstest" @@ -125,6 +126,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_factory_run.go b/internal/session/tools_factory_run.go index 070a222989..022e24458b 100644 --- a/internal/session/tools_factory_run.go +++ b/internal/session/tools_factory_run.go @@ -24,6 +24,7 @@ import ( "context" "encoding/json" "errors" + "github.com/Agent-Field/codeaf/internal/provider" "strings" "sync" @@ -377,6 +378,9 @@ func (a *Agent) SubmitRunnerNoteThrough(ctx context.Context, text string, door R stream.close() } } + if door != nil { + ctx = provider.WithRefusedEffortRecovery(ctx) + } events := a.startTurnLocked(ctx, user, nil, watchers...) if door != nil { turnRunDoors.Store(a, turnRunDoor{door: door, serial: a.teamTurnSerial}) diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 3152afde70..e306c18048 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/session/userbash.go b/internal/session/userbash.go index 5a05c51c41..d809b90caf 100644 --- a/internal/session/userbash.go +++ b/internal/session/userbash.go @@ -69,10 +69,12 @@ func (a *Agent) approveUserBash(ctx context.Context, hub *eventHub, call ai.Tool args := json.RawMessage(call.Function.Arguments) // THE FLOOR STILL ASKS. Enter consents to ordinary shell work, but it // cannot answer the critical table on behalf of a question not yet shown. - if governed && approval.AlwaysAsks(call.Function.Name, args) { + if governed && (decision.ProfileReadError != "" || approval.AlwaysAsks(call.Function.Name, args)) { // An allow-only policy asks the same critical matcher for the rule // shown on the card, even when the person's policy said "default". - decision = (approval.Policy{Default: approval.ActionAllow}).Check(call.Function.Name, args) + if decision.ProfileReadError == "" { + decision = (approval.Policy{Default: approval.ActionAllow}).Check(call.Function.Name, args) + } if !a.config.AskConsent || hub == nil { return refusal("needs approval but no resolver is attached: " + decision.Rule), false } diff --git a/internal/syncsetup/confirm_work.go b/internal/syncsetup/confirm_work.go index f49e3d073f..ba2abb3db5 100644 --- a/internal/syncsetup/confirm_work.go +++ b/internal/syncsetup/confirm_work.go @@ -2,20 +2,19 @@ package syncsetup import ( "context" - "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" ) // ConfirmWork checks only this chat's directory record without waiting for -// capture before reading ownership. Reacquisition captures closed steps before -// comparing heads. The caller bounds ctx and decides how to degrade a failed -// read. True confirms the current fence; false means either a viewer or a drive -// that cannot confirm ownership yet. +// capture before reading ownership. The caller bounds ctx and decides how to +// degrade a failed read. True confirms the current fence; false means either a +// viewer or a drive that cannot confirm ownership yet. func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { held := d.sendLeaseSnapshot() - if d.batcher == nil { + if d.batcher == nil || d.displaced() { return false, nil } id, fence := d.batcher.Lease() @@ -28,10 +27,6 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { if err != nil { return false, err } - v, err = d.renewIdleLease(ctx, v, fence) - if err != nil { - return false, err - } lease := v.Cell.Lease // A first publish or branch can change the lease during the read. That // answer confirms neither a takeover nor the batcher's new lease. @@ -40,29 +35,7 @@ func (d *Drive) ConfirmWork(ctx context.Context) (bool, error) { } if lease.Expires <= v.Now { d.availableAfter(held, lease) - // Never resume an older tree over work saved by another computer. - if err := cellstore.Captured(ctx, d.cell); err != nil { - return false, err - } - head, err := cellstore.Head(d.cell) - if err != nil || head == nil || head.Turn.ID != v.Cell.Head { - return false, err - } - resumed, err := d.batcher.Reacquire(ctx, v) - if err != nil || !resumed { - return false, err - } - d.mu.Lock() - d.viewer, d.lost = Viewer{}, false - d.mu.Unlock() - if err := d.recordSendLease(); err != nil { - d.available() - return false, err - } - if d.opt.OnDriving != nil { - d.opt.OnDriving() - } - return true, nil + return false, nil } if lease.Device == d.sync.Device.ID() && lease.Fence == fence { d.resumeViewer() @@ -135,15 +108,15 @@ func (d *Drive) available() { // availableAfter closes the work gate before receipt I/O can block and retires // only the receipt held before the reading, so a concurrent reacquisition survives. -// An unchanged owner and fence keeps its receipt even when released, because a -// heartbeat can still renew it. Readers hear the change after retirement finishes. +// Expiry and release retire the receipt too. Readers hear the change after +// retirement finishes; continuation must acquire a new hold. func (d *Drive) availableAfter(held sendLease, reading directory.Lease) { viewer := Viewer{Line: chatlist.Available} d.mu.Lock() changed := d.viewer != viewer d.viewer = viewer d.mu.Unlock() - if held.Fence != 0 && (reading.Device != d.sync.Device.ID() || reading.Fence != held.Fence) { + if held.Fence != 0 { if err := d.sync.forgetSendLease(d.cell.ID, held); err != nil { d.notice(err.Error()) } diff --git a/internal/syncsetup/continue_ownership_test.go b/internal/syncsetup/continue_ownership_test.go index 96e18ddc7e..8b8f2f5e57 100644 --- a/internal/syncsetup/continue_ownership_test.go +++ b/internal/syncsetup/continue_ownership_test.go @@ -42,9 +42,6 @@ func TestContinueChecksOwnershipBeforeRetiring(t *testing.T) { view, err := h.a.Dir.Cell(ctx, h.cell.ID) mustNil(t, err) seen := &directory.LeaseExpectation{Device: h.b.Device.ID(), Fence: view.Cell.Lease.Fence - 1} - if own { - seen = &directory.LeaseExpectation{Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence, Head: view.Cell.Head} - } if !own { seen = &directory.LeaseExpectation{Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence} _, err = h.b.Dir.Acquire(ctx, h.cell.ID, directory.AcquireOpts{Force: true, Expect: seen, RequireConditional: true}) diff --git a/internal/syncsetup/continue_reopened_writer_test.go b/internal/syncsetup/continue_reopened_writer_test.go index d85db69540..b504bb0a68 100644 --- a/internal/syncsetup/continue_reopened_writer_test.go +++ b/internal/syncsetup/continue_reopened_writer_test.go @@ -22,10 +22,6 @@ func TestAReturnAfterTheWriterReopensIsNotALostRace(t *testing.T) { moved, err := h.continuerB().Take(ctx, h.cell.ID) mustNil(t, err) stale := &directory.LeaseExpectation{Device: h.b.Device.ID(), Fence: moved.Taken.Driving.Fence} - // Opening an installed copy resumes the same hold (F1, #2009); a clean - // quit releases it, so the writer's return is a new ownership. - first := h.openOn(h.b, h.engB, moved.Taken.Cell, workspaceOf(moved.Taken.Cell.Root), nameB) - mustNil(t, first.drive.Close(ctx)) h.openOn(h.b, h.engB, moved.Taken.Cell, workspaceOf(moved.Taken.Cell.Root), nameB) current, err := h.a.Dir.Cell(ctx, h.cell.ID) mustNil(t, err) diff --git a/internal/syncsetup/displaced_same_device_test.go b/internal/syncsetup/displaced_same_device_test.go index e16d762669..4fe5094d38 100644 --- a/internal/syncsetup/displaced_same_device_test.go +++ b/internal/syncsetup/displaced_same_device_test.go @@ -245,42 +245,27 @@ func TestDisplacingReadingKeepsTheConcurrentlyReacquiredReceipt(t *testing.T) { } } -// A released fence can still heartbeat as its unchanged owner, so availability -// must retain its receipt until the same fence resumes. -func TestReleasedSameFenceKeepsTheReceiptItCanResume(t *testing.T) { +// A released fence retires its receipt and cannot regain work permission by heartbeat. +func TestReleasedSameFenceRetiresReceiptUntilContinuation(t *testing.T) { d := sameComputerDrive(t) held := d.recordedFence - assertReceipt := func() { - t.Helper() - remote, fence, err := d.sync.SendLease(d.cell.ID) - if err != nil || remote != d.directoryID || fence != held { - t.Fatalf("released same-fence receipt = %q, %d, %v; want %q, %d, nil", remote, fence, err, d.directoryID, held) - } - } mustNil(t, d.sync.Dir.Release(t.Context(), d.directoryID, held)) mustNil(t, d.CheckLease(t.Context())) - assertReceipt() - d.becomeViewer(cellsync.Superseded{}) - assertReceipt() - if err := d.Gate(); err == nil { - t.Fatal("released drive admits work before its heartbeat") + if _, _, err := d.sync.SendLease(d.cell.ID); !os.IsNotExist(err) { + t.Fatalf("released receipt survived: %v", err) } - if _, _, held := d.RenewalStatus(); held { - t.Fatal("released drive reports a held lease before its heartbeat") + if d.Gate() == nil { + t.Fatal("released drive admits work") } - renewed, err := d.sync.Dir.Heartbeat(t.Context(), d.directoryID, directory.Beat{Fence: held}) + _, err := d.sync.Dir.Heartbeat(t.Context(), d.directoryID, directory.Beat{Fence: held}) mustNil(t, err) - if renewed.Cell.Lease.Fence != held || renewed.Cell.Lease.Expires <= renewed.Now { - t.Fatalf("same-fence heartbeat = %+v at %d; want live fence %d", renewed.Cell.Lease, renewed.Now, held) - } mustNil(t, d.CheckLease(t.Context())) - if err := d.Gate(); err != nil { - t.Fatalf("same-fence heartbeat did not reopen the work gate: %v", err) + if d.Gate() == nil { + t.Fatal("heartbeat revived retired work permission") } - if _, _, held := d.RenewalStatus(); !held { - t.Fatal("resumed same-fence drive does not report its held lease") + if confirmed, err := d.ConfirmWork(t.Context()); err != nil || confirmed { + t.Fatalf("retired admission = %v, %v", confirmed, err) } - assertReceipt() } // Receipt lock contention must close the work gate immediately while delaying @@ -462,15 +447,14 @@ func TestDisplacedReceiptRetiresWhenTheReplacementIsFree(t *testing.T) { } // A lapse that may still renew the same fence keeps the receipt, so the window -// that resumes can send without being asked to continue its own chat. -func TestAvailableAfterALapseKeepsTheReceiptItMayResume(t *testing.T) { +// cannot send until continuation acquires a new hold. +func TestAvailableAfterALapseRetiresTheReceipt(t *testing.T) { now := time.Unix(1_700_000_000, 0) d := sameComputerDrive(t, func() time.Time { return now }) assertReceipt := func() { t.Helper() - remote, fence, err := d.sync.SendLease(d.cell.ID) - if err != nil || remote != d.directoryID || fence != d.recordedFence { - t.Fatalf("resumable receipt = %q, %d, %v; want %q, %d, nil", remote, fence, err, d.directoryID, d.recordedFence) + if _, _, err := d.sync.SendLease(d.cell.ID); !os.IsNotExist(err) { + t.Fatalf("lapsed receipt survived: %v", err) } } renewals, viewers := 0, 0 @@ -507,15 +491,6 @@ func TestAvailableAfterALapseKeepsTheReceiptItMayResume(t *testing.T) { if renewals != 1 || viewers != 1 || len(d.ownership) != 0 { t.Fatalf("repeated lapse notifications = %d renewal, %d viewer, %d ownership; want 1, 1, 0", renewals, viewers, len(d.ownership)) } - d.resumeViewer() - mustNil(t, d.recordSendLease()) - assertReceipt() - if _, _, held := d.RenewalStatus(); !held { - t.Fatal("resumed drive does not report its held lease") - } - if err := d.Gate(); err != nil { - t.Fatalf("resumed drive refuses work: %v", err) - } } // The receipt lock is a file lock, so a door that already held it when it @@ -617,7 +592,7 @@ func sameComputerDriveWithDirectory(t *testing.T, clock ...func() time.Time) (*D created, err := s.Dir.Create(t.Context(), c.ID, directory.CellInit{}) mustNil(t, err) d := &Drive{sync: s, cell: c, directoryID: c.ID, ownership: make(chan struct{}, 1), - batcher: &cellsync.Batcher{Driving: &cellsync.Driving{Cell: c, Fence: created.Cell.Lease.Fence}}} + batcher: &cellsync.Batcher{Publisher: &cellsync.Publisher{Dir: s.Dir}, Driving: &cellsync.Driving{Cell: c, Fence: created.Cell.Lease.Fence}}} mustNil(t, d.recordSendLease()) return d, memory } diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 0c47cadc29..ad812fcf13 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -315,10 +315,7 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri // Turns sealed here on top of the record's head are this computer's own // work, not an older tree: taking the free lease publishes them (catchUp). wasFree := view.Cell.Lease.Expires <= view.Now - // An outage can leave our own saved head ahead of the relay. The - // unchanged, unreleased hold can renew without discarding that work. - resumeOwn := view.Cell.Lease.Device == s.Device.ID() && view.Cell.Lease.Expires != 0 - if wasFree && !resumeOwn { + if wasFree { extends, err := cellstore.Extends(c, view.Cell.Head) if err != nil { return drv, "", err @@ -327,27 +324,19 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri return drv, nonemptyHolder(view.Cell.Lease.Device), nil } } - var v directory.CellView - if resumeOwn { - // Opening an installed copy, including just after a take or a process - // restart, resumes the same hold. Heartbeat checks its fence atomically - // and can renew an expired lease that nobody else acquired. - v, err = s.Dir.Heartbeat(ctx, drv.ID(), directory.Beat{Fence: view.Cell.Lease.Fence, Pending: view.Cell.Lease.Pending}) - } else { - v, err = s.Dir.Acquire(ctx, drv.ID(), directory.AcquireOpts{RequireConditional: wasFree, Expect: &directory.LeaseExpectation{ - Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence, - }}) - } + v, err := s.Dir.Acquire(ctx, drv.ID(), directory.AcquireOpts{RequireConditional: wasFree, Expect: &directory.LeaseExpectation{ + Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence, + }}) switch { case errors.Is(err, directory.ErrNotFound): return drv, "", nil - case errors.Is(err, directory.ErrLeaseHeld), errors.Is(err, directory.ErrFenceStale): + case errors.Is(err, directory.ErrLeaseHeld): return drv, s.holder(ctx, drv.ID()), nil case err != nil: return drv, "", err } known, err = cellstore.KnowsHead(c, v.Cell.Head) - if err != nil || !known || (wasFree && !resumeOwn && view.Cell.Head != v.Cell.Head) { + if err != nil || !known || (wasFree && view.Cell.Head != v.Cell.Head) { _ = s.Dir.Release(ctx, drv.ID(), v.Cell.Lease.Fence) return drv, nonemptyHolder(view.Cell.Lease.Device), err } @@ -419,22 +408,25 @@ func (d *Drive) info() cellsync.PublishInfo { return d.sync.publishInfo(d.cell, title) } -// loseHold retires this writer's permission to run tools at once, while -// leaving the viewer line, the notice and the send receipt until pending work -// has been kept as noted turns or in the WAL and the holder read ([Drive.becomeViewer]): -// a hold this computer still has keeps its receipt. +// loseHold retires this writer's permission to run tools and clears its send +// receipt at once, while leaving the viewer line and notice until pending work +// has been kept as noted turns or in the WAL and the new holder named. func (d *Drive) loseHold() { d.mu.Lock() changed := !d.lost && d.viewer.Line == "" d.lost = true + expected := sendLease{Cell: d.directoryID, Fence: d.recordedFence} d.mu.Unlock() + if d.sync != nil { + if err := d.sync.forgetSendLease(d.cell.ID, expected); err != nil { + d.notice(err.Error()) + } + } if changed && d.opt.OnRenewalChange != nil { d.opt.OnRenewalChange() } } -// displaced is whether this writer's hold is gone or another computer holds -// the chat: renewal health is not this window's to report then. func (d *Drive) displaced() bool { d.mu.Lock() defer d.mu.Unlock() @@ -454,6 +446,12 @@ func (d *Drive) becomeViewer(s cellsync.Superseded) { if err == nil { current, fresh = v.Cell.Lease, true } + // A delayed refusal cannot retire a hold this window has already + // reacquired. Expired or explicitly retired permission stays closed. + if err == nil && !d.displaced() && v.Cell.Lease.Device == d.sync.Device.ID() && + v.Cell.Lease.Fence == d.batcher.Fence() && v.Cell.Lease.Expires > v.Now { + return + } if err == nil && v.Cell.Lease.Expires <= v.Now { d.availableAfter(held, v.Cell.Lease) return @@ -585,27 +583,16 @@ func (d *Drive) RenewalStatus() (time.Time, time.Duration, bool) { // directory that cannot be asked answers true, because a drive side that cannot // tell must not be thrown away for it. func (d *Drive) HoldsLease(ctx context.Context) bool { - if line, viewer := d.Viewer(); viewer && line != chatlist.Available || d.batcher == nil { + if d.displaced() || d.batcher == nil { return false } v, err := d.sync.Dir.Cell(ctx, d.directoryID) - expired := err == nil && v.Cell.Lease.Expires <= v.Now - if err == nil { - v, err = d.renewIdleLease(ctx, v, d.batcher.Fence()) - } if err != nil { - if errors.Is(err, directory.ErrFenceStale) || errors.Is(err, cellstore.ErrReadOnly) || expired && !cellsync.Unreachable(err) { - return false - } return true } lease := v.Cell.Lease // The first publish may establish the fence while the read is in flight. - held := lease.Expires > v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == d.batcher.Fence() - if held { - d.resumeViewer() - } - return held + return lease.Expires > v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == d.batcher.Fence() } // CheckLease closes the stale-window gap before a tool runs, even when no @@ -623,33 +610,27 @@ func (d *Drive) CheckLease(ctx context.Context) error { if currentID, currentFence := d.batcher.Lease(); currentID != id || currentFence != fence { return nil } - if err == nil { - v, err = d.renewIdleLease(ctx, v, fence) - } if err == nil && v.Cell.Lease.Expires <= v.Now { + d.retireHold(ctx, held) d.availableAfter(held, v.Cell.Lease) return nil } if err == nil && (v.Cell.Lease.Expires <= v.Now || v.Cell.Lease.Device != d.sync.Device.ID() || v.Cell.Lease.Fence != d.batcher.Fence()) { if v.Cell.Lease.Device != d.sync.Device.ID() { - d.retireForAnotherHolder(ctx, held) + d.retireHold(ctx, held) } d.becomeViewer(cellsync.Superseded{By: v.Cell.Lease.Device}) - } else if err == nil { + } else if err == nil && !d.displaced() { + // A retired hold stays closed even when the reading still names it. d.resumeViewer() } return nil } -// retireForAnotherHolder retires this writer's hold when CheckLease finds -// another computer holding the chat. It retires the hold at once so no new -// tool starts, waits for any seal already past Gate, and lets the batcher keep -// unsent turns as a branch before the window names the new holder (#2091). -// The send receipt goes before the -// batcher marks itself stale, because that mark already wakes renewal readers -// and none of them may still find a receipt for a hold another computer has. -// The hold is retired quietly: becomeViewer announces the change once. -func (d *Drive) retireForAnotherHolder(ctx context.Context, held sendLease) { +// retireHold closes work admission when a checked hold expires or another +// computer holds the chat. It clears only the captured receipt, so a newer +// writer's receipt survives, then preserves unsent turns before notification. +func (d *Drive) retireHold(ctx context.Context, held sendLease) { d.mu.Lock() d.lost = true d.mu.Unlock() @@ -669,37 +650,6 @@ func (d *Drive) sendLeaseSnapshot() sendLease { return sendLease{Cell: d.directoryID, Fence: d.recordedFence} } -// renewIdleLease covers the gap between a watch reconnect and its ordinary -// heartbeat. Expiry alone never retires an unchanged owner that can still -// renew; an explicit release or a different fence still needs continuation. -func (d *Drive) renewIdleLease(ctx context.Context, v directory.CellView, fence uint64) (directory.CellView, error) { - lease := v.Cell.Lease - if lease.Expires != 0 && lease.Expires <= v.Now && lease.Device == d.sync.Device.ID() && lease.Fence == fence { - // Close stops writers under the same lock before its release. A late - // ownership read must never resurrect that released fence. - d.sealMu.Lock() - d.mu.Lock() - stopped := d.stopped - d.mu.Unlock() - if stopped { - d.sealMu.Unlock() - return v, cellstore.ErrReadOnly - } - renewed, err := d.sync.Dir.Heartbeat(ctx, d.directoryID, directory.Beat{Fence: fence, Pending: d.batcher.Pending()}) - d.sealMu.Unlock() - if errors.Is(err, directory.ErrFenceStale) { - d.becomeViewer(cellsync.Superseded{}) - } else if err != nil && !cellsync.Unreachable(err) { - d.available() - } - if err != nil { - return v, err - } - return renewed, err - } - return v, nil -} - func nonemptyHolder(id string) string { if id == "" { return unknownDevice diff --git a/internal/syncsetup/drive_refusal_safety_test.go b/internal/syncsetup/drive_refusal_safety_test.go index 4c76fec669..97e120e5d4 100644 --- a/internal/syncsetup/drive_refusal_safety_test.go +++ b/internal/syncsetup/drive_refusal_safety_test.go @@ -75,7 +75,7 @@ func TestTheSendProofNamesTheDrivesActualFence(t *testing.T) { } } -func TestFenceF1ReleasedHoldCannotAuthorizeTools(t *testing.T) { +func TestAnExpiredHoldCannotAuthorizeTools(t *testing.T) { r := newDriveRig(t) sealed, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) mustNil(t, err) @@ -84,11 +84,16 @@ func TestFenceF1ReleasedHoldCannotAuthorizeTools(t *testing.T) { drive, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) mustNil(t, err) defer drive.Close(t.Context()) + // Freeze renewal traffic so only the ownership check can act on expiry. + drive.cancel() + <-drive.done r.clock.Advance(directory.LeaseTTL + time.Second) - mustNil(t, r.a.Dir.Release(t.Context(), r.cell.ID, drive.batcher.Fence())) mustNil(t, drive.CheckLease(t.Context())) if drive.Gate() == nil { - t.Fatal("released ownership still authorizes tools") + t.Fatal("expired ownership still authorizes tools") + } + if confirmed, err := drive.ConfirmWork(t.Context()); err != nil || confirmed { + t.Fatalf("expired ownership admitted app work: %v, %v", confirmed, err) } } diff --git a/internal/syncsetup/failed_take_viewer_test.go b/internal/syncsetup/failed_take_viewer_test.go new file mode 100644 index 0000000000..f17ff1b6f3 --- /dev/null +++ b/internal/syncsetup/failed_take_viewer_test.go @@ -0,0 +1,30 @@ +package syncsetup + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestReleasedFailedTakeKeepsSourceClosedAndAvailable(t *testing.T) { + d, memory := sameComputerDriveWithDirectory(t) + other := memory.For("failed-taker") + taken, err := other.Acquire(t.Context(), d.cell.ID, directory.AcquireOpts{Force: true, Expect: &directory.LeaseExpectation{Device: d.sync.Device.ID(), Fence: d.batcher.Fence()}}) + mustNil(t, err) + mustNil(t, d.CheckLease(t.Context())) + if d.Gate() == nil { + t.Fatal("source admitted work under the taker's hold") + } + mustNil(t, other.Release(t.Context(), d.cell.ID, taken.Cell.Lease.Fence)) + mustNil(t, d.CheckLease(t.Context())) + if line, viewer := d.Viewer(); !viewer || line != chatlist.Available { + t.Fatalf("released take viewer=%q,%v; want %q,true", line, viewer, chatlist.Available) + } + if d.Gate() == nil { + t.Fatal("release restored stale work permission") + } + if _, _, err := d.sync.SendLease(d.cell.ID); err == nil { + t.Fatal("release restored the source send receipt") + } +} diff --git a/internal/syncsetup/fence_stability_test.go b/internal/syncsetup/fence_stability_test.go index e9d7e6639e..d873c10c0f 100644 --- a/internal/syncsetup/fence_stability_test.go +++ b/internal/syncsetup/fence_stability_test.go @@ -14,7 +14,7 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" ) -func TestFenceF1StartupRenewsExistingHold(t *testing.T) { +func TestStartupAcquiresNewHold(t *testing.T) { r := newDriveRig(t) sealed, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) mustNil(t, err) @@ -26,12 +26,12 @@ func TestFenceF1StartupRenewsExistingHold(t *testing.T) { defer drive.Close(t.Context()) after, err := r.a.Dir.Cell(t.Context(), r.cell.ID) mustNil(t, err) - if after.Cell.Lease.Fence != created.Cell.Lease.Fence { - t.Fatalf("F1 opening own live hold changed fence: %d -> %d", created.Cell.Lease.Fence, after.Cell.Lease.Fence) + if after.Cell.Lease.Fence != created.Cell.Lease.Fence+1 { + t.Fatalf("opening own live hold must acquire a new fence: %d -> %d", created.Cell.Lease.Fence, after.Cell.Lease.Fence) } } -func TestFenceF1IdleExpiredHoldRenewsWithoutAcquiring(t *testing.T) { +func TestIdleExpiredHoldRequiresContinuation(t *testing.T) { r := newDriveRig(t) sealed, err := r.engine.Seal(t.Context(), r.cell, cellstore.TurnInfo{}) mustNil(t, err) @@ -41,21 +41,23 @@ func TestFenceF1IdleExpiredHoldRenewsWithoutAcquiring(t *testing.T) { drive, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) mustNil(t, err) defer drive.Close(t.Context()) + // Freeze renewal traffic so only the ownership check can act on expiry. + drive.cancel() + <-drive.done r.clock.Advance(directory.LeaseTTL + time.Second) mustNil(t, drive.CheckLease(t.Context())) - if drive.Gate() != nil || !drive.HoldsLease(t.Context()) { - t.Fatal("F1 idle owner was retired instead of renewing its unchanged fence") + if drive.Gate() == nil || drive.HoldsLease(t.Context()) { + t.Fatal("expired idle owner still admits work") } view, err := r.a.Dir.Cell(t.Context(), r.cell.ID) mustNil(t, err) - if view.Cell.Lease.Fence != created.Cell.Lease.Fence || view.Cell.Lease.Expires <= view.Now { - t.Fatalf("F1 idle renewal changed fence or stayed expired: %+v", view) + if view.Cell.Lease.Fence != created.Cell.Lease.Fence+1 { + t.Fatalf("expiry check changed the ownership fence: %+v", view) } } -// Restart retains a live lease; a clean quit intentionally releases it for -// immediate continuation elsewhere and therefore needs a new lease on reopen. -func TestFenceF1IdleWatchReconnectAndProcessResume(t *testing.T) { +// Reopening acquires a new fence; watch reconnection alone does not. +func TestIdleWatchReconnectAndProcessReacquisition(t *testing.T) { connections := make(chan net.Conn, 16) var acquires, releases atomic.Int32 r := newDriveRig(t, func(next http.Handler) http.Handler { @@ -96,7 +98,7 @@ func TestFenceF1IdleWatchReconnectAndProcessResume(t *testing.T) { mustNil(t, drive.CheckLease(t.Context())) view, err := r.a.Dir.Cell(t.Context(), r.cell.ID) mustNil(t, err) - if view.Cell.Lease.Fence != created.Cell.Lease.Fence || acquires.Load() != 0 || releases.Load() != 0 { + if view.Cell.Lease.Fence != created.Cell.Lease.Fence+1 || acquires.Load() != 1 || releases.Load() != 0 { t.Fatalf("F1 reconnect changed hold: %+v; acquires=%d releases=%d", view.Cell.Lease, acquires.Load(), releases.Load()) } // End the old process's loops without its clean-quit release. @@ -107,7 +109,7 @@ func TestFenceF1IdleWatchReconnectAndProcessResume(t *testing.T) { defer resumed.Close(t.Context()) view, err = r.a.Dir.Cell(t.Context(), r.cell.ID) mustNil(t, err) - if view.Cell.Lease.Fence != created.Cell.Lease.Fence || acquires.Load() != 0 || releases.Load() != 0 { + if view.Cell.Lease.Fence != created.Cell.Lease.Fence+2 || acquires.Load() != 2 || releases.Load() != 0 { t.Fatalf("F1 idle restart changed hold: %+v; acquires=%d releases=%d", view.Cell.Lease, acquires.Load(), releases.Load()) } } @@ -137,15 +139,18 @@ func TestRelayOutageO4FailedIdleRenewalKeepsWorkLocal(t *testing.T) { drive, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) mustNil(t, err) defer drive.Close(t.Context()) + // Freeze renewal traffic so only the ownership check can act on expiry. + drive.cancel() + <-drive.done r.clock.Advance(directory.LeaseTTL + time.Second) wire.refuse.Store(true) mustNil(t, drive.CheckLease(t.Context())) - if drive.Gate() != nil || !drive.HoldsLease(t.Context()) { - t.Fatal("O4 unreachable renewal retired an unchanged own hold") + if drive.Gate() == nil || drive.HoldsLease(t.Context()) { + t.Fatal("expired ownership still admits tools") } wire.refuse.Store(false) - if !drive.HoldsLease(t.Context()) || drive.Gate() != nil { - t.Fatal("F1 recovered idle owner could not renew its original fence") + if drive.HoldsLease(t.Context()) || drive.Gate() == nil { + t.Fatal("a recovered connection revived a retired owner") } } diff --git a/internal/syncsetup/late_seal_test.go b/internal/syncsetup/late_seal_test.go new file mode 100644 index 0000000000..d5337e8d1e --- /dev/null +++ b/internal/syncsetup/late_seal_test.go @@ -0,0 +1,141 @@ +package syncsetup + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// TestLateSealAfterLostHoldOrders walks a call that finishes after its hold is +// gone through each order of events. The call stays in local history or a kept +// branch, and it never moves a head past a newer holder's fence. +func TestLateSealAfterLostHoldOrders(t *testing.T) { + for _, order := range []string{"take-seal-displace", "take-displace-seal", "expire-check-seal", "expire-confirm-seal", "take-seal-reopen", "displace-in-flight", "branch-seal"} { + t.Run(order, func(t *testing.T) { + r := newDriveRig(t) + ctx := t.Context() + r.spokenFixture() + first, err := r.engine.Seal(ctx, r.cell, cellstore.TurnInfo{}) + mustNil(t, err) + _, err = r.a.Dir.Create(ctx, r.cell.ID, directory.CellInit{Head: first.Turn.ID}) + mustNil(t, err) + mustNil(t, cellstore.NotePublished(r.cell, first.Turn.ID)) + d, err := r.a.Drive(ctx, r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + t.Cleanup(func() { mustNil(t, d.Close(context.Background())) }) + // Freeze background traffic to select the event order deterministically. + d.cancel() + <-d.done + before, err := r.a.Dir.Cell(ctx, r.cell.ID) + mustNil(t, err) + expiry := order == "expire-check-seal" || order == "expire-confirm-seal" + if expiry { + r.clock.Advance(directory.LeaseTTL + time.Second) + } else { + _, err = r.b.Dir.Acquire(ctx, r.cell.ID, directory.AcquireOpts{Force: true, Expect: &directory.LeaseExpectation{Device: before.Cell.Lease.Device, Fence: before.Cell.Lease.Fence}}) + mustNil(t, err) + } + if order == "take-displace-seal" || order == "expire-check-seal" { + mustNil(t, d.CheckLease(ctx)) + } + if order == "expire-confirm-seal" { + ok, err := d.ConfirmWork(ctx) + mustNil(t, err) + if ok || d.Gate() == nil { + t.Fatal("expired hold admitted work") + } + } + wantFence := before.Cell.Lease.Fence + if order == "branch-seal" { + _, err = d.Store(r.engine).Seal(ctx, r.cell, cellstore.TurnInfo{Calls: []cellstore.Executed{{Call: cellstore.Call{Tool: "earlier", Started: 0}}}}) + mustNil(t, err) + mustNil(t, d.CheckLease(ctx)) + wantFence = 1 + ok, err := d.ConfirmWork(ctx) + mustNil(t, err) + if ok || d.Gate() == nil { + t.Fatal("branch revived the displaced window") + } + } + sealed, err := d.Store(r.engine).Seal(ctx, r.cell, cellstore.TurnInfo{Calls: []cellstore.Executed{{Call: cellstore.Call{Tool: "external", ArgsHash: "finished", Started: 1, Ended: 2, SideEffect: "external"}, Stdout: []byte("finished once")}}}) + mustNil(t, err) + if sealed.Turn.Fence != wantFence || d.batcher.Pending() != 1 { + t.Fatal("late seal did not use the expected fence and one noted turn") + } + if order == "take-seal-reopen" { + // Reconstruct startup without letting the old process close or resolve. + reopened, err := openSync(t, r.homeA).Drive(ctx, r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer reopened.Close(context.Background()) + if reopened.Gate() == nil || reopened.batcher != nil { + t.Fatal("reopen took ownership from the current holder") + } + head, err := cellstore.Head(r.cell) + mustNil(t, err) + if head == nil || head.Turn.ID != sealed.Turn.ID || len(head.Receipt.Calls) != 1 { + t.Fatal("reopen lost the local sealed call") + } + return + } + if order == "take-seal-displace" { + mustNil(t, d.CheckLease(ctx)) + } + if order == "displace-in-flight" { + entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + newID := d.batcher.Brancher.NewID + d.batcher.Brancher.NewID = func() (string, error) { + close(entered) + <-release + return newID() + } + go func() { d.batcher.Displaced(ctx); close(done) }() + <-entered + checked := make(chan error, 1) + go func() { checked <- d.CheckLease(ctx) }() + select { + case err := <-checked: + t.Fatalf("CheckLease returned before the held call reached its branch: %v", err) + case <-time.After(200 * time.Millisecond): + } + releaseOnce.Do(func() { close(release) }) + mustNil(t, <-checked) + <-done + branches, err := branchMapOf(r.homeA) + mustNil(t, err) + if branches.Resolve(r.cell.ID) == r.cell.ID { + t.Fatal("CheckLease returned without the held call in a branch") + } + } + // Close drains noted work even when Displaced already ran with no work. + mustNil(t, d.Close(ctx)) + view, err := r.a.Dir.Cell(ctx, r.cell.ID) + mustNil(t, err) + branches, err := branchMapOf(r.homeA) + mustNil(t, err) + id := branches.Resolve(r.cell.ID) + if order == "expire-confirm-seal" { + if id != r.cell.ID || view.Cell.Head != sealed.Turn.ID || view.Cell.Lease.Fence != before.Cell.Lease.Fence { + t.Fatal("expected late publish to renew the unchanged expired fence") + } + return + } + if id == r.cell.ID || view.Cell.Head != first.Turn.ID { + t.Fatal("late call overwrote the original head or was not branched") + } + kept, err := r.a.Dir.Cell(ctx, id) + mustNil(t, err) + if kept.Cell.Head != sealed.Turn.ID || kept.Cell.ParentCell != r.cell.ID || d.batcher.Pending() != 0 { + t.Fatal("branch did not preserve exactly the noted head") + } + if !expiry && view.Cell.Lease.Device != r.b.Device.ID() { + t.Fatal("branch changed the current holder") + } + }) + } +} diff --git a/internal/syncsetup/member_client_test.go b/internal/syncsetup/member_client_test.go index 24e07cbe37..7513062ee4 100644 --- a/internal/syncsetup/member_client_test.go +++ b/internal/syncsetup/member_client_test.go @@ -454,6 +454,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) @@ -624,6 +625,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)) @@ -631,6 +634,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/outage_test.go b/internal/syncsetup/outage_test.go index 79864a845f..92c10f59a3 100644 --- a/internal/syncsetup/outage_test.go +++ b/internal/syncsetup/outage_test.go @@ -31,7 +31,7 @@ func TestRelayOutageO1O2OwnExpiredLeaseWithUnpublishedHead(t *testing.T) { waitFor(t, "O2 unpublished descendant to reach the relay", func() bool { return r.directoryHead(r.cell.ID) == latest.Turn.ID }) v, err := r.a.Dir.Cell(t.Context(), r.cell.ID) mustNil(t, err) - if v.Cell.Lease.Device != r.a.Device.ID() || v.Cell.Lease.Fence != created.Cell.Lease.Fence || v.Cell.Lease.Expires <= v.Now { - t.Fatalf("O2 recovery did not renew the unchanged own hold: %+v", v) + if v.Cell.Lease.Device != r.a.Device.ID() || v.Cell.Lease.Fence != created.Cell.Lease.Fence+1 || v.Cell.Lease.Expires <= v.Now { + t.Fatalf("O2 recovery did not acquire a fresh hold: %+v", v) } } diff --git a/internal/syncsetup/pair_merge_test.go b/internal/syncsetup/pair_merge_test.go index d968a4ec10..26fb1125ec 100644 --- a/internal/syncsetup/pair_merge_test.go +++ b/internal/syncsetup/pair_merge_test.go @@ -34,15 +34,29 @@ func TestM1M2M3M4M5PairMergeRealHomes(t *testing.T) { h.homeB = t.TempDir() old, err := identity.EnsureSolo(h.homeB) mustNil(t, err) - v, err := keys.Open(h.homeB) - mustNil(t, err) - mustNil(t, v.Put("p/KEY", keys.Entry{Name: "KEY", Value: "kept", Scope: "p"})) mustNil(t, os.WriteFile(filepath.Join(h.homeB, "config.json"), []byte(`{"ui.icons":"ascii","machine.path":"local"}`), 0600)) brain, err := store.Open(filepath.Join(h.homeB, "graph.db")) mustNil(t, err) memory, err := brain.AddMemory(store.Memory{Owner: store.OwnerUser, Text: "Remember this pairing preference."}) mustNil(t, err) mustNil(t, brain.Close()) + wantA, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + route := pair.Mailbox{Box: pairbox.NewHTTP(h.url, http.DefaultClient), URL: h.url, Host: "loopback"} + screen := pairScreen{codes: make(chan *pair.Code, 1)} + done := make(chan error, 1) + go func() { + _, err := pair.Offer(ctx, route, pair.Grant{Identity: h.a.Identity}, screen) + done <- err + }() + code := <-screen.codes + _, err = pair.Join(ctx, route, pair.Joining{Home: h.homeB, Label: nameB}, code.Shown(), screen) + mustNil(t, err) + mustNil(t, <-done) + // Local chats and provider keys are created only after the legal startup switch. + v, err := keys.Open(h.homeB) + mustNil(t, err) + mustNil(t, v.Put("p/KEY", keys.Entry{Name: "KEY", Value: "kept", Scope: "p"})) c, err := cell.CreateIn(filepath.Join(h.homeB, "v3", "projects", "local"), cell.Options{Class: cell.FilesOnly}) mustNil(t, err) work := filepath.Join(c.Root, "work") @@ -59,19 +73,6 @@ func TestM1M2M3M4M5PairMergeRealHomes(t *testing.T) { } wantB, err := os.ReadFile(transcriptOf(c)) mustNil(t, err) - wantA, err := os.ReadFile(transcriptOf(h.cell)) - mustNil(t, err) - route := pair.Mailbox{Box: pairbox.NewHTTP(h.url, http.DefaultClient), URL: h.url, Host: "loopback"} - screen := pairScreen{codes: make(chan *pair.Code, 1)} - done := make(chan error, 1) - go func() { - _, err := pair.Offer(ctx, route, pair.Grant{Identity: h.a.Identity}, screen) - done <- err - }() - code := <-screen.codes - _, err = pair.Join(ctx, route, pair.Joining{Home: h.homeB, Label: nameB}, code.Shown(), screen) - mustNil(t, err) - mustNil(t, <-done) h.b = openSync(t, h.homeB) mustNil(t, h.b.SyncState(ctx)) mustNil(t, h.a.SyncState(ctx)) diff --git a/internal/syncsetup/review_fixes3_test.go b/internal/syncsetup/review_fixes3_test.go index 809d7f4aa7..89c0bda993 100644 --- a/internal/syncsetup/review_fixes3_test.go +++ b/internal/syncsetup/review_fixes3_test.go @@ -11,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/keys" @@ -84,16 +85,20 @@ func (d *r3SwitchAfterList) List(ctx context.Context) (directory.Listing, error) // through the clients bound to the set it just left. func TestR3_1_StateRoundRefusesVaultFromNewPairing(t *testing.T) { for _, replace := range []bool{true, false} { - t.Run(map[bool]string{true: "replace", false: "merge"}[replace], func(t *testing.T) { + t.Run(map[bool]string{true: "replace", false: "startup"}[replace], func(t *testing.T) { home := t.TempDir() old, err := identity.EnsureSolo(home) mustNil(t, err) if replace { mustNil(t, identity.EndSolo(home)) } - v, err := keys.Open(home) - mustNil(t, err) - mustNil(t, v.Put("before", keys.Entry{Name: "BEFORE", Value: "old set"})) + if replace { + v, err := keys.Open(home) + mustNil(t, err) + mustNil(t, v.Put("before", keys.Entry{Name: "BEFORE", Value: "old set"})) + } else { + mustNil(t, config.WriteChatModel(home, "fixture/startup")) + } oldDir := directory.NewMemory(time.Now).For("old-device") oldStore := blobstore.NewMemory() next, err := identity.Mint() @@ -116,6 +121,8 @@ func TestR3_1_StateRoundRefusesVaultFromNewPairing(t *testing.T) { t.Fatal(ctx.Err()) } mustNil(t, <-done) + v, err := keys.Open(home) + mustNil(t, err) mustNil(t, v.Put("after", keys.Entry{Name: "AFTER", Value: "new set only"})) } dir := &r3SwitchAfterList{Client: oldDir, switchHome: switchHome} @@ -141,6 +148,8 @@ func TestR3_1_StateRoundRefusesVaultFromNewPairing(t *testing.T) { if listing.Identity.Vault == "" { t.Fatal("next round did not share the saved keys under the new binding") } + v, err := keys.Open(home) + mustNil(t, err) got, err := v.Get("after") if err != nil || got.Value != "new set only" { t.Fatalf("new saved key after retry = %+v, %v", got, err) diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index f69cc06aaf..6008d09261 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -221,9 +221,6 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx return Continued{}, errors.New("these turns are no longer available to open separately") } lease := view.Cell.Lease - if seen != nil && !seen.Matches(view.Cell) { - return Continued{}, directory.ErrLeaseHeld - } // Kept turns may name this device before their separate copy exists here. known, err := cellstore.KnowsHead(cell.Cell{ID: id, Root: root}, view.Cell.Head) if err != nil { @@ -244,6 +241,9 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx Cell: opened, Driving: cellsync.Driving{Cell: opened, Fence: lease.Fence, Head: view.Cell.Head, Frames: view.Cell.Frames, Size: view.Cell.Size}, }}, nil } + if seen != nil && !seen.Matches(view.Cell) { + return Continued{}, directory.ErrLeaseHeld + } if seen == nil { seen = &directory.LeaseExpectation{Device: lease.Device, Fence: lease.Fence} } @@ -805,8 +805,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 51914c0f4b..76c48fc617 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" @@ -20,6 +22,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/teams/teamstest" + "github.com/Agent-Field/codeaf/internal/vaultsync" ) func TestTeamFieldsAndUnknownMemberFieldsSurviveGuardedApply(t *testing.T) { @@ -323,6 +326,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") @@ -338,7 +342,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) } @@ -370,7 +374,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) } @@ -445,7 +449,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) } @@ -484,6 +488,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 { @@ -495,7 +500,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) } @@ -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) != 0 { t.Fatal("revoked device retained mirror", got) } @@ -564,6 +569,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") @@ -571,8 +578,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"}}}} @@ -644,3 +655,169 @@ 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 := teamstest.JSON(t, 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 := teamstest.JSON(t, dir) + backup := filepath.Join(dir, "teams.before-sharing.json") + restore := teamstest.Break(t, dir, []byte("damaged store")) + defer restore() + 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.Stat(backup); !os.IsNotExist(err) { + t.Fatal("failed backup left a copy", err) + } + restore() + 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 a51b83f6e5..1e520dbc26 100644 --- a/internal/teams/stamp.go +++ b/internal/teams/stamp.go @@ -210,16 +210,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 4976092867..09e2720ad3 100644 --- a/internal/teams/store.go +++ b/internal/teams/store.go @@ -2,6 +2,7 @@ package teams import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -321,6 +322,88 @@ func Inspect(profileDir string, fn func(*File) error) error { }) } +// BackupBeforeSharing preserves the full stored teams 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 + } + snapshot, err := readSnapshot(profileDir) + if err != nil { + return fmt.Errorf("preserving teams.before-sharing.json: %w", err) + } + if snapshot == nil { + return nil + } + file := snapshot.file() + for i := range file.Teams { + team := &file.Teams[i] + for _, members := range [][]Member{team.Members, team.FormerMembers} { + for j := range members { + members[j], err = WithBriefs(profileDir, team.ID, members[j]) + if err != nil { + return err + } + } + } + } + raw, err := json.MarshalIndent(struct { + Version int `json:"version"` + Teams []Team `json:"teams"` + }{Version: Version, Teams: file.Teams}, "", " ") + 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 a teams.db that is not this store's (bytes that are not a // database, or another program's database) out of the way, to // .unreadable- beside it with its WAL and shared-memory files, so diff --git a/internal/teams/store_test.go b/internal/teams/store_test.go index 7fd52be70d..61c7a50624 100644 --- a/internal/teams/store_test.go +++ b/internal/teams/store_test.go @@ -426,3 +426,76 @@ 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 := Save(dir, []Team{{ID: "local", Name: "harbor"}}); 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 || !json.Valid(got) || !bytes.Contains(got, []byte(`"name": "harbor"`)) { + t.Fatalf("fallback backup = %s, %v", got, err) + } + raw = got + info, err := os.Stat(backup) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("backup permissions", info, err) + } + if err := Save(dir, []Team{{ID: "later", Name: "later teams"}}); 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) + } +} + +func TestBackupBeforeSharingPreservesSQLiteTeamsAndBriefs(t *testing.T) { + dir := t.TempDir() + if err := Save(dir, []Team{{ID: "local", Name: "harbor", Members: []Member{{Key: "worker", Word: "worker", FactoryBrief: "keep the factory brief"}}}}); err != nil { + t.Fatal(err) + } + if err := BackupBeforeSharing(dir); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, "teams.before-sharing.json")) + if err != nil { + t.Fatal(err) + } + var backup struct { + Version int `json:"version"` + Teams []Team `json:"teams"` + } + if err := json.Unmarshal(raw, &backup); err != nil { + t.Fatalf("backup is not a team document: %v", err) + } + if len(backup.Teams) != 1 || backup.Teams[0].Name != "harbor" || len(backup.Teams[0].Members) != 1 || backup.Teams[0].Members[0].FactoryBrief != "keep the factory brief" { + t.Fatalf("backup lost saved team data: %+v", backup) + } +} 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 ae7b005814..dbe0b4649b 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/tui3/app.go b/internal/tui3/app.go index 3dc127a995..ec75db114d 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 @@ -3106,6 +3109,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, @@ -3313,7 +3317,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 @@ -3656,7 +3662,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 { @@ -3725,6 +3731,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. @@ -4323,6 +4330,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() @@ -6030,7 +6040,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() @@ -7088,7 +7102,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 a6508e7b72..e3345b62d7 100644 --- a/internal/tui3/conversationdelete_test.go +++ b/internal/tui3/conversationdelete_test.go @@ -298,11 +298,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 { @@ -358,11 +359,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 f5ad1515b4..4574e01df5 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." ) @@ -389,8 +389,8 @@ func (a *app) runPair(typed string) tea.Cmd { return a.startPair(pairJoiningWord, joinWork(door, typed, replace)) } -// joinWork shares the joining computer's chats by default. Only an explicitly -// typed --replace asks to discard them. It ends on the line for +// joinWork pairs a computer, refusing submitted chats unless --replace was +// explicitly requested and confirmed. It ends on the line for // how it went. It is the same errand from /pair and from the first-run // screen's field, so the two cannot end in different words. func joinWork(door Pairing, typed string, replace bool) func(*pairRun) { @@ -400,7 +400,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 } @@ -442,6 +442,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() @@ -456,9 +459,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, @@ -527,3 +533,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 ad88e045e9..256a8ba422 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 d3d8026255..3c6c0878c6 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) @@ -171,7 +174,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) @@ -194,7 +197,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) { @@ -285,7 +288,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 8c9369c187..0571b2ba58 100644 --- a/internal/tui3/teamseam_test.go +++ b/internal/tui3/teamseam_test.go @@ -52,7 +52,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() @@ -76,10 +76,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) @@ -275,11 +276,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") } @@ -335,7 +337,7 @@ func TestALocalStoreItCannotReadIsNotHeldEmpty(t *testing.T) { t.Fatal(err) } var watch teamstore.Watch - seam := localTeams(dir, &watch) + seam := localTeams(dir, &watch, nil) if teams, _, known := seam.Load(nil); known { t.Fatalf("a newer store loaded as known with %d teams", len(teams)) } 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 693cb8b3d5..bc05ae374f 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" "github.com/Agent-Field/codeaf/internal/teams/teamstest" ) @@ -84,31 +86,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"} { @@ -188,7 +185,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) @@ -205,7 +203,7 @@ func TestUnpairedRefreshClockReadsChangedFileWithoutRepairingOrWritingAnything(t 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") } } @@ -334,25 +332,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) } @@ -362,12 +374,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") @@ -378,15 +395,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 @@ -418,26 +453,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", "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) } } - 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") @@ -455,8 +498,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) { @@ -467,3 +528,270 @@ 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) + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + 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"}]}]}`) + teamstest.Write(t, dir, raw) + before, err := teamstore.Snapshot(dir) + if err != nil || len(before.Teams) != 1 || len(before.Teams[0].Members) != 1 || before.Teams[0].Members[0].Handle != "" { + t.Fatal("fixture must need handle repair", before, err) + } + got, _, same, err := seam.ReadSince(stamp, nil) + if err != nil || same || len(got) != 1 || len(got[0].Members) != 1 || got[0].Members[0].Handle == "" { + t.Fatal("unpaired read skipped repair", got, err) + } + after, err := teamstore.Snapshot(dir) + if err != nil || !reflect.DeepEqual(after.Teams, got) { + t.Fatal("unpaired read did not persist repair", after, err) + } +} + +// 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) + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: a.profileDir}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + 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") + } + if !a.teamsShare { + t.Fatal("update did not adopt sharing") + } + }) + } +} + +// 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() + row, _ := config.NewSettings(config.SettingsOptions{ProfileDir: engine}).Row(config.KeyTeamsShare) + if err := row.Apply("on"); err != nil { + t.Fatal(err) + } + 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 5a4e6f5da6..094b1ef86d 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 e8d7253ab4..be4aec0dad 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 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)) diff --git a/relay/hosted/src/identity.js b/relay/hosted/src/identity.js index 27f496f265..0bccc3fa08 100644 --- a/relay/hosted/src/identity.js +++ b/relay/hosted/src/identity.js @@ -88,7 +88,7 @@ export class IdentityDO extends DurableObject { async #noteLeft(ws) { const device = this.watchers.left(ws); const identity = device && (await this.ctx.storage.get('identity')); - if (identity && !(await this.#isGone()) && this.host?.ready) await this.host.operation(async()=>{await this.host.wakeMaintenance();await this.host.commitEffect({device_seen:{device}},Date.now());}); + if (identity && !(await this.#isGone()) && this.host?.ready) this.host.noteSeen(device); } async #serve(request) { @@ -140,6 +140,7 @@ export class IdentityDO extends DurableObject { const route = matchRoute(request.method, url.pathname); const caller = await checkCert(signedHeaders(request)); const leave = route.frames ? this.#enterPut() : () => {}; + let putWork; try { const body = await readBody(request, route.limit, route.over); const who = await checkRequest(caller, { method: request.method, uri: signedUri(this.env, url) }, body, Date.now()); @@ -148,10 +149,12 @@ export class IdentityDO extends DurableObject { const tenant = this.#tenantOf(who.identity); this.#admit(tenant, who.device); this.host ??= new HostedIdentity(this.ctx,this.env,tenant,this.legacyAdmission,at=>this.#arm(at)); - const reply=await this.host.dispatch({tenant,device:who.device,body,request,env:this.env},route); + const reply=await this.host.dispatch({tenant,device:who.device,body,request,env:this.env,retainPut:work=>{if(route.frames)putWork=work;}},route); this.watchers=this.host.watchers;return reply; } finally { - leave(); + // A rejected response can precede the actual blob write. Has and erasure + // must still wait for that write, including its durable index updates. + if(putWork)this.ctx.waitUntil(putWork.then(leave,leave));else leave(); } } diff --git a/relay/hosted/src/link/decide.js b/relay/hosted/src/link/decide.js index 4a95b4dfaa..df2ff90f14 100644 --- a/relay/hosted/src/link/decide.js +++ b/relay/hosted/src/link/decide.js @@ -26,10 +26,15 @@ async function certDevice(c, cert) { return proof.device; } +/** checkApprovalDevice validates the certificate before a request can be reserved. */ +export async function checkApprovalDevice(c, body, request) { + if (!isObject(body) || !isObject(body.device)) throw badRequest(); + if ((await certDevice(c, body.cert)) !== request.device) throw badRequest(); +} + /** asApproval checks the approve body against the request it answers, and answers the device record to write. */ async function asApproval(c, body, request) { - if (!isObject(body.device)) throw badRequest(); - if ((await certDevice(c, body.cert)) !== request.device) throw badRequest(); + await checkApprovalDevice(c, body, request); if (c.tenant.dir.revoked(request.device)) throw badRequest(); // a revoked device re-joins only as a new request and a new key const grant = asGrant(body.grant, c.tenant.limits.linkMaxGrant); return { grant, device: { ...body.device, platform: platformOf(body.device.platform ?? request.platform) } }; diff --git a/relay/hosted/src/raft/README.md b/relay/hosted/src/raft/README.md index 01aaec1106..bf04c6feaa 100644 --- a/relay/hosted/src/raft/README.md +++ b/relay/hosted/src/raft/README.md @@ -84,7 +84,7 @@ source is refused, never enrolled as a fresh group. `RaftMemberDO.legacyImport(config, manifest, row, finish)` stages bounded rows and records `legacy-pending` before provisioning. Pending imports cannot initialize/load a core after eviction. Exact retries survive interruption; -finish checks the complete digest, installs a three-voter genesis and portable +finish checks the complete digest, installs its immutable genesis and portable checkpoint, then removes only redundant staging rows and clears the pending marker durably. The stored ceiling includes all grandfathered rows. One import request per member can run at once, preventing page buffers stacking in memory. @@ -169,7 +169,9 @@ use ABI 29 and the same deterministic evaluation. Empty legacy frame plans are normalized before original-proof comparison, preserving replacement semantics. Public source serialization spans coordination, while external blob I/O leaves -that queue and passes a new authority barrier afterward. Public watch sockets +that queue. Blob requests authorize once, then recheck confirmed authority and +its applied index after I/O; changed authority requires authorization again. +Public watch sockets carry presence without the legacy socket-vouch promise; v1 clients keep ordinary beats. The hosted relay admits personal learners and serves their status, exchange and hibernating sockets. Joined computers can explicitly request leadership; @@ -222,10 +224,85 @@ retirement deadlines are then rearmed independently. An alarm on a cold sleeping object reads socket attachments and that hint without constructing a tenant, loading member handles, acquiring a read barrier or reopening peer queues. Platform auto-response timestamps supply presence without data writes. Real -application input still activates coordination and takes a fresh quorum barrier; -a remembered leader never authorizes a reply. Watch hold renewals retain their +application input still activates coordination. Mutations commit through Raft; +reads use a fresh safe barrier or the router's confirmed read certificate below. +A remembered leader alone never authorizes a reply. Watch hold renewals retain their committed path and shared renewal period. +## Confirmed hosted reads + +The identity router can briefly reuse a safe ReadIndex result when at least two +hosted voters in a stable three-voter configuration have identical clean durable +tails: log tip, commit and applied index agree, as do term, leader and configuration. +Those voters intersect every quorum, including the personal-leader topology. +During singleton birth, the sole hosted voter supplies the same clean-tail fence; +joint configurations require an ordinary safe barrier. +No unseen append acknowledgment can commit a later record from these clean tails. +Every subsequent commit therefore needs anchor traffic through this singleton router. + +The router invalidates the certificate before every member mutation, peer turn, +snapshot operation and release of peer traffic. New member RPCs default to mutation; +only named read methods bypass that fence. In-flight work prevents certification. +Reads pin the selected member's activation and check the router generation before +and after asynchronous reads. A generation change, recovery or ten-second expiry +requires fresh confirmation. Failed confirmation never serves a stale certificate. +Recent read activity renews confirmation during maintenance; idle groups still sleep. +The shared Go core keeps ReadOnlySafe and its minority-read contract unchanged. + +When member traffic interrupts a cached read, the request restarts authorization +and row selection with a fresh SafeReadIndex rather than recertifying another +lease. This request pins the member activation and actual applied index across +identity, device and listing pages, including a final view after storage sync. +Changed applied state restarts the whole read; bounded retries still fail closed +without a coherent confirmed view or quorum. Blob I/O stays outside retries, and +its completion rechecks the applied index before deciding whether to authorize again. + +Directory reads do not propose AdvanceTime. Their observed wall-clock floor is +durable in the router's local metadata and never moves backward after recovery; +subsequent v1 mutation translation includes that floor. Directory GET signatures +are checked against it too, so clock rollback cannot revive stale request proofs. +Native API ingress still commits a synchronous device_seen clock witness before +checking signed commands or worker reads, using the shape older native evaluators +accept. This also records caller presence. Without this witness, a backdated +command could enroll expired worker scope. Device presence from blob +authorization and watch departure is advisory, coalesced for thirty seconds with +at most 64 pending devices, and flushed one device per maintenance turn after +critical work. Failed presence writes remain pending and do not skip other work. + + +## New identities and background formation + +Only a newly provisioned identity with no legacy directory rows may begin with +one hosted voter. Admission drains before this eligibility check and the freeze +records the decision once. Existing and imported identities keep their three +voters. The public legacy manifest and client snapshot schema do not change. + +A private birth record binds the cluster UUID, original manifest digest and +initial voter to each member's immutable configuration. The first member imports +and commits locally; the first public write does not create the other two actors. +An interrupted publication reuses the same provenance. A published member that +loses its store is never reconstructed from the frozen original. + +Maintenance adds the other anchors as learners before allocating their stores, +catches them up, promotes both through explicit joint consensus, then leaves +joint consensus. The existing membership registry is initialized only after the +stable three-voter configuration commits. Machine coordination that needs that +registry remains unavailable until then. Hold renewal, join delivery and advisory +presence continue during formation, even when a new anchor is unavailable. An idle +singleton can quiesce using its actual one-voter configuration while those +anchors are unavailable. Learner admission refreshes the leader snapshot before +catch-up, so a previously compacted snapshot cannot exclude the new learner. + +Recovery follows the actual durable ConfState. The birth record cannot reduce +the quorum after joint consensus or three-voter formation. Older hosted code +rejects the changed immutable member configuration instead of serving a different +genesis. Private ABI extensions do not change released client messages. + +Committed application changes notify watchers and held store polls from applied +state, including changes replicated from another leader. Revocation closes the +device watch and rejects held polls before the next public request or sweep. A +rejected upload retains its in-flight slot until its underlying storage work +settles, so other devices’ Has requests and retirement still wait for that work. ## Personal leader departure and anchor restoration diff --git a/relay/hosted/src/raft/abi.js b/relay/hosted/src/raft/abi.js index f31728ebbe..918f9dfa66 100644 --- a/relay/hosted/src/raft/abi.js +++ b/relay/hosted/src/raft/abi.js @@ -1,3 +1,4 @@ +import { encodeCanonical } from './codec.js'; import { wasiPreview1 } from './wasi.js'; export const ABI_VERSION = 1; @@ -135,6 +136,6 @@ export class HostCodec { const v = { state: r.blob(), digest: r.blob() }; r.end(); return v; } configuration(conf, entry) { const r = this.call(frame(20).blob(conf).blob(entry)); const next = r.blob(); r.end(); return next; } - genesis(data) {const r=this.call(frame(13).blob(data));const v={entry:r.blob(),hard:r.blob(),conf:r.blob()};r.end();return v;} + genesis(data,birth=null) {const w=frame(13).blob(data);if(birth)w.blob(new TextEncoder().encode(encodeCanonical(birth)));const r=this.call(w);const v={entry:r.blob(),hard:r.blob(),conf:r.blob()};r.end();return v;} snapshot(data) { const r = this.call(frame(9).blob(data)); const b = r.blob(); r.end(); return b; } } diff --git a/relay/hosted/src/raft/actor.js b/relay/hosted/src/raft/actor.js index 81d52ee441..823e5e220a 100644 --- a/relay/hosted/src/raft/actor.js +++ b/relay/hosted/src/raft/actor.js @@ -93,7 +93,7 @@ export class MemberActor { this.checkpointIndex = BigInt(app.applied); this.cache = this.store.one('SELECT coalesce(sum(length(data)),0) AS n FROM journal').n; continue; } if (this.cache > CACHE_BYTES) { this.stopped = true; throw new Error('raft member: log cache capacity; recover'); } - return { send: sends, reads, status: this.status }; + return { send: sends, reads, status: this.status, applicationVersion:app.version??'0' }; } } input(tag, value) { value = value instanceof Uint8Array ? value.slice() : value && typeof value === 'object' ? structuredClone(value) : value; return this.serial(() => this.drive(tag, value)); } @@ -117,6 +117,18 @@ export class MemberActor { }); } // Evidence is an internal authenticated target reply, derived under the // durable member queue. No public request may set its own applied index. + formation() {return this.serial(async()=>{ + const manifest=this.store.read('membership','legacy-import'),registry=this.store.read('membership','registry'); + const r=response(this.reactor.call(frame(49).u32(this.handle).blob(manifest).blob(new TextEncoder().encode(encodeCanonical(this.config.birth))).blob(registry??new Uint8Array()).finish())); + const change=r.blob(),checkpoint=r.u32()!==0,settle=r.u32()!==0,configure=r.u32()!==0;r.end(); + let effect=configure?await this.drive(8,change):{send:[],reads:[],status:this.status}; + if(checkpoint){ + const snapshot=await this.store.checkpoint(),next=await this.drive(11,snapshot); + this.checkpointIndex=this.status.applied; + effect={...next,send:[...effect.send,...next.send],reads:[...effect.reads,...next.reads]}; + } + return {...effect,settle:!configure&&settle,progressed:configure||checkpoint}; + });} leadershipEvidence(revision, barrier) { return this.serial(() => { if(this.stopped||this.store.stopped||!this.handle)throw new Error('raft member: stopped; recover from durable storage'); const header = this.store.read('membership', 'registry'); diff --git a/relay/hosted/src/raft/forward.js b/relay/hosted/src/raft/forward.js index 1212b9da0c..4df61304a8 100644 --- a/relay/hosted/src/raft/forward.js +++ b/relay/hosted/src/raft/forward.js @@ -22,7 +22,7 @@ export class PersonalForward { const flight=new Flight(),pending={ws,node,release:flight.enter(),reply:null}; const promise=flight.idle(host.peers.limits.ForwardTimeout).then(completed=>{if(!completed)this.finish(id,null);return pending.reply;}); this.pending.set(id,pending); - try{ws.send(raw);}catch{this.finish(id,null);} + try{host.invalidateRead();ws.send(raw);}catch{this.finish(id,null);} return {id,promise}; } finish(id,reply){const p=this.pending.get(id);if(!p)return;this.pending.delete(id);p.reply=reply;p.release();this.host.signal();} diff --git a/relay/hosted/src/raft/hosted.js b/relay/hosted/src/raft/hosted.js index e6ab7b6a93..99c872e537 100644 --- a/relay/hosted/src/raft/hosted.js +++ b/relay/hosted/src/raft/hosted.js @@ -16,6 +16,9 @@ import { fromBase64Url } from '../codec.js'; import { parseBeat } from '../presence.js'; import { parseHolds } from '../vouch.js'; import { rotationView } from '../rotation.js'; +import { checkTime } from '../verify.js'; +import { checkApprovalDevice } from '../link/decide.js'; +import { asGrant } from '../link/code.js'; import { CommittedWatchers, CLOSE_REVOKED, CLOSE_ROTATED } from '../watch.js'; const enc=new TextEncoder(),dec=new TextDecoder('utf-8',{fatal:true}); @@ -23,7 +26,10 @@ const segment=s=>Array.from(enc.encode(s),b=>b.toString(16).padStart(2,'0')).joi const unsegment=s=>dec.decode(Uint8Array.from(s.match(/../g)??[],h=>parseInt(h,16))); const data=v=>enc.encode(encodeCanonical(v)); const MIGRATION_PAGE=64,COORDINATOR_QUEUE=64,MIN_MEMBERS=2; +const PRESENCE_INTERVAL=30_000,PRESENCE_BATCH=64,READ_ATTEMPTS=3; +const MEMBER_READS=new Set(['hostView','hostResult','hostSelected','hostPersonal','readEvidence','hostWatchState']); const unavailable=()=>new Wire('unreachable',503,1); +const interruptedRead=()=>Object.assign(unavailable(),{readInterrupted:true}); const status={not_found:404,unauthorized:401,revoked:401,rotated:410,bad_request:400,bad_grace:400,self_revoke:400,capacity:507,full:507}; function requireOK(code){if(code!=='ok')throw new Wire(code,status[code]??409);} async function keypair(){ @@ -41,6 +47,8 @@ export class HostedIdentity { this.policy=coordinationPolicy(sharedRuntime());this.started=Math.floor(performance.now());this.elapsed=0;this.asleep=false;this.wakeVersion=0;this.alarmTail=Promise.resolve(); this.ctx=ctx;this.env=env;this.tenant=tenant;this.runtime=sharedRuntime();this.codec=new HostCodec(this.runtime); this.source=tenant.migration(ctx.storage,this.runtime);this.tail=Promise.resolve();this.pending=0;this.operationTail=Promise.resolve();this.operations=0;this.waiters=new Set();this.progressVersion=0;this.forwarding=new PersonalForward(this);this.members=new Map();this.states=new Map();this.inactiveMembers=new Set();this.queue=[];this.reads=[]; + this.seen=new Map();this.pendingIO=new Map(); + this.readGeneration=0;this.memberWork=0;this.readLease=null; } serial(fn){ if(this.pending>=COORDINATOR_QUEUE)return Promise.reject(unavailable());this.pending++; @@ -91,10 +99,78 @@ export class HostedIdentity { await this.progress(deadline); } } - member(id){return this.env.RAFT_MEMBERS.get(this.env.RAFT_MEMBERS.idFromName(this.manifest.config.cluster+'/'+id));} + invalidateRead(){this.readGeneration++;this.readLease=null;} + member(id){ + const stub=this.env.RAFT_MEMBERS.get(this.env.RAFT_MEMBERS.idFromName(this.manifest.config.cluster+'/'+id)); + // Default to mutation: a new member RPC cannot silently bypass the fence. + return new Proxy(stub,{get:(target,name)=>{ + const call=Reflect.get(target,name,target);if(typeof call!=='function')return call; + return (...args)=>{ + if(MEMBER_READS.has(name))return Reflect.apply(call,target,args); + this.invalidateRead();this.memberWork++; + return Promise.resolve().then(()=>Reflect.apply(call,target,args)).finally(()=>{this.memberWork--;}); + }; + }}); + } + leaseCurrent(lease=this.readLease){return lease&&lease.generation===this.readGeneration&&!this.memberWork&&!this.asleep&&performance.now()this.members.has(id)); + if(voters.length<(singleton?1:2))return null; + const evidence=await Promise.all(voters.map(async node=>({node,...await this.members.get(node).readEvidence()}))); + if(generation!==this.readGeneration||this.memberWork||this.coordinationBusy())return null; + if(!selected.clean||BigInt(selected.index)e.clean&&e.index===selected.index&&e.term===selected.term&&e.conf===selected.conf&&e.leader===selected.leader))return null; + const leader=selected.leader;if(leader==='0'||!conf.voters.map(String).includes(leader))return null; + if(this.members.has(leader)&&!evidence.some(e=>e.node===leader&&e.role===2))return null; + // Every quorum intersects these hosted voters. With clean durable tails, + // another commit needs new anchor traffic, which invalidates this fence + // before delivery. The deadline additionally bounds idle reuse. + return this.readLease={node:b.node,index:BigInt(selected.index),term:selected.term,activation:selected.activation,generation,until:started+this.policy.tick_every*2}; + } + async readAuthority(fresh=false){ + this.lastReadAt=Math.max(this.elapsed,performance.now()-this.started); + if(!fresh&&this.leaseCurrent())return this.readLease; + const b=await this.barrier(),lease=!fresh&&await this.confirmRead(b); + if(lease)return lease; + const evidence=await this.members.get(b.node).readEvidence(); + return {...b,activation:evidence.activation}; + } + checkRead(b){if(b.generation!==undefined&&!this.leaseCurrent(b))throw interruptedRead();} + async retryRead(read){ + const deadline=Date.now()+(this.peers?.limits.ForwardTimeout??this.policy.tick_every); + for(let attempt=0;;attempt++){ + try{return await read(attempt>0);} + catch(error){ + if(!error.readInterrupted||attempt+1>=READ_ATTEMPTS||Date.now()>=deadline)throw error; + this.invalidateRead(); + } + } + } + async readView(b,namespace,key,after=null){ + this.checkRead(b); + let row;try{row=await this.members.get(b.node).hostView(b.index,namespace,key,after,b.activation??null);}catch(error){this.invalidateRead();throw unavailable();} + if(b.generation===undefined){ + b.applied??=String(row.app.applied); + if(b.applied!==String(row.app.applied))throw interruptedRead(); + } + this.checkRead(b);return row; + } + readNow(committed=0){ + const floor=this.tenant.meta.get('read-time')??0,now=Math.max(Date.now(),Number(committed),floor); + if(now>floor)this.tenant.meta.set('read-time',now);return now; + } + async finishRead(b,reply){ + await this.ctx.storage.sync(); + if(b.generation===undefined)await this.readView(b,'state','identity'); + this.checkRead(b);return reply; + } config(id){ const c=this.manifest.config; - return {cluster:c.cluster,id:String(id),seed:String(BigInt(id)+100n),bootstrap:c.anchors,ceiling:Number(this.manifest.ceiling),scope:{identity:c.identity,cluster:c.cluster,epoch:c.generation,protocol:1},anchors:c.anchors.map((node,i)=>({node,device:'',key:c.anchor_keys[i],incarnation:'1',protocol:1,anchor:true,retired:false,ready_index:'0'}))}; + return {cluster:c.cluster,id:String(id),seed:String(BigInt(id)+100n),bootstrap:this.source.gate()?.birth?.voters??c.anchors,...(this.source.gate()?.birth?{birth:this.source.gate().birth}:{}),ceiling:Number(this.manifest.ceiling),scope:{identity:c.identity,cluster:c.cluster,epoch:c.generation,protocol:1},anchors:c.anchors.map((node,i)=>({node,device:'',key:c.anchor_keys[i],incarnation:'1',protocol:1,anchor:true,retired:false,ready_index:'0'}))}; } async provision(identityKey){ let p=await this.ctx.storage.get('raft-provision'); @@ -103,44 +179,46 @@ export class HostedIdentity { if(this.source.gate())throw unavailable(); const anchors=[];for(let i=0;i<3;i++)anchors.push(await keypair()); const effects=await keypair(),limits=this.tenant.limits; - p={config:{cluster:crypto.randomUUID(),generation:'1',identity:this.tenant.identity,signing_key:b64(identityKey),anchors:['1','2','3'],anchor_keys:anchors.map(a=>a.public),effects_key:effects.public,now:Date.now(),grace:{min:limits.minGraceMs,max:limits.maxGraceMs,default:limits.defaultGraceMs}},anchors:anchors.map(a=>a.private),effects:effects.seed}; + p={singleton:true,config:{cluster:crypto.randomUUID(),generation:'1',identity:this.tenant.identity,signing_key:b64(identityKey),anchors:['1','2','3'],anchor_keys:anchors.map(a=>a.public),effects_key:effects.public,now:Date.now(),grace:{min:limits.minGraceMs,max:limits.maxGraceMs,default:limits.defaultGraceMs}},anchors:anchors.map(a=>a.private),effects:effects.seed}; await this.ctx.storage.put('raft-provision',p);await this.ctx.storage.sync();return p; } async ensure(identityKey){ // Recover inactive members without replacing live routing or sockets. - if(this.ready&&this.inactiveMembers.size){ + if((this.ready||this.initialized)&&this.inactiveMembers.size){ for(const id of [...this.inactiveMembers]){ try{ - const member=this.member(id);if(!await member.importReceipt(this.manifest))continue; + const member=this.member(id);if(!await member.importReceipt(this.manifest,this.birth??this.source.gate()?.birth??null))continue; const initialized=await member.initialize(this.config(id));if(initialized.capacity)continue; this.members.set(id,member);this.accept(id,initialized);this.accept(id,await member.drainRecovery()); }catch{this.members.delete(id);this.states.delete(id);} } - if([...this.members.keys()].filter(id=>!this.inactiveMembers.has(id)).length!this.inactiveMembers.has(id)).length<(this.birth?1:MIN_MEMBERS))throw unavailable(); } if(this.ready)return; if(this.initialized)return this.completeEnsure(); this.provisioning=await this.provision(identityKey); - if(!this.source.gate())await this.source.freeze(this.provisioning.config); + if(!this.source.gate())await this.source.freeze(this.provisioning.config,this.provisioning.singleton===true); this.manifest=await this.source.inventory(); if(encodeCanonical(this.manifest.config)!==encodeCanonical(this.provisioning.config))throw unavailable(); const owned=this.source.gate().stage==='raft-owned'; + this.birth=this.source.gate().birth??null; + const imports=this.birth?this.birth.voters:this.manifest.config.anchors; if(!owned){ // Each retry advances a bounded page; the cursor is durable only after // all three copies accepted identical bytes. A cut merely repeats a row. const imported=[]; - for(const id of this.manifest.config.anchors)if(await this.member(id).importReceipt(this.manifest))imported.push(id); - if(imported.length<3){ + for(const id of imports)if(await this.member(id).importReceipt(this.manifest,this.birth))imported.push(id); + if(imported.length? OR (namespace=? AND key>?) ORDER BY namespace,key LIMIT ${MIGRATION_PAGE+1}`,cursor.namespace,cursor.namespace,cursor.key)){ if(count++===MIGRATION_PAGE)throw unavailable(); const copied={namespace:row.namespace,key:row.key,data:bytes(row.data)}; - for(const id of this.manifest.config.anchors){ + for(const id of imports){ if(!imported.includes(id)){await this.member(id).legacyImport(this.config(id),this.manifest,copied);} } cursor={namespace:row.namespace,key:row.key};await this.ctx.storage.put('raft-import-cursor',cursor);await this.ctx.storage.sync(); } - for(const id of this.manifest.config.anchors){ + for(const id of imports){ if(!imported.includes(id)){await this.member(id).legacyImport(this.config(id),this.manifest,null,true);} } } @@ -148,9 +226,9 @@ export class HostedIdentity { // AFTER publication, absent or unclaimed stores are excluded, never // imported again under old voter IDs. Two actual survivors are required. const receipts=[]; - for(const id of this.manifest.config.anchors){ + for(const id of (owned?this.manifest.config.anchors:imports)){ try{ - const receipt=await this.member(id).importReceipt(this.manifest);if(!receipt)continue; + const receipt=await this.member(id).importReceipt(this.manifest,this.birth??this.source.gate()?.birth??null);if(!receipt)continue; const member=this.member(id);this.members.set(id,member);receipts.push(receipt); const initialized=await member.initialize(this.config(id)); // Durable import receipts cover all anchors; serving needs a live @@ -158,12 +236,12 @@ export class HostedIdentity { if(initialized.capacity){this.inactiveMembers.add(id);this.members.delete(id);this.states.delete(id);continue;} this.accept(id,initialized);this.elapsed=Math.max(this.elapsed,initialized.elapsed??0); this.accept(id,await member.drainRecovery()); - }catch(error){this.members.delete(id);this.states.delete(id);if(!owned)throw error;} + }catch(error){this.inactiveMembers.add(id);this.members.delete(id);this.states.delete(id);if(!owned)throw error;} } - if(this.members.sizetrue); this.watchers=new CommittedWatchers(this.ctx,this.tenant.limits.maxWatchers,this.tenant.watchers.ttlMs,at=>this.ctx.waitUntil(this.armAlarm(at))); @@ -178,6 +256,15 @@ export class HostedIdentity { } async completeEnsure(){ const b=await this.barrier(),header=await this.members.get(b.node).hostView(b.index,'membership','registry'); + this.forming=!!this.birth&&!header.raw; + if(this.forming){ + const conf=this.configuration(header.app.conf); + if(conf.joint||conf.outgoing?.length||conf.voters.length!==3){ + const root=await this.members.get(b.node).hostView(b.index,'state','identity');await this.rememberRetirement(this.project('identity',root.raw)); + if(!this.peers)await this.restorePeers(header.app.conf); + await this.wakeMaintenance();this.ready=true;return; + } + } if(!header.raw){ const anchors=this.config(this.manifest.config.anchors[0]).anchors; requireOK((await this.anchorCommand({init_membership:{anchors,limit:this.policy.max_member_records}},Date.now())).value.code); @@ -185,11 +272,12 @@ export class HostedIdentity { const root=await this.barrierRecord({namespace:'state',key:'identity'}); await this.rememberRetirement(this.project('identity',root)); if(!this.peers)await this.restorePeers();else await this.peers.recover(); + this.forming=false; this.membershipPending=!!(header.raw&&decodeCanonical(dec.decode(header.raw)).pending); await this.reconcileMembership(); await this.wakeMaintenance();this.ready=true; } - async restorePeers(){ + async restorePeers(initialConf=null){ const generation=await this.ctx.storage.get('raft-router-generation')??'0'; if(typeof generation!=='string'||!/^(0|[1-9][0-9]*)$/.test(generation)||BigInt(generation)>=(1n<<64n)-1n)throw unavailable(); const saved=await this.ctx.storage.get('raft-router-sleep'),active=await this.ctx.storage.get('raft-maintenance'); @@ -211,10 +299,12 @@ export class HostedIdentity { await this.ctx.storage.put('raft-router-generation',next);await this.ctx.storage.sync(); this.peers=new PersonalPeers(this,next); for(const ws of this.peers.sockets())this.peers.close(ws); - await this.peers.refresh(); - await this.peers.recover(); + this.peers.restoring=initialConf!==null; + try{await this.peers.refresh();}finally{this.peers.restoring=false;} + await this.peers.recover(initialConf); } - accept(id,e){if(!e)return;if(e.status){this.inactiveMembers?.delete(String(id));this.states.set(String(id),e.status);}this.queue.push(...(e.send??[]).map(m=>({...m,from:String(id),to:String(m.to)})));this.reads.push(...(e.reads??[]).map(r=>({...r,node:String(id)})));this.signal();} + accept(id,e){if(!e)return;if(e.status){this.inactiveMembers?.delete(String(id));this.states.set(String(id),e.status);} + if(e.applicationVersion!==undefined)this.appliedVersion=this.appliedVersion===undefined?BigInt(e.applicationVersion):this.appliedVersion>BigInt(e.applicationVersion)?this.appliedVersion:BigInt(e.applicationVersion);this.queue.push(...(e.send??[]).map(m=>({...m,from:String(id),to:String(m.to)})));this.reads.push(...(e.reads??[]).map(r=>({...r,node:String(id)})));this.signal();} async input(id,tag,value){const result=await this.members.get(String(id)).hostInput(tag,value);this.accept(id,result);return result;} // Reserve durable sequence ranges before emission. Concurrent public and // peer turns share one allocator, and recovery skips every unused sequence. @@ -245,13 +335,31 @@ export class HostedIdentity { if(this.peers)this.peers.blocked=new Set(); this.pumping=this.pumpQueue(); try{await this.pumping;}finally{this.pumping=null;} + await this.notifyApplied(); if(this.peers)await this.peers.push(); } + async notifyApplied(){ + if(!this.watchers||this.appliedVersion===undefined||this.notifiedVersion===this.appliedVersion)return; + const id=this.replica();if(!id)return; + const index=this.states.get(id).applied,devices=[...new Set([...this.ctx.getWebSockets().filter(ws=>!ws.deserializeAttachment()?.clusterPeer).map(ws=>this.ctx.getTags(ws)[0]),...this.pendingIO.keys()])]; + for(let offset=0;offset===0||offset=2048){this.queue=[];throw unavailable();} - const m=this.queue.shift(),to=this.members.get(m.to);if(!to){if(this.peers)await this.peers.send(m);continue;} - const snapshot=this.codec.inspect(4,m.bytes).snapshot; + const m=this.queue.shift(),to=this.members.get(m.to),snapshot=this.codec.inspect(4,m.bytes).snapshot; + if(!to){ + if(this.forming&&this.manifest.config.anchors.includes(m.to)){if(snapshot.length)await this.input(m.from,12,{peer:m.to,snapshot:true,success:false});} + else if(this.peers)await this.peers.send(m); + continue; + } try{ if(snapshot.length){ const meta=this.codec.inspect(3,snapshot),current=await to.hostInput(0);this.accept(m.to,current); @@ -272,7 +380,7 @@ export class HostedIdentity { if(snapshot.length)await this.input(m.from,12,{peer:m.to,snapshot:true,success:result.installed===true}); }catch(error){ if(snapshot.length)await this.input(m.from,12,{peer:m.to,snapshot:true,success:false}); - this.members.delete(m.to);this.states.delete(m.to); + this.inactiveMembers?.add(m.to);this.members.delete(m.to);this.states.delete(m.to); } } } @@ -285,13 +393,13 @@ export class HostedIdentity { for(const id of this.members.keys()){await this.input(id,16);await this.pump();if(this.leader())return;} throw unavailable(); } - async barrier(id=crypto.randomUUID(),deadline=Date.now()+(this.peers?.limits.ForwardTimeout??this.policy.tick_every)){ + async barrier(id=crypto.randomUUID(),deadline=Date.now()+(this.peers?.limits.ForwardTimeout??this.policy.tick_every),background=false){ await this.elect();const leader=this.leader(); - if(!leader){try{const reply=await this.remote({action:'read',id});return this.appliedReplica(reply.barrier,deadline);}catch(error){if(this.leader())return this.barrier(id,deadline);throw error;}} - try{await this.input(leader,4,id);await this.pump();} - catch(error){await this.refreshRoute();if(this.leader()!==leader)return this.barrier(id,deadline);throw error;} + if(!leader){try{const reply=await this.remote({action:background?'idle-read':'read',id});return this.appliedReplica(reply.barrier,deadline);}catch(error){if(this.leader())return this.barrier(id,deadline,background);throw error;}} + try{await this.input(leader,background?15:4,id);await this.pump();} + catch(error){await this.refreshRoute();if(this.leader()!==leader)return this.barrier(id,deadline,background);throw error;} try{for(;;){ - if(this.leader()!==leader){await this.refreshRoute();if(this.leader()!==leader)return this.barrier(id,deadline);} + if(this.leader()!==leader){await this.refreshRoute();if(this.leader()!==leader)return this.barrier(id,deadline,background);} const at=this.reads.findIndex(r=>r.node===leader&&r.id===id);if(at>=0)return this.reads.splice(at,1)[0]; if(!this.externalVoter())throw unavailable();await this.progress(deadline); }} @@ -342,15 +450,40 @@ export class HostedIdentity { async barrierOutbox(after,limit){const b=await this.barrier();return (await this.members.get(b.node).hostView(b.index,'outbox','',after)).rows.slice(0,limit);} projectJSON(kind,raw){return projectRecord(this.runtime,kind,raw);} project(kind,raw){return JSON.parse(this.projectJSON(kind,raw));} - async authorize(device,certificate,write=false){ + async authorize(device,certificate,write=false,fresh=false){ await this.wakeMaintenance(); - requireOK((await this.commitEffect({device_seen:{device}},Date.now())).code); - const b=await this.barrier(),member=this.members.get(b.node),root=await member.hostView(b.index,'state','identity'),actor=await member.hostView(b.index,'state','device/'+segment(device)); - const r=response(this.runtime.call(frame(32).blob(root.raw).blob(actor.raw??new Uint8Array()).str(device).str(certificate).u32(write?1:0).finish()));requireOK(r.str());r.end();return {barrier:b,app:root.app,identity:this.project('identity',root.raw)}; + const b=await this.readAuthority(fresh),root=await this.readView(b,'state','identity'),actor=await this.readView(b,'state','device/'+segment(device)); + const r=response(this.runtime.call(frame(32).blob(root.raw).blob(actor.raw??new Uint8Array()).str(device).str(certificate).u32(write?1:0).finish()));requireOK(r.str());r.end();this.noteSeen(device);return {barrier:b,app:root.app,identity:this.project('identity',root.raw)}; + } + async recheckAuthority(previous,device,certificate,write,fresh){ + const current=await this.readAuthority(fresh); + const applied=current.generation===undefined?(await this.readView(current,'state','identity')).app.applied:current.index; + const changed=current.node!==previous.barrier.node||BigInt(applied)!==BigInt(previous.app.applied); + const authority=changed?await this.authorize(device,certificate,write,fresh):null; + await this.finishRead(authority?.barrier??current,null); + } + // Presence is advisory, like unflushed store statistics. It may be lost on + // eviction, but never delays authority checks or grows with request volume. + noteSeen(device,now=Date.now()){ + const pending=this.seen.get(device); + if(pending){pending.at=now;return;} + if(this.seen.size>=PRESENCE_BATCH)return; + this.seen.set(device,{at:now,due:now+PRESENCE_INTERVAL}); + this.ctx.waitUntil(this.wakeMaintenance()); + } + async flushSeen(now){ + for(const [device,pending]of this.seen){ + if(pending.due>now)continue; + const at=pending.at; + try{requireOK((await this.commitEffect({device_seen:{device}},at)).code);}catch{return;} + if(pending.at===at)this.seen.delete(device); + else pending.due=now+PRESENCE_INTERVAL; + return; + } } async listingJSON(b,app){ const devices=[],cells=[];let identity='null',after=''; - for(;;){const page=await this.members.get(b.node).hostView(b.index,'state','',after);if(!page.rows.length)break; + for(;;){const page=await this.readView(b,'state','',after);if(!page.rows.length)break; // The coordinator holds the only public mutation queue throughout pages. for(const row of page.rows){ after=row.key; @@ -423,7 +556,7 @@ export class HostedIdentity { } async maintenance(now=Date.now(),elapsed=Math.floor(performance.now())){ if(this.asleep)return; - const wakeVersion=this.wakeVersion,busy=this.coordinationBusy()||!!this.wakePending; + const wakeVersion=this.wakeVersion,busy=this.coordinationBusy()||!!this.wakePending||this.seen.size>0; this.wakePending=false; this.elapsed=Math.max(this.elapsed,elapsed-this.started); // Followers must advance their election clocks before any authority read: @@ -435,7 +568,7 @@ export class HostedIdentity { const batch=await member.scheduled(); if(batch){this.accept(id,{send:batch.send.map(m=>({to:m.to,bytes:unb64(m.bytes)}))});await this.pump();await member.acknowledgeScheduled(batch.id);} const effects=await member.schedule(this.elapsed,now,busy);this.accept(id,effects);plans.set(id,effects.plan); - }catch{this.members.delete(id);this.states.delete(id);} + }catch{this.inactiveMembers?.add(id);this.members.delete(id);this.states.delete(id);} } await this.pump(); try{ @@ -457,6 +590,11 @@ export class HostedIdentity { // upload receipts and the raw body pages of a large request that was cut // short, and those are not canonical records to decode. if(app.rows.some(row=>{if(!row.key.startsWith('join/'))return false;const value=decodeCanonical(dec.decode(row.data));return value.decision&&!value.outcome;}))await this.effects.replay(code=>this.env.LINK_REQUEST.get(this.env.LINK_REQUEST.idFromName(code)),scan,now); + if(this.forming){ + this.formationSoon=false; + try{this.formationSoon=await this.formNext();}catch{} + if(this.formationSoon){await this.flushSeen(now);return;} + } let remoteAck,remoteStatus; const schedule=async(acks=[])=>{ if(remote){ @@ -512,11 +650,48 @@ export class HostedIdentity { } }catch{this.asleep=false;} } + await this.flushSeen(now); + if(!this.asleep&&this.elapsed-this.lastReadAt{entry.reject=reject;});entry.stopped.catch(()=>{}); + waits.add(entry);this.pendingIO.set(device,waits); + return {entry,stopped:entry.stopped,close:()=>{waits.delete(entry);if(!waits.size)this.pendingIO.delete(device);}}; + } + stopIO(device,code){ + for(const entry of this.pendingIO.get(device)??[]){ + if(code==='rotated'&&!entry.write)continue; + entry.error=new Wire(code,code==='revoked'?401:410);entry.reject(entry.error); + } + } async dispatch(c,route){ - if(!new URL(c.request.url).pathname.startsWith('/v1/store/'))return this.operation(()=>this.serve(c,route)); + if(!new URL(c.request.url).pathname.startsWith('/v1/store/'))return this.operation(()=>c.request.method==='GET'?this.retryRead(fresh=>this.serve(c,route,fresh)):this.serve(c,route)); const cert=c.request.headers.get('codeaf-cert'); - await this.operation(async()=>{ + const ticket=await this.operation(()=>this.retryRead(async fresh=>{ await this.ensure(fromBase64Url(c.request.headers.get('codeaf-identity'))); - await this.authorize(c.device,cert,route.write); - }); + const authority=await this.authorize(c.device,cert,route.write,fresh); + return {authority,io:this.waitIO(c.device,route.write)}; + })); // Blob waits cannot hold the coordination queue: revoke/rotation must be // able to commit during I/O, and Has must not block queued frame puts. - const result=await route.handler(c,route.args);await this.ctx.storage.sync(); - await this.operation(()=>this.authorize(c.device,cert,route.write));return result; - } - async serve(c,route){ + try{ + if(ticket.io.entry.error)throw ticket.io.entry.error; + const work=Promise.resolve().then(()=>route.handler(c,route.args)); + c.retainPut?.(work); + const result=await Promise.race([work,ticket.io.stopped]);await this.ctx.storage.sync(); + await this.operation(()=>this.retryRead(fresh=>this.recheckAuthority(ticket.authority,c.device,cert,route.write,fresh)));return result; + }finally{ticket.io.close();} + } + async serve(c,route,fresh=false){ const {request,device,body}=c,url=new URL(request.url),path=url.pathname,cert=request.headers.get('codeaf-cert'); await this.ensure(fromBase64Url(request.headers.get('codeaf-identity'))); await this.wakeMaintenance(); if(path==='/v1/dir/watch'){ if(request.headers.get('upgrade')?.toLowerCase()!=='websocket')throw new Wire('upgrade_required',426); const holds=parseHolds(url.searchParams.getAll('hold'),this.tenant.limits.maxHolds),beat=parseBeat(url.searchParams.getAll('beat')); - const a=await this.authorize(device,cert,true); + const a=await this.authorize(device,cert,true,fresh); + await this.finishRead(a.barrier,null); const reply=this.watchers.accept(device,Number(a.app.version),Date.now(),{holds,beat,events:url.searchParams.get('events')==='1',receipts:url.searchParams.get('receipts')==='1',certificate:cert}); - this.holds.opened(Date.now(),holds.length>0); - if(holds.length)await this.holds.renew(Date.now(),this.watchers.holdEvidence(Date.now())); - if(this.holds.next!==null)await this.armAlarm(this.holds.next);return reply; + try{ + this.holds.opened(Date.now(),holds.length>0); + if(holds.length)await this.holds.renew(Date.now(),this.watchers.holdEvidence(Date.now())); + if(this.holds.next!==null)await this.armAlarm(this.holds.next); + await this.retryRead(fresh=>this.recheckAuthority(a,device,cert,true,fresh));return reply; + }catch(error){ + const code=error.code==='revoked'?CLOSE_REVOKED:error.code==='rotated'?CLOSE_ROTATED:1012; + this.watchers.closeDevice(device,code,error.code??'reconnecting'); + throw error.readInterrupted?unavailable():error; + } } - const join=/^\/v1\/dir\/requests\/([^/]+)\/(approve|deny)$/.exec(path); - if(join){await this.authorize(device,cert,true);const code=decodeURIComponent(join[1]).trim().toLowerCase();const box=this.env.LINK_REQUEST.get(this.env.LINK_REQUEST.idFromName(code));await this.effects.admit(box,code,Date.now());} - let raw;try{raw=v1Command(this.runtime,this.manifest.config.cluster,Date.now(),this.proof(request,body));}catch{throw new Wire('bad_request',400);} - const result=await this.commitV1(raw); - const b=await this.barrier(),snapshot=await this.members.get(b.node).hostView(b.index,'state','identity'),identity=this.project('identity',snapshot.raw),version=snapshot.app.version; - await this.rememberRetirement(identity); - this.watchers.broadcast(version); - if(join){const code=decodeURIComponent(join[1]).trim().toLowerCase();await this.effects.deliver(this.env.LINK_REQUEST.get(this.env.LINK_REQUEST.idFromName(code)),code,Date.now());unwrap(await this.env.LINK_GATE.get(this.env.LINK_GATE.idFromName('gate')).decided(code));} if(request.method==='GET'){ - if(path==='/v1/identity/rotation')return json(rotationView(identity.rotation,Number(snapshot.app.now),this.tenant.limits)); + const authority=await this.authorize(device,cert,false,fresh),b=authority.barrier; + const now=this.readNow(authority.app.now),app={...authority.app,now:String(now)}; + checkTime(request.headers.get('codeaf-time'),now); + if(path==='/v1/identity/rotation')return this.finishRead(b,json(rotationView(authority.identity.rotation,now,this.tenant.limits))); if(path==='/v1/dir/list'||path==='/v1/dir/presence'){ - const raw=await this.listingJSON(b,snapshot.app),reply=path.endsWith('/presence')?json(this.watchers.presence(JSON.parse(raw).devices,Number(snapshot.app.now))):this.jsonBytes(raw);reply.headers.set('Codeaf-Dir-Version',version);return reply; + const raw=await this.listingJSON(b,app),reply=path.endsWith('/presence')?json(presenceOf(JSON.parse(raw).devices,this.watchers.online(),now)):this.jsonBytes(raw); + reply.headers.set('Codeaf-Dir-Version',app.version);return this.finishRead(b,reply); + } + const row=await this.readView(b,'state','cell/'+segment(decodeURIComponent(route.args[0])));if(!row.raw)throw new Wire('not_found',404); + const reply=this.jsonBytes('{"now":'+now+',"cell":'+this.projectJSON('cell',row.raw)+'}');reply.headers.set('codeaf-conditional-takes','1');return this.finishRead(b,reply); + } + const join=/^\/v1\/dir\/requests\/([^/]+)\/(approve|deny)$/.exec(path); + let joinCode,joinBox; + if(join){ + // Link decisions need fresh quorum authorization because dispatch does not retry them. + const authority=await this.authorize(device,cert,true,true);joinCode=decodeURIComponent(join[1]).trim().toLowerCase();joinBox=this.env.LINK_REQUEST.get(this.env.LINK_REQUEST.idFromName(joinCode)); + // Invalid approvals cannot reserve a request for this cluster. The Go + // command still checks authority and revocation at its committed index. + const pending=unwrap(await joinBox.read(0)); + if(join[2]==='approve'){ + const approval=JSON.parse(dec.decode(body));await checkApprovalDevice(c,approval,pending); + const target=await this.readView(authority.barrier,'state','device/'+segment(pending.device)); + if(target.raw&&this.project('device',target.raw).revoked)throw new Wire('bad_request',400); + asGrant(approval.grant,this.tenant.limits.linkMaxGrant); } - const row=await this.members.get(b.node).hostView(b.index,'state','cell/'+segment(decodeURIComponent(route.args[0])));if(!row.raw)throw new Wire('not_found',404); - const reply=this.jsonBytes('{"now":'+row.app.now+',"cell":'+this.projectJSON('cell',row.raw)+'}');reply.headers.set('codeaf-conditional-takes','1');return reply; } + let raw;try{raw=v1Command(this.runtime,this.manifest.config.cluster,this.readNow(),this.proof(request,body));}catch{throw new Wire('bad_request',400);} + if(join)await this.effects.admit(joinBox,joinCode,Date.now()); + const result=await this.commitV1(raw); + const b=await this.appliedReplica(result.index),snapshot=await this.members.get(b.node).hostView(b.index,'state','identity'),identity=this.project('identity',snapshot.raw); + await this.rememberRetirement(identity); + if(join){const code=decodeURIComponent(join[1]).trim().toLowerCase();await this.effects.deliver(this.env.LINK_REQUEST.get(this.env.LINK_REQUEST.idFromName(code)),code,Date.now());unwrap(await this.env.LINK_GATE.get(this.env.LINK_GATE.idFromName('gate')).decided(code));} if(path==='/v1/identity/rotation'){ this.watchers.closeAll(CLOSE_ROTATED,'rotated');if(identity.rotation?.retire_at)await this.ctx.storage.setAlarm(identity.rotation.retire_at); return json(rotationView(identity.rotation,Number(snapshot.app.now),this.tenant.limits)); } - if(path.endsWith('/revoke'))this.watchers.closeDevice(decodeURIComponent(route.args[0]),CLOSE_REVOKED,'revoked'); + if(path.endsWith('/revoke')){ + const device=decodeURIComponent(route.args[0]); + this.watchers.announce({t:'revoked',device},device);this.watchers.closeDevice(device,CLOSE_REVOKED,'revoked'); + } if(path.endsWith('/release')||path.endsWith('/archive'))return empty(); return result.value.view?this.jsonBytes(this.projectJSON('view',result.raw)):empty(); } diff --git a/relay/hosted/src/raft/legacy.js b/relay/hosted/src/raft/legacy.js index 6e564f8a64..79d103cf4a 100644 --- a/relay/hosted/src/raft/legacy.js +++ b/relay/hosted/src/raft/legacy.js @@ -52,11 +52,11 @@ export class LegacySource { } gate() { const r=this.sql.exec('SELECT doc FROM legacy_gate WHERE one=1').toArray()[0];return r?decodeCanonical(r.doc):null; } keep(g) { this.sql.exec('INSERT OR REPLACE INTO legacy_gate VALUES(1,?)',encodeCanonical(g)); } - async freeze(config) { - if(this.admission)return this.admission.drain(()=>this.freezeSource(config)); - return this.freezeSource(config); + async freeze(config,singleton=false) { + if(this.admission)return this.admission.drain(()=>this.freezeSource(config,singleton)); + return this.freezeSource(config,singleton); } - async freezeSource(config) { + async freezeSource(config,singleton=false) { if(await this.storage.get?.('gone'))throw new Wire('gone',410); // Go validates the binding before the source acquires an irreversible gate. convertLegacy(this.runtime,config,'identity','',JSON.stringify({V:1,identity:config.identity})); @@ -69,7 +69,8 @@ export class LegacySource { const until=c.lease.expires===0?0:this.dir.watchers.vouchedUntil(c.lease.device,id,c.lease.fence); if(until>c.lease.expires)this.sql.exec('INSERT INTO legacy_holds VALUES(?,?)',id,until); } - this.keep({stage:'frozen',config}); + const empty=!this.sql.exec('SELECT 1 FROM dir LIMIT 1').toArray().length; + this.keep({stage:'frozen',config,...(singleton&&empty?{birth:true}:{})}); }); await this.storage.sync(); this.dir.watchers.closeAll(1012,'reconnecting'); @@ -99,6 +100,7 @@ export class LegacySource { manifest={config:g.config,version:String(version),rows:String(count),bytes:String(total),digest:hex(digestRows),ceiling:"0"}; for(let i=0;i<8;i++){const next=String(Math.max(DEFAULT_ROWS_BYTES,total+size({namespace:'membership',key:importKey,data:data(manifest)})));if(next===manifest.ceiling)break;manifest.ceiling=next;} this.codec.genesis(data(manifest)); + if(g.birth===true)g.birth={cluster:manifest.config.cluster,digest:manifest.digest,voters:[manifest.config.anchors[0]]}; g.stage='inventoried';g.manifest=manifest;this.keep(g); });await this.storage.sync();return manifest; } @@ -107,7 +109,10 @@ export class LegacySource { this.storage.transactionSync(()=>{ const g=this.gate();check(g?.manifest);const nodes=new Set(); for(const r of receipts)if(g.config.anchors.includes(String(r.node))&&BigInt(r.applied)>=1n&&encodeCanonical(r.manifest)===encodeCanonical(g.manifest))nodes.add(String(r.node)); - check(nodes.size>=2);g.stage='raft-owned';this.keep(g); + if(g.birth){ + const genesis=this.codec.genesis(data(g.manifest),g.birth); + check(nodes.has(g.birth.voters[0])&&receipts.some(r=>r.node===g.birth.voters[0]&&r.conf===b64(genesis.conf)&&encodeCanonical(r.birth)===encodeCanonical(g.birth))); + }else check(nodes.size>=2);g.stage='raft-owned';this.keep(g); });await this.storage.sync(); } // A stale routing hint can never resurrect the old writer. The host assembles @@ -117,7 +122,7 @@ export class LegacySource { // Member storage imports a bounded row stream in one durable genesis. Existing // imported stores return their provenance without overwriting later cluster work. -export async function importLegacy(store, manifest, source) { +export async function importLegacy(store, manifest, source, birth=null) { const m=manifest, encoded=data(m), k={namespace:'membership',key:importKey}; check(m.config.cluster===store.identity.cluster&&m.config.anchors.includes(store.identity.id)&&encoded.length+128<=reserve); check(String(store.identity.ceiling)===m.ceiling); @@ -139,7 +144,7 @@ export async function importLegacy(store, manifest, source) { check(String(count)===m.rows&&String(total)===m.bytes&&hex(digest)===m.digest);add({...k,data:encoded}); // These protobuf messages contain only the common genesis term/index and // the source-authorized complete voter set, encoded by the Go ABI below. - const genesis=store.codec.genesis(data(m)); + const genesis=store.codec.genesis(data(m),birth); store.rows('INSERT INTO journal VALUES(?,?,?)','0000000000000001',genesis.entry,store.hash(genesis.entry)); store.put('tip','1');store.put('hard',b64(genesis.hard)); store.put('application',{applied:'1',conf:b64(genesis.conf),version:m.version,now:String(m.config.now),count:count+1,size:total+size({...k,data:encoded}),hash:hex(aggregate)}); @@ -150,8 +155,8 @@ export async function importLegacy(store, manifest, source) { // Cross-object import stages one bounded row per request. Incomplete stages // never start a core and never become application authority. Retries compare // exact bytes; final installation rechecks the source's complete stream digest. -export async function stageLegacyImport(store, manifest, row) { - store.codec.genesis(data(manifest)); +export async function stageLegacyImport(store, manifest, row, birth=null) { + store.codec.genesis(data(manifest),birth); check(manifest.config.cluster===store.identity.cluster&&manifest.config.anchors.includes(store.identity.id)&&String(store.identity.ceiling)===manifest.ceiling); await store.transaction(()=>{ store.rows('CREATE TABLE IF NOT EXISTS legacy_stage (one INTEGER PRIMARY KEY CHECK(one=1), doc TEXT NOT NULL)'); @@ -167,8 +172,8 @@ export async function stageLegacyImport(store, manifest, row) { store.rows('INSERT INTO legacy_stage_rows VALUES(?,?,?)',row.namespace,row.key,row.data); }); } -export async function finishLegacyImport(store, manifest) { - const snapshot=await importLegacy(store,manifest,function*(){for(const r of store.rows('SELECT namespace,key,data FROM legacy_stage_rows ORDER BY namespace,key'))yield {...r,data:bytes(r.data)};}); +export async function finishLegacyImport(store, manifest, birth=null) { + const snapshot=await importLegacy(store,manifest,function*(){for(const r of store.rows('SELECT namespace,key,data FROM legacy_stage_rows ORDER BY namespace,key'))yield {...r,data:bytes(r.data)};},birth); // These are redundant staging bytes. The immutable legacy ORIGINAL remains // at its source; imported provenance and rows remain in every snapshot. await store.transaction(()=>{store.rows('DROP TABLE IF EXISTS legacy_stage_rows');store.rows('DROP TABLE IF EXISTS legacy_stage');}); diff --git a/relay/hosted/src/raft/member.js b/relay/hosted/src/raft/member.js index 133cbdf393..908b058027 100644 --- a/relay/hosted/src/raft/member.js +++ b/relay/hosted/src/raft/member.js @@ -17,7 +17,7 @@ export { installRaftModule, runtimeMetrics } from './actor.js'; // public Worker routes identity coordination through its authenticated source. // Enrollment and evaluation use the durable Go registry and shared Go policy. export class RaftMemberDO extends DurableObject { - constructor(ctx, env) { super(ctx, env); replaceMemberActivation(ctx.id.toString()); this.actor = null; this.loading = null; this.ctx = ctx; this.env = env; } + constructor(ctx, env) { super(ctx, env); replaceMemberActivation(ctx.id.toString()); this.actor = null; this.loading = null; this.ctx = ctx; this.env = env; this.readActivation=crypto.randomUUID(); } evaluator(config) { const evaluate=directoryEvaluator(sharedRuntime(),config.cluster,config.ceiling??DEFAULT_ROWS_BYTES); this.results=new Map();this.resultBytes=0; @@ -34,23 +34,30 @@ export class RaftMemberDO extends DurableObject { } // These RPC methods have no public fetch route. The authenticated source // coordinator alone provisions stores and drives the same durable actor. - async importReceipt(manifest) { + async importReceipt(manifest,birth=null) { if(await this.ctx.storage.get('raft-erased'))throw new Error('raft member: identity retired'); const config=await this.ctx.storage.get('raft-config'); if(!config)return null; + if(encodeCanonical(config.birth??null)!==encodeCanonical(birth))throw new Error('raft member: genesis provenance differs'); const store=new MemberStorage(this.ctx.storage,new HostCodec(sharedRuntime())); if(!store.one("SELECT name FROM sqlite_master WHERE type='table' AND name='meta'"))return null; await store.open({cluster:config.cluster,id:config.id,protocol:1},config.ceiling); const raw=store.read('membership','legacy-import'); if(!raw)return null; if(new TextDecoder().decode(raw)!==encodeCanonical(manifest))throw new Error('raft member: import provenance differs'); - return {node:config.id,applied:store.application().applied,manifest:decodeCanonical(new TextDecoder().decode(raw))}; + return {node:config.id,applied:store.application().applied,conf:store.application().conf,birth:config.birth??null,manifest:decodeCanonical(new TextDecoder().decode(raw))}; } async eraseRetired(manifest) { const gone=await this.ctx.storage.get('raft-erased'); if(gone && gone!==manifest.config.cluster)throw new Error('raft member: wrong erased cluster'); if(!gone){ - if(!await this.importReceipt(manifest))throw new Error('raft member: import provenance absent'); + const config=await this.ctx.storage.get('raft-config'); + if(!config){ + const store=new MemberStorage(this.ctx.storage,new HostCodec(sharedRuntime())); + if(store.one("SELECT name FROM sqlite_master WHERE type='table' AND name='records'"))throw new Error('raft member: missing configuration'); + await this.ctx.storage.put('raft-erased',manifest.config.cluster);await this.ctx.storage.sync();return; + } + if(!await this.importReceipt(manifest,config.birth??null))throw new Error('raft member: import provenance absent'); await this.load(); await this.actor.serial(async()=>{ const raw=this.actor.store.read('state','identity'),record=raw&&JSON.parse(projectRecord(sharedRuntime(),'identity',raw)); @@ -77,8 +84,9 @@ export class RaftMemberDO extends DurableObject { } return this.actor.input(tag,value); } - async hostView(index,namespace,key,after=null) { + async hostView(index,namespace,key,after=null,activation=null) { await this.load();return this.actor.serial(()=>{ + if(activation!==null&&activation!==this.readActivation)throw new Error('raft member: read activation changed'); if(this.actor.stopped||this.actor.store.stopped||this.actor.status.applied64)throw new Error('raft member: watch page capacity'); + await this.load();return this.actor.serial(()=>{ + if(this.actor.stopped||this.actor.store.stopped||this.actor.status.appliedb.toString(16).padStart(2,'0')).join(''),raw=store.read('state','device/'+key); + if(raw&&JSON.parse(projectRecord(sharedRuntime(),'device',raw)).revoked)revoked.push(device); + } + return {version:store.application().version,identity:JSON.parse(projectRecord(sharedRuntime(),'identity',store.read('state','identity'))),revoked}; + }); + } + // A clean tail excludes append acknowledgments already in flight that could + // let a personal leader commit a record absent from this durable view. + async readEvidence(){ + await this.load();return this.actor.serial(async()=>{ + const actor=this.actor,app=actor.store.application(),status=actor.status; + const scheduled=await this.ctx.storage.get('raft-scheduled'); + const staged=actor.store.one('SELECT count(*) AS n FROM snapshots WHERE complete=0').n; + return {activation:this.readActivation,index:app.applied,conf:app.conf,term:String(status.term),leader:String(status.leader),role:status.role, + clean:!actor.stopped&&!actor.store.stopped&&actor.pending===1&&!scheduled&&!staged&&!this.recoveredEffects?.send?.length&&actor.store.get('tip')===app.applied&&String(status.commit)===app.applied&&String(status.applied)===app.applied}; + }); + } // The coordinator's fresh ReadIndex must have applied before a scoped // read runs. Authority/time and selected rows share this member queue. async hostSelected(raw,index) { @@ -140,15 +171,15 @@ export class RaftMemberDO extends DurableObject { this.importPending=true; const run=Promise.resolve().then(async()=>{ const store=new MemberStorage(this.ctx.storage,new HostCodec(sharedRuntime())); - const canonical={...config,id:String(config.id),seed:String(config.seed),bootstrap:manifest.config.anchors.map(String),ceiling:Number(manifest.ceiling)}; + const canonical={...config,id:String(config.id),seed:String(config.seed),bootstrap:(config.birth?.voters??manifest.config.anchors).map(String),ceiling:Number(manifest.ceiling)}; const previous=await this.ctx.storage.get('raft-config'); if(previous&&JSON.stringify(previous)!==JSON.stringify(canonical))throw new Error('raft member: provisioned identity cannot change'); await this.ctx.storage.put('legacy-pending',true);await this.ctx.storage.sync(); if(!previous){await this.ctx.storage.put('raft-config',canonical);await this.ctx.storage.sync();} await store.open({cluster:canonical.cluster,id:canonical.id,protocol:1},canonical.ceiling); - await stageLegacyImport(store,manifest,row); + await stageLegacyImport(store,manifest,row,canonical.birth??null); if(!finish)return; - const snapshot=await finishLegacyImport(store,manifest); + const snapshot=await finishLegacyImport(store,manifest,canonical.birth??null); await this.ctx.storage.delete('legacy-pending');await this.ctx.storage.sync();this.importPending=false; return snapshot; }); @@ -225,7 +256,7 @@ export class RaftMemberDO extends DurableObject { const recovered=this.recoveredEffects; const sends=[...(recovered?.send??[]),...effects.send]; if(!sends.length&&!effects.plan.wake?.length)return []; - const view=this.actor.store.read('membership','registry')?committedRegistry(sharedRuntime(),this.actor.store):provisionedRegistry(sharedRuntime(),this.config); + const view=this.actor.store.read('membership','registry')?committedRegistry(sharedRuntime(),this.actor.store):provisionedRegistry(sharedRuntime(),this.config,this.actor.store); sends.sort((a,b)=>Number(!view[String(a.to)]?.voter)-Number(!view[String(b.to)]?.voter)); const id=String(BigInt(await this.ctx.storage.get('raft-scheduled-sequence')??'0')+1n); const batch={id,send:[],wake:effects.plan.wake},dropped=[]; @@ -271,6 +302,7 @@ export class RaftMemberDO extends DurableObject { async leadershipEvidence(revision,barrier) { await this.load(); return this.actor.leadershipEvidence(revision,barrier); } async leadership(evidence) { await this.load(); return this.actor.leadership(evidence); } async leadershipReceipt() { await this.load(); return this.actor.leadershipReceipt(); } + async formation() {await this.load();return this.actor.formation();} async membership() { await this.load(); return this.actor.membership(); } async joinStatus(advertised) { await this.load(); return this.actor.joinStatus(advertised); } async drainRecovery() { @@ -281,7 +313,7 @@ export class RaftMemberDO extends DurableObject { // view within that index, and invalidate it after every committed change. committedPeers(){ const index=this.actor.store.application().applied; - if(this.registryIndex!==index){this.registryCache=this.actor.store.read('membership','registry')?committedRegistry(sharedRuntime(),this.actor.store):provisionedRegistry(sharedRuntime(),this.config);this.registryIndex=index;} + if(this.registryIndex!==index){this.registryCache=this.actor.store.read('membership','registry')?committedRegistry(sharedRuntime(),this.actor.store):provisionedRegistry(sharedRuntime(),this.config,this.actor.store);this.registryIndex=index;} return this.registryCache; } async peer(raw) { diff --git a/relay/hosted/src/raft/membership.js b/relay/hosted/src/raft/membership.js index c3c1df361d..4cc3c63ff2 100644 --- a/relay/hosted/src/raft/membership.js +++ b/relay/hosted/src/raft/membership.js @@ -28,8 +28,10 @@ export function committedRegistry(runtime, store) { // Only authenticated internal provisioning supplies the three public anchor // records. Before registry commitment, personal keys cannot submit peer data. -export function provisionedRegistry(runtime, config) { - const raw = new TextEncoder().encode(encodeCanonical({ anchors: config.anchors, voters: config.bootstrap })); - const r = response(runtime.call(frame(19).blob(raw).finish())), peers = decode(r.blob()); r.end(); +export function provisionedRegistry(runtime, config, store) { + const raw = new TextEncoder().encode(encodeCanonical({ anchors: config.anchors, voters: config.anchors.map(a=>a.node) })); + const w=frame(19).blob(raw); + if(config.birth)w.blob(unb64(store.application().conf)).blob(store.read('membership','legacy-import')).blob(new TextEncoder().encode(encodeCanonical(config.birth))); + const r = response(runtime.call(w.finish())), peers = decode(r.blob()); r.end(); return Object.fromEntries(peers.map(p => [p.node, { incarnation: p.incarnation, key: p.key, voter: p.voter, revoked: p.revoked }])); } diff --git a/relay/hosted/src/raft/peers.js b/relay/hosted/src/raft/peers.js index fe4b54caa0..7babb1bb5c 100644 --- a/relay/hosted/src/raft/peers.js +++ b/relay/hosted/src/raft/peers.js @@ -27,7 +27,7 @@ export class PersonalPeers { this.host=host;this.generation=generation;this.scope=host.config('1').scope;this.view={};this.boxes=new Map();this.used=[0,0,0];this.progress=new Map();this.snapshots=new Map();this.acks=new Map();this.tail=Promise.resolve();this.pending=0;this.blocked=new Set();this.retryTargets=new Set();this.checkpoints=new Map(); this.limits=peerLimits(host.runtime); } - serial(fn){if(this.pending>=64)return Promise.reject(refused('busy'));this.pending++;const p=this.tail.then(fn);this.tail=p.catch(()=>{});return p.finally(()=>{this.pending--;this.host.signal?.();});} + serial(fn){if(this.pending>=64)return Promise.reject(refused('busy'));this.host.invalidateRead();this.pending++;const p=this.tail.then(fn);this.tail=p.catch(()=>{});return p.finally(()=>{this.pending--;this.host.signal?.();});} members(){return Object.entries(this.view).map(([node,p])=>({node,...p}));} // Public and peer turns publish in order, so a delayed member read cannot // restore an older transport authority after another turn has advanced it. @@ -111,12 +111,15 @@ export class PersonalPeers { return messages; } wire(node,extra={}){ + this.host.invalidateRead(); const box=this.boxes.get(node),chunks=[...(box?.credits??[])].sort(([a],[b])=>BigInt(a)({node,sequence})); const r=response(this.host.runtime.call(frame(38).u32(1).blob(enc.encode(JSON.stringify({...extra,chunks,generation:this.generation,messages:this.deliveries(node)}))).finish()));const raw=r.blob();r.end();return raw; } - async recover(){ + async recover(configuration=null){ const from=this.host.leader();if(!from)return; - for(const [to,member]of Object.entries(this.view))if(!member.revoked&&!this.host.members.has(to)){ + const conf=this.host.configuration(configuration??(await this.host.members.get(from).readEvidence()).conf); + const configured=new Set(['voters','outgoing','learners','learners_next'].flatMap(key=>conf[key]??[]).map(String)); + for(const [to,member]of Object.entries(this.view))if(configured.has(to)&&!member.revoked&&!this.host.members.has(to)){ await this.host.input(from,12,{peer:to,snapshot:true,success:false}); await this.host.input(from,12,{peer:to,snapshot:false,success:false}); } @@ -214,7 +217,7 @@ export class PersonalPeers { this.validate(unb64(a.auth));const raw=this.wire(a.node),text=this.host.hash(raw); if(text===a.last)continue; const body=JSON.parse(dec.decode(raw));if(!body.messages.length&&!body.chunks?.length)continue; - ws.send(raw);const deadline=a.deadline??(body.messages.length?Date.now()+this.limits.Retention:null);ws.serializeAttachment({...a,last:text,deadline});if(deadline)await this.host.armAlarm(deadline); + this.host.invalidateRead();ws.send(raw);const deadline=a.deadline??(body.messages.length?Date.now()+this.limits.Retention:null);ws.serializeAttachment({...a,last:text,deadline});if(deadline)await this.host.armAlarm(deadline); }catch{this.close(ws);} } } @@ -227,7 +230,7 @@ export class PersonalPeers { const deadline=initial&&JSON.parse(dec.decode(initial)).messages.length?Date.now()+this.limits.Retention:null; const {client,server}=acceptHibernatingSocket(this.host.ctx,['cluster-peer'],{clusterPeer:true,kind,node:req.node,generation:this.generation,auth:b64(raw),last:initial?this.host.hash(initial):null,deadline}); if(deadline)await this.host.armAlarm(deadline); - if(initial)server.send(initial); + if(initial){this.host.invalidateRead();server.send(initial);} return new Response(null,{status:101,webSocket:client}); } async frame(ws,raw){ @@ -235,7 +238,7 @@ export class PersonalPeers { try{ this.validate(unb64(att.auth));if(att.kind==='forward'){this.host.forwarding.frame(ws,raw);return;}if(att.kind!=='peer'||!(raw instanceof ArrayBuffer))throw refused('invalid'); const bytes=new Uint8Array(raw),next=JSON.parse(dec.decode(bytes));if(next.node!==att.node||next.generation!==att.generation)throw refused('unauthorized'); - const result=await this.exchange(bytes);ws.send(result.raw);const deadline=JSON.parse(dec.decode(result.raw)).messages.length?Date.now()+this.limits.Retention:null;ws.serializeAttachment({...att,last:this.host.hash(result.raw),deadline});if(deadline)await this.host.armAlarm(deadline); + const result=await this.exchange(bytes);this.host.invalidateRead();ws.send(result.raw);const deadline=JSON.parse(dec.decode(result.raw)).messages.length?Date.now()+this.limits.Retention:null;ws.serializeAttachment({...att,last:this.host.hash(result.raw),deadline});if(deadline)await this.host.armAlarm(deadline); }catch{this.close(ws);} } async departed(ws){ diff --git a/relay/hosted/src/rules.js b/relay/hosted/src/rules.js index 14972bbe26..5ae7d79a12 100644 --- a/relay/hosted/src/rules.js +++ b/relay/hosted/src/rules.js @@ -31,7 +31,7 @@ export function makeRules({ ttlMs, renewOnPublish }) { const acquire = (c, device, now, force = false, expect = undefined) => { if (expect != null && (c.lease.device !== expect.device || (expect.head && c.head !== expect.head) || - (c.lease.fence !== expect.fence && !(expect.head && c.lease.fence > expect.fence)))) throw refuse('lease_held'); + c.lease.fence !== expect.fence)) throw refuse('lease_held'); if (!force && c.lease.expires > now && c.lease.device !== device) throw refuse('lease_held'); return { ...c, lease: { device, fence: c.lease.fence + 1, expires: now + ttlMs, pending: 0 } }; }; diff --git a/relay/hosted/src/verify.js b/relay/hosted/src/verify.js index b58bfce28c..220bdce02f 100644 --- a/relay/hosted/src/verify.js +++ b/relay/hosted/src/verify.js @@ -46,7 +46,9 @@ async function requestMessage({ method, uri, time }, body) { return enc.encode(`${REQ_LABEL}${method}\n${uri}\n${time}\n${hex(await sha256(body))}`); } -const withinSkew = (time, now) => /^-?\d+$/.test(time) && Math.abs(now - Number(time)) <= SKEW_MS; +export function checkTime(time, now) { + if (!/^-?\d+$/.test(time) || Math.abs(now - Number(time)) > SKEW_MS) throw new Refusal('skew', 'clock'); +} /** * checkCert is the half of verification that needs no body: the headers are present and the @@ -74,7 +76,7 @@ export async function checkCert(headers) { export async function checkRequest(caller, req, body, nowMs) { const msg = await requestMessage({ method: req.method, uri: req.uri, time: caller.time }, body); if (!(await verifySig(await importKey(caller.devicePub), fromB64Url(caller.sig), msg))) throw unauthorized('bad signature'); - if (!withinSkew(caller.time, nowMs)) throw new Refusal('skew', 'clock'); + checkTime(caller.time, nowMs); return { identity: caller.identity, device: caller.device }; } diff --git a/relay/hosted/test/api.e2e.mjs b/relay/hosted/test/api.e2e.mjs index ecf0b33ee5..29eeb12115 100644 --- a/relay/hosted/test/api.e2e.mjs +++ b/relay/hosted/test/api.e2e.mjs @@ -38,7 +38,10 @@ assert.equal(streamed.status, 401, 'an unsigned stream is refused as unsigned, h assert.equal((await call(a, 'POST', '/v1/dir/cells/c1', init())).json.cell.lease.fence, 1); assert.equal((await call(a, 'POST', '/v1/dir/cells/c1', init())).status, 409); assert.equal(answerOf(await call(b, 'POST', '/v1/dir/cells/c1/acquire', {})), 'lease_held'); -assert.equal((await call(b, 'POST', '/v1/dir/cells/c1/acquire', { force: true })).json.cell.lease.fence, 2); +const blind = await call(b, 'POST', '/v1/dir/cells/c1/acquire', { force: true }); +assert.deepEqual([blind.status, blind.json.err], [409, 'lease_held']); +const observed = (await call(b, 'GET', '/v1/dir/cells/c1')).json.cell.lease; +assert.equal((await call(b, 'POST', '/v1/dir/cells/c1/acquire', { force: true, expect: { device: observed.device, fence: observed.fence } })).json.cell.lease.fence, 2); const pub = (fence) => ({ fence, old_head: 'h0', head: 'h1', size: 0, class: 'work', pending: 0 }); const stale = await call(a, 'POST', '/v1/dir/cells/c1/publish', pub(1)); assert.deepEqual([stale.status, stale.json.err], [409, 'fence_stale']); diff --git a/relay/hosted/test/caps.e2e.mjs b/relay/hosted/test/caps.e2e.mjs index f41f531743..5c08d45303 100644 --- a/relay/hosted/test/caps.e2e.mjs +++ b/relay/hosted/test/caps.e2e.mjs @@ -73,8 +73,15 @@ const tests = { }, }; -for (const [name, run] of Object.entries(tests)) { - await run(); - console.log('caps ok', name); +const selected = process.argv[2]; +if (selected === '--list') { + console.log(Object.keys(tests).join('\n')); +} else { + const cases = Object.entries(tests).filter(([name]) => !selected || name === selected); + if (!cases.length) throw new Error(`unknown caps case: ${selected}`); + for (const [name, run] of cases) { + await run(); + console.log('caps ok', name); + } + console.log('caps: all pass'); } -console.log('caps: all pass'); diff --git a/relay/hosted/test/conditional-take.test.js b/relay/hosted/test/conditional-take.test.js index 6503c369d5..f8faa421b2 100644 --- a/relay/hosted/test/conditional-take.test.js +++ b/relay/hosted/test/conditional-take.test.js @@ -18,7 +18,7 @@ test('C7 the Worker states conditional takes on the cell read', async () => { assert.equal(dir.cell('c7').cell.lease.device, 'dev_a'); }); -test('F2 a same-holder fence bump is harmless only with the same saved head', () => { +test('conditional takes refuse stale fences even with the same saved head', () => { for (const change of ['fence', 'head', 'holder']) { const dir = new Directory(memorySql(), 'id_f2', () => 1_000_000, AMENDED); dir.create('f2', init(), 'dev_a'); @@ -29,7 +29,6 @@ test('F2 a same-holder fence bump is harmless only with the same saved head', () const current = dir.cell('f2').cell; dir.publish('f2', 'dev_a', { fence: current.lease.fence, old_head: current.head, head: 'new-head' }); } - if (change === 'fence') assert.equal(dir.acquire('f2', 'dev_b', true, seen).cell.lease.device, 'dev_b'); - else assert.throws(() => dir.acquire('f2', 'dev_b', true, seen), (e) => e.code === 'lease_held'); + assert.throws(() => dir.acquire('f2', 'dev_b', true, seen), (e) => e.code === 'lease_held'); } }); diff --git a/relay/hosted/test/e2e.sh b/relay/hosted/test/e2e.sh index e0262df37c..a8645e8616 100644 --- a/relay/hosted/test/e2e.sh +++ b/relay/hosted/test/e2e.sh @@ -9,16 +9,41 @@ pids="" stop() { for p in $pids; do kill "$p" 2>/dev/null || true; done; rm -rf "$work"; } trap stop EXIT +# All local workers consume the same reactor. Multiple custom-build watchers +# otherwise keep rewriting it and restarting each other during requests. +sh ../../scripts/raft-hosted-build.sh +node --input-type=module - "$work/wrangler.toml" <<'JS' +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +const config = readFileSync('wrangler.toml', 'utf8') + .replace(/^\[build\][\s\S]*?(?=^\[|$(?![\s\S]))/m, '') + .replace(/^main = "([^"]+)"/m, (_, path) => `main = ${JSON.stringify(resolve(path))}`); +writeFileSync(process.argv[2], config); +JS + start() { # port, extra wrangler args port=$1; shift + persist="$work/$port" + if [ "$port" = "${scenario_port:-}" ]; then persist=$scenario_state; fi : >"$work/$port.log" - node_modules/.bin/wrangler dev --local --ip 127.0.0.1 --port "$port" --persist-to "$work/$port" "$@" >"$work/$port.log" 2>&1 & + # Reuse the opt-in local proxy transport policy; retries remain off by default. + sh test/local.sh node_modules/.bin/wrangler dev --config "$work/wrangler.toml" --local --ip 127.0.0.1 --port "$port" --persist-to "$persist" "$@" >"$work/$port.log" 2>&1 & last=$! pids="$pids $last" for _ in $(seq 60); do grep -q 'Ready on' "$work/$port.log" && return 0; sleep 1; done cat "$work/$port.log"; return 1 } +scenario_count=0 +restartScenario() { # port, previous pid, limits, label, extra wrangler args + scenario_port=$1; previous_pid=$2; scenario_limits=$3; scenario_label=$4; shift 4 + kill "$previous_pid"; wait "$previous_pid" 2>/dev/null || true + # Prior scenarios' durable alarms must not reactivate groups in this isolate. + scenario_count=$((scenario_count + 1)) + scenario_state="$work/scenario-$scenario_count-$scenario_port-$scenario_label" + start "$scenario_port" --var "CAF_LIMITS:$scenario_limits" "$@" +} + TIGHT='{"newIdentitiesPerIpPerDay":3,"requestsPerMinute":40,"requestsPerMinutePerDevice":25,"framesPerDay":6,"storeBytes":3000,"storeObjects":12,"concurrentPuts":1,"pairTtlMs":3000,"pairCreatePerHour":4,"pairWritePerMinute":10,"pairConcurrentPolls":2,"pairMaxBoxes":4}' # The default relay takes any number of new identities from one address, as the conformance suite # makes one per case; the tight one keeps the short limits, and the third is for the restart check. @@ -29,28 +54,65 @@ TIGHT='{"newIdentitiesPerIpPerDay":3,"requestsPerMinute":40,"requestsPerMinutePe # It also has the shortest grace and a two-object sweep page, so the deletion case (and the page loop) run in seconds. OPEN='{"newIdentitiesPerIpPerDay":1000000,"pairTtlMs":4000,"minGraceMs":2000,"sweepPageSize":2,"requestsPerMinute":100000,"requestsPerMinutePerDevice":100000}' start 18791 --var "CAF_LIMITS:$OPEN" --var TRUST_PROXY:1 +default_pid=$last +restartDefault() { + restartScenario 18791 "$default_pid" "$OPEN" "$1" --var TRUST_PROXY:1 + default_pid=$last + default_state=$scenario_state +} +scenario() { + script=$1; shift + restartDefault "$script" + IDENTITY_DO_DIR="$default_state/v3/do/codeaf-hosted-relay-IdentityDO" R2_DIR="$default_state/v3/r2/miniflare-R2BucketObject" node "test/$script.e2e.mjs" "$@" +} start 18792 --var "CAF_LIMITS:$TIGHT" +tight_pid=$last +restartTight() { + restartScenario 18792 "$tight_pid" "$TIGHT" "$1" + tight_pid=$last + tight_state=$scenario_state +} start 18794 --var 'CAF_LIMITS:{"newIdentitiesPerIpPerDay":1000000,"pairMaxBoxes":2}' --var TRUST_PROXY:1 # The watch cap case needs a relay that lets one identity hold five sockets, not a thousand. start 18796 --var 'CAF_LIMITS:{"newIdentitiesPerIpPerDay":1000000,"maxWatchers":5}' --var TRUST_PROXY:1 # Link pairing: a 3 s request life, decided requests kept 1.5 s, 40 live requests, 6 creates an hour, 3 misses a minute. LINK='{"newIdentitiesPerIpPerDay":1000000,"pairTtlMs":3000,"linkDecidedKeepMs":1500,"linkMaxLive":40,"linkCreatePerHour":6,"linkMissPerMinute":3,"linkReadPerMinute":500,"requestsPerMinute":100000,"requestsPerMinutePerDevice":100000}' +# Capacity needs forty simultaneously live requests. Use the production TTL +# only there; expiry and every other link case retain the short test TTL. +LINK_CAPACITY=$(node -e 'const limits=JSON.parse(process.argv[1]);delete limits.pairTtlMs;console.log(JSON.stringify(limits));' "$LINK") start 18797 --var "CAF_LIMITS:$LINK" --var TRUST_PROXY:1 +link_pid=$last +restartLink() { + link_limits=$LINK + if [ "$1" = RelayFull ]; then link_limits=$LINK_CAPACITY; fi + restartScenario 18797 "$link_pid" "$link_limits" "$1" --var TRUST_PROXY:1 + link_pid=$last +} # A relay served under /fabric, as the hosted deployment is: the same wires, beneath one prefix. start 18798 --var "CAF_LIMITS:$OPEN" --var TRUST_PROXY:1 --var BASE_PATH:/fabric node test/base.e2e.mjs RELAY=http://127.0.0.1:18798/fabric node test/api.e2e.mjs -for script in api race flight dedup; do node "test/$script.e2e.mjs"; done -node test/caps.e2e.mjs +for script in api race flight dedup; do scenario "$script"; done +for cap in $(node test/caps.e2e.mjs --list); do + restartTight "$cap" + node test/caps.e2e.mjs "$cap" +done +restartTight pair node test/pair.e2e.mjs -node test/link.e2e.mjs -node test/watch.e2e.mjs -node test/watch.e2e.mjs lease -WATCH_LOG="$work/18791.log" node test/watch.e2e.mjs idle -IDENTITY_DO_DIR="$work/18791/v3/do/codeaf-hosted-relay-IdentityDO" R2_DIR="$work/18791/v3/r2/miniflare-R2BucketObject" node test/rotation.e2e.mjs -IDENTITY_DO_DIR="$work/18792/v3/do/codeaf-hosted-relay-IdentityDO" node test/newcomers.e2e.mjs +for link_case in $(node test/link.e2e.mjs --list); do + restartLink "$link_case" + node test/link.e2e.mjs "$link_case" +done +for watch_case in $(node test/watch.e2e.mjs --list); do scenario watch "$watch_case"; done +scenario watch lease +WATCH_LOG="$work/18791.log" scenario watch idle +scenario rotation +restartTight newcomers +# The runtime creates this namespace lazily; the initial stored count is zero. +mkdir -p "$tight_state/v3/do/codeaf-hosted-relay-IdentityDO" +IDENTITY_DO_DIR="$tight_state/v3/do/codeaf-hosted-relay-IdentityDO" node test/newcomers.e2e.mjs # Counts survive the relay process: count, wait for the flush, stop the relay, start it again over the same port (so the same storage). PERSIST='{"newIdentitiesPerIpPerDay":1000000,"statsFlushMs":300}' diff --git a/relay/hosted/test/flight.e2e.mjs b/relay/hosted/test/flight.e2e.mjs index 75622cf1e7..58eba4ef6f 100644 --- a/relay/hosted/test/flight.e2e.mjs +++ b/relay/hosted/test/flight.e2e.mjs @@ -20,6 +20,9 @@ const myOther = await newDevice(mine.identity); const theirs = await newDevice(await newIdentity()); const f = frame([rid(1)]); +// Measure isolation between active identities, separately from cold preparation. +for (const device of [mine, theirs]) assert.equal((await call(device, 'GET', '/v1/store/stats')).status, 200); + const putting = trickle(await signed(mine, 'POST', '/v1/store/frames', f.bytes), '/v1/store/frames', f.bytes, PAUSE_MS); await sleep(300); diff --git a/relay/hosted/test/link.e2e.mjs b/relay/hosted/test/link.e2e.mjs index 1730681a7c..1dbdbc4500 100644 --- a/relay/hosted/test/link.e2e.mjs +++ b/relay/hosted/test/link.e2e.mjs @@ -1,6 +1,7 @@ // Link pairing end to end (docs/ux-pairing-contract.md): create, read, approve, deny, presence, // and every limit the contract names. Runs against a relay started with a 3 s request time to live, // decided requests kept 1.5 s, 40 live requests, 6 creates an hour, 3 misses a minute (see e2e.sh). +// The isolated capacity case uses production pending expiry to keep all forty requests live. // Each case that needs its own allowances comes from its own IP (X-Forwarded-For). import assert from 'node:assert/strict'; import { randomBytes } from 'node:crypto'; @@ -23,6 +24,7 @@ async function open(method, path, { ip = freshIp(), body } = {}) { async function member() { const identity = await newIdentity(); const approver = await newDevice(identity); + assert.equal((await call(approver, 'GET', '/v1/dir/list', undefined, { base: BASE })).status, 200); const newcomer = async (platform) => { const device = await newDevice(identity); const pubkey = Buffer.from(device.cert.device, 'hex'); @@ -48,7 +50,8 @@ const asked = async (m, platform) => { return { n, ...made.json }; }; -const limits = (await open('GET', '/v1/link/limits')).json; +const selected = process.argv[2]; +const limits = selected === '--list' ? null : (await open('GET', '/v1/link/limits')).json; const tests = { async Limits() { @@ -92,13 +95,15 @@ const tests = { const listed = (await call(m.approver, 'GET', '/v1/dir/list', undefined, { base: BASE })).json.devices[device]; assert.deepEqual([listed.platform, listed.revoked, typeof listed.created], ['linux', false, 'number'], 'the key is linked to the identity'); assert.equal((await approve(m, code, body)).status, 204, 'the same approve again is idempotent'); + const changed = await approve(m, code, { ...body, grant: b64u(randomBytes(64)) }); + assert.deepEqual([changed.status, answerOf(changed)], [409, 'already_decided'], 'a different grant cannot replace the committed decision'); assert.equal(answerOf(await deny(m, code)), 'already_decided'); }, async ClientCannotSetCreatedOrLastSeen() { const m = await member(); const { n, code, device } = await asked(m); - await approve(m, code, approveBody(n, { device: { device: { created: 5, last_seen: 5 } } })); + assert.equal((await approve(m, code, approveBody(n, { device: { created: 5, last_seen: 5 } }))).status, 204); const rec = (await call(m.approver, 'GET', '/v1/dir/list', undefined, { base: BASE })).json.devices[device]; assert.ok(rec.created > 1_700_000_000_000 && rec.last_seen === undefined, JSON.stringify(rec)); }, @@ -116,7 +121,8 @@ const tests = { async RaceHasOneWinner() { const m = await member(); const { n, code } = await asked(m); - const [a1, d1, a2, d2] = await Promise.all([approve(m, code, approveBody(n)), deny(m, code), approve(m, code, approveBody(n)), deny(m, code)]); + const body = approveBody(n); + const [a1, d1, a2, d2] = await Promise.all([approve(m, code, body), deny(m, code), approve(m, code, body), deny(m, code)]); const state = (await read(code)).json.state; const [wins, loses] = state === 'approved' ? [[a1, a2], [d1, d2]] : [[d1, d2], [a1, a2]]; assert.deepEqual([...wins, ...loses].map((a) => a.status), [204, 204, 409, 409], `the first decision (${state}) wins and the other is refused`); @@ -151,6 +157,26 @@ const tests = { assert.equal(answerOf(await approve(m, again.json.code, approveBody(first.n))), 'bad_request'); }, + async InvalidApprovalDoesNotReserveTheRequest() { + const owner = await member(), other = await member(); + for (const invalid of ['certificate', 'identity', 'grant', 'device']) { + const { n, code, device } = await asked(owner); + const cert = { ...n.device.cert, sig: '' }; + cert.sig = Buffer.from(await crypto.subtle.sign('Ed25519', other.identity.pair.privateKey, new TextEncoder().encode('codeaf/device-cert/v1\n' + JSON.stringify(cert)))).toString('hex'); + const body = approveBody({ ...n, cert: b64u(JSON.stringify(cert)) }); + if (invalid === 'certificate') body.cert = 'invalid'; + if (invalid === 'identity') body.cert = n.cert; + if (invalid === 'grant') body.grant = b64u(randomBytes(4097)); + if (invalid === 'device') body.device = []; + const refused = await approve(other, code, body); + assert.deepEqual([refused.status, answerOf(refused)], invalid === 'grant' ? [413, 'too_big'] : [400, 'bad_request'], invalid); + assert.equal((await read(code)).json.state, 'pending', invalid); + const listed = (await call(other.approver, 'GET', '/v1/dir/list', undefined, { base: BASE })).json; + assert.equal(listed.devices[device], undefined, invalid); + assert.equal((await approve(owner, code, approveBody(n))).status, 204, `${invalid} did not reserve the request for another identity`); + } + }, + async ExpiryIsGone() { const m = await member(); const { n, code } = await asked(m); @@ -220,14 +246,19 @@ const tests = { }, async RelayFull() { - await sleep(limits.ttl_ms + 300); + // The selected capacity case starts in a fresh namespace in e2e.sh. + if (!selected) await sleep(limits.ttl_ms + 300); const m = await member(); - let ip = freshIp(); - for (let i = 0; i < limits.max_live; i++) { - if (i % limits.pending_per_ip === 0) ip = freshIp(); - assert.equal((await create((await m.newcomer()).body, ip)).status, 201, `request ${i}`); - } - const full = await create((await m.newcomer()).body, freshIp()); + // Prepare keys first so the capacity check measures live request slots. + const requests = await Promise.all(Array.from({ length: limits.max_live + 1 }, () => m.newcomer())); + const ips = Array.from({ length: Math.ceil(limits.max_live / limits.pending_per_ip) }, () => freshIp()); + const started = Date.now(); + const made = await Promise.all(requests.slice(0, limits.max_live).map((n, i) => create(n.body, ips[Math.floor(i / limits.pending_per_ip)]))); + for (const [i, answer] of made.entries()) assert.equal(answer.status, 201, `request ${i}`); + const full = await create(requests.at(-1).body, freshIp()); + const finished = Date.now(), expires = made.map((answer) => answer.json.expires_at); + console.log('link live capacity', JSON.stringify({ live: expires.filter((at) => at > finished).length, span_ms: finished - started, earliest_expiry_ms: Math.min(...expires) - finished, status: full.status })); + assert.ok(expires.every((at) => at > finished), 'all requests remain live when capacity is checked'); assert.deepEqual([full.status, full.json.err], [503, 'full']); }, @@ -253,8 +284,14 @@ const tests = { }, }; -for (const [name, run] of Object.entries(tests)) { - await run(); - console.log('link ok', name); +if (selected === '--list') { + console.log(Object.keys(tests).join('\n')); +} else { + const cases = Object.entries(tests).filter(([name]) => !selected || name === selected); + if (!cases.length) throw new Error(`unknown link case: ${selected}`); + for (const [name, run] of cases) { + await run(); + console.log('link ok', name); + } + console.log('link: all pass'); } -console.log('link: all pass'); diff --git a/relay/hosted/test/race.e2e.mjs b/relay/hosted/test/race.e2e.mjs index 6c895ad0b5..0b39f0f436 100644 --- a/relay/hosted/test/race.e2e.mjs +++ b/relay/hosted/test/race.e2e.mjs @@ -22,8 +22,11 @@ const pubs = tally(await Promise.all(Array.from({ length: 50 }, (_, i) => call(o assert.deepEqual(pubs, { 200: 1, head_moved: 49 }); console.log('head race', JSON.stringify(pubs)); +const blind = await call(rivals[0], 'POST', '/v1/dir/cells/heads/acquire', { force: true }); +assert.deepEqual([blind.status, blind.json.err], [409, 'lease_held']); +const observed = (await call(rivals[0], 'GET', '/v1/dir/cells/heads')).json.cell.lease; const t0 = performance.now(); -const took = await call(rivals[0], 'POST', '/v1/dir/cells/heads/acquire', { force: true }); +const took = await call(rivals[0], 'POST', '/v1/dir/cells/heads/acquire', { force: true, expect: { device: observed.device, fence: observed.fence } }); const ms = Math.round(performance.now() - t0); assert.equal(took.json.cell.lease.fence, fence + 1); const stale = await call(owner, 'POST', '/v1/dir/cells/heads/publish', { fence, old_head: 'h0', head: 'hx', size: 1, class: 'work', pending: 0 }); diff --git a/relay/hosted/test/raft/hosted-bootstrap.test.js b/relay/hosted/test/raft/hosted-bootstrap.test.js new file mode 100644 index 0000000000..1633d9fcb9 --- /dev/null +++ b/relay/hosted/test/raft/hosted-bootstrap.test.js @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hostedFixture } from './hosted-fixture.js'; + +test('the first write does not recover peers outside the committed singleton configuration',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start(); + assert.equal((await h.call(a,'PUT','/v1/dir/devices/'+a.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + const metrics=await h.control({op:'metrics'}); + assert.equal(metrics.idleReads,0,'the initial transport view has no prior delivery to refresh'); + assert.equal(metrics.peerFailures,0,'unconfigured anchors have no failed Raft delivery to recover'); + }finally{await h.close();} +}); + +test('a new identity commits its first write without either additional anchor',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start({needle:' member(id){',after:"if(String(id)!=='1')throw new Error('fixture: additional anchors unavailable');"}); + const made=await h.call(a,'POST','/v1/dir/cells/chat',{head:'first',class:'work',size:0,keys:{}}); + assert.equal(made.status,200,JSON.stringify(made)); + const source=JSON.parse((await h.control({op:'source'})).gate),cluster=source.config.cluster; + assert.deepEqual(source.birth.voters,['1']); + for(const node of ['2','3'])assert.equal(await h.control({op:'has-member',cluster,node}),false); + const read=await h.call(a,'GET','/v1/dir/cells/chat');assert.equal(read.status,200);assert.equal(read.body.cell.head,'first'); + }finally{await h.close();} +}); + + +test('background formation preserves the first write through joint consensus and restart',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'POST','/v1/dir/cells/chat',{head:'first',class:'work',size:0,keys:{}})).status,200); + let state; + for(let turn=0;turn<16;turn++){ + state=await h.control({op:'formation-turn'}); + if(state.registry)break; + } + assert.equal(state.registry,true,JSON.stringify(state));assert.deepEqual(state.conf.voters,['1','2','3']);assert.equal(state.conf.outgoing?.length??0,0); + await h.start();const read=await h.call(a,'GET','/v1/dir/cells/chat');assert.equal(read.status,200);assert.equal(read.body.cell.head,'first'); + }finally{await h.close();} +}); + +test('recovery after singleton publication retains immutable birth provenance',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start({needle:'if(!owned){await this.source.own(receipts);',after:"throw new Error('fixture: crash after singleton publication');"}); + assert.equal((await h.call(a,'POST','/v1/dir/cells/chat',{head:'first',class:'work',size:0,keys:{}})).status,500); + await h.start();assert.equal((await h.call(a,'POST','/v1/dir/cells/chat',{head:'first',class:'work',size:0,keys:{}})).status,200); + let state;for(let turn=0;turn<16;turn++){state=await h.control({op:'formation-turn'});if(state.registry)break;} + assert.equal(state.registry,true,JSON.stringify(state)); + assert.deepEqual(JSON.parse((await h.control({op:'source'})).gate).birth.voters,['1']); + }finally{await h.close();} +}); + +test('singleton formation keeps renewing live holds while additional anchors remain unavailable',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true,now:Date.now()}),a=h.client(),sockets=[]; + const watch=async()=>{const response=await h.request(a,'GET','/v1/dir/watch?hold=chat:1&receipts=1',undefined,{headers:{upgrade:'websocket'}});assert.equal(response.status,101);response.webSocket.accept();sockets.push(response.webSocket);}; + try{ + await h.start({needle:' member(id){',after:"if(String(id)!=='1')throw new Error('fixture: additional anchors unavailable');"}); + const created=await h.call(a,'POST','/v1/dir/cells/chat',{head:'first',class:'work',size:0,keys:{}});assert.equal(created.status,200); + await watch();let expires=(await h.call(a,'GET','/v1/dir/cells/chat')).body.cell.lease.expires; + for(let turn=0;turn<4;turn++){ + await h.control({op:'shift-clock',records:20_000});await watch(); + await h.control({op:'shift-clock',records:10_000});await h.control({op:'formation-turn'}); + const cell=await h.call(a,'GET','/v1/dir/cells/chat');assert.equal(cell.status,200);assert(cell.body.cell.lease.expires>expires,'maintenance must renew fresh watch evidence during formation');expires=cell.body.cell.lease.expires; + } + }finally{for(const socket of sockets)socket.close();await h.close();} +}); + + +test('a lost published singleton is not recreated from the frozen source',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),cut={needle:' member(id){',after:"if(String(id)!=='1')throw new Error('fixture: additional anchors unavailable');"}; + try{ + await h.start(cut);assert.equal((await h.call(a,'POST','/v1/dir/cells/chat',{head:'first',class:'work',size:0,keys:{}})).status,200); + const cluster=JSON.parse((await h.control({op:'source'})).gate).config.cluster; + await h.control({op:'discard-unformed-member',cluster,node:'1'});await h.start(cut); + assert.equal((await h.call(a,'GET','/v1/dir/cells/chat')).status,503); + assert.equal(await h.control({op:'has-member',cluster,node:'1'}),false); + }finally{await h.close();} +}); + +test('formation resumes from durable configuration after every background turn',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'POST','/v1/dir/cells/chat',{head:'first',class:'work',size:0,keys:{}})).status,200); + let state; + for(let turn=0;turn<16;turn++){ + state=await h.control({op:'formation-turn'});await h.start(); + const read=await h.call(a,'GET','/v1/dir/cells/chat');assert.equal(read.status,200,JSON.stringify(state));assert.equal(read.body.cell.head,'first'); + if(state.registry)break; + } + assert.equal(state.registry,true,JSON.stringify(state)); + }finally{await h.close();} +}); + +test('an idle singleton releases memory when additional anchors remain unavailable',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),cut={needle:' member(id){',after:"if(String(id)!=='1')throw new Error('fixture: additional anchors unavailable');"}; + try{ + await h.start(cut);assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + const idle=await h.control({op:'idle-schedule'});assert.equal(idle.sleeping,true);assert.equal(idle.recurringMemberCalls,0); + assert.equal((await h.control({op:'metrics'})).active,0); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200,'the durable singleton can wake through its actual quorum'); + }finally{await h.close();} +}); diff --git a/relay/hosted/test/raft/hosted-capacity.test.js b/relay/hosted/test/raft/hosted-capacity.test.js index d0545809f3..a065e01b94 100644 --- a/relay/hosted/test/raft/hosted-capacity.test.js +++ b/relay/hosted/test/raft/hosted-capacity.test.js @@ -56,10 +56,10 @@ test('r4.3 incomplete sleep retains busy members and full admission retries afte try{ await h.start();const sleeping=household(h);assert.equal((await sleeping.request()).status,200);await idle(sleeping); let next,refusal; - for(let n=0;n<5;n++){ + for(let n=0;n<16;n++){ const a=household(h),reply=await a.request(); if(reply.status!==200){next=a;refusal=reply;break;} - const states=await a.control('retention-state','remember');a.busyNodes=Object.keys(states).filter(node=>states[node].active);assert(a.busyNodes.length>=2); + const states=await a.control('retention-state','remember');a.busyNodes=Object.keys(states).filter(node=>states[node].active);assert(a.busyNodes.length>=1); busy.push(a);await a.control('pause-public'); } assert(next,'busy activation exceeded the admission budget');assert(busy.length>0); @@ -76,7 +76,8 @@ test('r4.3 incomplete sleep retains busy members and full admission retries afte const compact=m=>({active:m.active,reservedBytes:m.reservedBytes,memory:m.memory}); console.log('busy admission',JSON.stringify({before:compact(before),released:compact(released),retry:retry.status,final:compact(await h.control({op:'metrics'}))})); assert.equal(retry.status,200,'released reservations did not admit the retry'); - assert(busy.length>=2);await busy[1].control('resume-public');await idle(busy[1]); + assert(busy.length>=2); + for(const a of busy.slice(1)){await a.control('resume-public');await idle(a);} assert.equal((await sleeping.request()).status,200,'refused wake did not recover after capacity became available'); }finally{for(const a of busy)await a.control('resume-public').catch(()=>{});await h.close();} }); @@ -84,7 +85,7 @@ test('r4.3 incomplete sleep retains busy members and full admission retries afte test('r4.4 released members stay silent, fence stale references and wake on signed input',{timeout:60000},async()=>{ const h=await hostedFixture({fixture:true}),a=household(h); try{ - await h.start();assert.equal((await a.request()).status,200);await a.control('retention-state','remember');await idle(a); + await h.start();assert.equal((await a.request()).status,200);await a.control('form-cluster');await a.control('retention-state','remember');await idle(a); const stale=await a.control('retention-state','stale'); for(const state of Object.values(stale)){assert.deepEqual(state.refused,[true,true,true]);assert.equal(state.handle,0);assert.equal(state.stopped,true);} const before=await a.control('quiet-state');assert.equal(before.alarm,null);assert.equal(before.maintenance,false);assert.deepEqual(before.memberAlarms,{'1':null,'2':null,'3':null}); diff --git a/relay/hosted/test/raft/hosted-fallback.test.js b/relay/hosted/test/raft/hosted-fallback.test.js index e17c3620c4..3bcf1a0666 100644 --- a/relay/hosted/test/raft/hosted-fallback.test.js +++ b/relay/hosted/test/raft/hosted-fallback.test.js @@ -12,7 +12,7 @@ test('R7 pending membership cannot stop surviving follower election clocks',asyn }])); members.set('3',{drainRecovery:async()=>{throw Error('standby unavailable');}}); const host=Object.assign(Object.create(HostedIdentity.prototype),{ - asleep:false,wakeVersion:0,started:0,elapsed:0,operations:1,membershipPending:true, + asleep:false,wakeVersion:0,started:0,elapsed:0,operations:1,membershipPending:true,seen:new Map(), manifest:{config:{anchors:['1','2','3']}},members,states:new Map(),queue:[],reads:[],waiters:new Set(),progressVersion:0, holds:{next:null},policy:{tick_every:5000},forwarding:{pending:new Map(),socket:()=>null}, peers:{view:{'4':{voter:true}},boxes:new Map(),snapshots:new Map(),retryTargets:new Set()}, diff --git a/relay/hosted/test/raft/hosted-fixture.js b/relay/hosted/test/raft/hosted-fixture.js index aef087ae70..34d22ad1c6 100644 --- a/relay/hosted/test/raft/hosted-fixture.js +++ b/relay/hosted/test/raft/hosted-fixture.js @@ -51,10 +51,10 @@ export async function hostedFixture({fixture=false,base='',go=false,rootLabel,no mf=new Miniflare(convertV4MiniflareOptions({log:diagnostics,handleStructuredLogs:entry=>diagnostics.capture(entry.level,entry.message),host:'127.0.0.1',inspectorPort:0,resourcePersistencePath:join(dir,'persist'),telemetry:{enabled:false},cf:false,workers:[worker]})); await mf.ready;return mf; }; - const request=async(c,method,path,body)=>{ + const request=async(c,method,path,body,options={})=>{ const raw=body===undefined?'':body instanceof Uint8Array?body:JSON.stringify(body),at=String(Date.now()); const headers={'Codeaf-Identity':rootKey.toString('base64url'),'Codeaf-Cert':Buffer.from(JSON.stringify(c.cert)).toString('base64url'),'Codeaf-Time':at,'Codeaf-Sig':sign(null,Buffer.from('codeaf-req-v1\n'+method+'\n'+prefix+path+'\n'+at+'\n'+sha(raw)),c.key.privateKey).toString('base64url')}; - return mf.dispatchFetch('http://local'+prefix+path,{method,headers,...(method!=='GET'&&raw?{body:raw}:{})}); + return mf.dispatchFetch('http://local'+prefix+path,{method,headers:{...headers,...options.headers},...(method!=='GET'&&raw?{body:raw}:{})}); }; const call=async(...args)=>{const response=await request(...args),text=await response.text();return {status:response.status,body:text?JSON.parse(text):null};}; const control=async value=>{assert(fixture);const r=await mf.dispatchFetch('http://local/fixture',{method:'POST',body:JSON.stringify({identity,...value})});assert.equal(r.status,200,await r.clone().text());return r.json();}; @@ -69,5 +69,5 @@ export async function hostedFixture({fixture=false,base='',go=false,rootLabel,no certificate={v:1,identity,...certificate,signature:null}; certificate.signature=sign(null,Buffer.from('codeaf/worker-cert/v1\0'+encodeCanonical(certificate)),root.privateKey).toString('base64');return certificate; }; - return {identity,client,dir,start,request,call,control,api,delegate,diagnostics,get mf(){return mf;},async close(){if(mf)await mf.dispose();await rm(dir,{recursive:true,force:true});}}; + return {identity,rootPublicKey:rootKey,client,dir,start,request,call,control,api,delegate,diagnostics,get mf(){return mf;},async close(){if(mf)await mf.dispose();await rm(dir,{recursive:true,force:true});}}; } diff --git a/relay/hosted/test/raft/hosted-forward.test.js b/relay/hosted/test/raft/hosted-forward.test.js index 455665a2b9..f55fc76b60 100644 --- a/relay/hosted/test/raft/hosted-forward.test.js +++ b/relay/hosted/test/raft/hosted-forward.test.js @@ -89,7 +89,7 @@ test('R6 a stale disconnected hint cannot allocate a second forwarding ID',async test('R6 R9 HTTP catch-up lets peer progress finish before returning an empty response',{timeout:peerLimits(runtime).ForwardTimeout},async()=>{ let delivered=false; const peer=Object.assign(Object.create((await import('../../src/raft/peers.js')).PersonalPeers.prototype),{tail:Promise.resolve(),pending:0,view:{'5':{voter:false}},refresh:async()=>{},validate:()=>{},wire:()=>new TextEncoder().encode(JSON.stringify({messages:delivered?['append']:[]})),exchange:async()=>({request:{node:'5',applied:'14',messages:[]},raw:new TextEncoder().encode('{"messages":[]}'),rejected:false})}); - const host={peers:peer,asleep:false,leader:()=>undefined,leaderID:()=> '4',replica:()=> '1',states:new Map([['1',{applied:15n}]]),forwarding:{socket:()=>({})},forward:async(node,work)=>{assert.equal(node,'4');assert.equal(work.action,'poke');await peer.serial(()=>{delivered=true;});return {barrier:'15'};}}; + const host={invalidateRead(){},peers:peer,asleep:false,leader:()=>undefined,leaderID:()=> '4',replica:()=> '1',states:new Map([['1',{applied:15n}]]),forwarding:{socket:()=>({})},forward:async(node,work)=>{assert.equal(node,'4');assert.equal(work.action,'poke');await peer.serial(()=>{delivered=true;});return {barrier:'15'};}}; peer.host=host; const result=await peer.httpExchange(new Uint8Array());assert.deepEqual(JSON.parse(new TextDecoder().decode(result.raw)).messages,['append']); }); @@ -99,7 +99,7 @@ test('R6 R9 HTTP catch-up lets peer progress finish before returning an empty re test('R6 R9 enrollment retains admitted traffic between unchanged members',async()=>{ const r=rig(),members={'1':{incarnation:'1',key:r.anchor.raw,voter:true,revoked:false},'4':{incarnation:'1',key:r.personal.raw,voter:true,revoked:false}}; let view=members; - const host={runtime,config:()=>({scope:r.host.peers.scope}),states:new Map([['1',{applied:13n}]]),leader:()=>undefined,replica:()=> '1',leaderID:()=> '4',members:new Map([['1',{refreshEnrollment:async()=>view}]]),ctx:{storage:{put:async()=>{},sync:async()=>{}}},voterHint:['1','4'],forwarding:{socket:()=>null},input:async()=>{},hash:()=>'',asleep:false}; + const host={invalidateRead(){},runtime,config:()=>({scope:r.host.peers.scope}),states:new Map([['1',{applied:13n}]]),leader:()=>undefined,replica:()=> '1',leaderID:()=> '4',members:new Map([['1',{refreshEnrollment:async()=>view}]]),ctx:{storage:{put:async()=>{},sync:async()=>{}}},voterHint:['1','4'],forwarding:{socket:()=>null},input:async()=>{},hash:()=>'',asleep:false}; const peers=new PersonalPeers(host,'2');host.peers=peers;peers.sockets=()=>[];await peers.refresh(); peers.enqueue([{from:'4',to:'1',sequence:'7',payload:'AQ=='}]); const before=structuredClone(peers.deliveries('1')),used=[...peers.used]; @@ -114,7 +114,7 @@ test('R6 R9 enrollment retains admitted traffic between unchanged members',async test('R6 R9 overlapping membership refreshes leave the latest committed view',async()=>{ const first=Promise.withResolvers(),second=Promise.withResolvers();let calls=0; const old={'1':{incarnation:'1',key:'anchor',voter:true,revoked:false}},newer={...old,'5':{incarnation:'1',key:'personal',voter:false,revoked:false}}; - const host={runtime,config:()=>({scope:rig().host.peers.scope}),states:new Map([['1',{applied:13n}]]),leader:()=>undefined,replica:()=> '1',leaderID:()=> '4',members:new Map([['1',{refreshEnrollment:()=>++calls===1?first.promise:second.promise}]]),ctx:{storage:{put:async()=>{},sync:async()=>{}}},voterHint:['1'],forwarding:{socket:()=>null},input:async()=>{},asleep:false}; + const host={invalidateRead(){},runtime,config:()=>({scope:rig().host.peers.scope}),states:new Map([['1',{applied:13n}]]),leader:()=>undefined,replica:()=> '1',leaderID:()=> '4',members:new Map([['1',{refreshEnrollment:()=>++calls===1?first.promise:second.promise}]]),ctx:{storage:{put:async()=>{},sync:async()=>{}}},voterHint:['1'],forwarding:{socket:()=>null},input:async()=>{},asleep:false}; const peers=new PersonalPeers(host,'2');host.peers=peers;peers.sockets=()=>[]; const earlier=peers.refresh();host.states.get('1').applied=14n;const later=peers.refresh(); second.resolve(newer);await Promise.resolve();await Promise.resolve();first.resolve(old); @@ -125,7 +125,7 @@ test('R6 R9 overlapping membership refreshes leave the latest committed view',as // queue must obtain a new quorum round without holding the peer queue. test('R6 R27 a drained voter queue retries a dropped quorum round',async()=>{ const r=rig(),view={'1':{incarnation:'1',key:r.anchor.raw,voter:true,revoked:false},'4':{incarnation:'1',key:r.personal.raw,voter:true,revoked:false}},reply=Promise.withResolvers(),actions=[]; - const host={runtime,codec:new HostCodec(runtime),config:()=>({scope:r.host.peers.scope}),states:new Map([['1',{applied:13n}]]),leader:()=>undefined,replica:()=> '1',leaderID:()=> '4',members:new Map([['1',{refreshEnrollment:async()=>view}]]),ctx:{storage:{put:async()=>{},sync:async()=>{}}},voterHint:['1','4'],forwarding:{socket:()=>({}),begin:async(node,work)=>{actions.push([node,work.action]);return {id:'retry',promise:reply.promise};}},serial:fn=>fn(),input:async()=>{},pump:async()=>{},wakeMaintenance:async()=>{},asleep:false}; + const host={invalidateRead(){},runtime,codec:new HostCodec(runtime),config:()=>({scope:r.host.peers.scope}),states:new Map([['1',{applied:13n}]]),leader:()=>undefined,replica:()=> '1',leaderID:()=> '4',members:new Map([['1',{refreshEnrollment:async()=>view}]]),ctx:{storage:{put:async()=>{},sync:async()=>{}}},voterHint:['1','4'],forwarding:{socket:()=>({}),begin:async(node,work)=>{actions.push([node,work.action]);return {id:'retry',promise:reply.promise};}},serial:fn=>fn(),input:async()=>{},pump:async()=>{},wakeMaintenance:async()=>{},asleep:false}; const peers=new PersonalPeers(host,'2');host.peers=peers;peers.sockets=()=>[];await peers.refresh();actions.length=0; peers.anchors=async()=>{};peers.chunks=async()=>{};peers.push=async()=>{};peers.validate=raw=>({request:JSON.parse(new TextDecoder().decode(raw)),obsolete:new Set()}); for(let n=0;n{ const cuts=[ - {name:'freeze',needle:'if(!this.source.gate())await this.source.freeze(this.provisioning.config);'}, + {name:'freeze',needle:'if(!this.source.gate())await this.source.freeze(this.provisioning.config,this.provisioning.singleton===true);'}, {name:'inventory',needle:'this.manifest=await this.source.inventory();'}, ...['1','2','3'].map(id=>({name:'stage-'+id,needle:'await this.member(id).legacyImport(this.config(id),this.manifest,copied);',after:`if(id==='${id}')throw new Error('H2 injected process cut');`})), ...['1','2','3'].map(id=>({name:'install-'+id,needle:'await this.member(id).legacyImport(this.config(id),this.manifest,null,true);',after:`if(id==='${id}')throw new Error('H2 injected process cut');`})), {name:'readback',needle:'await this.pump();await this.elect();await this.barrier();'}, - {name:'ownership',needle:'if(!owned)await this.source.own(receipts);'}, + {name:'ownership',needle:'if(!owned){await this.source.own(receipts);'}, ]; for(const cut of cuts){ const h=await hostedFixture({fixture:true}),a=h.client(); diff --git a/relay/hosted/test/raft/hosted-presence.test.js b/relay/hosted/test/raft/hosted-presence.test.js new file mode 100644 index 0000000000..1a87017dda --- /dev/null +++ b/relay/hosted/test/raft/hosted-presence.test.js @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hostedFixture } from './hosted-fixture.js'; +import { encode } from '../../src/frame.js'; + +test('blob requests do not synchronously commit advisory device presence',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start(); + assert.equal((await h.call(a,'PUT','/v1/dir/devices/'+a.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + await h.control({op:'reset-metrics'}); + const rid='a'.repeat(64),frame=encode('0'.repeat(32),[{rid,bytes:new TextEncoder().encode('AGEO\x01sealed')}]); + assert.equal((await h.call(a,'POST','/v1/store/frames',frame)).status,200); + const get=await h.request(a,'GET','/v1/store/objects/'+rid);assert.equal(get.status,200);await get.arrayBuffer(); + assert.equal((await h.call(a,'POST','/v1/store/has',{rids:[rid]})).status,200); + assert.equal((await h.control({op:'metrics'})).presenceCommits,0); + assert.equal((await h.control({op:'idle-schedule'})).sleeping,true); + assert.equal((await h.control({op:'metrics'})).presenceCommits,1,'one maintenance commit covers all three requests'); + const list=await h.call(a,'GET','/v1/dir/list');assert.equal(list.status,200);assert(list.body.devices[a.device].last_seen>0); + }finally{await h.close();} +}); + +test('watch departure coalesces presence without a synchronous proposal',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'PUT','/v1/dir/devices/'+a.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + const watch=await h.request(a,'GET','/v1/dir/watch',undefined,{headers:{upgrade:'websocket'}});assert.equal(watch.status,101); + const socket=watch.webSocket;socket.accept(); + await h.control({op:'reset-metrics'}); + const closed=new Promise(resolve=>socket.addEventListener('close',resolve,{once:true}));socket.close(1000);await closed; + assert.equal((await h.control({op:'metrics'})).presenceCommits,0); + assert.equal((await h.control({op:'idle-schedule'})).sleeping,true); + assert.equal((await h.control({op:'metrics'})).presenceCommits,1); + }finally{await h.close();} +}); + +test('queued presence cannot restore a device revoked during a streamed take',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),b=h.client(); + try{ + await h.start(); + for(const c of [a,b])assert.equal((await h.call(c,'PUT','/v1/dir/devices/'+c.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + const sealed=new Uint8Array(2<<20);sealed.set(new TextEncoder().encode('AGEO\x01sealed')); + const frame=encode('0'.repeat(32),[{rid:'b'.repeat(64),bytes:sealed}]); + const put=await h.call(a,'POST','/v1/store/frames',frame);assert.equal(put.status,200); + const take=await h.request(b,'GET','/v1/store/frames/'+put.body.frame);assert.equal(take.status,200); + const reader=take.body.getReader(),first=await reader.read();assert(first.value.length>0); + assert.equal((await h.call(a,'POST','/v1/dir/devices/'+b.device+'/revoke')).status,204); + assert.deepEqual(await h.call(b,'GET','/v1/store/stats'),{status:401,body:{err:'revoked'}}); + let bytes=first.value.length;for(;;){const chunk=await reader.read();if(chunk.done)break;bytes+=chunk.value.length;} + assert.equal(bytes,frame.length); + assert.equal((await h.control({op:'idle-schedule'})).sleeping,true); + assert.deepEqual(await h.call(b,'GET','/v1/dir/list'),{status:401,body:{err:'revoked'}}); + const retained=await h.request(a,'GET','/v1/store/frames/'+put.body.frame);assert.equal(retained.status,200); + assert.deepEqual(new Uint8Array(await retained.arrayBuffer()),frame); + }finally{await h.close();} +}); + + +test('a committed revoke interrupts a held store poll before its upload finishes',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),b=h.client();let upload,poll; + const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); + try{ + await h.start(); + for(const c of [a,b])assert.equal((await h.call(c,'PUT','/v1/dir/devices/'+c.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + await h.control({op:'hold-upload'}); + upload=h.call(a,'POST','/v1/store/frames',encode('0'.repeat(32),[{rid:'c'.repeat(64),bytes:new TextEncoder().encode('AGEO\x01sealed')}])); + let started=false;for(let n=0;n<500&&!started;n++){await delay(10);started=await h.control({op:'upload-started'});} + assert(started,'the upload reached its actual storage call before Has begins'); + poll=h.call(b,'POST','/v1/store/has',{rids:['c'.repeat(64)]}); + let waiting=false;for(let n=0;n<100&&!waiting;n++){await delay(10);waiting=await h.control({op:'has-waiting'});} + assert(waiting,'the authenticated store poll reached its real in-flight upload wait'); + assert.equal((await h.call(a,'POST','/v1/dir/devices/'+b.device+'/revoke')).status,204); + assert.equal(await Promise.race([poll.then(r=>r.status),delay(750).then(()=>null)]),401,'revocation releases a held poll on commit'); + }finally{await h.control({op:'release-upload'}).catch(()=>{});await Promise.allSettled([upload,poll].filter(Boolean));await h.close();} +}); + +test('revoking an upload keeps other devices waiting until its actual write finishes',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),b=h.client();let upload,poll; + const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); + try{ + await h.start(); + for(const c of [a,b])assert.equal((await h.call(c,'PUT','/v1/dir/devices/'+c.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + await h.control({op:'hold-upload'}); + const rid='d'.repeat(64); + upload=h.call(b,'POST','/v1/store/frames',encode('0'.repeat(32),[{rid,bytes:new TextEncoder().encode('AGEO\x01sealed')}])); + let started=false;for(let n=0;n<500&&!started;n++){await delay(10);started=await h.control({op:'upload-started'});} + assert(started,'the upload reached its actual storage call before Has begins'); + poll=h.call(a,'POST','/v1/store/has',{rids:[rid]}); + let waiting=false;for(let n=0;n<100&&!waiting;n++){await delay(10);waiting=await h.control({op:'has-waiting'});} + assert(waiting,'the other device is waiting for the actual upload'); + assert.equal((await h.call(a,'POST','/v1/dir/devices/'+b.device+'/revoke')).status,204); + assert.equal(await Promise.race([upload.then(r=>r.status),delay(750).then(()=>null)]),401,'the revoked upload returns without waiting for storage'); + assert.equal(await Promise.race([poll.then(()=>true),delay(100).then(()=>false)]),false,'Has cannot overtake an upload that still writes'); + await h.control({op:'release-upload'}); + assert.deepEqual(await poll,{status:200,body:{have:[true]}}); + }finally{await h.control({op:'release-upload'}).catch(()=>{});await Promise.allSettled([upload,poll].filter(Boolean));await h.close();} +}); diff --git a/relay/hosted/test/raft/hosted-readpath.test.js b/relay/hosted/test/raft/hosted-readpath.test.js new file mode 100644 index 0000000000..2df2bf2478 --- /dev/null +++ b/relay/hosted/test/raft/hosted-readpath.test.js @@ -0,0 +1,166 @@ +import WebSocket from 'ws'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hostedFixture } from './hosted-fixture.js'; +import { encode } from '../../src/frame.js'; +import { createHash, randomBytes, sign } from 'node:crypto'; +import { encodeCanonical } from '../../src/raft/codec.js'; + +test('warm blob and directory reads reuse confirmed authority without proposals or barriers',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + await h.control({op:'reset-metrics'}); + const raw=encode('0'.repeat(32),[{rid:'a'.repeat(64),bytes:new TextEncoder().encode('AGEO\x01sealed')}]); + assert.equal((await h.call(a,'POST','/v1/store/frames',raw)).status,200); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + const metrics=await h.control({op:'metrics'}); + assert.equal(metrics.proposals,0); + assert.equal(metrics.barriers,0); + assert.equal(metrics.authorizations,2,'each request authorizes once'); + }finally{await h.close();} +}); + +test('native enrollment cannot revive a certificate expired at an observed read time',{timeout:30000},async()=>{ + const base=Date.now(),h=await hostedFixture({fixture:true,now:base}),a=h.client(),worker=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + const cluster=JSON.parse((await h.control({op:'source'})).gate).config.cluster; + const certificate=h.delegate({cluster,key:worker.key.publicKey.export({format:'der',type:'spki'}).subarray(-32).toString('base64'),made:base-1000,expires:base+15_000,cells:['chat'],learner:false}); + await h.control({op:'shift-clock',records:30_000}); + const observed=await h.call(a,'GET','/v1/dir/list');assert(observed.body.now>certificate.expires); + await h.control({op:'shift-clock',records:-20_000}); + const fields={cluster,actor:a.device,session:'clock-floor',sequence:'1',change:{enroll_worker:{certificate,grants:{chat:'gQ=='}}}}; + const command={schema:1,...fields,certificate:Buffer.from(JSON.stringify(a.cert)).toString('base64url'),request_digest:createHash('sha256').update(encodeCanonical(fields)).digest('hex'),now:Date.now(),signature:null}; + command.signature=sign(null,Buffer.from('codeaf.directory.command.v1\0'+encodeCanonical(command)),a.key.privateKey).toString('base64'); + await h.control({op:'reset-metrics'}); + const result=await h.api(a,{cluster,action:'submit',incarnation:'0',command}); + assert.equal((await h.control({op:'metrics'})).presenceCommits,1,'native ingress commits a compatible device_seen clock witness'); + assert.equal(result.status,200,JSON.stringify(result));assert.equal(result.body.result.code,'bad_request'); + }finally{await h.close();} +}); + +test('proposal-free reads retain their observed time across clock regression and recovery',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true,now:Date.now()}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + await h.control({op:'shift-clock',records:30_000}); + const first=await h.call(a,'GET','/v1/dir/list');assert.equal(first.status,200); + await h.control({op:'shift-clock',records:-20_000}); + const second=await h.call(a,'GET','/v1/dir/list');assert.equal(second.status,200);assert(second.body.now>=first.body.now); + await h.start();const recovered=await h.call(a,'GET','/v1/dir/list');assert.equal(recovered.status,200);assert(recovered.body.now>=first.body.now); + }finally{await h.close();} +}); + +test('expired cached authority cannot answer from an isolated leader',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + await h.control({op:'expire-read-quorum'}); + const read=await h.call(a,'GET','/v1/dir/list');assert.equal(read.status,503);assert.equal(read.body.err,'unreachable'); + }finally{await h.close();} +}); + +test('a selected member recovering cannot reuse its previous activation certificate',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + await h.control({op:'replace-read-member'}); + const read=await h.call(a,'GET','/v1/dir/list');assert.equal(read.status,503);assert.equal(read.body.err,'unreachable'); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + }finally{await h.close();} +}); + +for(const interruption of ['member recovery','one scheduled interruption','continuous scheduled interruptions']){ + test('link approvals use fresh quorum authority after '+interruption,{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),b=h.client(); + const openLink=async client=>{ + const opened=await h.mf.dispatchFetch('http://local/v1/link/requests',{method:'POST',body:JSON.stringify({pubkey:Buffer.from(client.cert.device,'hex').toString('base64url'),x25519:randomBytes(32).toString('base64url'),name_sealed:randomBytes(45).toString('base64url'),platform:'linux'})}); + assert.equal(opened.status,201); + const pending=await opened.json(); + return {code:pending.code,approval:{device:{V:1,name:'c2VhbGVk',platform:'linux'},cert:Buffer.from(JSON.stringify(client.cert)).toString('base64url'),grant:randomBytes(64).toString('base64url')}}; + }; + const decision=async code=>{ + const reply=await h.mf.dispatchFetch('http://local/v1/link/requests/'+code); + assert.equal(reply.status,200);return reply.json(); + }; + try{ + await h.start(); + assert.equal((await h.call(a,'PUT','/v1/dir/devices/'+a.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + await h.control({op:'form-cluster'}); + const configuration=await h.control({op:'formation-turn'}); + assert.deepEqual(configuration.conf.voters.map(String),['1','2','3']); + assert.equal(configuration.conf.outgoing?.length??0,0);assert.equal(configuration.forming,false); + const pending=await openLink(b); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + if(interruption==='member recovery'){ + const replacement=await h.control({op:'replace-read-member'}); + assert.equal(replacement.after,replacement.before,'recovery preserves the applied index'); + assert.equal(replacement.refused,true,'the old activation remains fenced'); + }else await h.control({op:'interrupt-next-read',records:interruption==='one scheduled interruption'?'once':'always'}); + const approved=await h.call(a,'POST','/v1/dir/requests/'+pending.code+'/approve',pending.approval); + assert.equal(approved.status,204,'the first approval must succeed: '+JSON.stringify(approved)); + const delivered=await decision(pending.code); + assert.equal(delivered.state,'approved','the decision is delivered to the link request'); + assert.equal(delivered.grant,pending.approval.grant,'the sealed grant is delivered unchanged'); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + assert.equal((await h.call(b,'GET','/v1/dir/list')).status,200,'the admitted device can read the directory'); + await h.control({op:'interrupt-next-read',records:false}); + assert.equal((await h.call(a,'POST','/v1/dir/devices/'+b.device+'/revoke')).status,204); + assert.deepEqual(await h.call(b,'GET','/v1/dir/list'),{status:401,body:{err:'revoked'}}); + const next=await openLink(h.client()); + assert.deepEqual(await h.call(b,'POST','/v1/dir/requests/'+next.code+'/approve',next.approval),{status:401,body:{err:'revoked'}}); + assert.equal((await decision(next.code)).state,'pending'); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + await h.control({op:'expire-read-quorum'}); + assert.deepEqual(await h.call(a,'POST','/v1/dir/requests/'+next.code+'/approve',next.approval),{status:503,body:{err:'unreachable',retry_after:1}}); + assert.equal((await decision(next.code)).state,'pending','an isolated leader cannot deliver an approval'); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,503); + }finally{await h.close();} + }); +} + +test('snapshot traffic invalidates warm authority before the following read',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(); + try{ + await h.start();assert.equal((await h.call(a,'POST','/v1/dir/cells/chat',{head:'saved',class:'work',size:0,keys:{}})).status,200); + assert.equal((await h.call(a,'GET','/v1/dir/list')).status,200); + await h.control({op:'reset-metrics'}); + assert.equal(await h.control({op:'stale-snapshot'}),3); + const read=await h.call(a,'GET','/v1/dir/cells/chat');assert.equal(read.status,200);assert.equal(read.body.cell.head,'saved'); + assert((await h.control({op:'metrics'})).barriers>0); + }finally{await h.close();} +}); + + +test('directory reads reject signatures stale against previously observed time after clock rollback',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true,now:Date.now()}),a=h.client(),path='/v1/dir/list'; + try{ + await h.start();assert.equal((await h.call(a,'GET',path)).status,200); + const advance=6*60*1000,at=String(Date.now()+advance); + const signature=sign(null,Buffer.from('codeaf-req-v1\nGET\n'+path+'\n'+at+'\n'+createHash('sha256').update('').digest('hex')),a.key.privateKey).toString('base64url'); + await h.control({op:'shift-clock',records:advance}); + assert.equal((await h.call(a,'GET',path,undefined,{headers:{'Codeaf-Time':at,'Codeaf-Sig':signature}})).status,200); + await h.control({op:'shift-clock',records:-advance}); + assert.equal((await h.call(a,'GET',path)).status,401,'a regressed ingress clock cannot revive an old signed request'); + }finally{await h.close();} +}); + + +test('a replicated revoke closes the device watch before another public request',{timeout:30000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),b=h.client();let socket; + try{ + await h.start(); + for(const c of [a,b])assert.equal((await h.call(c,'PUT','/v1/dir/devices/'+c.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + const path='/v1/dir/watch',at=String(Date.now()),url=new URL(path,await h.mf.ready);url.protocol='ws:'; + const headers={'Codeaf-Identity':h.rootPublicKey.toString('base64url'),'Codeaf-Cert':Buffer.from(JSON.stringify(b.cert)).toString('base64url'),'Codeaf-Time':at,'Codeaf-Sig':sign(null,Buffer.from('codeaf-req-v1\nGET\n'+path+'\n'+at+'\n'+createHash('sha256').update('').digest('hex')),b.key.privateKey).toString('base64url')}; + socket=new WebSocket(url,{headers,closeTimeout:200});await new Promise((resolve,reject)=>{socket.once('open',resolve);socket.once('error',reject);}); + const cluster=JSON.parse((await h.control({op:'source'})).gate).config.cluster; + const fields={cluster,actor:a.device,session:'external-revoke',sequence:'1',change:{revoke:{device:b.device}}}; + const command={schema:1,...fields,certificate:Buffer.from(JSON.stringify(a.cert)).toString('base64url'),request_digest:createHash('sha256').update(encodeCanonical(fields)).digest('hex'),now:Date.now(),signature:null}; + command.signature=sign(null,Buffer.from('codeaf.directory.command.v1\0'+encodeCanonical(command)),a.key.privateKey).toString('base64'); + const closed=new Promise(resolve=>{const timeout=setTimeout(()=>resolve(null),1500);socket.once('close',code=>{clearTimeout(timeout);resolve(code);});}); + assert.equal(await h.control({op:'direct-commit',records:Buffer.from(encodeCanonical(command)).toString('base64')}),'ok'); + assert.equal(await closed,4401,'committed revocation closes the watch without a sweep or subsequent request'); + }finally{socket?.close();await h.close();} +}); diff --git a/relay/hosted/test/raft/hosted-recovery.test.js b/relay/hosted/test/raft/hosted-recovery.test.js new file mode 100644 index 0000000000..fe67c06dea --- /dev/null +++ b/relay/hosted/test/raft/hosted-recovery.test.js @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {encode} from '../../src/frame.js'; +import {createHash,sign} from 'node:crypto'; +import {encodeCanonical} from '../../src/raft/codec.js'; +import {hostedFixture} from './hosted-fixture.js'; + +test('formed identity retries cold anchors after temporary activation capacity clears',{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}); + try{ + await h.start();const c=h.client(); + assert.equal((await h.call(c,'POST','/v1/dir/cells/retained',{head:'retained-head',class:'work',size:7,keys:{}})).status,200); + assert.equal((await h.call(c,'POST','/v1/dir/cells/retained/release',{fence:1})).status,204); + await h.control({op:'form-cluster'}); + const formed=await h.call(c,'GET','/v2/cluster/control');assert.equal(formed.status,200); + assert.deepEqual(formed.body.configuration.voters.map(String),['1','2','3']); + assert.equal((await h.control({op:'idle-schedule'})).sleeping,true); + await h.control({op:'evict-idle'});await h.control({op:'transient-capacity',records:true}); + assert.equal((await h.call(c,'GET','/v1/dir/cells/retained')).status,503,'one formed voter cannot authorize a read'); + await h.control({op:'transient-capacity',records:false}); + const recovered=await h.call(c,'GET','/v1/dir/cells/retained'); + assert.equal(recovered.status,200,'healthy anchors must be retried without replacing the coordinator'); + assert.equal(recovered.body.cell.head,'retained-head'); + }finally{await h.close();} +}); + +test('ordinary member maintenance during a confirmed directory read does not refuse the read',{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}); + try{ + await h.start();const c=h.client(); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'form-cluster'}); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'interrupt-next-read'}); + const raced=await h.call(c,'GET','/v1/dir/list'); + assert.equal(raced.status,200,'safe maintenance during the read must refresh authority and finish'); + }finally{await h.close();} +}); + +for(const mode of ['no-quorum','advance'])test('interrupted read fails closed with '+mode,{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}); + try{ + await h.start();const c=h.client(); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'form-cluster'}); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'interrupt-next-read',records:mode}); + const refused=await h.call(c,'GET','/v1/dir/list'); + assert.equal(refused.status,503);assert.equal(refused.body.err,'unreachable'); + }finally{await h.close();} +}); + +test('continuous harmless maintenance permits a fresh quorum-confirmed directory read',{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}); + try{ + await h.start();const c=h.client(); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'form-cluster'}); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'interrupt-next-read',records:'always'}); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200,'healthy quorum must remain readable during ordinary member maintenance'); + }finally{await h.close();} +}); + +test('interrupted upload authorization retries without repeating blob I/O',{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}); + try{ + await h.start();const c=h.client(); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'form-cluster'}); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'interrupt-next-read'}); + const frame=encode('0'.repeat(32),[{rid:'a'.repeat(64),bytes:new TextEncoder().encode('AGEO\x01sealed')}]); + assert.equal((await h.call(c,'POST','/v1/store/frames',frame)).status,200); + const stats=await h.call(c,'GET','/v1/store/stats');assert.equal(stats.status,200);assert.equal(stats.body.puts,1); + }finally{await h.close();} +}); + +test('serving identity retries anchors removed after a temporary peer RPC failure',{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}); + try{ + await h.start();const c=h.client(); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'form-cluster'}); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.control({op:'transient-peer-failure',records:true}); + assert.equal((await h.call(c,'POST','/v1/dir/cells/pending',{head:'pending-head',class:'work',size:7,keys:{}})).status,503); + await h.control({op:'transient-peer-failure',records:false}); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200,'healthy anchors must recover without evicting a serving coordinator'); + }finally{await h.close();} +}); + +test('a revoke after selecting a row is enforced before a fresh-authority response finishes',{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),b=h.client(); + try{ + await h.start(); + for(const c of [a,b])assert.equal((await h.call(c,'PUT','/v1/dir/devices/'+c.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + assert.equal((await h.call(a,'POST','/v1/dir/cells/retained',{head:'retained-head',class:'work',size:7,keys:{}})).status,200); + await h.control({op:'form-cluster'}); + assert.equal((await h.call(b,'GET','/v1/dir/cells/retained')).status,200); + const cluster=JSON.parse((await h.control({op:'source'})).gate).config.cluster; + const fields={cluster,actor:a.device,session:'fresh-read-revoke',sequence:'1',change:{revoke:{device:b.device}}}; + const command={schema:1,...fields,certificate:Buffer.from(JSON.stringify(a.cert)).toString('base64url'),request_digest:createHash('sha256').update(encodeCanonical(fields)).digest('hex'),now:Date.now(),signature:null}; + command.signature=sign(null,Buffer.from('codeaf.directory.command.v1\0'+encodeCanonical(command)),a.key.privateKey).toString('base64'); + await h.control({op:'interrupt-next-read',records:{command:Buffer.from(encodeCanonical(command)).toString('base64'),after:4}}); + assert.deepEqual(await h.call(b,'GET','/v1/dir/cells/retained'),{status:401,body:{err:'revoked'}}); + }finally{await h.close();} +}); + +test('a revoke during fresh authorization cannot admit a new watch',{timeout:60000},async()=>{ + const h=await hostedFixture({fixture:true}),a=h.client(),b=h.client(); + try{ + await h.start(); + for(const c of [a,b])assert.equal((await h.call(c,'PUT','/v1/dir/devices/'+c.device,{V:1,name:'c2VhbGVk',platform:'linux'})).status,204); + assert.equal((await h.call(a,'POST','/v1/dir/cells/retained',{head:'retained-head',class:'work',size:7,keys:{}})).status,200); + await h.control({op:'form-cluster'}); + assert.equal((await h.call(b,'GET','/v1/dir/cells/retained')).status,200); + const cluster=JSON.parse((await h.control({op:'source'})).gate).config.cluster; + const fields={cluster,actor:a.device,session:'fresh-read-revoke',sequence:'1',change:{revoke:{device:b.device}}}; + const command={schema:1,...fields,certificate:Buffer.from(JSON.stringify(a.cert)).toString('base64url'),request_digest:createHash('sha256').update(encodeCanonical(fields)).digest('hex'),now:Date.now(),signature:null}; + command.signature=sign(null,Buffer.from('codeaf.directory.command.v1\0'+encodeCanonical(command)),a.key.privateKey).toString('base64'); + await h.control({op:'interrupt-next-read',records:{command:Buffer.from(encodeCanonical(command)).toString('base64'),after:3}}); + const response=await h.request(b,'GET','/v1/dir/watch',undefined,{headers:{Upgrade:'websocket'}}); + assert.equal(response.status,401); + assert.deepEqual(await response.json(),{err:'revoked'}); + }finally{await h.close();} +}); diff --git a/relay/hosted/test/raft/hosted-resources.test.js b/relay/hosted/test/raft/hosted-resources.test.js index 90de29a838..a0739a14a0 100644 --- a/relay/hosted/test/raft/hosted-resources.test.js +++ b/relay/hosted/test/raft/hosted-resources.test.js @@ -26,6 +26,7 @@ test('H4 R27 public Worker stays within isolate and ordinary-turn CPU budgets',{ try{ await h.start();inspector=await inspect(h.mf); const begin=performance.now();assert.equal((await h.call(a,'POST','/v1/dir/cells/chat',{head:'saved',class:'work',size:0,keys:{}})).status,200);report.coldMs=performance.now()-begin; + await h.control({op:'form-cluster'}); await h.control({op:'reset-metrics'});const before=await sample();report.samples.push(before); for(let i=0;i<24;i++){ const start=performance.now(),r=await h.call(a,'GET','/v1/dir/cells/chat');assert.equal(r.status,200);report.calls.push(performance.now()-start); diff --git a/relay/hosted/test/raft/hosted-worker.js b/relay/hosted/test/raft/hosted-worker.js index ce6ca32bc4..4e84a45917 100644 --- a/relay/hosted/test/raft/hosted-worker.js +++ b/relay/hosted/test/raft/hosted-worker.js @@ -11,7 +11,7 @@ import {CORE_MEMORY_BUDGET} from '../../src/raft/abi.js'; import { runtimeMetrics,sharedRuntime } from '../../src/raft/actor.js'; import {coordinationPolicy} from '../../src/raft/liveness.js'; const recoveryHistory=new Map(); -const measurements={turns:0,wall:[],membership:0,campaigns:0,http:0,peerIn:0,peerOut:0,sweeps:0,writes:0}; +const measurements={turns:0,wall:[],membership:0,campaigns:0,http:0,peerIn:0,peerOut:0,sweeps:0,writes:0,presenceCommits:0,proposals:0,barriers:0,authorizations:0,idleReads:0,peerFailures:0}; async function measured(fn){const start=performance.now();measurements.turns++;try{return await fn();}finally{measurements.wall.push(performance.now()-start);}} import { policyOf } from '../../src/rules.js'; @@ -19,6 +19,14 @@ export class IdentityDO extends Source { constructor(ctx,env){ Date.now();if(globalThis.fixtureClockOffset){const setAlarm=ctx.storage.setAlarm.bind(ctx.storage);ctx.storage.setAlarm=at=>setAlarm(Number(at)-globalThis.fixtureClockOffset);} super(ctx,env); + let host; + Object.defineProperty(this,'host',{get:()=>host,set:value=>{ + host=value;if(!host)return; + const commit=host.commit.bind(host);host.commit=raw=>{this.lastCommand=raw.slice();return commit(raw);}; + const effect=host.commitEffect.bind(host);host.commitEffect=(change,...args)=>{if(change.device_seen)measurements.presenceCommits++;return effect(change,...args);}; + const input=host.input.bind(host);host.input=(node,tag,...args)=>{if(tag===15)measurements.idleReads++;if(tag===12)measurements.peerFailures++;return input(node,tag,...args);}; + for(const [method,counter]of [['commit','proposals'],['barrier','barriers'],['authorize','authorizations']]){const original=host[method].bind(host);host[method]=(...args)=>{measurements[counter]++;return original(...args);};} + }}); const accept=ctx.acceptWebSocket.bind(ctx); ctx.acceptWebSocket=(ws,tags)=>{accept(ws,tags);const send=ws.send.bind(ws);ws.send=raw=>{if(ws.deserializeAttachment()?.clusterPeer)measurements.peerOut++;return send(raw);};}; for(const key of ['put','delete']){const call=ctx.storage[key].bind(ctx.storage);ctx.storage[key]=(...args)=>{measurements.writes++;return call(...args);};} @@ -26,7 +34,81 @@ export class IdentityDO extends Source { const exec=ctx.storage.sql.exec.bind(ctx.storage.sql); ctx.storage.sql.exec=(query,...args)=>{if(!/^\s*(SELECT|PRAGMA)/i.test(query))measurements.writes++;return exec(query,...args);}; } + interruptNextRead(mode){ + this.stopReadInterruption?.(); + if(mode===false)return true; + const host=this.current.host;let pending=true,views=0; + this.stopReadInterruption=()=>{pending=false;}; + for(const [id,member] of host.members){ + host.members.set(id,new Proxy(member,{get(target,name){ + const call=Reflect.get(target,name,target); + if(name==='hostView')return async(...args)=>{ + const result=await Reflect.apply(call,target,args); + if(pending){ + pending=false;views++;const leader=host.leader()??host.replica(); + try{ + await host.members.get(leader).scheduled(); + if(mode==='advance')await host.commitEffect({device_seen:{device:host.seen.keys().next().value}}); + if(mode?.command&&views>=mode.after)await host.commit(unb64(mode.command)); + if(mode==='no-quorum'){for(const node of [...host.members.keys()])if(node!==leader){host.members.delete(node);host.states.delete(node);}host.queue=[];} + }finally{pending=mode==='always'||mode==='advance'||!!mode?.command&&viewsReflect.apply(call,target,args):call; + }})); + } + return true; + } + transientPeerFailure(blocked){ + this.blockedPeerCalls=blocked; + if(!blocked)return true; + const host=this.current.host,self=this,leader=host.leader(); + for(const [id,member]of host.members){ + if(id===leader)continue; + host.members.set(id,new Proxy(member,{get(target,name){ + const call=Reflect.get(target,name,target); + if(name==='peer')return async(...args)=>{ + if(self.blockedPeerCalls)throw new Error('fixture: temporary peer RPC failure'); + return Reflect.apply(call,target,args); + }; + return typeof call==='function'?(...args)=>Reflect.apply(call,target,args):call; + }})); + } + return true; + } + async transientCapacity(blocked){ + this.blockedAnchors=blocked; + if(!this.transientEnvironment){ + const cfg=await this.ctx.storage.get('raft-provision'),ns=this.env.RAFT_MEMBERS,self=this; + const missing=new Set(cfg.config.anchors.slice(1).map(node=>ns.idFromName(cfg.config.cluster+'/'+node).toString())); + this.transientEnvironment={...this.env,RAFT_MEMBERS:{idFromName:name=>ns.idFromName(name),get:id=>{ + const stub=ns.get(id);return new Proxy(stub,{get(target,name){ + if(name==='initialize'&&missing.has(id.toString())&&self.blockedAnchors)return async()=>({capacity:true}); + const value=Reflect.get(target,name,target);return typeof value==='function'?async(...args)=>Reflect.apply(value,target,args):value; + }}); + }}}; + this.rebuilt=new Source(this.ctx,this.transientEnvironment); + } + return true; + } get current(){return this.rebuilt??this;} + async directCommit(raw){return this.host.operation(async()=>(await this.host.commit(unb64(raw))).value.code);} + async formed(){ + if(!this.host?.forming)return; + await this.host.operation(async()=>{ + for(let turn=0;turn<16&&this.host.forming;turn++)await this.host.maintenance(Date.now(),Math.floor(performance.now())); + if(this.host.forming){const id=this.host.replica(),view=await this.host.members.get(id).hostView(this.host.states.get(id).applied,'state','identity'),conf=this.host.configuration(view.app.conf);if(conf.voters.length!==3||conf.outgoing?.length)throw new Error('fixture: three-voter formation did not complete');} + await this.host.readAuthority(); + }); + } + async formationTurn(){ + return this.host.operation(async()=>{ + let error;try{await this.host.maintenance(Date.now(),Math.floor(performance.now()));}catch(e){error=e.message;} + const id=this.host.replica(),view=await this.host.members.get(id).hostView(this.host.states.get(id).applied,'membership','registry'); + return {conf:this.host.configuration(view.app.conf),registry:!!view.raw,forming:this.host.forming,error:error??null}; + }); + } async evictIdle(){ const old=this.current.host; if(old){old.ready=false;old.members.clear();old.states.clear();old.queue=[];old.reads=[];old.peers=null;old.holds=null;} @@ -69,11 +151,7 @@ export class IdentityDO extends Source { webSocketClose(ws,code){return this.rebuilt?this.rebuilt.webSocketClose(ws,code):super.webSocketClose(ws,code);} webSocketError(ws){return this.rebuilt?this.rebuilt.webSocketError(ws):super.webSocketError(ws);} async fetch(request){ - const result=await measured(()=>this.rebuilt?this.rebuilt.fetch(request):super.fetch(request)); - if(this.host&&!this.capturing){ - const commit=this.host.commit.bind(this.host);this.host.commit=async raw=>{this.lastCommand=raw.slice();return commit(raw);};this.capturing=true; - } - return result; + return measured(()=>this.rebuilt?this.rebuilt.fetch(request):super.fetch(request)); } async sequenceProof(){return this.host.operation(async()=>{ const from=this.host.manifest.config.anchors[0],to=this.host.manifest.config.anchors[1],term=Number(this.host.states.get(this.host.leader()).term); @@ -93,18 +171,36 @@ export class IdentityDO extends Source { return this.host.members.get(id).transportCheckpoint(); } holdUpload(){ - const put=this.host.tenant.store.put.bind(this.host.tenant.store); + const put=this.host.tenant.store.put.bind(this.host.tenant.store),has=this.host.tenant.has.bind(this.host.tenant); + this.host.tenant.has=async(...args)=>{this.hasWaiting=true;try{return await has(...args);}finally{this.hasWaiting=false;}}; this.pauseUpload=new Promise(resolve=>{this.releaseUpload=resolve;}); this.host.tenant.store.put=async(...args)=>{this.uploadStarted=true;await this.pauseUpload;return put(...args);}; } + startedUpload(){return !!this.uploadStarted;} + waitingHas(){return !!this.hasWaiting;} releaseHeldUpload(){this.releaseUpload?.();} async retryWithoutQuorum(){ + if(!(this.lastCommand instanceof Uint8Array))throw new Error('fixture: no committed command to replay'); + const original=this.lastCommand.slice();await this.formed(); return this.host.operation(async()=>{ const leader=this.host.leader();for(const id of [...this.host.members.keys()])if(id!==leader)this.host.members.delete(id); this.host.queue=[]; - try{await this.host.commit(this.lastCommand);return 'acknowledged';}catch{return 'unavailable';} + try{await this.host.commit(original);return 'acknowledged';}catch{return 'unavailable';} }); } + async expireReadQuorum(){ + await this.formed(); + const host=this.host,leader=host.leader(); + if(!host.readLease)throw new Error('fixture: expected cached authority'); + host.readLease.until=performance.now()-1; + for(const id of [...host.members.keys()])if(id!==leader)host.members.delete(id); + host.queue=[];return true; + } + async replaceReadMember(){ + const lease=this.host.readLease;if(!lease)throw new Error('fixture: expected cached authority'); + return this.env.RAFT_MEMBERS.get(this.env.RAFT_MEMBERS.idFromName(this.host.manifest.config.cluster+'/'+lease.node)).replaceActivation(); + } + invalidateReadAuthority(){this.host?.invalidateRead();} async seed(identity,records) { const dir=new Directory(this.ctx.storage.sql,identity,Date.now,policyOf()); for(const r of records){ @@ -114,6 +210,7 @@ export class IdentityDO extends Source { await this.ctx.storage.put({identity,admitted:true});await this.ctx.storage.sync(); } async replayStale() { + await this.formed(); return this.host.operation(async()=>{ const from=this.host.leader(),to=this.host.manifest.config.anchors.find(id=>id!==from); const member=this.host.member(to),snapshot=await member.oldSnapshotAndCheckpoint(); @@ -187,7 +284,7 @@ export class RaftMemberDO extends Member { await this.load();const old=this.actor,before=old.store.application().applied; const restored=new Member(this.ctx,this.env);await restored.load(); let refused=false;try{await old.input(1);}catch{refused=true;} - for(const key of ['actor','config','registry','results','resultBytes','recoveredEffects'])this[key]=restored[key]; + for(const key of ['actor','config','registry','results','resultBytes','recoveredEffects','readActivation'])this[key]=restored[key]; return {before,after:this.actor.store.application().applied,refused,oldHandle:old.handle,active:runtimeMetrics().active}; } async retentionState(op){ @@ -245,10 +342,17 @@ export default { if(op==='leadership-ticks')return new Response(encodeCanonical(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).leadershipTicks(ticks,missing))); if(op==='stale-retirement')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).staleRetirement()); if(op==='diagnostic'){console.warn('local runtime warning capture');console.error('local runtime error capture');return Response.json(true);} + if(op==='shift-clock'){globalThis.fixtureClockOffset+=records;return Response.json(true);} if(op==='evict-minority')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).evictMinority()); if(op==='presence-schedule')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).presenceSchedule()); if(op==='retention-state')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).retentionState(records)); + if(op==='direct-commit')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).directCommit(records)); + if(op==='form-cluster'){await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).formed();return Response.json(true);} + if(op==='formation-turn')return new Response(encodeCanonical(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).formationTurn())); if(op==='quiet-state')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).quietState()); + if(op==='interrupt-next-read')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).interruptNextRead(records)); + if(op==='transient-peer-failure')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).transientPeerFailure(records)); + if(op==='transient-capacity')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).transientCapacity(records)); if(op==='evict-idle')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).evictIdle()); if(op==='presence-turn')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).presenceTurn()??true); if(op==='member-alarm')return Response.json(await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).alarmState()); @@ -264,16 +368,24 @@ export default { if(op==='reset-metrics'){for(const key of Object.keys(measurements))measurements[key]=key==='wall'?[]:0;return Response.json(true);} if(op==='seed'){await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).seed(identity,records);return Response.json(true);} if(op==='hold-upload'){await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).holdUpload();return Response.json(true);} + if(op==='upload-started')return Response.json(!!await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).startedUpload()); + if(op==='has-waiting')return Response.json(!!await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).waitingHas()); if(op==='release-upload'){await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).releaseHeldUpload();return Response.json(true);} if(op==='retry-without-quorum')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).retryWithoutQuorum()); + if(op==='expire-read-quorum')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).expireReadQuorum()); + if(op==='replace-read-member')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).replaceReadMember()); if(op==='stale-snapshot')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).replayStale()); if(op==='idle-schedule')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).idleSchedule()); if(op==='source')return Response.json(await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).inspect()); - if(op==='seed-transport-tail'){await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).seedTransportTail();return Response.json(true);} + if(op==='seed-transport-tail'){await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).formed();await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).seedTransportTail();return Response.json(true);} if(op==='retained-control')return Response.json(await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).retainedControl()); - if(op==='discard-member'){await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).discardStore();return Response.json(true);} + if(op==='discard-unformed-member'){await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).discardStore();return Response.json(true);} + if(op==='discard-member'){await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).formed();await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).discardStore();return Response.json(true);} if(op==='has-member')return Response.json(await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).hasStore()); - if(op==='replace-activation')return Response.json(await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).replaceActivation()); + if(op==='replace-activation'){ + await env.IDENTITY.get(env.IDENTITY.idFromName(identity)).invalidateReadAuthority(); + return Response.json(await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).replaceActivation()); + } if(op==='staging')return Response.json(await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).staging()); if(op==='member')return Response.json(await env.RAFT_MEMBERS.get(env.RAFT_MEMBERS.idFromName(cluster+'/'+node)).inspect()); return new Response(null,{status:404}); diff --git a/relay/hosted/test/raft/v1-large.test.js b/relay/hosted/test/raft/v1-large.test.js index 93c8d9f49f..03ef226737 100644 --- a/relay/hosted/test/raft/v1-large.test.js +++ b/relay/hosted/test/raft/v1-large.test.js @@ -22,6 +22,7 @@ test('R25 archived requests above 64 KiB and just under 1 MiB commit in workerd' try{ await h.start(); for(const c of fixture.cases)await exact(h,c); + await h.control({op:'form-cluster'}); const before=await h.control({op:'metrics'});console.log(JSON.stringify({peakCoreBytes:before.peakMemory,activeHandles:before.active,reservedBytes:before.reservedBytes})); assert.equal(before.active,3,'cold object recreation leaked live handles'); const cluster=JSON.parse((await h.control({op:'source'})).gate).config.cluster; @@ -45,6 +46,7 @@ test('R25 interrupted workerd staging stays atomic and bounded after restart',{t const failed=await send(h,c);assert(failed.status>=500,`interruption returned ${failed.status}`);await failed.text(); await h.start(); const absent=await send(h,c,true);assert.equal(absent.status,404);await absent.text(); + await h.control({op:'form-cluster'}); const cluster=JSON.parse((await h.control({op:'source'})).gate).config.cluster; for(const node of ['1','2','3']){const state=await h.control({op:'staging',cluster,node});assert(state.rows.n>0);assert(state.rows.n<=33);assert(state.rows.bytes<=1048576+65536);assert.equal(state.app.version,'0');} if(attempt===0)await h.start(cut); @@ -95,6 +97,7 @@ test('R25 maximum legacy title escaping remains lossless in workerd',{timeout:60 } await h.start();const headers={...c.headers};headers['Codeaf-Sig']=sign(null,Buffer.from(`codeaf-req-v1\nGET\n${c.uri}\n${headers['Codeaf-Time']}\n${sha('')}`),key).toString('base64url'); const read=await h.mf.dispatchFetch('http://local'+c.uri,{headers});assert.equal(read.status,200,(await read.clone().text()).slice(0,200));assert.equal((await read.json()).cell.title,title); + await h.control({op:'form-cluster'}); const metrics=await h.control({op:'metrics'});assert.equal(metrics.active,3);assert(metrics.peakMemory<=metrics.coreBudget,JSON.stringify(metrics));console.log(JSON.stringify({escapedTitleCoreBytes:metrics.peakMemory,activeHandles:metrics.active})); }finally{await h.close();} }); @@ -113,6 +116,7 @@ test('R25 small legacy resumes preserve large retained frame plans in workerd',{ const response=await send(h,request);assert.equal(response.status,200,(await response.clone().text()).slice(0,200)); const view=await response.json();assert.equal(view.cell.lease.fence,fence);assert.deepEqual(view.cell.frames,JSON.parse(c.body).frames); } + await h.control({op:'form-cluster'}); const metrics=await h.control({op:'metrics'});console.log(JSON.stringify({resumedFrameCoreBytes:metrics.peakMemory,activeHandles:metrics.active}));assert.equal(metrics.active,3);assert(metrics.peakMemory<=metrics.coreBudget); }finally{await h.close();} }); diff --git a/relay/hosted/test/watch.e2e.mjs b/relay/hosted/test/watch.e2e.mjs index 699ac39eed..4ff7249869 100644 --- a/relay/hosted/test/watch.e2e.mjs +++ b/relay/hosted/test/watch.e2e.mjs @@ -6,6 +6,7 @@ // maxWatchers 5, named by RELAY_WATCH_CAP; the restart case talks to RELAY_PERSIST. import assert from 'node:assert/strict'; import http from 'node:http'; +import WebSocket from 'ws'; import { readFileSync, writeFileSync } from 'node:fs'; import { newIdentity, newDevice, signed, deviceId } from './party.js'; import { init } from './helpers.js'; @@ -20,7 +21,7 @@ const record = { V: 1, name: '', added_by: '', revoked: false, caps: { os: 'linu /** openWatch dials the watch route signed by dev and answers a socket that remembers every text frame. */ async function openWatch(dev, base = BASE, query = '') { const headers = await signed(dev, 'GET', `/v1/dir/watch${query}`); - const ws = new WebSocket(base.replace(/^http/, 'ws') + `/v1/dir/watch${query}`, { headers }); + const ws = new WebSocket(base.replace(/^http/, 'ws') + `/v1/dir/watch${query}`, { headers, closeTimeout: 200 }); const w = { frames: [], cursor: 0, ws, send: (text) => ws.send(text), close: () => ws.close(), wake: () => {} }; w.closed = new Promise((done) => ws.addEventListener('close', (e) => done({ code: e.code, reason: e.reason }))); ws.addEventListener('message', (e) => (w.frames.push(e.data), w.wake())); @@ -137,7 +138,7 @@ const cases = { assert.match(await next(wc), WIRE); await next(wc); assert.deepEqual(await event(wa), { t: 'presence', device: idc, online: true }); - assert.equal((await call(a, 'POST', `/v1/dir/devices/${idc}/revoke`, {})).status, 204); + assert.equal((await call(a, 'POST', `/v1/dir/devices/${idc}/revoke`)).status, 204); assert.match(await next(wa), WIRE); assert.deepEqual(await event(wa), { t: 'revoked', device: idc }); assert.deepEqual((await wc.closed).code, 4401); @@ -207,7 +208,7 @@ const cases = { ['acquire', () => call(a, 'POST', '/v1/dir/cells/c1/acquire', {})], ['publish', () => call(a, 'POST', '/v1/dir/cells/c1/publish', pub(2, 'h1', 'h0'))], ['release', () => call(a, 'POST', '/v1/dir/cells/c1/release', { fence: 2 })], - ['archive', () => call(a, 'POST', '/v1/dir/cells/c1/archive', {})], + ['archive', () => call(a, 'POST', '/v1/dir/cells/c1/archive')], ['device put', async () => call(a, 'PUT', `/v1/dir/devices/${await deviceId(a)}`, { ...record, name: 'renamed' })], ['vault', () => call(a, 'POST', '/v1/dir/vault', { old: '', new: 'x' })], ]; @@ -299,9 +300,9 @@ const cases = { async holdQuery() { const [a] = await party(); const plain = await dialled(a, ''); - assert.deepEqual([plain.status, plain.headers['codeaf-vouch']], [101, '1'], 'a relay that vouches says so on every upgrade'); + assert.deepEqual([plain.status, plain.headers['codeaf-presence'], plain.headers['codeaf-vouch']], [101, '1', undefined], 'presence is advertised while lease authority stays with committed holds'); const held = await dialled(a, '?hold=c1:1&hold=nowhere:9&hold=c1:1'); - assert.deepEqual([held.status, held.headers['codeaf-vouch']], [101, '1'], 'holds of cells that do not exist are accepted'); + assert.deepEqual([held.status, held.headers['codeaf-presence'], held.headers['codeaf-vouch']], [101, '1', undefined], 'holds of cells that do not exist are accepted without claiming socket lease authority'); const sixteen = Array.from({ length: 16 }, (_, i) => `hold=c${i}:1`).join('&'); assert.equal((await dialled(a, `?${sixteen}`)).status, 101); const refusals = ['?hold=c1', '?hold=:1', '?hold=c1:', '?hold=c1:x', '?hold=c1:-1', `?hold=${'c'.repeat(65)}:1`, `?${sixteen}&hold=c16:1`]; @@ -406,6 +407,10 @@ async function lease() { } const [mode, file] = process.argv.slice(2); +if (mode === '--list') { + console.log(Object.keys(cases).join('\n')); + process.exit(0); +} if (mode === 'idle') await idle(); else if (mode === 'lease') await lease(); else if (mode === 'put') await putState(file); diff --git a/scripts/relay-wrangler-config.sh b/scripts/relay-wrangler-config.sh index 01cf601879..fb1326bd0a 100644 --- a/scripts/relay-wrangler-config.sh +++ b/scripts/relay-wrangler-config.sh @@ -19,6 +19,7 @@ case "$channel" in worker_name="caf-fabric" bucket_name="caf-fabric-frames" base_path="/fabric" + caf_limits='{"globalNewIdentitiesPerDay":10000,"globalStoreBytes":1099511627776}' rate_offset=0 ;; staging) @@ -26,6 +27,7 @@ case "$channel" in worker_name="caf-fabric-staging" bucket_name="caf-fabric-staging-frames" base_path="" + caf_limits="" rate_offset=100 ;; dev) @@ -33,6 +35,7 @@ case "$channel" in worker_name="caf-fabric-dev" bucket_name="caf-fabric-dev-frames" base_path="" + caf_limits="" rate_offset=200 ;; *) @@ -67,9 +70,20 @@ awk \ -v worker_name="$worker_name" \ -v bucket_name="$bucket_name" \ -v base_path="$base_path" \ + -v caf_limits="$caf_limits" \ -v rate_offset="$rate_offset" \ -v account_id="$account_id" \ ' +function emit_vars_tail() { + if (!emitted_base_path) { + printf "BASE_PATH = \"%s\"\n", base_path + emitted_base_path = 1 + } + if (caf_limits != "" && !emitted_caf_limits) { + printf "CAF_LIMITS = \x27%s\x27\n", caf_limits + emitted_caf_limits = 1 + } +} BEGIN { section = "" saw_name = 0 @@ -77,12 +91,12 @@ BEGIN { saw_bucket = 0 saw_vars = 0 emitted_base_path = 0 + emitted_caf_limits = 0 ratelimits = 0 } /^[[:space:]]*\[/ { - if (section == "[vars]" && !emitted_base_path) { - printf "BASE_PATH = \"%s\"\n", base_path - emitted_base_path = 1 + if (section == "[vars]") { + emit_vars_tail() } section = $0 sub(/^[[:space:]]+/, "", section) @@ -126,6 +140,13 @@ section == "[vars]" && /^BASE_PATH[[:space:]]*=/ { emitted_base_path = 1 next } +section == "[vars]" && /^CAF_LIMITS[[:space:]]*=/ { + if (caf_limits != "") { + printf "CAF_LIMITS = \x27%s\x27\n", caf_limits + emitted_caf_limits = 1 + } + next +} section == "[[ratelimits]]" && /^namespace_id[[:space:]]*=/ { val = $0 sub(/^[^"]*"/, "", val) @@ -142,9 +163,8 @@ section == "[[ratelimits]]" && /^namespace_id[[:space:]]*=/ { print $0 } END { - if (section == "[vars]" && !emitted_base_path) { - printf "BASE_PATH = \"%s\"\n", base_path - emitted_base_path = 1 + if (section == "[vars]") { + emit_vars_tail() } if (!saw_name || !saw_compat || !saw_bucket || !saw_vars || ratelimits < 2) { printf "incomplete base wrangler.toml (name=%d compat=%d bucket=%d vars=%d ratelimits=%d)\n", \ diff --git a/scripts/relay-wrangler-config_test.sh b/scripts/relay-wrangler-config_test.sh index 3468d6bf35..d93e126272 100644 --- a/scripts/relay-wrangler-config_test.sh +++ b/scripts/relay-wrangler-config_test.sh @@ -48,7 +48,7 @@ assert_not_contains() { fi } -# Production shape: routes, BASE_PATH = "/fabric", workers_dev = false, observability, 1001/1002. +# Production shape: routes, BASE_PATH = "/fabric", CAF_LIMITS, workers_dev = false, observability, 1001/1002. assert_contains "$tmp/prod.toml" 'name = "caf-fabric"' assert_contains "$tmp/prod.toml" 'account_id = "acct_0123456789abcdef"' assert_contains "$tmp/prod.toml" 'workers_dev = false' @@ -56,6 +56,7 @@ assert_contains "$tmp/prod.toml" '{ pattern = "codeaf.agentfield.ai/fabric*", zo assert_contains "$tmp/prod.toml" '{ pattern = "codeaf.agentfield.ai/p/*", zone_name = "agentfield.ai" }' assert_contains "$tmp/prod.toml" 'bucket_name = "caf-fabric-frames"' assert_contains "$tmp/prod.toml" 'BASE_PATH = "/fabric"' +assert_contains "$tmp/prod.toml" 'CAF_LIMITS = '\''{"globalNewIdentitiesPerDay":10000,"globalStoreBytes":1099511627776}'\''' assert_contains "$tmp/prod.toml" '[observability]' assert_contains "$tmp/prod.toml" 'preview_urls = false' assert_not_contains "$tmp/prod.toml" '[observability.logs]' @@ -65,24 +66,26 @@ assert_contains "$tmp/prod.toml" 'namespace_id = "1001"' assert_contains "$tmp/prod.toml" 'namespace_id = "1002"' assert_not_contains "$tmp/prod.toml" 'workers_dev = true' -# Staging shape: workers_dev = true, no routes, empty BASE_PATH, +100 rate-limit ids. +# Staging shape: workers_dev = true, no routes, empty BASE_PATH, no CAF_LIMITS, +100 rate-limit ids. assert_contains "$tmp/staging.toml" 'name = "caf-fabric-staging"' assert_contains "$tmp/staging.toml" 'account_id = "acct_0123456789abcdef"' assert_contains "$tmp/staging.toml" 'workers_dev = true' assert_contains "$tmp/staging.toml" 'bucket_name = "caf-fabric-staging-frames"' assert_contains "$tmp/staging.toml" 'BASE_PATH = ""' +assert_not_contains "$tmp/staging.toml" 'CAF_LIMITS' assert_contains "$tmp/staging.toml" '[observability]' assert_contains "$tmp/staging.toml" 'namespace_id = "1101"' assert_contains "$tmp/staging.toml" 'namespace_id = "1102"' assert_not_contains "$tmp/staging.toml" 'routes =' assert_not_contains "$tmp/staging.toml" 'zone_name' -# Dev shape: workers_dev = true, no routes, empty BASE_PATH, +200 rate-limit ids. +# Dev shape: workers_dev = true, no routes, empty BASE_PATH, no CAF_LIMITS, +200 rate-limit ids. assert_contains "$tmp/dev.toml" 'name = "caf-fabric-dev"' assert_contains "$tmp/dev.toml" 'account_id = "acct_0123456789abcdef"' assert_contains "$tmp/dev.toml" 'workers_dev = true' assert_contains "$tmp/dev.toml" 'bucket_name = "caf-fabric-dev-frames"' assert_contains "$tmp/dev.toml" 'BASE_PATH = ""' +assert_not_contains "$tmp/dev.toml" 'CAF_LIMITS' assert_contains "$tmp/dev.toml" '[observability]' assert_contains "$tmp/dev.toml" 'namespace_id = "1201"' assert_contains "$tmp/dev.toml" 'namespace_id = "1202"' @@ -131,6 +134,7 @@ assert.equal(prod.name, 'caf-fabric'); assert.equal(prod.account_id, 'acct_0123456789abcdef'); assert.equal(prod.workers_dev, false); assert.equal(prod.vars.BASE_PATH, '/fabric'); +assert.equal(prod.vars.CAF_LIMITS, '{"globalNewIdentitiesPerDay":10000,"globalStoreBytes":1099511627776}'); assert.equal(prod.vars.LEASE_POLICY, 'amended'); assert.equal(prod.r2_buckets[0].bucket_name, 'caf-fabric-frames'); assert.equal(prod.routes.length, 2); @@ -147,6 +151,7 @@ assert.equal(staging.name, 'caf-fabric-staging'); assert.equal(staging.workers_dev, true); assert.equal(staging.routes, undefined); assert.equal(staging.vars.BASE_PATH, ''); +assert.equal(staging.vars.CAF_LIMITS, undefined); assert.equal(staging.r2_buckets[0].bucket_name, 'caf-fabric-staging-frames'); assert.deepEqual(staging.ratelimits.map((r) => r.namespace_id), ['1101', '1102']); @@ -155,6 +160,7 @@ assert.equal(dev.name, 'caf-fabric-dev'); assert.equal(dev.workers_dev, true); assert.equal(dev.routes, undefined); assert.equal(dev.vars.BASE_PATH, ''); +assert.equal(dev.vars.CAF_LIMITS, undefined); assert.equal(dev.r2_buckets[0].bucket_name, 'caf-fabric-dev-frames'); assert.deepEqual(dev.ratelimits.map((r) => r.namespace_id), ['1201', '1202']); JS diff --git a/scripts/release_downloads.py b/scripts/release_downloads.py deleted file mode 100644 index 7e96c2a7f3..0000000000 --- a/scripts/release_downloads.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 -"""Report release download counts to PostHog. - -The numbers come from public GitHub release data: the GitHub API exposes a -running download count for every asset on a release, and this script reads -that count and sends one PostHog event per binary asset so the download -trend is visible next to the other codeaf telemetry. It runs daily from -.github/workflows/release-downloads.yml, and runs locally too: - - python3 scripts/release_downloads.py --dry-run - -Nothing is inferred and nothing about the requester is known: an asset's -download count is public, the event carries the release tag, the asset's -platform, and that count. Only assets named codeaf--[.exe] are -reported; sidecars like checksums.txt are not releases of the binary. - -An asset is reported once per day even if the run repeats: the event's id is -derived from the tag, the asset and the UTC date, so PostHog's event -deduplication makes a same-day rerun land on the first copy instead of -counting twice. -""" - -import argparse -import datetime -import json -import os -import re -import sys -import urllib.request -import uuid - -POSTHOG_EVENT = "codeaf:release_downloads" -DISTINCT_ID = "codeaf-release-downloads" -DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" -GITHUB_API = "https://api.github.com" -NAMESPACE = uuid.NAMESPACE_URL -# codeaf--[.exe]. os/arch follow the release.yml build matrix, so -# anything outside this set is a file this script has no opinion about. -ASSET_RE = re.compile( - r"^codeaf-(?Pdarwin|linux|windows)-" - r"(?Pamd64|arm64)(?P\.exe)?$" -) -# Tag grammar, matching cmd/codeaf-release/version.go exactly: stable and -# rc tags are vMAJOR.MINOR.PATCH (rc with an -rc.N counter), and the dev and -# staging channels are date-and-commit tags. Anything else is no channel this -# script can name, so it is reported as "unknown". -STABLE_TAG_RE = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") -RC_TAG_RE = re.compile( - r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$" -) -DEV_TAG_RE = re.compile(r"^dev-[0-9]{8}-[0-9a-f]{12}$") -STAGING_TAG_RE = re.compile(r"^staging-[0-9]{8}-[0-9a-f]{12}$") -PROPS = ( - "release_tag", - "channel", - "prerelease", - "os", - "arch", - "download_count", - "snapshot_date", -) - - -def release_channel(tag: str) -> str: - """The channel of a tag: stable, rc, dev or staging, else "unknown".""" - if STABLE_TAG_RE.match(tag): - return "stable" - if RC_TAG_RE.match(tag): - return "rc" - if DEV_TAG_RE.match(tag): - return "dev" - if STAGING_TAG_RE.match(tag): - return "staging" - return "unknown" - - -def asset_os_arch(name: str): - """(os, arch) for an asset this script reports, else None.""" - match = ASSET_RE.match(name) - if not match: - return None - return match.group("os"), match.group("arch") - - -def event_uuid(tag: str, asset: str, snapshot_date: str) -> str: - """One id per release, asset and day, so a same-day rerun deduplicates.""" - return str( - uuid.uuid5( - NAMESPACE, f"{DISTINCT_ID}|{tag}|{asset}|{snapshot_date}" - ) - ) - - -def fetch_releases(repository: str, token: str): - """Every release of the repository, following GitHub's pagination.""" - releases = [] - page = 1 - while True: - request = urllib.request.Request( - f"{GITHUB_API}/repos/{repository}/releases" - f"?per_page=100&page={page}", - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "codeaf-release-downloads", - }, - ) - with urllib.request.urlopen(request, timeout=30) as response: - batch = json.load(response) - if not isinstance(batch, list): - raise ValueError(f"unexpected GitHub API response on page {page}") - releases.extend(batch) - if len(batch) < 100: - return releases - page += 1 - - -def build_events(releases, snapshot_date: str): - """One PostHog event per binary asset, with the properties it carries.""" - events = [] - for release in releases: - if release.get("draft"): - continue - tag = release["tag_name"] - for asset in release.get("assets") or []: - name = asset.get("name") or "" - platform = asset_os_arch(name) - if platform is None: - continue - os_name, arch = platform - events.append( - { - "event": POSTHOG_EVENT, - "uuid": event_uuid(tag, name, snapshot_date), - "distinct_id": DISTINCT_ID, - "properties": { - "release_tag": tag, - "channel": release_channel(tag), - "prerelease": bool(release.get("prerelease")), - "os": os_name, - "arch": arch, - # The cumulative total the GitHub API reports. - "download_count": asset["download_count"], - "snapshot_date": snapshot_date, - # The sender is a CI runner; its location is meaningless. - "$process_person_profile": False, - "$geoip_disable": True, - }, - } - ) - return events - - -def build_payload(events, project_key: str): - return {"api_key": project_key, "batch": events} - - -def send(payload, posthog_host: str) -> None: - body = json.dumps(payload).encode() - request = urllib.request.Request( - f"{posthog_host.rstrip('/')}/batch/", - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(request, timeout=30) as response: - response.read() - - -def main(argv=None) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument( - "--dry-run", - action="store_true", - help="print the batch JSON instead of sending it", - ) - parser.add_argument( - "--fixture", - metavar="PATH", - help="read the releases API response from a file instead of the network", - ) - parser.add_argument( - "--repository", - default=os.environ.get("GITHUB_REPOSITORY", "Agent-Field/codeaf"), - help="owner/name of the repository whose releases are read", - ) - args = parser.parse_args(argv) - - project_key = os.environ.get("CODEAF_POSTHOG_PROJECT_KEY", "") - if not project_key and not args.dry_run: - print( - "error: CODEAF_POSTHOG_PROJECT_KEY is not set; nothing sent", - file=sys.stderr, - ) - return 1 - # --dry-run is the local and test path: it must work without a key, so a - # missing key is printed as a placeholder rather than stopping the run. - if not project_key: - project_key = "" - - if args.fixture: - with open(args.fixture) as fixture: - releases = json.load(fixture) - else: - token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN", "") - if not token: - print( - "error: GITHUB_TOKEN is not set; nothing sent", - file=sys.stderr, - ) - return 1 - releases = fetch_releases(args.repository, token) - - snapshot_date = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y-%m-%d" - ) - events = build_events(releases, snapshot_date) - payload = build_payload(events, project_key) - - if args.dry_run: - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - send(payload, os.environ.get("POSTHOG_HOST") or DEFAULT_POSTHOG_HOST) - print(f"sent {len(events)} events to PostHog") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/test_release_downloads.py b/scripts/test_release_downloads.py deleted file mode 100644 index ae1df8f9e1..0000000000 --- a/scripts/test_release_downloads.py +++ /dev/null @@ -1,444 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scripts/release_downloads.py: dry-run mode against a recorded -fixture of the GitHub releases API, plus the identifiers the wire spec spells -out, the workflow that schedules the script, and the docs paragraph. -""" - -import ast -import datetime -import importlib.util -import json -import os -import pathlib -import re -import subprocess -import sys -import unittest -import uuid -from unittest import mock - -HERE = pathlib.Path(__file__).resolve().parent -SCRIPT = HERE / "release_downloads.py" -FIXTURE = HERE / "testdata" / "github_releases.json" -WORKFLOW = HERE.parent / ".github" / "workflows" / "release-downloads.yml" -DOCS = HERE.parent / "docs" - -_spec = importlib.util.spec_from_file_location("release_downloads", SCRIPT) -module = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(module) - - -class FakeResponse: - """Stand-in for urlopen's return: a context manager whose read() is JSON.""" - - def __init__(self, payload): - self._payload = ( - payload if isinstance(payload, bytes) else json.dumps(payload).encode() - ) - - def __enter__(self): - return self - - def __exit__(self, *exception): - return False - - def read(self): - return self._payload - - -try: - import yaml # type: ignore -except ImportError: # pragma: no cover - depends on the machine - yaml = None - - -EVENT_PROPERTIES = ( - "release_tag", - "channel", - "prerelease", - "os", - "arch", - "download_count", - "snapshot_date", -) - - -def run_script(*args, env_extra=None): - env = dict(os.environ) - env.pop("CODEAF_POSTHOG_PROJECT_KEY", None) - env.update(env_extra or {}) - return subprocess.run( - [sys.executable, str(SCRIPT), *args], - capture_output=True, - text=True, - env=env, - ) - - -def dry_run_payload(env_extra=None): - result = run_script( - "--dry-run", "--fixture", str(FIXTURE), env_extra=env_extra - ) - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -class ReleaseDownloadsTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.payload = dry_run_payload({"CODEAF_POSTHOG_PROJECT_KEY": "phx_test"}) - cls.batch = cls.payload["batch"] - - def test_one_event_per_binary_asset(self): - # 6 assets x 2 full releases plus one asset on each of the dev, staging - # and beta tags; checksums.txt and any other sidecar on the fixture is - # not a binary asset and gets no event. - self.assertEqual(len(self.batch), 15) - tags = {event["properties"]["release_tag"] for event in self.batch} - self.assertEqual( - tags, - { - "v1.2.0", - "v1.3.0-rc.1", - "dev-20260917-09299019bcdc", - "staging-20260916-ed5fe58eb546", - "v1.4.0-beta.1", - }, - ) - - def test_payload_shape(self): - self.assertEqual( - self.payload["api_key"], "phx_test" - ) - for event in self.batch: - self.assertEqual(event["event"], "codeaf:release_downloads") - self.assertEqual(event["distinct_id"], "codeaf-release-downloads") - - def test_event_properties(self): - for event in self.batch: - properties = event["properties"] - self.assertEqual( - sorted(properties), - sorted( - EVENT_PROPERTIES - + ("$process_person_profile", "$geoip_disable") - ), - ) - self.assertIs(properties["$process_person_profile"], False) - # The sender is a CI runner; its location is meaningless. - self.assertIs(properties["$geoip_disable"], True) - self.assertEqual( - properties["snapshot_date"], - datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d"), - ) - self.assertIsInstance(properties["prerelease"], bool) - self.assertIsInstance(properties["download_count"], int) - - def test_channel_and_prerelease(self): - channels = { - event["properties"]["release_tag"]: ( - event["properties"]["channel"], - event["properties"]["prerelease"], - ) - for event in self.batch - } - self.assertEqual(channels["v1.2.0"], ("stable", False)) - self.assertEqual(channels["v1.3.0-rc.1"], ("rc", True)) - self.assertEqual(channels["dev-20260917-09299019bcdc"], ("dev", True)) - self.assertEqual( - channels["staging-20260916-ed5fe58eb546"], ("staging", True) - ) - # Not one of the four tag shapes this repository publishes. - self.assertEqual(channels["v1.4.0-beta.1"], ("unknown", True)) - - def test_platforms_and_counts(self): - counts = { - (event["properties"]["os"], event["properties"]["arch"]): event[ - "properties" - ]["download_count"] - for event in self.batch - if event["properties"]["release_tag"] == "v1.2.0" - } - self.assertEqual( - counts, - { - ("linux", "amd64"): 15010, - ("linux", "arm64"): 11240, - ("darwin", "amd64"): 18930, - ("darwin", "arm64"): 22471, - ("windows", "amd64"): 4109, - ("windows", "arm64"): 2051, - }, - ) - - def test_event_uuid_format_and_uniqueness(self): - uuids = [event["uuid"] for event in self.batch] - self.assertEqual(len(set(uuids)), len(uuids)) - for value in uuids: - self.assertRegex( - value, - r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", - ) - self.assertEqual(uuid.UUID(value).version, 5) - - def test_same_day_rerun_is_deduplicated(self): - # The id is derived from the tag, the asset and the UTC date, so a - # rerun on the same day produces the same ids and PostHog drops the - # repeat instead of counting the downloads twice. - again = dry_run_payload({"CODEAF_POSTHOG_PROJECT_KEY": "phx_test"}) - self.assertEqual( - [event["uuid"] for event in again["batch"]], - [event["uuid"] for event in self.batch], - ) - - def test_uuid_depends_on_snapshot_date(self): - # A different day is a new event, not a repeat of yesterday's. - tag, asset, distinct = "v1.2.0", "codeaf-linux-amd64", "codeaf-release-downloads" - self.assertNotEqual( - uuid.uuid5(uuid.NAMESPACE_URL, f"{distinct}|{tag}|{asset}|2026-02-11"), - uuid.uuid5(uuid.NAMESPACE_URL, f"{distinct}|{tag}|{asset}|2026-02-12"), - ) - - def test_no_checksums_or_asset_names_in_payload(self): - # Sidecars are not binary releases: their names and counts never - # appear in the payload at all. - text = json.dumps(self.payload) - self.assertNotIn("checksums.txt", text) - for event in self.batch: - self.assertNotIn("name", event["properties"]) - - def test_dry_run_without_project_key_prints_the_batch(self): - # --dry-run is the local and test path: no key in the environment is no - # obstacle, the key is printed as a placeholder and the batch still - # comes out, with no notice. - result = run_script("--dry-run", "--fixture", str(FIXTURE)) - self.assertEqual(result.returncode, 0, result.stderr) - payload = json.loads(result.stdout) - self.assertEqual(payload["api_key"], "") - self.assertEqual(len(payload["batch"]), 15) - - def test_sending_without_project_key_fails(self): - # Only the sending path stops on a missing key; it never reaches the - # network or the PostHog host. - result = run_script() - self.assertEqual(result.returncode, 1) - self.assertIn("error", result.stderr) - self.assertIn("CODEAF_POSTHOG_PROJECT_KEY", result.stderr) - self.assertEqual(result.stdout, "") - - def test_sending_without_github_token_fails(self): - result = run_script(env_extra={ - "CODEAF_POSTHOG_PROJECT_KEY": "phx_test", - "GH_TOKEN": "", "GITHUB_TOKEN": "", - }) - self.assertEqual(result.returncode, 1) - self.assertIn("GITHUB_TOKEN", result.stderr) - self.assertEqual(result.stdout, "") - - -class PaginationTest(unittest.TestCase): - def test_all_pages_are_followed(self): - page_one = [ - { - "tag_name": f"v1.{number}.0", - "draft": False, - "prerelease": False, - "assets": [{"name": "codeaf-linux-amd64", "download_count": 1}], - } - for number in range(100) - ] - page_two = [ - { - "tag_name": "v0.1.0", - "draft": False, - "prerelease": False, - "assets": [{"name": "codeaf-linux-arm64", "download_count": 2}], - } - ] - with mock.patch( - "urllib.request.urlopen", - side_effect=[FakeResponse(page_one), FakeResponse(page_two)], - ) as urlopen: - releases = module.fetch_releases("Agent-Field/codeaf", "token") - self.assertEqual(urlopen.call_count, 2) - self.assertIn("page=1", urlopen.call_args_list[0].args[0].full_url) - self.assertIn("page=2", urlopen.call_args_list[1].args[0].full_url) - self.assertEqual(len(releases), 101) - self.assertEqual(releases[0]["tag_name"], "v1.0.0") - self.assertEqual(releases[-1]["tag_name"], "v0.1.0") - - def test_posthog_host_is_overridable(self): - with mock.patch("urllib.request.urlopen") as urlopen: - urlopen.return_value = FakeResponse(b"") - module.send({}, "https://telemetry.example.com") - request = urlopen.call_args.args[0] - self.assertEqual(request.full_url, "https://telemetry.example.com/batch/") - - -class WorkflowTest(unittest.TestCase): - """The workflow that schedules the script, asserted as text so the check - reads the same file GitHub Actions does.""" - - @classmethod - def setUpClass(cls): - cls.text = WORKFLOW.read_text() - - def test_workflow_file_exists(self): - self.assertTrue(WORKFLOW.exists()) - - def test_workflow_is_scheduled_daily_and_dispatchable(self): - self.assertIn("workflow_dispatch", self.text) - self.assertIn("'0 6 * * *'", self.text) - - def test_workflow_permissions_are_contents_read_only(self): - self.assertIn("permissions:", self.text) - self.assertIn("contents: read", self.text) - - def test_workflow_runs_the_script(self): - self.assertIn("python3 scripts/release_downloads.py", self.text) - self.assertIn("runs-on: ubuntu-latest", self.text) - self.assertIn("actions/checkout", self.text) - - def test_workflow_uses_builtin_token_secret_and_host_variable(self): - self.assertIn("GH_TOKEN: ${{ github.token }}", self.text) - self.assertIn( - "CODEAF_POSTHOG_PROJECT_KEY: ${{ secrets.CODEAF_POSTHOG_PROJECT_KEY }}", - self.text, - ) - self.assertIn("POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}", self.text) - - def test_workflow_parses_as_yaml(self): - if yaml is None: - self.skipTest("PyYAML is not installed; the text checks above stand") - document = yaml.safe_load(self.text) - triggers = document[True] - self.assertEqual(triggers["schedule"][0]["cron"], "0 6 * * *") - self.assertIn("workflow_dispatch", triggers) - self.assertEqual(document["permissions"], {"contents": "read"}) - - def test_workflow_and_script_are_the_only_nonstandard_files(self): - # The feature is workflow + script + fixture + test + docs; no Go - # source anywhere carries it. - text = self.text - self.assertNotIn("go run", text) - self.assertNotIn("go build", text) - - -class DocsTest(unittest.TestCase): - def test_download_counts_paragraph_exists(self): - text = (DOCS / "TELEMETRY.md").read_text() - flat = " ".join(text.split()) - self.assertIn("## Download counts", text) - self.assertIn("public GitHub release data", flat) - - -class DocsAndWorkflowMixTest(unittest.TestCase): - def test_docs_paragraph_names_workflow_and_script(self): - text = (DOCS / "TELEMETRY.md").read_text() - self.assertIn("## Download counts", text) - self.assertIn("release-downloads.yml", text) - self.assertIn("scripts/release_downloads.py", text) - self.assertIn("checksums.txt", text) - - -class ScriptSourceTest(unittest.TestCase): - """The script is python3 standard library only, and imports prove it.""" - - def test_script_imports_stdlib_only(self): - tree = ast.parse(SCRIPT.read_text()) - imported = set() - for node in ast.walk(tree): - if isinstance(node, ast.Import): - imported.update(alias.name.split(".")[0] for alias in node.names) - elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: - imported.add(node.module.split(".")[0]) - self.assertEqual( - imported, - {"argparse", "datetime", "json", "os", "re", "sys", "urllib", "uuid"}, - ) - # Nothing outside the standard library is imported, so the script - # runs anywhere python3 does, including the workflow runner. - # sys.stdlib_module_names exists from Python 3.10; on older interpreters - # the pinned import set above is the check that stands. - stdlib = getattr(sys, "stdlib_module_names", None) - if stdlib is not None: - self.assertLessEqual(imported, set(stdlib)) - - -class ChannelTest(unittest.TestCase): - """The four tag shapes of cmd/codeaf-release/version.go, and nothing - else: any other tag is reported as "unknown" rather than mislabelled.""" - - def test_stable_tags(self): - self.assertEqual(module.release_channel("v1.2.0"), "stable") - self.assertEqual(module.release_channel("v0.0.1"), "stable") - self.assertEqual(module.release_channel("v10.20.30"), "stable") - - def test_rc_tags(self): - self.assertEqual(module.release_channel("v1.3.0-rc.1"), "rc") - self.assertEqual(module.release_channel("v2.0.0-rc.12"), "rc") - - def test_dev_tags(self): - self.assertEqual( - module.release_channel("dev-20260917-09299019bcdc"), "dev" - ) - self.assertEqual(module.release_channel("dev-20250101-abcdef012345"), "dev") - - def test_staging_tags(self): - self.assertEqual( - module.release_channel("staging-20260916-ed5fe58eb546"), "staging" - ) - self.assertEqual( - module.release_channel("staging-20250102-000000000abc"), "staging" - ) - - def test_unrecognised_tags_are_unknown(self): - for tag in ( - "v1.4.0-beta.1", - "v1.3.0-rc.0", - "v1.3.0-rc", - "v01.2.0", - "1.2.0", - "v1.2", - "dev-20260917-09299019bcdcX", - "staging-20260916-ED5FE58EB546", - "nightly-20260917-09299019bcdc", - "", - ): - self.assertEqual(module.release_channel(tag), "unknown", tag) - - -class SendEndpointTest(unittest.TestCase): - def test_default_host_is_posthog_us(self): - with mock.patch("urllib.request.urlopen") as urlopen: - urlopen.return_value = FakeResponse(b"") - module.send({}, "https://us.i.posthog.com") - self.assertEqual( - urlopen.call_args.args[0].full_url, "https://us.i.posthog.com/batch/" - ) - - -class AssetMatchingTest(unittest.TestCase): - def test_sidecars_and_nonmatching_names_are_skipped(self): - self.assertIsNone(module.asset_os_arch("checksums.txt")) - self.assertIsNone(module.asset_os_arch("codeaf-linux-amd64.tar.gz")) - self.assertIsNone(module.asset_os_arch("codeaf-solaris-sparc")) - self.assertEqual(module.asset_os_arch("codeaf-linux-amd64"), ("linux", "amd64")) - self.assertEqual( - module.asset_os_arch("codeaf-windows-amd64.exe"), ("windows", "amd64") - ) - - -class DraftTest(unittest.TestCase): - def test_draft_releases_produce_no_events(self): - draft = { - "tag_name": "v9.9.9", - "draft": True, - "prerelease": False, - "assets": [{"name": "codeaf-linux-amd64", "download_count": 5}], - } - self.assertEqual(module.build_events([draft], "2026-02-11"), []) - - -if __name__ == "__main__": - unittest.main()