diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py
index efd8705..701739d 100644
--- a/api/routers/workstreams.py
+++ b/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/dashboard/src/docs/overview.md b/dashboard/src/docs/overview.md
index d0bf8e5..d41d53a 100644
--- a/dashboard/src/docs/overview.md
+++ b/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/dashboard/src/index.md b/dashboard/src/index.md
index 1fcc05f..a518ea3 100644
--- a/dashboard/src/index.md
+++ b/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/dashboard/src/workstreams/[id].md b/dashboard/src/workstreams/[id].md
index dbcd46c..b76c2e3 100644
--- a/dashboard/src/workstreams/[id].md
+++ b/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`
+ | Status | Priority | Task | Human |
+ ${sortedTasks.map(t => html`
+ | ${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/tests/test_routers_core.py b/tests/test_routers_core.py
index fb1ed50..8f2a310 100644
--- a/tests/test_routers_core.py
+++ b/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