diff --git a/cmd/codeaf/chatv3_host_teams.go b/cmd/codeaf/chatv3_host_teams.go index bc89028646..4ee5456d85 100644 --- a/cmd/codeaf/chatv3_host_teams.go +++ b/cmd/codeaf/chatv3_host_teams.go @@ -38,12 +38,15 @@ type hostTeams struct { defaults func() (teamstore.Defaults, error) // applyDefault writes one `teams.` row ([remote.Welcome.TeamSettings]). // Nil leaves the settings Teams tab read-only. - applyDefault func(key, raw string) (teamstore.Defaults, error) - packets func(scope, stamp string) (remote.PacketsReading, error) - raise func(p teamstore.Packet) (teamstore.Packet, error) - decide func(id, by, decision, reason string) (teamstore.Packet, error) - escalate func(id, by, to, reason string) (teamstore.Packet, error) - spend func(team, day, stamp string) (remote.SpendReading, error) + applyDefault func(key, raw string) (teamstore.Defaults, error) + packets func(scope, stamp string) (remote.PacketsReading, error) + raise func(p teamstore.Packet) (teamstore.Packet, error) + decide func(id, by, decision, reason string) (teamstore.Packet, error) + escalate func(id, by, to, reason string) (teamstore.Packet, error) + spend func(team, day, stamp string) (remote.SpendReading, error) + // total is one team's total live cost ([remote.Welcome.TeamsTotal]). Nil + // leaves the page's totals out over the connection. + total func(team, stamp string) (remote.TotalReading, error) deleteOne func(team string) (remote.DeleteTeamReply, error) deleteChecked func(team string, expected []string) (remote.DeleteTeamReply, error) // The wrap-up's two doors, nil when the engine does not answer them @@ -69,7 +72,7 @@ const hostTeamsTries = 3 // errHostTeamsBusy is a write that met another writer on every try. var errHostTeamsBusy = errors.New("the teams on the far machine kept changing while this was written; try again") -func newHostTeams(far hostFar, delegation, wrapUp, settings bool) *hostTeams { +func newHostTeams(far hostFar, delegation, wrapUp, settings, totals bool) *hostTeams { h := &hostTeams{ read: far.client.TeamsRead, write: far.client.TeamsUpdate, @@ -84,6 +87,9 @@ func newHostTeams(far hostFar, delegation, wrapUp, settings bool) *hostTeams { } h.defaults, h.packets, h.raise = c.TeamsDefaults, c.TeamsPackets, c.TeamsRaise h.decide, h.escalate, h.spend, h.deleteOne = c.TeamsDecide, c.TeamsEscalate, c.TeamsSpend, func(team string) (remote.DeleteTeamReply, error) { return c.TeamsDelete(team) } + if totals { + h.total = c.TeamsTotal + } if wrapUp { h.wrapUp, h.acceptClosing = c.TeamsWrapUp, c.TeamsAcceptClosing } @@ -105,7 +111,7 @@ func hostTeamsSeam(far hostFar, welcome remote.Welcome) tui3.TeamsSeam { if welcome.TeamMembershipVersion != remote.TeamMembershipVersion { return tui3.TeamsSeam{} } - return newHostTeams(far, welcome.Delegation, welcome.WrapUp, welcome.TeamSettings).seam() + return newHostTeams(far, welcome.Delegation, welcome.WrapUp, welcome.TeamSettings, welcome.TeamsTotal).seam() } // seam is h as the surface's functions. The delegation doors are handed only @@ -121,6 +127,9 @@ func (h *hostTeams) seam() tui3.TeamsSeam { } s.Defaults, s.Raise, s.Decide, s.Escalate = h.defaults, h.raise, h.decide, h.escalate s.Packets, s.Spend, s.Delete = h.readPackets, h.readSpend, h.forget + if h.total != nil { + s.Total = h.readTotal + } if h.deleteChecked != nil { s.DeleteChecked = h.forgetChecked } @@ -171,6 +180,22 @@ func (h *hostTeams) readSpend(team, day, since string) (teamstore.Spend, string, return *got.Spend, got.Stamp, false, nil } +// readTotal is [tui3.TeamsSeam.Total]: one round trip, a few bytes when +// neither the engine's teams file nor its ledger moved. +func (h *hostTeams) readTotal(team, since string) (teamstore.Total, string, bool, error) { + got, err := h.total(team, since) + if err != nil { + return teamstore.Total{}, "", false, err + } + if got.Same { + return teamstore.Total{}, got.Stamp, true, nil + } + if got.Total == nil { + return teamstore.Total{Team: team}, got.Stamp, false, nil + } + return *got.Total, got.Stamp, false, nil +} + // forget is [tui3.TeamsSeam.Delete]: the engine forgets the team and its // files, and the list held here is read again, because the file moved. func (h *hostTeams) forget(team string) ([]string, error) { diff --git a/cmd/codeaf/chatv3_host_teams_test.go b/cmd/codeaf/chatv3_host_teams_test.go index 0bed218f6f..a4416467a4 100644 --- a/cmd/codeaf/chatv3_host_teams_test.go +++ b/cmd/codeaf/chatv3_host_teams_test.go @@ -2,6 +2,9 @@ package main import ( "errors" + "fmt" + "os" + "path/filepath" "testing" "github.com/Agent-Field/codeaf/internal/remote" @@ -222,6 +225,67 @@ func TestTheWrapUpDoorsCrossHostOnlyWhenTheEngineSaysSo(t *testing.T) { } } +// THE TOTAL DOOR CROSSES --host WHEN THE ENGINE SAYS SO, and an engine that +// does not hands a seam without it. The engine reads TeamTotal from ITS +// profile: one line in its own usage ledger, the member's own call, totals to +// $67.46 over the seam, and the same-stamp ask is answered `same` without a +// second read. welcome.TeamsTotal=false leaves the door nil, and the page in +// front of that connection draws no totals and never reads this machine's +// ledger. +func TestTheTotalDoorCrossesHostOnlyWhenTheEngineSaysSo(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + far := t.TempDir() + loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: &quietAgent{}, ProfileDir: far}, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + const ( + harbor = "0a0a0a0a0a0a" + sess = "0123456789abcdef" + ) + if err := teamstore.Save(far, []teamstore.Team{{ID: harbor, Name: "harbor", + Members: []teamstore.Member{{Key: filepath.Join("/srv/sessions", sess, "transcript.jsonl"), Handle: "web"}}}}); err != nil { + t.Fatal(err) + } + ledger := teamstore.UsageLedgerPath() + if err := os.MkdirAll(filepath.Dir(ledger), 0o700); err != nil { + t.Fatal(err) + } + line := fmt.Sprintf(`{"at":"2026-10-23T10:00:00-04:00","day":%q,"usd":67.46,"calls":1,"session":%q,"root":""}`, + teamstore.Today(), sess) + if err := os.WriteFile(ledger, []byte(line+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + welcome := loop.Client.Welcome() + if !welcome.TeamsTotal { + t.Fatalf("the engine welcome has no total door: %+v", welcome) + } + seam := hostTeamsSeam(hostFar{client: loop.Client}, welcome) + if seam.Total == nil { + t.Fatal("an engine with the total door got a seam without it") + } + total, stamp, same, err := seam.Total(harbor, "") + if err != nil || same || total.USD != 67.46 || total.Calls != 1 { + t.Fatalf("the total over the seam: %+v %q %v %v", total, stamp, same, err) + } + if _, _, same, _ := seam.Total(harbor, stamp); !same { + t.Fatal("a quiet total read over the seam was not same") + } + + welcome.TeamsTotal = false + older := hostTeamsSeam(hostFar{client: loop.Client}, welcome) + if older.Load == nil || older.Update == nil { + t.Fatal("an engine with the teams doors and not the total door lost its teams seam") + } + if older.Total != nil { + t.Fatal("an engine without the total door was handed it") + } +} + // Contract 6.2: The host wire never lends the laptop a closed team's packet files. func TestHostTeamsKeepsClosedHistoryOffTheWire(t *testing.T) { loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { diff --git a/docs/changes/unreleased/1878-team-total.md b/docs/changes/unreleased/1878-team-total.md new file mode 100644 index 0000000000..168ee5bdb5 --- /dev/null +++ b/docs/changes/unreleased/1878-team-total.md @@ -0,0 +1,14 @@ +--- +kind: added +title: the teams page draws a team's total live cost of its own +pr: 1878 +surface: [chat, remote] +invalidates: + - "The teams page used to draw one money figure per team: the cap reading `$1.20 of $5 today`, dim, in the header strip. It now also draws the team's own total live cost — all of it, all time, every team under it, whole conversation chains, money following the conversation across membership changes — on each rail row, in the pane for the selected team, and in the team's Settings card beside each member's share; the cap reading stays where it is." + - "A member whose conversation left a team used to vanish from that team's money as of the next day. A removal now stamps the end of the membership, the team keeps the member's spend while they were in it, and nothing after the removal counts." +--- + +A team's total is a read over the same ledger and teams file the day's spend +comes from, so it answers the same stamp and costs no extra disk read: `Teams.Total` +over --host behind the new `Welcome.TeamsTotal` flag, with no totals drawn when +an engine does not answer it. \ No newline at end of file diff --git a/go.sum b/go.sum index 4066505585..7a8d8eda8e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,8 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/AOShei/go-fast-pdf v0.1.1 h1:1ZnZLX82194jgwBcR0fUHQ5nBpZNes3R9Yhs3UU7ISk= github.com/AOShei/go-fast-pdf v0.1.1/go.mod h1:W2DEM8ieuiYlefEEu+IjivQCapFOqDlAgVMrP80ELes= github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260801225427-e6587ade0886 h1:IntHt8hUmpHlFL8qo373dfaBDJ+5pGGh+Q60zWAJcNw= @@ -54,8 +57,11 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -78,8 +84,10 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -92,8 +100,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -109,6 +119,7 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyL github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -116,6 +127,7 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -123,6 +135,7 @@ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+ github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gtank/ristretto255 v0.1.2 h1:JEqUCPA1NvLq5DwYtuzigd7ss8fwbYay9fi4/5uMzcc= @@ -139,6 +152,8 @@ github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8i github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -146,10 +161,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -158,6 +175,7 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -166,14 +184,18 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neilotoole/slogt v1.1.0 h1:c7qE92sq+V0yvCuaxph+RQ2jOKL61c4hqS1Bv9W7FZE= github.com/neilotoole/slogt v1.1.0/go.mod h1:RCrGXkPc/hYybNulqQrMHRtvlQ7F6NktNVLuLwk6V+w= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -182,12 +204,14 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spyzhov/ajson v0.9.6 h1:iJRDaLa+GjhCDAt1yFtU/LKMtLtsNVKkxqlpvrHHlpQ= github.com/spyzhov/ajson v0.9.6/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -197,6 +221,7 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelslog v0.20.0 h1:oEl2Pw/i4OQwhAuda2pAHFAcOMivA+Xa+iTccBfab/g= @@ -209,6 +234,7 @@ go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypR go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= @@ -271,6 +297,7 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -279,6 +306,7 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -301,6 +329,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -308,6 +338,9 @@ gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXa gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= diff --git a/internal/remote/delegation.go b/internal/remote/delegation.go index 6a5a8c6bde..a973a87941 100644 --- a/internal/remote/delegation.go +++ b/internal/remote/delegation.go @@ -18,7 +18,7 @@ import ( func (s *server) delegationCall(call Frame) (json.RawMessage, bool, error) { switch call.Method { case MethodTeamsDefaults, MethodTeamsApplyDefault, MethodTeamsPackets, MethodTeamsRaise, MethodTeamsDecide, - MethodTeamsEscalate, MethodTeamsSpend, MethodTeamsDelete, + MethodTeamsEscalate, MethodTeamsSpend, MethodTeamsTotal, MethodTeamsDelete, MethodTeamsWrapUp, MethodTeamsAcceptClosing: default: return nil, false, nil @@ -111,6 +111,21 @@ func delegationAnswer(dir string, call Frame) (any, error) { } return SpendReading{Stamp: stamp, Spend: &spend}, nil + case MethodTeamsTotal: + args, err := arg[TotalArgs](call) + if err != nil { + return nil, err + } + stamp := teamstore.TeamTotalStamp(dir, args.Team) + if args.Stamp != "" && args.Stamp == stamp { + return TotalReading{Stamp: stamp, Same: true}, nil + } + total, err := teamstore.TeamTotal(dir, args.Team) + if err != nil { + return nil, err + } + return TotalReading{Stamp: stamp, Total: &total}, nil + case MethodTeamsWrapUp: args, err := arg[WrapUpArgs](call) if err != nil { @@ -190,6 +205,12 @@ func (c *Client) TeamsSpend(team, day, stamp string) (SpendReading, error) { return delegationAsk[SpendReading](c, MethodTeamsSpend, SpendArgs{Team: team, Day: day, Stamp: stamp}) } +// TeamsTotal is one team's total live cost, all time, or word that neither +// the teams file nor the ledger moved since stamp. +func (c *Client) TeamsTotal(team, stamp string) (TotalReading, error) { + return delegationAsk[TotalReading](c, MethodTeamsTotal, TotalArgs{Team: team, Stamp: stamp}) +} + // TeamsDelete forgets a closed team on the engine. func (c *Client) TeamsDelete(team string, expected ...[]string) (DeleteTeamReply, error) { args := DeleteTeamArgs{Team: team} diff --git a/internal/remote/server.go b/internal/remote/server.go index 8ab94491b2..cad1752a33 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1246,6 +1246,8 @@ func (sess *Session) welcomeLocked(s *server) Welcome { TeamSettings: true, // And the wrap-up's two doors, for the same reason. WrapUp: true, + // And a team's total live cost beside the spend, for the same reason. + TeamsTotal: true, // The two model asks are the agent's, so they are asked of it. TeamAsk: teamAskKnown(sess.agent), // This revision checks it in the handler, for every engine behind it diff --git a/internal/remote/wire.go b/internal/remote/wire.go index a995f40c71..8f6bcfde15 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1291,6 +1291,13 @@ type Welcome struct { // with `Close now` only over that connection, said as such. WrapUp bool `json:"wrap_up,omitempty"` + // TeamsTotal says this engine ANSWERS [MethodTeamsTotal]: the teams page + // can draw a team's total live cost over the connection. A flag beside + // [Welcome.Delegation] for its reason, so an engine built between the two + // reads the spend and not the total. ABSENCE IS false, and false leaves + // the page's totals out over that connection, said as such. + TeamsTotal bool `json:"teams_total,omitempty"` + // TeamAsk says this engine ANSWERS THE WALL'S TWO MODEL ASKS // ([MethodTeamsName], [MethodTeamsPropose]): its agent names a group of // conversations and proposes teams on its own naming role. diff --git a/internal/remote/wire_delegation.go b/internal/remote/wire_delegation.go index b90b682483..2d3a66cc1a 100644 --- a/internal/remote/wire_delegation.go +++ b/internal/remote/wire_delegation.go @@ -48,6 +48,10 @@ const ( // MethodTeamsSpend is one team's spend on a day, or word that neither the // teams file nor the ledger moved since the stamp the window holds. MethodTeamsSpend = "Teams.Spend" // SpendArgs → SpendReading + // MethodTeamsTotal is one team's total live cost, all time, or word that + // neither the teams file nor the ledger moved since the stamp the window + // holds. + MethodTeamsTotal = "Teams.Total" // TotalArgs → TotalReading // MethodTeamsDelete forgets a closed team and its files. MethodTeamsDelete = "Teams.Delete" // DeleteTeamArgs → DeleteTeamReply ) @@ -138,6 +142,20 @@ type SpendReading struct { Spend *teamstore.Spend `json:"spend,omitempty"` } +// TotalArgs is one team and the stamp the window last got. +type TotalArgs struct { + Team string `json:"team"` + Stamp string `json:"stamp,omitempty"` +} + +// TotalReading is the team's total and its stamp; Same says nothing moved and +// then Total is absent. +type TotalReading struct { + Stamp string `json:"stamp"` + Same bool `json:"same,omitempty"` + Total *teamstore.Total `json:"total,omitempty"` +} + // DeleteTeamArgs names the closed team to forget. type DeleteTeamArgs struct { Expected []string `json:"expected,omitempty"` diff --git a/internal/teams/leftat_test.go b/internal/teams/leftat_test.go new file mode 100644 index 0000000000..920e5846cd --- /dev/null +++ b/internal/teams/leftat_test.go @@ -0,0 +1,73 @@ +package teams + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// A removal is the end of a membership: [File.RemoveMember] stamps LeftAt +// while keeping JoinedAt, so the team's total ([TeamTotal]) keeps the member's +// spend while they were here and nothing after. SetManager and SetHome never +// touch the field, because a replaced manager or a moved home is still a +// member. +func TestRemoveMemberStampsLeftAt(t *testing.T) { + joined := time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC) + f := managed() + f.Teams[0].Members = append(f.Teams[0].Members, Member{Key: "k", JoinedAt: joined}) + f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "k", Started: true}) + tidy(f.Teams) + if err := f.RemoveMember("aaaaaaaaaaaa", "k"); err != nil { + t.Fatal(err) + } + harbor := f.Teams[0] + if len(harbor.Members) != len(managed().Teams[0].Members) || len(harbor.FormerMembers) != 1 { + t.Fatalf("roster: %+v", harbor) + } + former := harbor.FormerMembers[0] + if !former.JoinedAt.Equal(joined) || former.LeftAt.IsZero() || !former.LeftAt.After(joined) { + t.Fatalf("former: %+v", former) + } + left := former.LeftAt + if err := f.SetManager("aaaaaaaaaaaa", "lead"); err != nil { + t.Fatal(err) + } + if err := f.SetHome("k", "dddddddddddd"); err != nil { + t.Fatal(err) + } + if after := f.Teams[0].FormerMembers[0].LeftAt; !after.Equal(left) { + t.Fatalf("SetManager/SetHome moved LeftAt: %v", after) + } +} + +// LeftAt survives the wire and unknown fields do too: an old build that never +// heard of left_at keeps reading new files, and a new build keeps a removed +// member's boundary after an old build rewrote the file. +func TestMemberLeftAtRoundTripKeepsUnknownFields(t *testing.T) { + left := time.Date(2026, 9, 24, 13, 0, 0, 0, time.UTC) + var member Member + raw := `{"key":"k","joined_at":"2026-09-24T10:00:00Z","left_at":"2026-09-24T13:00:00Z","future_authority":{"mode":"explicit"}}` + must(t, json.Unmarshal([]byte(raw), &member)) + if !member.LeftAt.Equal(left) { + t.Fatalf("left_at: %v", member.LeftAt) + } + data, err := json.Marshal(member) + must(t, err) + if !strings.Contains(string(data), `"future_authority":{"mode":"explicit"}`) || !strings.Contains(string(data), "left_at") { + t.Fatalf("round trip: %s", data) + } + var back Member + must(t, json.Unmarshal(data, &back)) + if !back.LeftAt.Equal(left) { + t.Fatalf("left_at lost: %v", back.LeftAt) + } + // A member without a removal leaves no field at all, so an old build + // writes nothing new for an open membership. + var open Member + data, err = json.Marshal(open) + must(t, err) + if strings.Contains(string(data), "left_at") { + t.Fatalf("open membership wrote left_at: %s", data) + } +} diff --git a/internal/teams/member_json.go b/internal/teams/member_json.go index 971e993817..95ebe9bab9 100644 --- a/internal/teams/member_json.go +++ b/internal/teams/member_json.go @@ -13,11 +13,12 @@ type memberFields Member type wireMember struct { memberFields JoinedAt *time.Time `json:"joined_at,omitempty"` + LeftAt *time.Time `json:"left_at,omitempty"` } var knownMemberFields = map[string]bool{ "key": true, "file": true, "where": true, "word": true, "handle": true, - "home": true, "independent": true, "joined_at": true, "start_id": true, "started": true, "handle_by": true, + "home": true, "independent": true, "joined_at": true, "start_id": true, "started": true, "handle_by": true, "left_at": true, } func (m *Member) UnmarshalJSON(raw []byte) error { @@ -33,6 +34,9 @@ func (m *Member) UnmarshalJSON(raw []byte) error { if w.JoinedAt != nil { m.JoinedAt = *w.JoinedAt } + if w.LeftAt != nil { + m.LeftAt = *w.LeftAt + } for k, v := range all { if !knownMemberFields[k] { if m.extra == nil { @@ -50,6 +54,10 @@ func (m Member) MarshalJSON() ([]byte, error) { at := m.JoinedAt w.JoinedAt = &at } + if !m.LeftAt.IsZero() { + at := m.LeftAt + w.LeftAt = &at + } raw, err := json.Marshal(w) if err != nil || len(m.extra) == 0 { return raw, err diff --git a/internal/teams/spend.go b/internal/teams/spend.go index 8a369df923..f63adadb3b 100644 --- a/internal/teams/spend.go +++ b/internal/teams/spend.go @@ -63,6 +63,23 @@ type Spend struct { ByMember map[string]float64 `json:"by_member,omitempty"` } +// Total is what a team and every team under it has cost, all time: every +// ledger line whose conversation belongs to the subtree, whole conversation +// chains, money following the conversation (membership intervals), floored at +// the owning team's Made. It is the figure of the team's own the teams page +// draws on its rail rows and in the pane; the day's spend against the cap +// ([Spend]) is the different figure the header keeps. A conversation in two +// teams counts in both, and once in the root team's. +type Total struct { + Team string `json:"team"` + USD float64 `json:"usd"` + Calls int `json:"calls"` + // ByMember is each counted conversation's share, by conversation key: + // the line that shows one runaway member, and why the total is computed + // per conversation at all. + ByMember map[string]float64 `json:"by_member,omitempty"` +} + // TeamSpend is team teamID's spend on day ("2006-01-02", local) from this // machine's usage ledger. func TeamSpend(profileDir, teamID, day string) (Spend, error) { @@ -112,6 +129,132 @@ func TeamSpendIn(profileDir, ledger, teamID, day string) (Spend, error) { return out, nil } +// TeamTotal is team teamID's total live cost, all time, from this machine's +// usage ledger. +func TeamTotal(profileDir, teamID string) (Total, error) { + return TeamTotalIn(profileDir, UsageLedgerPath(), teamID) +} + +// TeamTotalIn is [TeamTotal] against the ledger at ledger. +// +// WHOLE CHAINS, NOT ONE HOP. The day spend counts a line whose session or +// root is a member; the total also follows a root that is itself a delegated +// conversation, so a task's own sub-task lands on the member whose chain it +// sits under, at any depth. The owner of a line (its ByMember key) is the +// terminal member of its session's chain, falling back to its root's. +// +// MONEY FOLLOWS THE CONVERSATION. A line counts when its owner's membership +// of the subtree held it at the moment the line was written: inside one of +// the owner's [Member.JoinedAt, Member.LeftAt) intervals, a removal's LeftAt +// stamped by [File.RemoveMember], a removal written before the field existed +// left none and bounds it from above, and at or after the owning team's Made. +// Runs are kept per ledger line, so the boundary is exact even when a join or +// a removal lands mid-day. +func TeamTotalIn(profileDir, ledger, teamID string) (Total, error) { + f, err := Load(profileDir) + if err != nil { + return Total{}, err + } + out := Total{Team: teamID} + team, ok := f.Team(teamID) + if !ok { + return out, nil + } + ids := map[string]string{} // session id -> member key + type interval struct{ from, to, made int64 } + intervals := map[string][]interval{} + for _, t := range append([]Team{team}, f.Descendants(teamID)...) { + made := t.Made.UnixNano() + for _, m := range t.Members { + for _, id := range sessionIDs(m.Key) { + if _, ok := ids[id]; !ok { + ids[id] = m.Key + } + } + intervals[m.Key] = append(intervals[m.Key], interval{from: m.JoinedAt.UnixNano(), made: made}) + } + for _, m := range t.FormerMembers { + for _, id := range sessionIDs(m.Key) { + if _, ok := ids[id]; !ok { + ids[id] = m.Key + } + } + iv := interval{from: m.JoinedAt.UnixNano(), made: made} + if !m.LeftAt.IsZero() { + iv.to = m.LeftAt.UnixNano() + } + intervals[m.Key] = append(intervals[m.Key], iv) + } + } + spendCache.mu.Lock() + defer spendCache.mu.Unlock() + fold, err := spendCache.refresh(ledger) + if err != nil { + return out, err + } + // The chain edges, session -> the conversation it delegated to; a chain + // is followed up to a member, bounded by the seen set so a cycle cannot + // hang it. + parent := map[string]string{} + for pair := range fold.pairs { + if pair.session != "" && pair.root != "" { + if _, ok := parent[pair.session]; !ok { + parent[pair.session] = pair.root + } + } + } + terminal := func(start string) (string, bool) { + seen := map[string]bool{} + for at := start; at != ""; { + if key, ok := ids[at]; ok { + return key, true + } + if seen[at] { + return "", false + } + seen[at] = true + next, ok := parent[at] + if !ok { + return "", false + } + at = next + } + return "", false + } + for pair, runs := range fold.pairs { + owner, ok := terminal(pair.session) + if !ok && pair.root != "" { + owner, ok = terminal(pair.root) + } + if !ok { + continue + } + ivs := intervals[owner] + if len(ivs) == 0 { + continue + } + for _, run := range runs { + inside := false + for _, iv := range ivs { + if run.at >= iv.from && (iv.to == 0 || run.at < iv.to) && run.at >= iv.made { + inside = true + break + } + } + if !inside { + continue + } + out.USD += run.usd + out.Calls += run.calls + if out.ByMember == nil { + out.ByMember = map[string]float64{} + } + out.ByMember[owner] += run.usd + } + } + return out, nil +} + // SpendStamp is one stamp over what a team's spend is read from: the teams // file (membership) and the ledger. Equal stamps on one day are one answer. func SpendStamp(profileDir, ledger string) string { @@ -125,6 +268,13 @@ func TeamSpendStamp(profileDir, teamID, day string) string { return day + "|" + teamID + "|" + SpendStamp(profileDir, UsageLedgerPath()) } +// TeamTotalStamp is the stamp of one team's total as [TeamTotal] reads it: +// the team and [SpendStamp] over the machine's ledger. The engine and a local +// window both answer "same" from it. +func TeamTotalStamp(profileDir, teamID string) string { + return teamID + "|t|" + SpendStamp(profileDir, UsageLedgerPath()) +} + // sessionIDs is the ledger ids a conversation key can carry: its folder's // name, the ordinary layout, and for an older flat file its own name. func sessionIDs(key string) []string { @@ -163,11 +313,26 @@ type spendSum struct { calls int } +// spendRun is one ledger line, its time kept whole for the exact +// [JoinedAt, LeftAt) boundary the team's total counts on. +type spendRun struct { + at int64 // unix nanos + usd float64 + calls int +} + // ledgerFold is one ledger as read so far. type ledgerFold struct { stamp string offset int64 days map[string]map[spendPair]spendSum + // pairs is every line's (session, root) pair, in order, with each line's + // time and money: the fold the all-time total reads. Runs are per line + // and never evicted, because dropping an old line would silently + // undercount a total; that is roughly 24 bytes per ledger line, the + // price of answering "what has this team cost" without re-reading the + // ledger. + pairs map[spendPair][]spendRun } // spendMemory is every ledger folded so far, shared by the process. @@ -212,13 +377,16 @@ func (m *spendMemory) day(path, day string) (map[spendPair]spendSum, error) { // moved, the appended bytes when it grew, the whole file when it is new here // or shrank. The caller holds m.mu. func (m *spendMemory) refresh(path string) (*ledgerFold, error) { + if m.ledgers == nil { + m.ledgers = map[string]*ledgerFold{} + } stamp := stampOf(path) f := m.ledgers[path] if f != nil && f.stamp == stamp { return f, nil } if stamp == MissingStamp { - f = &ledgerFold{stamp: stamp, days: map[string]map[spendPair]spendSum{}} + f = &ledgerFold{stamp: stamp, days: map[string]map[spendPair]spendSum{}, pairs: map[spendPair][]spendRun{}} m.ledgers[path] = f return f, nil } @@ -227,7 +395,7 @@ func (m *spendMemory) refresh(path string) (*ledgerFold, error) { return nil, err } if f == nil || info.Size() < f.offset { - f = &ledgerFold{days: map[string]map[spendPair]spendSum{}} + f = &ledgerFold{days: map[string]map[spendPair]spendSum{}, pairs: map[spendPair][]spendRun{}} } file, err := os.Open(path) if err != nil { @@ -263,6 +431,7 @@ func (m *spendMemory) refresh(path string) (*ledgerFold, error) { sum.usd += line.USD sum.calls += calls totals[pair] = sum + f.pairs[pair] = append(f.pairs[pair], spendRun{at: line.At.UnixNano(), usd: line.USD, calls: calls}) } } if err == io.EOF { diff --git a/internal/teams/spend_total_test.go b/internal/teams/spend_total_test.go new file mode 100644 index 0000000000..7ecd2f0f9d --- /dev/null +++ b/internal/teams/spend_total_test.go @@ -0,0 +1,198 @@ +package teams + +import ( + "fmt" + "path/filepath" + "testing" + "time" +) + +// key is a conversation transcript path, an id's place on disk: the shape a +// member's Key takes and the session id a ledger line names. +func key(id string) string { + return filepath.Join("/srv/sessions", id, "transcript.jsonl") +} + +// tline is one usage ledger line on 2026-09-24 with a precise time, so a +// membership boundary or a Made lands mid-line and the total's half-open +// [JoinedAt, LeftAt) rule can be measured exactly. +func tline(at time.Time, usd float64, session, root string) string { + return fmt.Sprintf(`{"at":%q,"day":"2026-09-24","usd":%v,"calls":1,"session":%q,"root":%q}`, at.Format(time.RFC3339), usd, session, root) +} + +// cents is a float as integer cents, so totals and ByMember shares compare +// without float noise. +func cents(f float64) int { return int(f*100 + 0.5) } + +// The team's total, all time, every team under it, whole conversation chains. +// +// The fixture, one day of one tree: +// +// r (made 00:00) a in harbor, f a former member of harbor +// ├─ harbor (made 08:00) b in sub, c in the closed sub, e in harbor +// │ ├─ sub (made 09:00) and in twins: a conversation in two teams +// │ ├─ closed (made 08:00) +// │ └─ twins (made 08:00) d in other, outside harbor +// └─ other (made 08:00) +// +// The lines and what they are: +// +// 08:30 a member's own call counts in harbor (joined 07:00, made 08:00) +// 07:30 a before harbor's made counts nowhere +// 06:30 a before joined and made counts nowhere +// 09:00 task1↜a delegated by a counts (one hop) +// 09:05 task2↜task1 sub-task of a's task counts (the hop today's read drops) +// 10:30 f inside [10:00, 13:00) counts +// 13:00 f exactly the removal counts nowhere (half-open) +// 12:59 f inside counts +// 09:30 b sub member counts in sub and harbor +// 09:05 b before joined (09:10)/made counts nowhere +// 11:00 c closed sub's member counts in harbor and r +// 14:00 e in harbor and twins counts in both, once in r +// 09:00 d other's member counts in r only +func TestTeamTotalWholeChainsMembershipAndSubtree(t *testing.T) { + dir := t.TempDir() + at := func(h, m int) time.Time { return time.Date(2026, 9, 24, h, m, 0, 0, time.UTC) } + must(t, Save(dir, []Team{ + {ID: "rrrrrrrrrrrr", Name: "r", Root: true, Made: at(0, 0)}, + {ID: "aaaaaaaaaaaa", Name: "harbor", Parent: "rrrrrrrrrrrr", Made: at(8, 0), + Members: []Member{{Key: key("0123456789abcdef"), JoinedAt: at(7, 0)}, {Key: key("3333333333333333")}}, + FormerMembers: []Member{{Key: key("2222222222222222"), JoinedAt: at(10, 0), LeftAt: at(13, 0)}}}, + {ID: "bbbbbbbbbbbb", Name: "sub", Parent: "aaaaaaaaaaaa", Made: at(9, 0), + Members: []Member{{Key: key("1111111111111111"), JoinedAt: at(9, 10)}}}, + {ID: "cccccccccccc", Name: "closed", Parent: "aaaaaaaaaaaa", Made: at(8, 0), State: TeamClosed, + Members: []Member{{Key: key("4444444444444444")}}}, + {ID: "eeeeeeeeeeee", Name: "twins", Parent: "aaaaaaaaaaaa", Made: at(8, 0), + Members: []Member{{Key: key("3333333333333333")}}}, + {ID: "dddddddddddd", Name: "other", Parent: "rrrrrrrrrrrr", Made: at(8, 0), + Members: []Member{{Key: key("5555555555555555"), JoinedAt: at(7, 0)}}}, + })) + ledger := filepath.Join(dir, "usage.jsonl") + writeLedger(t, ledger, + tline(at(8, 30), 1.00, "0123456789abcdef", ""), // a's own + tline(at(7, 30), 2.00, "0123456789abcdef", ""), // before made + tline(at(6, 30), 4.00, "0123456789abcdef", ""), // before joined and made + tline(at(9, 0), 0.25, "aaaabbbbcccc0001", "0123456789abcdef"), // a's task + tline(at(9, 5), 0.10, "aaaabbbbcccc0002", "aaaabbbbcccc0001"), // the task's sub-task + tline(at(10, 30), 0.50, "2222222222222222", ""), // f, while here + tline(at(13, 0), 0.75, "2222222222222222", ""), // f, exactly at LeftAt + tline(at(12, 59), 0.25, "2222222222222222", ""), // f, still here + tline(at(9, 30), 3.00, "1111111111111111", ""), // b in sub + tline(at(9, 5), 5.00, "1111111111111111", ""), // b before joined + tline(at(11, 0), 1.50, "4444444444444444", ""), // closed sub's c + tline(at(14, 0), 2.00, "3333333333333333", ""), // e, in harbor and twins + tline(at(9, 0), 0.60, "5555555555555555", ""), // d in other + ) + + harbor, err := TeamTotalIn(dir, ledger, "aaaaaaaaaaaa") + must(t, err) + if cents(harbor.USD) != 860 || harbor.Calls != 8 { + t.Fatalf("harbor total: %+v", harbor) + } + want := map[string]int{ + key("0123456789abcdef"): 135, // own + task + sub-task + key("2222222222222222"): 75, // inside [10:00, 13:00) only + key("1111111111111111"): 300, + key("4444444444444444"): 150, + key("3333333333333333"): 200, + } + if len(harbor.ByMember) != len(want) { + t.Fatalf("harbor by member: %+v", harbor.ByMember) + } + sum := 0 + for k, v := range want { + sum += v + if cents(harbor.ByMember[k]) != v { + t.Fatalf("harbor %s: %v", k, harbor.ByMember) + } + } + if sum != cents(harbor.USD) { + t.Fatalf("by member did not add up: %v vs %v", sum, harbor.USD) + } + + sub, _ := TeamTotalIn(dir, ledger, "bbbbbbbbbbbb") + if cents(sub.USD) != 300 || sub.Calls != 1 || cents(sub.ByMember[key("1111111111111111")]) != 300 { + t.Fatalf("sub total: %+v", sub) + } + + twins, _ := TeamTotalIn(dir, ledger, "eeeeeeeeeeee") + if cents(twins.USD) != 200 { + t.Fatalf("twins total (e in two teams counts in each): %+v", twins) + } + + root, _ := TeamTotalIn(dir, ledger, "rrrrrrrrrrrr") + // everything counted once: d's line belongs to the root's subtree alone. + if cents(root.USD) != 920 || root.Calls != 9 { + t.Fatalf("root total: %+v", root) + } + if cents(root.ByMember[key("3333333333333333")]) != 200 { + t.Fatalf("e's line counted twice in the root: %+v", root.ByMember) + } + + if none, _ := TeamTotalIn(dir, ledger, "ffffffffffff"); none.USD != 0 { + t.Fatalf("an unknown team totals %v", none.USD) + } + + // TeamTotalStamp says the same thing twice: a quiet ledger costs no read. + first, err := TeamTotalIn(dir, ledger, "aaaaaaaaaaaa") + must(t, err) + before := spendCache.bytes() + again, _ := TeamTotalIn(dir, ledger, "aaaaaaaaaaaa") + if spendCache.bytes() != before { + t.Fatal("a quiet ledger was read again") + } + if cents(again.USD) != cents(first.USD) || TeamTotalStamp(dir, "aaaaaaaaaaaa") == "" { + t.Fatalf("same-stamp read: %+v %q", again, TeamTotalStamp(dir, "aaaaaaaaaaaa")) + } +} + +// A LEDGER THAT APPEARS AFTER A MISSING READ IS FOLDED, NOT PANICKED: the +// first read caches the absent file (its fold carries no lines), the second +// read of the same path finds the file now present, and refresh fills that +// cached fold — the cache from a missing ledger must not be reused as a +// sheet with no line list, or the first line of the appeared file writes +// into a nil map. +func TestTeamTotalOfALedgerThatAppearsAfterAMissingRead(t *testing.T) { + dir := t.TempDir() + at := func(h, m int) time.Time { return time.Date(2026, 9, 24, h, m, 0, 0, time.UTC) } + must(t, Save(dir, []Team{{ID: "aaaaaaaaaaaa", Name: "harbor", + Members: []Member{{Key: key("0123456789abcdef")}}}})) + ledger := filepath.Join(dir, "usage.jsonl") + if none, err := TeamTotalIn(dir, ledger, "aaaaaaaaaaaa"); err != nil || none.USD != 0 { + t.Fatalf("a missing ledger answered %+v, %v", none, err) + } + writeLedger(t, ledger, tline(at(10, 0), 1.5, "0123456789abcdef", "")) + total, err := TeamTotalIn(dir, ledger, "aaaaaaaaaaaa") + must(t, err) + if cents(total.USD) != 150 || total.Calls != 1 { + t.Fatalf("the appeared ledger totalled %+v", total) + } +} + +// THE LEGACY REMOVAL: a former member moved out before Member.LeftAt existed +// carries no boundary at all. The rule is then "unbounded above" — the plan's +// zero-LeftAt sentence — so the team keeps the whole membership: a line after +// JoinedAt counts, a line before it does not. A removal since LeftAt exists +// stays half-open [JoinedAt, LeftAt). +func TestTeamTotalLegacyFormerMemberWithoutLeftAtCountsFromJoinedAt(t *testing.T) { + dir := t.TempDir() + at := func(h, m int) time.Time { return time.Date(2026, 9, 24, h, m, 0, 0, time.UTC) } + must(t, Save(dir, []Team{{ID: "aaaaaaaaaaaa", Name: "harbor", Made: at(0, 0), + FormerMembers: []Member{ + {Key: key("2222222222222222"), JoinedAt: at(9, 0)}, // legacy: no LeftAt + {Key: key("6666666666666666"), JoinedAt: at(9, 0), LeftAt: at(13, 0)}, // stamped + }}})) + ledger := filepath.Join(dir, "usage.jsonl") + writeLedger(t, ledger, + tline(at(8, 0), 1.00, "2222222222222222", ""), // before JoinedAt: nowhere + tline(at(10, 0), 2.00, "2222222222222222", ""), // after JoinedAt, no LeftAt: counts + tline(at(14, 0), 4.00, "2222222222222222", ""), // even after any plausible removal: counts + tline(at(12, 0), 8.00, "6666666666666666", ""), // inside [9:00, 13:00): counts + tline(at(14, 0), 16.00, "6666666666666666", ""), // after its LeftAt: nowhere + ) + total, err := TeamTotalIn(dir, ledger, "aaaaaaaaaaaa") + must(t, err) + if cents(total.USD) != 1400 || total.Calls != 3 || cents(total.ByMember[key("2222222222222222")]) != 600 { + t.Fatalf("legacy removal totals %+v", total) + } +} diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 0cc764dd8a..5a9c1e3a90 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -37,6 +37,11 @@ type Member struct { // JoinedAt bounds delivery for a conversation added through membership editing. // An ended membership cannot regain directives from its previous lifetime. JoinedAt time.Time `json:"joined_at,omitempty"` + // LeftAt is when this membership ended: [File.RemoveMember] stamps it when + // it moves a member to FormerMembers. Zero is an open membership, or a + // removal written before the field existed, and bounds it from above in + // the team's total ([TeamTotal]). + LeftAt time.Time `json:"left_at,omitempty"` // StartID is the Traffic start admitted when this membership was created. // Only that brief may cross its join boundary; handles can be reused. StartID string `json:"start_id,omitempty"` @@ -456,6 +461,12 @@ func (f *File) RemoveMember(id, key string) error { if j := t.member(key); j >= 0 { m := t.Members[j] m.Home, m.Independent = false, false + // THE REMOVAL IS THE END OF THE MEMBERSHIP. All time counts a team's + // money through membership intervals ([TeamTotal]): what was spent + // while the member was here stays the team's, and nothing after + // LeftAt is. SetManager and SetHome never touch the field, because a + // replaced manager or a moved home is still a member. + m.LeftAt = time.Now() t.FormerMembers = append(t.FormerMembers, m) t.Members = append(t.Members[:j:j], t.Members[j+1:]...) } diff --git a/internal/tui3/credits_test.go b/internal/tui3/credits_test.go index 420e6bf456..a7062c07bc 100644 --- a/internal/tui3/credits_test.go +++ b/internal/tui3/credits_test.go @@ -227,6 +227,7 @@ func TestFailedCreditReadKeepsThePreviousSurfaceState(t *testing.T) { func TestLowCreditReadSwapsOnlyAnUntouchedImplicitConversation(t *testing.T) { a := placeApp(t) a.readCredits = func(context.Context) (credits.Reading, error) { return credits.Reading{Known: true, Low: true}, nil } + config.SetCrewFreeRoutes(a.profileDir, true) // the automatic swap honors free-route consent seedLowCredits(t, a) a.implicitTalk = true a.model = config.DefaultModel diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index ffd8a260fd..0fd42ce31e 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -91,6 +91,10 @@ type TeamsSeam struct { // Spend is team's spend on day ("" that machine's today) with its stamp, // or same when neither the teams file nor the ledger moved since since. Spend func(team, day, since string) (spend teamstore.Spend, stamp string, same bool, err error) + // Total is team's total live cost, all time, with its stamp, or same when + // neither the teams file nor the ledger moved since since. It is the + // page's own figure, kept apart from the day's Spend. + Total func(team, since string) (total teamstore.Total, stamp string, same bool, err error) // Delete forgets a closed team, the teams under it and their files, and // answers the ids forgotten. The window reads the list again after it // (ReadSince), because the file moved. @@ -212,6 +216,14 @@ func localTeams(dir string, watch *teamstore.Watch) TeamsSeam { spend, err := teamstore.TeamSpend(dir, team, day) return spend, stamp, false, err }, + Total: func(team, since string) (teamstore.Total, string, bool, error) { + stamp := teamstore.TeamTotalStamp(dir, team) + if since != "" && since == stamp { + return teamstore.Total{}, stamp, true, nil + } + total, err := teamstore.TeamTotal(dir, team) + return total, stamp, false, err + }, Delete: func(team string) ([]string, error) { return teamstore.Delete(dir, team) }, DeleteChecked: func(team string, expected []string) ([]string, error) { return teamstore.Delete(dir, team, expected) }, History: func(team string) ([]teamstore.Packet, error) { @@ -290,6 +302,14 @@ func (a *app) teamsCanReadHistory() bool { return !a.teamsDisk.door.present() || a.teamsDisk.door.History != nil } +// teamsCanReadTotals reports whether the seam reads a team's total live cost. +// A local seam always can; over --host, only when the engine answers +// [remote.Welcome.TeamsTotal]. The page in front of an engine that cannot +// draws no totals, and never reads this machine's ledger. +func (a *app) teamsCanReadTotals() bool { + return !a.teamsDisk.door.present() || a.teamsDisk.door.Total != nil +} + // teamsOff reports whether this window has no teams it can keep: over --host, // facing an engine that does not answer the teams doors. The window's own // profile is never the answer there, so the writes and the manager say diff --git a/internal/tui3/teamsheet.go b/internal/tui3/teamsheet.go index f16fc11fa6..2426e85bea 100644 --- a/internal/tui3/teamsheet.go +++ b/internal/tui3/teamsheet.go @@ -369,6 +369,26 @@ func (a *app) teamSheetSettingsLines(t team, inner int) []wallCardLine { if s.err != "" { lines = append(lines, wallCardLine{s: pal.bad(fit(s.err, inner))}) } + // THE TEAM'S OWN TOTAL, beside the settings: the figure the page owes the + // team, and the per-member share that shows who it belongs to. A team + // with nothing read yet, or nothing spent, draws no rows. + if tv, ok := a.tp.total[t.ID]; ok && tv.USD > 0 { + lines = append(lines, wallCardLine{rule: true}) + total := pal.dim(teamsPad("Total cost", valueX)) + pal.ink(teamsMoney(teamstore.RoundMoney(tv.USD))) + lines = append(lines, wallCardLine{s: total}) + for _, m := range append(append([]teamMember(nil), t.Members...), t.FormerMembers...) { + share, ok := tv.ByMember[m.Key] + if !ok || share <= 0 { + continue + } + name := m.Word + if m.Handle != "" { + name = "@" + m.Handle + } + row := pal.dim(teamsPad(name, valueX)) + pal.dim(teamsMoney(teamstore.RoundMoney(share))) + lines = append(lines, wallCardLine{s: row}) + } + } if !t.Root { lines = append(lines, wallCardLine{rule: true}) button, hit, _ := a.teamSheetButton("Delete team", "", tsDeleteTeam, 0, false) diff --git a/internal/tui3/teamspage.go b/internal/tui3/teamspage.go index 35d3c254b5..2389a8f43d 100644 --- a/internal/tui3/teamspage.go +++ b/internal/tui3/teamspage.go @@ -98,6 +98,8 @@ type teamsPage struct { packetsKnown bool spend map[string]teamstore.Spend spendStamp map[string]string + total map[string]teamstore.Total + totalStamp map[string]string defaults teamstore.Defaults defaultsOK bool world map[string]session.SessionRow @@ -549,6 +551,8 @@ type teamsGot struct { defaultsOK bool spend map[string]teamstore.Spend spendStamp map[string]string + total map[string]teamstore.Total + totalStamp map[string]string world map[string]session.SessionRow worldKnown bool worldAsked bool @@ -628,6 +632,28 @@ func (a *app) teamsRead(withWorld bool) tea.Cmd { for i, id := range pools { spendSince[i] = a.tp.spendStamp[id] } + // THE TOTALS, FOR EVERY TEAM THE RAIL DRAWS AND THE SELECTION. Each is + // its own figure beside the day's spend, stamped, so a quiet ledger + // leaves every one standing. A seam without the Total door (an older + // engine over --host, [app.teamsCanReadTotals]) asks for none and the + // page draws no totals. + var totalIDs []string + var totalSince []string + if a.teamsCanReadTotals() { + sel := a.tp.sel + seen := map[string]bool{} + for _, t := range a.wall.teams { + if t.Closed() && !a.tp.closedOpen && t.ID != sel { + continue + } + if seen[t.ID] { + continue + } + seen[t.ID] = true + totalIDs = append(totalIDs, t.ID) + totalSince = append(totalSince, a.tp.totalStamp[t.ID]) + } + } var files []string if withWorld { files = a.teamsMemberFiles() @@ -662,7 +688,8 @@ func (a *app) teamsRead(withWorld bool) tea.Cmd { rowsDoor = session.ReadRows } return a.besideLine(func() func(bool) tea.Cmd { - got := teamsGot{spend: map[string]teamstore.Spend{}, spendStamp: map[string]string{}} + got := teamsGot{spend: map[string]teamstore.Spend{}, spendStamp: map[string]string{}, + total: map[string]teamstore.Total{}, totalStamp: map[string]string{}} var wg sync.WaitGroup // A large hierarchy must not start one remote request per team at once. slots := make(chan struct{}, 4) @@ -713,6 +740,22 @@ func (a *app) teamsRead(withWorld bool) tea.Cmd { }) } } + type totalGot struct { + total teamstore.Total + stamp string + ok bool + } + totals := make([]totalGot, len(totalIDs)) + if a.teamsCanReadTotals() { + for i, id := range totalIDs { + side(func() { + to, stamp, same, err := seam.Total(id, totalSince[i]) + if err == nil && !same { + totals[i] = totalGot{total: to, stamp: stamp, ok: true} + } + }) + } + } var histories [][]teamstore.Packet if seam.History != nil && len(closedReports) > 0 { histories = make([][]teamstore.Packet, len(closedReports)) @@ -741,6 +784,11 @@ func (a *app) teamsRead(withWorld bool) tea.Cmd { got.spend[id], got.spendStamp[id] = spends[i].spend, spends[i].stamp } } + for i, id := range totalIDs { + if totals[i].ok { + got.total[id], got.totalStamp[id] = totals[i].total, totals[i].stamp + } + } // ONLY THE HISTORIES THAT WERE ASKED FOR. A seam with no History door // (an older engine over --host) asks for none, and a closed team is // then shown without its record rather than indexing an empty list. @@ -854,6 +902,15 @@ func (a *app) teamsFold(got teamsGot) tea.Cmd { } changed = true } + if len(got.total) > 0 { + if a.tp.total == nil { + a.tp.total, a.tp.totalStamp = map[string]teamstore.Total{}, map[string]string{} + } + for id, tv := range got.total { + a.tp.total[id], a.tp.totalStamp[id] = tv, got.totalStamp[id] + } + changed = true + } if got.worldAsked && got.worldKnown && !teamsSameWorld(a.tp.world, got.world) { a.tp.world, a.tp.worldKnown = got.world, true changed = true diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index 4dc3ac60ac..5db128050d 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -1,6 +1,8 @@ package tui3 import ( + "fmt" + "os" "path/filepath" "strings" "testing" @@ -920,3 +922,246 @@ func TestTeamSettingsWakeUsesTheSharedOverrideAndResetMechanics(t *testing.T) { t.Fatal("settings clutter returned") } } + +// ── the team's own total ──────────────────────────────────────────────────── + +// THE RAIL ROW CARRIES THE TEAM'S OWN TOTAL, DIM AT ITS RIGHT: `$67.46` on +// harbor's row and `$12.00` on orbit's, figures of the teams themselves, one +// per row, apart from the day's `of $5` cap reading that stays in the header +// strip. The two figures sit in different homes, so a capped day does not +// move the total and a quiet total does not move the cap. +func TestTeamsRailRowCarriesTheTeamsOwnTotal(t *testing.T) { + a, harbor, orbit := teamsPlaceLabIDs(t) + a.tp.defaultsOK = true + a.tp.defaults = teamstore.Defaults{CapUSDDay: 5} + a.tp.spend = map[string]teamstore.Spend{harbor: {USD: 1.2}} + a.tp.total = map[string]teamstore.Total{ + harbor: {Team: harbor, USD: 67.46}, + orbit: {Team: orbit, USD: 12.00}, + } + // The day's cap reading is in the header strip, not on the rail. + if text := teamsFrameText(a); !strings.Contains(text, "$1.20 of $5 today") { + t.Fatalf("the strip lost the day's cap reading:\n%s", text) + } + lines := strings.Split(teamsFrameText(a), "\n") + for i, l := range lines { + if at := strings.Index(l, "│"); at >= 0 { + lines[i] = l[:at] + } + } + var harborRow, orbitRow string + for y, l := range lines { + if y < placeHeadRows || y >= a.height-1 { + continue + } + if strings.Contains(l, "harbor") { + harborRow = l + } + if strings.Contains(l, "orbit") { + orbitRow = l + } + } + if harborRow == "" || orbitRow == "" { + t.Fatalf("no rail rows:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(harborRow, "$67.46") { + t.Fatalf("the harbor rail row lost its total: %q", harborRow) + } + if !strings.Contains(orbitRow, "$12") { + t.Fatalf("the orbit rail row lost its total: %q", orbitRow) + } + // The total is not the cap reading: no `of $` figure on the rail. + if strings.Contains(harborRow, "of $") { + t.Fatalf("the cap reading sits on the rail row: %q", harborRow) + } +} + +// THE PANE BODY DRAWS ONE MONEY LINE OF THE TEAM'S OWN: `total $67.46 · +// $1.20 of $5 today`, the all-time figure beside the day's cap reading, for +// an open team and for a closed one. The cap piece stays alone in the strip; +// this is the line the total lives on. +func TestTeamsPaneBodyDrawsTheTotalBesideTheDaysSpend(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + a.tp.defaultsOK = true + a.tp.defaults = teamstore.Defaults{CapUSDDay: 5} + a.tp.spend = map[string]teamstore.Spend{harbor: {USD: 1.2}} + a.tp.total = map[string]teamstore.Total{harbor: {Team: harbor, USD: 67.46}} + team := mustTeam(t, a, harbor) + dot := a.teamsDot() + want := "total $67.46 " + dot + " $1.20 of $5 today" + if got := a.teamsBodyMoney(team); got != want { + t.Fatalf("the body money line is %q, want %q", got, want) + } + if text := teamsFrameText(a); !strings.Contains(text, want) { + t.Fatalf("the open pane lost the money line %q:\n%s", want, text) + } + // WHILE THE TEAM STAYS OPEN THE STRIP KEEPS THE CAP PIECE: the total and + // the cap are drawn in their two homes, and nothing under $67.46 is the + // cap's place. + if text := teamsFrameText(a); !strings.Contains(text, "$1.20 of $5 today") { + t.Fatalf("the strip lost the day's cap reading:\n%s", text) + } + + // A CLOSED TEAM'S PANE KEEPS THE TOTAL AND LOSES THE CAP PIECE: + // disbanding spends nothing, and the figure belongs to the team, not to + // its day — but a closed team has no effective cap (`CapFrom` closed), so + // the day's reading is `$1.20 today`, as it always was on a closed pane; + // the strip's cap piece was never a closed team's. The close's own write + // read the real, quiet store and folded its zeros over the page's figures, + // so put the page's figures back before drawing. + drive(t, a, runCmd(a.teamsCloseNow(harbor, ""))...) + // the close's own write read the real, quiet store and folded its defaults, + // spend and totals over the page's figures, so put the page's state back. + a.tp.defaultsOK = true + a.tp.defaults = teamstore.Defaults{CapUSDDay: 5} + a.tp.spend[harbor] = teamstore.Spend{USD: 1.2} + a.tp.total[harbor] = teamstore.Total{Team: harbor, USD: 67.46} + if sel, ok := a.teamsSelected(); !ok || sel.ID != harbor || !sel.Closed() { + t.Fatalf("the close did not leave the closed team selected: %+v %v", sel, ok) + } + closedWant := "total $67.46 " + dot + " $1.20 today" + if text := teamsFrameText(a); !strings.Contains(text, closedWant) { + sel, _ := a.teamsSelected() + eff := a.teamTree().Effective(sel.ID, a.tp.defaults) + t.Fatalf("the closed pane lost the money line %q (words %q, defaults %+v, effective %+v):\n%s", + closedWant, a.teamsSpendWords(sel), a.tp.defaults, eff, text) + } + if text := teamsFrameText(a); strings.Contains(text, "of $5 today") { + t.Fatalf("a closed team kept a cap reading:\n%s", text) + } +} + +// THE SETTINGS CARD SHOWS THE TOTAL AND EVERY MEMBER'S SHARE OF IT, FORMER +// MEMBERS INCLUDED: `Total cost $67.46` and one row per conversation that +// spent something, the runaway-member line the ByMember split is computed +// for. A member that has left keeps their share on the card, under their +// handle. +func TestTeamsSettingsCardShowsTheTotalAndThePerMemberShare(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + // A member who has left: their lines stay the team's, and their share + // stays on the card. + gonePath := filepath.Join(t.TempDir(), "gone.jsonl") + gone := a.convKey(gonePath) + if err := a.teamEdit(func(f *teamstore.File) error { + if err := f.AddMember(harbor, teamstore.Member{Key: gone, File: gonePath, Where: t.TempDir(), Word: "gone", Handle: "gone"}); err != nil { + return err + } + return f.RemoveMember(harbor, gone) + }); err != nil { + t.Fatal(err) + } + team := mustTeam(t, a, harbor) + var keys = map[string]bool{} + for _, m := range append(append([]teamMember(nil), team.Members...), team.FormerMembers...) { + keys[m.Key] = true + } + if !keys[gone] { + t.Fatalf("the removed member is not among the team's members: %+v", team.Members) + } + a.tp.total = map[string]teamstore.Total{harbor: {Team: harbor, USD: 67.46, ByMember: map[string]float64{ + team.Members[0].Key: 40.12, + team.Members[1].Key: 25.35, + gone: 1.99, + }}} + var totalLine, shareLine, formerLine int + for _, ln := range a.teamSheetSettingsLines(team, 64) { + s := strings.TrimSpace(plain(ln.s)) + switch { + case strings.HasPrefix(s, "Total cost") && strings.Contains(s, "$67.46"): + totalLine++ + case strings.Contains(s, "@gone") && strings.Contains(s, "$1.99"): + formerLine++ + case strings.Contains(s, "$40.12") || strings.Contains(s, "$25.35"): + shareLine++ + } + } + if totalLine != 1 || shareLine != 2 || formerLine != 1 { + t.Fatalf("the settings card lost its total or a share (total %d, current shares %d, former %d):\n%s", + totalLine, shareLine, formerLine, teamsFrameText(a)) + } +} + +// OVER A SEAM WITHOUT THE TOTAL DOOR THE PAGE DRAWS NO TOTALS, and over the +// same store behind a door that does answer, it draws the figure. The door +// without [TeamsSeam.Total] is an older engine over --host: [app.teamsCanReadTotals] +// answers false, [app.teamsRead] asks for none, folds none, and the rail row +// and the body money line carry no `total $…` — the connection never gets +// figures off this machine's ledger. The day's spend still crosses the same +// door, so the absence is the total door's, not the store's and not the +// page's. +func TestTeamsWithNoTotalDoorDrawsNoTotals(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + farDir := t.TempDir() + const ( + sess = "0123456789abcdef" + harbor = "0a0a0a0a0a0a" + ) + if err := teamstore.Save(farDir, []teamstore.Team{{ID: harbor, Name: "harbor", + Members: []teamstore.Member{{Key: filepath.Join("/srv", sess, "transcript.jsonl"), Handle: "web"}}}}); err != nil { + t.Fatal(err) + } + line := fmt.Sprintf(`{"at":"2026-10-23T10:00:00-04:00","day":%q,"usd":67.46,"calls":1,"session":%q,"root":""}`, + teamstore.Today(), sess) + ledger := teamstore.UsageLedgerPath() + if err := os.MkdirAll(filepath.Dir(ledger), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ledger, []byte(line+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + a, _, _ := teamsPlaceLabIDs(t) + // THE OLDER ENGINE'S SEAM: the same store, the Total door taken out. + older := localTeams(farDir, &a.teamsDisk.watch) + older.Total = nil + a.teamsDisk.door = older + a.wall.loaded = false + a.teamsEnsure() + if a.teamsCanReadTotals() { + t.Fatal("a seam without the total door says it can read totals") + } + drive(t, a, runCmd(a.teamsRead(true))...) + if tv, ok := a.tp.total[harbor]; ok { + t.Fatalf("a read without the total door folded a total: %+v", tv) + } + drive(t, a, runCmd(a.teamsSelect(harbor))...) + text := teamsFrameText(a) + if strings.Contains(text, "total $") { + t.Fatalf("the pane drew a total over a door without it:\n%s", text) + } + if !strings.Contains(text, "$67.46 today") { + t.Fatalf("the day's spend failed to cross the same door:\n%s", text) + } + + // THE SAME STORE BEHIND A DOOR THAT ANSWERS THE TOTAL: the figure lands + // on the rail row the moment the door has it, for the same store that a + // moment ago drew none. + withTotal := localTeams(farDir, &a.teamsDisk.watch) + a.teamsDisk.door = withTotal + drive(t, a, runCmd(a.teamsRead(true))...) + tv, ok := a.tp.total[harbor] + if !ok || int(tv.USD*100+0.5) != 6746 { + t.Fatalf("the total door folded %+v", tv) + } + text = teamsFrameText(a) + if !strings.Contains(text, "total $67.46") { + t.Fatalf("the pane lost the total over a door with it:\n%s", text) + } + var harborRow string + rows := strings.Split(text, "\n") + for i, l := range rows { + if i == len(rows)-1 { + continue // the keyboard footer is its own row, not the rail + } + if at := strings.Index(l, "│"); at >= 0 { + l = l[:at] + } + if strings.Contains(l, "harbor") { + harborRow = l + break + } + } + if !strings.Contains(harborRow, "$67.46") { + t.Fatalf("the harbor rail row lost its total: %q\nframe:\n%s", harborRow, text) + } +} diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index 8de8ac76d2..161fd414d5 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -287,8 +287,14 @@ func (a *app) teamsRailTeam(d *teamsDraw, t team, depth, width, y int) string { if t.Root { name = teamstore.RootName } + // The team's own total, dim at the row's right, before the needs/working + // mark: the name truncates first, so the figure never drops off the row. + total, totalW := "", 0 + if words := a.teamsTotalWords(t); words != "" { + total, totalW = pal.dim(words), ansi.StringWidth(words) + } lead := " " + strings.Repeat(" ", depth) - room := width - ansi.StringWidth(lead) - 2 - markW - 2 + room := width - ansi.StringWidth(lead) - 2 - markW - totalW - 2 if room < 3 { room = 3 } @@ -305,6 +311,10 @@ func (a *app) teamsRailTeam(d *teamsDraw, t team, depth, width, y int) string { if t.Manager != "" { text += " " + pal.dim(a.teamManagerMark()) } + if total != "" { + gap := width - ansi.StringWidth(text) - totalW - markW - 2 + text += strings.Repeat(" ", max(gap, 1)) + total + } if mark != "" { gap := width - ansi.StringWidth(text) - markW - 1 text += strings.Repeat(" ", max(gap, 1)) + mark @@ -436,7 +446,33 @@ func (a *app) teamsSpendWords(t team) string { return words } -// teamsRaisedCap is today's ceiling after a person accepted a raise. The +// teamsTotalWords is team t's total live cost, all time: `$67.46`, the figure +// of the team's own that has no cap in it. "" while no total is read yet, or +// when the team has spent nothing at all. +func (a *app) teamsTotalWords(t team) string { + tv, ok := a.tp.total[t.ID] + if !ok || tv.USD <= 0 { + return "" + } + return teamsMoney(teamstore.RoundMoney(tv.USD)) +} + +// teamsBodyMoney is the pane body's one money line for team t: its total live +// cost beside the day's spend against the cap, `total $67.46 · $1.20 of $5 +// today`. "" when there is nothing to say yet. The strip's cap piece +// ([app.teamsSpendWords]) stays where it is; this is the figure of the team +// itself, drawn open and closed. +func (a *app) teamsBodyMoney(t team) string { + var parts []string + if total := a.teamsTotalWords(t); total != "" { + parts = append(parts, "total "+total) + } + if spend := a.teamsSpendWords(t); spend != "" { + parts = append(parts, spend) + } + return strings.Join(parts, " "+a.teamsDot()+" ") +} + // decision is deliberately not a team setting, so the header must read the // same packet the cap gate uses rather than the stored recurring ceiling. func (a *app) teamsRaisedCap(owner string, cap float64) float64 { @@ -850,6 +886,10 @@ func (a *app) teamsPaneRest(d *teamsDraw, width, y int) []string { out = append(out, a.teamsClosedRows(d, t, width, y)...) default: out = append(out, "") + if money := a.teamsBodyMoney(t); money != "" { + out = append(out, " "+pal.dim(money)) + out = append(out, "") + } out = append(out, a.teamsMemberCards(d, t, width, y+len(out))...) } return out @@ -919,8 +959,8 @@ func (a *app) teamsClosedRows(d *teamsDraw, t team, width, y int) []string { out = append(out, "", " "+pal.muted("members")) members := a.teamsMembersRows(d, t, width, y+len(out)) out = append(out, members...) - if spend := a.teamsSpendWords(t); spend != "" { - out = append(out, "", " "+pal.dim(spend)) + if money := a.teamsBodyMoney(t); money != "" { + out = append(out, "", " "+pal.dim(money)) } out = append(out, "", " "+pal.muted("Decisions")) for _, packet := range a.tp.history[t.ID] {