refactor(server): rename dashboard row id → ws_id for v1 row-shape consistency

The /v1/api/dashboard endpoint was the last workstream-listing surface
keyed on `id` rather than `ws_id`. The Stage 2 list-verb lift converged
the active list (`/v1/api/workstreams`) and saved list
(`/v1/api/workstreams/saved`) on `ws_id` but explicitly left dashboard
alone to keep that PR's diff focused. This lands the same rename on
the remaining endpoint so v1 row shape is consistent across the family.

Scope kept narrow:

- Pydantic `DashboardWorkstream` and TS SDK `DashboardWorkstream`
  interface both rename `id: str/string` → `ws_id`.
- The bundled web UI (`turnstone/ui/static/app.js`) is the only consumer
  reading `dashboard.workstreams[].id` and is updated atomically.
- Console `_fetch_live_block` (cluster-inspect's projection over a
  remote node's dashboard payload at `turnstone/console/server.py`)
  flips its `entry.get("id")` lookup to `entry.get("ws_id")`.
- Drive-by: stale `id` example in `docs/api-reference.md` for the
  earlier `/v1/api/workstreams` rename also fixed.

`_build_node_snapshot` (the global-events SSE node_snapshot payload
consumed by the cluster collector) deliberately stays on `id` — it's
part of a separate cluster-row family (collector → cluster_workstreams
→ console UI) that is internally consistent on `id` and would need its
own coordinated sweep. CHANGELOG documents the bounded blast radius.

Tests: 4554 passing (-m "not live"). ruff + mypy clean.
This commit is contained in:
Patrick Buckley
2026-04-26 17:50:53 -07:00
committed by Patrick Buckley
parent 3abd2c441b
commit 6572437c5d
11 changed files with 53 additions and 30 deletions
+17
View File
@@ -17,6 +17,23 @@ Three release tracks are maintained:
### Changed
- **Dashboard row shape: ``id`` → ``ws_id``.** The
``GET /v1/api/dashboard`` row dict now keys the workstream
identifier as ``ws_id`` (matching the rest of the v1 workstream
surface — active list, saved list, history, detail). The Stage 2
list-verb lift converged ``/v1/api/workstreams`` and
``/v1/api/workstreams/saved`` on ``ws_id`` but left dashboard
alone to keep that PR's diff focused; this lands the same rename
on the remaining endpoint so the v1 row shape is consistent
across the family. Pydantic ``DashboardWorkstream`` and the
TypeScript SDK ``DashboardWorkstream`` interface both rename the
field accordingly. The bundled web UI is the only consumer that
reads ``dashboard.workstreams[].id`` and is updated atomically;
no external SDK on a stable line reads the field, so the swap is
bounded by normal static-asset reload. Console
``_fetch_live_block`` (cluster-inspect's projection over a
remote node's dashboard payload) is updated to match.
- **Coordinator gains rich `ws_state` payload + live activity broadcast**
([§ Post-P3 reckoning item #2 follow-up]). Pre-lift coord's
cluster broadcast was state-only — the dashboard's coord rows
+3 -3
View File
@@ -571,8 +571,8 @@ Returns a list of all active workstreams.
```json
{
"workstreams": [
{"id": "abc123", "name": "default", "state": "idle"},
{"id": "def456", "name": "hacker-news", "state": "thinking"}
{"ws_id": "abc123", "name": "default", "state": "idle"},
{"ws_id": "def456", "name": "hacker-news", "state": "thinking"}
]
}
```
@@ -581,7 +581,7 @@ Each workstream object:
| Field | Type | Description |
|--------------|-------------|--------------------------------------------------------|
| `id` | string | Unique workstream routing identifier |
| `ws_id` | string | Unique workstream routing identifier |
| `name` | string | Display name (alias if set, otherwise `ws-xxxx`) |
| `state` | string | Current state (see state values above) |
+4 -3
View File
@@ -2342,9 +2342,10 @@
"type": "object"
},
"DashboardWorkstream": {
"description": "Dashboard row shape for ``GET /v1/api/dashboard``.\n\nRenamed ``id`` \u2192 ``ws_id`` for v1 row-shape consistency with\nthe rest of the workstream surface (active list, saved list,\nhistory, detail, etc.). Frontend consumers reading\n``dashboard.workstreams[].id`` swap to ``.ws_id``.",
"properties": {
"id": {
"title": "Id",
"ws_id": {
"title": "Ws Id",
"type": "string"
},
"name": {
@@ -2423,7 +2424,7 @@
}
},
"required": [
"id",
"ws_id",
"name",
"state"
],
+1 -1
View File
@@ -201,7 +201,7 @@ export interface WorkstreamHistoryResponse {
}
export interface DashboardWorkstream {
id: string;
ws_id: string;
name: string;
state: string;
title?: string;
+3 -3
View File
@@ -1565,7 +1565,7 @@ def test_cluster_inspect_node_backed_success(storage):
payload = {
"workstreams": [
{
"id": ws_id,
"ws_id": ws_id,
"state": "running",
"tokens": 512,
"context_ratio": 0.25,
@@ -1608,7 +1608,7 @@ def test_cluster_inspect_node_backed_pending_approval_synthesized(storage):
payload = {
"workstreams": [
{
"id": ws_id,
"ws_id": ws_id,
"state": "attention",
"activity_state": "approval",
"activity": "awaiting approval",
@@ -1661,7 +1661,7 @@ def test_cluster_inspect_node_missing_entry_live_null(storage):
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
payload = {
"workstreams": [
{"id": "different-" + "x" * 24, "state": "idle"},
{"ws_id": "different-" + "x" * 24, "state": "idle"},
]
}
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
+1 -1
View File
@@ -60,7 +60,7 @@ async def test_dashboard():
{
"workstreams": [
{
"id": "ws1",
"ws_id": "ws1",
"name": "demo",
"state": "idle",
"tokens": 100,
+2 -2
View File
@@ -318,13 +318,13 @@ class TestDashboardCache4xxLogLevel:
calls["n"] += 1
if calls["n"] == 1:
return httpx.Response(403, text="forbidden")
return httpx.Response(200, json={"workstreams": [{"id": "ws-1"}]})
return httpx.Response(200, json={"workstreams": [{"ws_id": "ws-1"}]})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
first = await cache.get("node-1", "http://node-1:8001", client, {})
second = await cache.get("node-1", "http://node-1:8001", client, {})
assert first is None
assert second == {"workstreams": [{"id": "ws-1"}]}
assert second == {"workstreams": [{"ws_id": "ws-1"}]}
assert calls["n"] == 2, "4xx must bypass the cache so the retry reaches upstream"
+9 -1
View File
@@ -220,7 +220,15 @@ class ListWorkstreamsResponse(BaseModel):
class DashboardWorkstream(BaseModel):
id: str
"""Dashboard row shape for ``GET /v1/api/dashboard``.
Renamed ``id`` → ``ws_id`` for v1 row-shape consistency with
the rest of the workstream surface (active list, saved list,
history, detail, etc.). Frontend consumers reading
``dashboard.workstreams[].id`` swap to ``.ws_id``.
"""
ws_id: str
name: str
state: str
title: str = ""
+1 -1
View File
@@ -809,7 +809,7 @@ async def _fetch_live_block(
if payload is None:
return None
for entry in payload.get("workstreams", []) or []:
if isinstance(entry, dict) and entry.get("id") == ws_id:
if isinstance(entry, dict) and entry.get("ws_id") == ws_id:
live = {k: entry.get(k) for k in _CLUSTER_WS_LIVE_KEYS if k in entry}
# Derived field — kept in lockstep with
# _coordinator_live_snapshot so both origins produce the
+2 -9
View File
@@ -1135,14 +1135,7 @@ async def global_events_sse(request: Request) -> Response:
async def dashboard(request: Request) -> JSONResponse:
"""GET /v1/api/dashboard — enriched workstream data + aggregate stats.
NOTE: row shape still uses ``"id"`` (not ``"ws_id"``) — Stage 2's
list-verb lift converged ``/v1/api/workstreams`` and
``/saved`` on ``ws_id`` but left dashboard alone to keep that
PR's diff focused. The same rename should land here as a separate
cleanup so the v1 row shape is consistent across the family.
"""
"""GET /v1/api/dashboard — enriched workstream data + aggregate stats."""
from turnstone.core.memory import get_workstream_display_name
mgr: SessionManager = request.app.state.workstreams
@@ -1170,7 +1163,7 @@ async def dashboard(request: Request) -> JSONResponse:
title = get_workstream_display_name(ws.session.ws_id) or ""
ws_list.append(
{
"id": ws.id,
"ws_id": ws.id,
"name": title or ws.name,
"state": ws.state.value,
"title": title,
+10 -6
View File
@@ -3644,7 +3644,7 @@ function loadDashboard() {
renderDashboardTable(wsList, agg);
var activeWsIds = {};
wsList.forEach(function (ws) {
activeWsIds[ws.id] = true;
activeWsIds[ws.ws_id] = true;
});
var savedList = (res[1].workstreams || []).filter(function (s) {
return !activeWsIds[s.ws_id];
@@ -3674,14 +3674,18 @@ function renderDashboardTable(wsList, agg) {
}
wsList.forEach(function (ws) {
var liveState =
(workstreams[ws.id] && workstreams[ws.id].state) || ws.state || "idle";
(workstreams[ws.ws_id] && workstreams[ws.ws_id].state) ||
ws.state ||
"idle";
var liveName =
(workstreams[ws.id] && workstreams[ws.id].name) || ws.name || ws.id;
(workstreams[ws.ws_id] && workstreams[ws.ws_id].name) ||
ws.name ||
ws.ws_id;
var sd = STATE_DISPLAY[liveState] || STATE_DISPLAY.idle;
var row = document.createElement("div");
row.className = "dash-row";
row.dataset.wsId = ws.id;
row.dataset.wsId = ws.ws_id;
row.dataset.state = liveState;
row.setAttribute("role", "button");
row.setAttribute("tabindex", "0");
@@ -3754,12 +3758,12 @@ function renderDashboardTable(wsList, agg) {
row.appendChild(sub);
row.onclick = function () {
dashboardSwitchWorkstream(ws.id);
dashboardSwitchWorkstream(ws.ws_id);
};
row.onkeydown = function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
dashboardSwitchWorkstream(ws.id);
dashboardSwitchWorkstream(ws.ws_id);
}
};