on main help sign in
feat(api): report how many clients are watching each session

Session list and detail responses now include a count of how many connected clients are currently looking at each session. Clients that have only reserved a workspace but have not yet opened an event stream are excluded, and so are clients that are connected but not actively viewing the session. This count enables UI features like a live indicator showing whether someone else is already in a session. Co-Authored-By: Charm Crush <crush@charm.land>

commitec85e9ef686c23ce3366c46589cdfabc05cd2199 authorChristian Rocha <christian@rocha.is> dateTue 19 May 2026, 23:53 (5 months ago) parent178cd11
7 files changed+297
internal/backend/backend.go+30 internal/backend/backend_test.go+88 internal/backend/testing.go+8 internal/proto/session.go+7 internal/server/events.go+11 internal/server/proto.go+4 internal/server/sessions_isbusy_test.go+149
internal/backend/backend.go+30
@@ -507,6 +507,36 @@ func (b *Backend) SetCurrentSession(workspaceID, clientID, sessionID string) err
507507 return nil
508508}
509509
510+// AttachedClients returns the number of clients currently viewing
511+// sessionID in the given workspace. Only clients with at least one live
512+// SSE stream (streams > 0) AND a matching currentSessionID are counted;
513+// pure creation holds do not contribute. Returns [ErrWorkspaceNotFound]
514+// if the workspace is unknown.
515+func (b *Backend) AttachedClients(workspaceID, sessionID string) (int, error) {
516+ ws, ok := b.workspaces.Get(workspaceID)
517+ if !ok {
518+ return 0, ErrWorkspaceNotFound
519+ }
520+ return ws.AttachedClientsForSession(sessionID), nil
521+}
522+
523+// AttachedClientsForSession returns the number of clients in this
524+// workspace whose currentSessionID equals sessionID and which have at
525+// least one live SSE stream. Hold-only clients (streams == 0) do not
526+// contribute. Acquires the workspace's [clientsMu] briefly; the
527+// returned count is a point-in-time snapshot.
528+func (w *Workspace) AttachedClientsForSession(sessionID string) int {
529+ w.clientsMu.Lock()
530+ defer w.clientsMu.Unlock()
531+ n := 0
532+ for _, cs := range w.clients {
533+ if cs.streams > 0 && cs.currentSessionID == sessionID {
534+ n++
535+ }
536+ }
537+ return n
538+}
539+
510540// GetWorkspaceProto returns the proto representation of a workspace.
511541func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
512542 ws, err := b.GetWorkspace(id)
internal/backend/backend_test.go+88
@@ -1132,3 +1132,91 @@ func TestSetCurrentSession_RaceWithDetach(t *testing.T) {
11321132 require.Contains(t, ws.clients, cidB, "remaining client must still be present")
11331133 require.Equal(t, "SB", ws.clients[cidB].currentSessionID, "remaining client must keep its last set session")
11341134}
1135+
1136+// TestAttachedClients_BasicLifecycle walks one session's count through
1137+// attach -> set -> second client joins -> switch -> detach. It also
1138+// confirms hold-only and unselected clients do not contribute.
1139+func TestAttachedClients_BasicLifecycle(t *testing.T) {
1140+ t.Parallel()
1141+
1142+ b, _ := newTestBackend(t)
1143+ // Keep the grace window long so the hold-only client survives.
1144+ b.createGrace = time.Hour
1145+ ws, _ := insertTestWorkspace(t, b, "/tmp/attached-clients-basic")
1146+
1147+ // No clients yet.
1148+ n, err := b.AttachedClients(ws.ID, "S1")
1149+ require.NoError(t, err)
1150+ require.Zero(t, n)
1151+
1152+ // Attach A, set to S1. Count for S1 is 1; count for S2 is 0.
1153+ cidA := newClientID(t)
1154+ require.NoError(t, b.AttachClient(ws.ID, cidA))
1155+ require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
1156+
1157+ n, err = b.AttachedClients(ws.ID, "S1")
1158+ require.NoError(t, err)
1159+ require.Equal(t, 1, n)
1160+ n, err = b.AttachedClients(ws.ID, "S2")
1161+ require.NoError(t, err)
1162+ require.Zero(t, n)
1163+
1164+ // Attach B, set to S1. Count for S1 is 2.
1165+ cidB := newClientID(t)
1166+ require.NoError(t, b.AttachClient(ws.ID, cidB))
1167+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
1168+
1169+ n, _ = b.AttachedClients(ws.ID, "S1")
1170+ require.Equal(t, 2, n)
1171+
1172+ // B switches to S2; counts redistribute.
1173+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
1174+ n, _ = b.AttachedClients(ws.ID, "S1")
1175+ require.Equal(t, 1, n)
1176+ n, _ = b.AttachedClients(ws.ID, "S2")
1177+ require.Equal(t, 1, n)
1178+
1179+ // A hold-only client must NOT be counted, even if we were able to
1180+ // imagine a currentSessionID on it. registerClient leaves
1181+ // currentSessionID empty by construction, and SetCurrentSession
1182+ // rejects hold-only writers — so the contract holds two ways.
1183+ cidHold := newClientID(t)
1184+ b.registerClient(ws, cidHold)
1185+ t.Cleanup(func() { _ = b.releaseHold(ws.ID, cidHold) })
1186+ n, _ = b.AttachedClients(ws.ID, "S1")
1187+ require.Equal(t, 1, n, "hold-only client must not contribute")
1188+ n, _ = b.AttachedClients(ws.ID, "")
1189+ require.Equal(t, 0, n,
1190+ "empty sessionID must not match the hold-only entry (streams==0)")
1191+
1192+ // A client with streams > 0 but currentSessionID == "" is NOT
1193+ // counted toward any non-empty session, and is matched only
1194+ // against the empty session id (which represents the landing
1195+ // screen).
1196+ cidC := newClientID(t)
1197+ require.NoError(t, b.AttachClient(ws.ID, cidC))
1198+ n, _ = b.AttachedClients(ws.ID, "S1")
1199+ require.Equal(t, 1, n, "stream-only client with empty currentSessionID must not be counted toward S1")
1200+ n, _ = b.AttachedClients(ws.ID, "")
1201+ require.Equal(t, 1, n, "stream-only client with empty currentSessionID matches the empty session id")
1202+
1203+ // B detaches: count for S2 drops to 0.
1204+ b.DetachClient(ws.ID, cidB)
1205+ n, _ = b.AttachedClients(ws.ID, "S2")
1206+ require.Zero(t, n)
1207+ n, _ = b.AttachedClients(ws.ID, "S1")
1208+ require.Equal(t, 1, n, "A still on S1")
1209+
1210+ // Final cleanup.
1211+ b.DetachClient(ws.ID, cidA)
1212+ b.DetachClient(ws.ID, cidC)
1213+}
1214+
1215+// TestAttachedClients_UnknownWorkspace verifies the error surface.
1216+func TestAttachedClients_UnknownWorkspace(t *testing.T) {
1217+ t.Parallel()
1218+
1219+ b, _ := newTestBackend(t)
1220+ _, err := b.AttachedClients("00000000-0000-0000-0000-000000000000", "S1")
1221+ require.ErrorIs(t, err, ErrWorkspaceNotFound)
1222+}
internal/backend/testing.go+8
@@ -30,3 +30,11 @@ func RegisterClientForTesting(b *Backend, ws *Workspace, clientID string) error
3030 b.registerClient(ws, clientID)
3131 return nil
3232}
33+
34+// SetWorkspaceShutdownFnForTest overrides the workspace teardown
35+// callback. Useful for tests in other packages that drive synthetic
36+// workspaces (where the embedded [app.App] is incomplete) through
37+// detach paths that would otherwise crash inside App.Shutdown.
38+func SetWorkspaceShutdownFnForTest(ws *Workspace, fn func()) {
39+ ws.shutdownFn = fn
40+}
internal/proto/session.go+7
@@ -7,6 +7,12 @@ package proto
77// It is populated by REST handlers in internal/server/proto.go from the
88// workspace's AgentCoordinator. The Session SSE event path does not set
99// it, since SSE consumers can compute presence from other agent signals.
10+//
11+// AttachedClients counts the number of clients currently viewing this
12+// session — i.e. entries in the workspace's clients map whose
13+// currentSessionID equals this session's ID and which have at least one
14+// live SSE stream. Hold-only clients (streams == 0) do not contribute.
15+// Like IsBusy, it is computed on read by REST handlers.
1016type Session struct {
1117 ID string `json:"id"`
1218 ParentSessionID string `json:"parent_session_id"`
@@ -20,6 +26,7 @@ type Session struct {
2026 CreatedAt int64 `json:"created_at"`
2127 UpdatedAt int64 `json:"updated_at"`
2228 IsBusy bool `json:"is_busy"`
29+ AttachedClients int `json:"attached_clients"`
2330}
2431
2532// Todo represents a single todo entry on a session in the proto layer.
internal/server/events.go+11
@@ -156,6 +156,17 @@ func isSessionBusy(ws *backend.Workspace, sessionID string) bool {
156156 return ws.AgentCoordinator.IsSessionBusy(sessionID)
157157}
158158
159+// attachedClients returns the number of clients currently viewing
160+// sessionID in ws. Hold-only clients (streams == 0) do not contribute.
161+// A nil workspace is treated as zero so handlers can pass GetWorkspace's
162+// result through without an extra guard.
163+func attachedClients(ws *backend.Workspace, sessionID string) int {
164+ if ws == nil {
165+ return 0
166+ }
167+ return ws.AttachedClientsForSession(sessionID)
168+}
169+
159170func todosToProto(todos []session.Todo) []proto.Todo {
160171 if len(todos) == 0 {
161172 return nil
internal/server/proto.go+4
@@ -381,6 +381,7 @@ func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http
381381 for i, s := range sessions {
382382 result[i] = sessionToProto(s)
383383 result[i].IsBusy = isSessionBusy(ws, s.ID)
384+ result[i].AttachedClients = attachedClients(ws, s.ID)
384385 }
385386 jsonEncode(w, result)
386387}
@@ -416,6 +417,7 @@ func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *htt
416417 ws, _ := c.backend.GetWorkspace(id)
417418 out := sessionToProto(sess)
418419 out.IsBusy = isSessionBusy(ws, sess.ID)
420+ out.AttachedClients = attachedClients(ws, sess.ID)
419421 jsonEncode(w, out)
420422}
421423
@@ -441,6 +443,7 @@ func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.
441443 ws, _ := c.backend.GetWorkspace(id)
442444 out := sessionToProto(sess)
443445 out.IsBusy = isSessionBusy(ws, sess.ID)
446+ out.AttachedClients = attachedClients(ws, sess.ID)
444447 jsonEncode(w, out)
445448}
446449
@@ -520,6 +523,7 @@ func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.
520523 ws, _ := c.backend.GetWorkspace(id)
521524 out := sessionToProto(saved)
522525 out.IsBusy = isSessionBusy(ws, saved.ID)
526+ out.AttachedClients = attachedClients(ws, saved.ID)
523527 jsonEncode(w, out)
524528}
525529
internal/server/sessions_isbusy_test.go+149
@@ -174,3 +174,152 @@ func TestProtoSessionIsBusyBackwardCompat(t *testing.T) {
174174 require.Equal(t, "s1", old.ID)
175175 require.Equal(t, "t", old.Title)
176176}
177+
178+// buildMultiSessionWorkspace returns a controller wired to a backend
179+// that owns a workspace with the given session IDs. Used to exercise
180+// AttachedClients counts across more than one session.
181+func buildMultiSessionWorkspace(t *testing.T, sessionIDs ...string) (*controllerV1, *backend.Workspace) {
182+ t.Helper()
183+
184+ b := backend.New(context.Background(), nil, nil)
185+ a := &app.App{AgentCoordinator: &stubCoordinator{}}
186+ sessions := make([]session.Session, len(sessionIDs))
187+ for i, sid := range sessionIDs {
188+ sessions[i] = session.Session{ID: sid, Title: sid}
189+ }
190+ a.Sessions = &stubSessions{all: sessions}
191+
192+ ws := &backend.Workspace{
193+ ID: uuid.New().String(),
194+ Path: t.TempDir(),
195+ App: a,
196+ }
197+ backend.InsertWorkspaceForTest(b, ws)
198+ // Synthetic workspaces have an incomplete App; bypass the
199+ // default teardown to avoid panics when the last client detaches.
200+ backend.SetWorkspaceShutdownFnForTest(ws, func() {})
201+
202+ s := &Server{backend: b}
203+ return &controllerV1{backend: b, server: s}, ws
204+}
205+
206+// listSessions invokes handleGetWorkspaceSessions and returns the
207+// decoded response so tests can assert per-session counts.
208+func listSessions(t *testing.T, c *controllerV1, wsID string) []proto.Session {
209+ t.Helper()
210+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/"+wsID+"/sessions", nil)
211+ req.SetPathValue("id", wsID)
212+ rec := httptest.NewRecorder()
213+ c.handleGetWorkspaceSessions(rec, req)
214+ require.Equal(t, http.StatusOK, rec.Code)
215+ var got []proto.Session
216+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
217+ return got
218+}
219+
220+func countsBySessionID(sessions []proto.Session) map[string]int {
221+ out := make(map[string]int, len(sessions))
222+ for _, s := range sessions {
223+ out[s.ID] = s.AttachedClients
224+ }
225+ return out
226+}
227+
228+// TestSessionListIncludesAttachedClients walks two sessions through
229+// the same lifecycle covered by TestAttachedClients_BasicLifecycle in
230+// the backend package, but observed at the handler boundary.
231+func TestSessionListIncludesAttachedClients(t *testing.T) {
232+ t.Parallel()
233+ c, ws := buildMultiSessionWorkspace(t, "S1", "S2")
234+
235+ // No attached clients yet.
236+ counts := countsBySessionID(listSessions(t, c, ws.ID))
237+ require.Equal(t, 0, counts["S1"])
238+ require.Equal(t, 0, counts["S2"])
239+
240+ // Attach A, set to S1: S1=1.
241+ cidA := uuid.New().String()
242+ require.NoError(t, c.backend.AttachClient(ws.ID, cidA))
243+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cidA) })
244+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cidA, "S1"))
245+ counts = countsBySessionID(listSessions(t, c, ws.ID))
246+ require.Equal(t, 1, counts["S1"])
247+ require.Equal(t, 0, counts["S2"])
248+
249+ // Attach B, set to S1: S1=2.
250+ cidB := uuid.New().String()
251+ require.NoError(t, c.backend.AttachClient(ws.ID, cidB))
252+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cidB, "S1"))
253+ counts = countsBySessionID(listSessions(t, c, ws.ID))
254+ require.Equal(t, 2, counts["S1"])
255+ require.Equal(t, 0, counts["S2"])
256+
257+ // B switches to S2: counts redistribute.
258+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cidB, "S2"))
259+ counts = countsBySessionID(listSessions(t, c, ws.ID))
260+ require.Equal(t, 1, counts["S1"])
261+ require.Equal(t, 1, counts["S2"])
262+
263+ // B detaches: S2 drops to 0.
264+ c.backend.DetachClient(ws.ID, cidB)
265+ counts = countsBySessionID(listSessions(t, c, ws.ID))
266+ require.Equal(t, 1, counts["S1"])
267+ require.Equal(t, 0, counts["S2"])
268+}
269+
270+// TestSessionListExcludesHoldOnlyClient verifies that a registered
271+// client without an SSE stream (streams == 0) does not contribute to
272+// AttachedClients, even though it has an entry in the workspace's
273+// clients map.
274+func TestSessionListExcludesHoldOnlyClient(t *testing.T) {
275+ t.Parallel()
276+ c, ws := buildMultiSessionWorkspace(t, "S1")
277+
278+ cid := uuid.New().String()
279+ require.NoError(t, backend.RegisterClientForTesting(c.backend, ws, cid))
280+ t.Cleanup(func() { _ = c.backend.DeleteWorkspace(ws.ID, cid) })
281+
282+ counts := countsBySessionID(listSessions(t, c, ws.ID))
283+ require.Equal(t, 0, counts["S1"], "hold-only client must not be counted")
284+}
285+
286+// TestSessionListExcludesUnselectedAttachedClient verifies that a
287+// client with a live SSE stream but no current session
288+// (currentSessionID == "") does not show up under any session's count.
289+func TestSessionListExcludesUnselectedAttachedClient(t *testing.T) {
290+ t.Parallel()
291+ c, ws := buildMultiSessionWorkspace(t, "S1")
292+
293+ cid := uuid.New().String()
294+ require.NoError(t, c.backend.AttachClient(ws.ID, cid))
295+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
296+ // Intentionally do NOT call SetCurrentSession.
297+
298+ counts := countsBySessionID(listSessions(t, c, ws.ID))
299+ require.Equal(t, 0, counts["S1"],
300+ "attached client with no current session must not contribute to S1")
301+}
302+
303+// TestSessionGetIncludesAttachedClients verifies the single-session
304+// handler also populates AttachedClients.
305+func TestSessionGetIncludesAttachedClients(t *testing.T) {
306+ t.Parallel()
307+ c, ws := buildMultiSessionWorkspace(t, "S1")
308+
309+ cid := uuid.New().String()
310+ require.NoError(t, c.backend.AttachClient(ws.ID, cid))
311+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
312+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cid, "S1"))
313+
314+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
315+ "/v1/workspaces/"+ws.ID+"/sessions/S1", nil)
316+ req.SetPathValue("id", ws.ID)
317+ req.SetPathValue("sid", "S1")
318+ rec := httptest.NewRecorder()
319+ c.handleGetWorkspaceSession(rec, req)
320+ require.Equal(t, http.StatusOK, rec.Code)
321+
322+ var got proto.Session
323+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
324+ require.Equal(t, 1, got.AttachedClients)
325+}