Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions cmd/codeaf/chatv3_host_teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
64 changes: 64 additions & 0 deletions cmd/codeaf/chatv3_host_teams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package main

import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"

"github.com/Agent-Field/codeaf/internal/remote"
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1878-team-total.md
Original file line number Diff line number Diff line change
@@ -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.
Loading