From c48f26dd1fa091e4e3f42ed6d41c8d0a22680f15 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 2 May 2026 12:01:45 +0200 Subject: [PATCH] Overview shows workstreams by repo now and allows drilldown --- state-hub/api/routers/workstreams.py | 74 ++++++++++++++ state-hub/dashboard/src/docs/overview.md | 26 +++-- state-hub/dashboard/src/index.md | 104 ++++++++++++++------ state-hub/dashboard/src/workstreams/[id].md | 94 +++++++++++++++++- state-hub/tests/test_routers_core.py | 5 + workplans/ADHOC-2026-05-02.md | 31 ++++++ 6 files changed, 291 insertions(+), 43 deletions(-) create mode 100644 workplans/ADHOC-2026-05-02.md diff --git a/state-hub/api/routers/workstreams.py b/state-hub/api/routers/workstreams.py index efd8705..701739d 100644 --- a/state-hub/api/routers/workstreams.py +++ b/state-hub/api/routers/workstreams.py @@ -1,10 +1,14 @@ import uuid +import socket +from pathlib import Path +from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session +from api.models.managed_repo import ManagedRepo from api.models.workstream import Workstream from api.schemas.workstream import ( WorkstreamCreate, @@ -16,6 +20,45 @@ from api.schemas.workstream import ( router = APIRouter(prefix="/workstreams", tags=["workstreams"]) +def _repo_path(repo: ManagedRepo) -> Path | None: + hostname = socket.gethostname() + candidates = [] + host_paths = repo.host_paths or {} + if host_paths.get(hostname): + candidates.append(host_paths[hostname]) + if repo.local_path: + candidates.append(repo.local_path) + for raw in candidates: + path = Path(raw).expanduser() + if path.is_dir(): + return path + return None + + +def _frontmatter(path: Path) -> dict[str, Any]: + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + if not text.startswith("---\n"): + return {} + end = text.find("\n---", 4) + if end == -1: + return {} + + data: dict[str, Any] = {} + for raw_line in text[4:end].splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + data[key.strip()] = value + return data + + @router.get("/", response_model=list[WorkstreamRead]) async def list_workstreams( topic_id: uuid.UUID | None = None, @@ -44,6 +87,37 @@ async def list_workstreams( return list(result.scalars().all()) +@router.get("/workplan-index") +async def workplan_index(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + """Map file-backed workstream ids to their local workplan filenames.""" + result = await session.execute( + select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug) + ) + index: dict[str, Any] = {} + for repo in result.scalars().all(): + root = _repo_path(repo) + if root is None: + continue + for directory, archived in ( + (root / "workplans", False), + (root / "workplans" / "archived", True), + ): + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.md")): + data = _frontmatter(path) + workstream_id = data.get("state_hub_workstream_id") + if not workstream_id: + continue + index[str(workstream_id)] = { + "filename": path.name, + "relative_path": str(path.relative_to(root)), + "repo_slug": repo.slug, + "archived": archived, + } + return {"workstreams": index} + + @router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED) async def create_workstream( body: WorkstreamCreate, diff --git a/state-hub/dashboard/src/docs/overview.md b/state-hub/dashboard/src/docs/overview.md index d0bf8e5..d41d53a 100644 --- a/state-hub/dashboard/src/docs/overview.md +++ b/state-hub/dashboard/src/docs/overview.md @@ -12,10 +12,10 @@ blocking decisions, and system-derived next-step suggestions. ## Sections -### Open Workstreams by Domain +### Open Workstreams by Repository -A horizontal stacked bar chart showing every active workstream across all six -domains. Each bar is broken into four task-status segments: +A horizontal stacked bar chart showing workstreams grouped by domain and then +by repository. Each bar is broken into four task-status segments: | Colour | Segment | |--------|---------| @@ -24,9 +24,13 @@ domains. Each bar is broken into four task-status segments: | orange-red | blocked | | light grey | todo | -The left axis shows domain labels (one per group of workstreams). The `done/total` -count is printed to the right of each bar. Workstreams with no tasks yet show -a grey "— no tasks yet" label. +The left axis shows the `domain / repository` label once per repository group. +The `done/total` count is printed to the right of each bar. Workstreams with no +tasks yet show a grey "— no tasks yet" label. + +Hovering a bar shows the repository, domain, and backing workplan filename when +the workstream is file-backed. Clicking a bar or its label opens the workstream +drilldown page with the attached task list. ### Contribution & SBOM Health @@ -78,7 +82,9 @@ and summary. ## Data source -Polls `GET /state/summary` every **15 seconds**. Blocking decisions are fetched -separately via `GET /decisions/?decision_type=pending` and only re-fetched -after a successful resolve action — this prevents the inline form from being -wiped on every poll. +Polls `GET /state/summary` every **15 seconds**. The workstream chart also polls +`GET /workstreams/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`, +and `GET /workstreams/workplan-index` for repository grouping, task counts, and +workplan filename tooltips. Blocking decisions are fetched separately via +`GET /decisions/?decision_type=pending` and only re-fetched after a successful +resolve action — this prevents the inline form from being wiped on every poll. diff --git a/state-hub/dashboard/src/index.md b/state-hub/dashboard/src/index.md index 1fcc05f..a518ea3 100644 --- a/state-hub/dashboard/src/index.md +++ b/state-hub/dashboard/src/index.md @@ -88,17 +88,20 @@ const wsChartState = (async function*() { while (true) { let wsAll = [], ok = false; try { - const [rw, rt, rto, rr] = await Promise.all([ + const [rw, rt, rto, rr, rwi] = await Promise.all([ fetch(`${API}/workstreams/`), fetch(`${API}/tasks/?limit=2000`), fetch(`${API}/topics/`), fetch(`${API}/repos/`), + fetch(`${API}/workstreams/workplan-index`), ]); ok = rw.ok && rt.ok && rto.ok && rr.ok; if (ok) { const [wsList, taskList, topicList, repoList] = await Promise.all([ rw.json(), rt.json(), rto.json(), rr.json(), ]); + const workplanIndex = rwi.ok ? await rwi.json() : {workstreams: {}}; + const workplanMap = workplanIndex.workstreams ?? {}; const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); // Aggregate task counts per workstream @@ -112,11 +115,21 @@ const wsChartState = (async function*() { else if (t.status === "blocked") counts[wid].blocked++; else if (t.status === "todo") counts[wid].todo++; } - wsAll = wsList.map(w => ({ - ...w, - domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", - ...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), - })); + wsAll = wsList.map(w => { + const repo = repoMap[w.repo_id]; + const topic = topicMap[w.topic_id]; + const workplan = workplanMap[w.id] ?? {}; + return { + ...w, + domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown", + repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned", + workplan_filename: workplan.filename ?? null, + workplan_relative_path: workplan.relative_path ?? null, + workplan_archived: workplan.archived ?? false, + href: `./workstreams/${w.id}`, + ...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), + }; + }); } } catch {} yield {wsAll, ok}; @@ -152,7 +165,7 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/overview"); display(html`
⚠️ ${summary.error ?? ''}
`); ``` -## Workstreams by Domain +## Workstreams by Repository ```js // view() is the idiomatic Observable Framework reactive input: @@ -236,16 +249,13 @@ const _chartWsFiltered = ( })() ); -// Sort domains top-to-bottom by most recent workstream update (most active domain first). -// Within a domain, most recently updated workstream comes first. -const _domainLatest = {}; -for (const w of _chartWsFiltered) { - const t = new Date(w.updated_at).getTime(); - if (!_domainLatest[w.domain] || t > _domainLatest[w.domain]) _domainLatest[w.domain] = t; -} +// Sort by domain, then repository, then most recently updated workstream. +// The axis labels show each domain/repo group once. const chartWs = [..._chartWsFiltered].sort((a, b) => { - const dd = (_domainLatest[b.domain] ?? 0) - (_domainLatest[a.domain] ?? 0); - if (dd !== 0) return dd; + const domainCompare = (a.domain ?? "").localeCompare(b.domain ?? ""); + if (domainCompare !== 0) return domainCompare; + const repoCompare = (a.repo_label ?? "").localeCompare(b.repo_label ?? ""); + if (repoCompare !== 0) return repoCompare; return new Date(b.updated_at) - new Date(a.updated_at); }); @@ -254,24 +264,35 @@ const chartWs = [..._chartWsFiltered].sort((a, b) => { const _isTimeBased = !_STATUS_MODES.has(_chartMode); function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; } -// ── y-axis: domain label for first workstream per group only ────────────────── +// ── y-axis: domain/repo label for first workstream per repository only ──────── const _yLabels = {}; const _seen = new Set(); for (const w of chartWs) { - _yLabels[w.title] = _seen.has(w.domain) ? "" : w.domain; - _seen.add(w.domain); + const group = `${w.domain} / ${w.repo_label}`; + _yLabels[w.id] = _seen.has(group) ? "" : group; + _seen.add(group); } const statusOrder = ["done", "in progress", "blocked", "todo"]; const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; const _taskRows = chartWs.flatMap(w => [ - {label: w.title, status: "done", count: w.done ?? 0}, - {label: w.title, status: "in progress", count: w.in_progress ?? 0}, - {label: w.title, status: "blocked", count: w.blocked ?? 0}, - {label: w.title, status: "todo", count: w.todo ?? 0}, + {id: w.id, title: w.title, status: "done", count: w.done ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, + {id: w.id, title: w.title, status: "in progress", count: w.in_progress ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, + {id: w.id, title: w.title, status: "blocked", count: w.blocked ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, + {id: w.id, title: w.title, status: "todo", count: w.todo ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, ]).filter(d => d.count > 0); +function _wsTitle(d) { + return [ + d.title, + `Repo: ${d.repo ?? "unassigned"}`, + `Domain: ${d.domain ?? "unknown"}`, + `Workplan: ${d.workplan ?? "not file-backed"}`, + `${d.status}: ${d.count}`, + ].join("\n"); +} + // ── Render ──────────────────────────────────────────────────────────────────── if (chartWs.length === 0) { const _emptyMsg = { @@ -292,34 +313,59 @@ if (chartWs.length === 0) { display(Plot.plot({ y: { label: null, tickSize: 0, - domain: chartWs.map(w => w.title), + domain: chartWs.map(w => w.id), tickFormat: t => _yLabels[t] ?? "", }, x: {label: "Tasks", grid: true}, color: {domain: statusOrder, range: statusColors, legend: true}, marks: [ - Plot.barX(_taskRows, {y: "label", x: "count", fill: "status", tip: true}), + Plot.barX(_taskRows, { + y: "id", x: "count", fill: "status", + title: _wsTitle, + href: "href", + target: "_self", + tip: true, + }), // Title label — pushed to lower half of bar row (dy: +7) to separate from count Plot.text(chartWs.filter(w => w.total > 0), { - y: "title", x: 0, dx: 6, dy: 7, + y: "id", x: 0, dx: 6, dy: 7, text: d => d.title.length > 72 ? d.title.slice(0, 70) + "…" : d.title, textAnchor: "start", fontSize: 10, fill: "#1e293b", fontWeight: d => _isTimeBased ? _wsWeight(d.status) : "normal", + title: d => [ + d.title, + `Repo: ${d.repo_label ?? "unassigned"}`, + `Domain: ${d.domain ?? "unknown"}`, + `Workplan: ${d.workplan_filename ?? "not file-backed"}`, + ].join("\n"), + href: "href", + target: "_self", }), Plot.text(chartWs.filter(w => w.total === 0), { - y: "title", x: 0, dx: 6, dy: 7, + y: "id", x: 0, dx: 6, dy: 7, text: d => `${d.title.length > 48 ? d.title.slice(0, 46) + "…" : d.title} — no tasks yet`, textAnchor: "start", fontSize: 10, fill: "#94a3b8", + title: d => [ + d.title, + `Repo: ${d.repo_label ?? "unassigned"}`, + `Domain: ${d.domain ?? "unknown"}`, + `Workplan: ${d.workplan_filename ?? "not file-backed"}`, + ].join("\n"), + href: "href", + target: "_self", }), // Count label — pushed to upper half of bar row (dy: -7) to separate from title Plot.text(chartWs.filter(w => w.total > 0), { - y: "title", x: "total", + y: "id", x: "total", text: d => ` ${d.done}/${d.total}`, dx: 4, dy: -7, textAnchor: "start", fontSize: 11, fill: "gray", + title: d => `${d.title}\nWorkplan: ${d.workplan_filename ?? "not file-backed"}`, + href: "href", + target: "_self", }), Plot.ruleX([0]), ], - marginLeft: 160, + marginLeft: 220, marginRight: 70, height: Math.max(80, chartWs.length * 44 + 50), width: 700, diff --git a/state-hub/dashboard/src/workstreams/[id].md b/state-hub/dashboard/src/workstreams/[id].md index dbcd46c..b76c2e3 100644 --- a/state-hub/dashboard/src/workstreams/[id].md +++ b/state-hub/dashboard/src/workstreams/[id].md @@ -9,19 +9,56 @@ import {fieldRow} from "../components/field-help.js"; ```js const wsId = observable.params.id; -const raw = await fetch(`${API}/workstreams/${wsId}`) - .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) - .catch(e => ({error: String(e)})); +const [raw, taskRows, workplanIndex] = await Promise.all([ + fetch(`${API}/workstreams/${wsId}`) + .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) + .catch(e => ({error: String(e)})), + fetch(`${API}/tasks/?workstream_id=${wsId}&limit=1000`) + .then(r => r.ok ? r.json() : []) + .catch(() => []), + fetch(`${API}/workstreams/workplan-index`) + .then(r => r.ok ? r.json() : {workstreams: {}}) + .catch(() => ({workstreams: {}})), +]); ``` ```js if (raw.error) { display(html`
⚠️ ${raw.error}
`); } else { + const workplan = (workplanIndex.workstreams ?? {})[wsId] ?? {}; const name = raw.title || raw.slug || wsId; const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name; display(html`

Workstream · ${shortName}

`); - display(html`

← Workstreams  |  ← Token Cost

`); + display(html`

← Overview  |  ← Workstreams  |  ← Token Cost

`); + + display(html`
+
Status${raw.status ?? "—"}
+
Workplan${workplan.filename ?? "not file-backed"}
+
Tasks${taskRows.length}
+
`); + + const statusOrder = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4}; + const sortedTasks = [...taskRows].sort((a, b) => { + const statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9); + if (statusCompare !== 0) return statusCompare; + return (a.title ?? "").localeCompare(b.title ?? ""); + }); + + display(html`

Tasks

`); + if (sortedTasks.length === 0) { + display(html`

No tasks are attached to this workstream.

`); + } else { + display(html` + + ${sortedTasks.map(t => html` + + + + + `)} +
StatusPriorityTaskHuman
${t.status}${t.priority ?? "—"}${t.title ?? t.id}${t.needs_human ? "yes" : ""}
`); + } const FIELD_ORDER = [ "id","slug","title","status","topic_id","repo_id","repo_goal_id", @@ -39,3 +76,52 @@ if (raw.error) { `); } ``` + + diff --git a/state-hub/tests/test_routers_core.py b/state-hub/tests/test_routers_core.py index fb1ed50..8f2a310 100644 --- a/state-hub/tests/test_routers_core.py +++ b/state-hub/tests/test_routers_core.py @@ -133,6 +133,11 @@ class TestWorkstreams: assert r.status_code == 200 assert len(r.json()) == 1 + async def test_workplan_index_route(self, client): + r = await client.get("/workstreams/workplan-index") + assert r.status_code == 200 + assert "workstreams" in r.json() + # --------------------------------------------------------------------------- # Task tests diff --git a/workplans/ADHOC-2026-05-02.md b/workplans/ADHOC-2026-05-02.md new file mode 100644 index 0000000..9db8c4d --- /dev/null +++ b/workplans/ADHOC-2026-05-02.md @@ -0,0 +1,31 @@ +--- +id: ADHOC-2026-05-02 +type: workplan +title: "Ad Hoc Tasks — 2026-05-02" +domain: custodian +repo: the-custodian +status: active +owner: custodian +topic_slug: custodian +created: "2026-05-02" +updated: "2026-05-02" +state_hub_workstream_id: "3f54ce9c-1f95-42de-894f-0f81a52ba2e8" +--- + +# ADHOC-2026-05-02 — Ad Hoc Tasks + +Small same-day improvements that are useful to track, but do not justify a +dedicated requirement/workplan cycle. + +## Overview workstreams by repository + +```task +id: ADHOC-2026-05-02-T01 +status: done +priority: medium +state_hub_task_id: "e9a302b7-81ab-4643-a256-4565a8c753e0" +``` + +Changed the State Hub Overview workstream chart from domain-first grouping to +domain/repository grouping, added workplan filename detail to chart hover data, +and made workstream bars open the workstream drilldown with attached tasks.