diff --git a/CHANGELOG.md b/CHANGELOG.md index 008725d6..57e3f6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api-reference.md b/docs/api-reference.md index 955c059d..ac2eef78 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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) | diff --git a/sdk/typescript/openapi-server.json b/sdk/typescript/openapi-server.json index 5dbcf731..59be394f 100644 --- a/sdk/typescript/openapi-server.json +++ b/sdk/typescript/openapi-server.json @@ -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" ], diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index bcc952c1..d22d1b89 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -201,7 +201,7 @@ export interface WorkstreamHistoryResponse { } export interface DashboardWorkstream { - id: string; + ws_id: string; name: string; state: string; title?: string; diff --git a/tests/test_coordinator_endpoints.py b/tests/test_coordinator_endpoints.py index 650241ed..9eec84c3 100644 --- a/tests/test_coordinator_endpoints.py +++ b/tests/test_coordinator_endpoints.py @@ -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()) diff --git a/tests/test_sdk_server.py b/tests/test_sdk_server.py index 86f87d2d..be06781e 100644 --- a/tests/test_sdk_server.py +++ b/tests/test_sdk_server.py @@ -60,7 +60,7 @@ async def test_dashboard(): { "workstreams": [ { - "id": "ws1", + "ws_id": "ws1", "name": "demo", "state": "idle", "tokens": 100, diff --git a/tests/test_service_auth_boundary.py b/tests/test_service_auth_boundary.py index a7310c48..6544ebda 100644 --- a/tests/test_service_auth_boundary.py +++ b/tests/test_service_auth_boundary.py @@ -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" diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index 1504e3de..cc292283 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -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 = "" diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 3f8f709c..74abb20d 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -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 diff --git a/turnstone/server.py b/turnstone/server.py index fdf7426e..920075c2 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -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, diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index 0d815be8..c24c26b8 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -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); } };