Overview shows workstreams by repo now and allows drilldown

This commit is contained in:
2026-05-02 12:01:45 +02:00
parent e521f267ca
commit e0f6a3b7a9
5 changed files with 260 additions and 43 deletions

View File

@@ -1,10 +1,14 @@
import uuid import uuid
import socket
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.workstream import Workstream from api.models.workstream import Workstream
from api.schemas.workstream import ( from api.schemas.workstream import (
WorkstreamCreate, WorkstreamCreate,
@@ -16,6 +20,45 @@ from api.schemas.workstream import (
router = APIRouter(prefix="/workstreams", tags=["workstreams"]) 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]) @router.get("/", response_model=list[WorkstreamRead])
async def list_workstreams( async def list_workstreams(
topic_id: uuid.UUID | None = None, topic_id: uuid.UUID | None = None,
@@ -44,6 +87,37 @@ async def list_workstreams(
return list(result.scalars().all()) 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) @router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
async def create_workstream( async def create_workstream(
body: WorkstreamCreate, body: WorkstreamCreate,

View File

@@ -12,10 +12,10 @@ blocking decisions, and system-derived next-step suggestions.
## Sections ## Sections
### Open Workstreams by Domain ### Open Workstreams by Repository
A horizontal stacked bar chart showing every active workstream across all six A horizontal stacked bar chart showing workstreams grouped by domain and then
domains. Each bar is broken into four task-status segments: by repository. Each bar is broken into four task-status segments:
| Colour | Segment | | Colour | Segment |
|--------|---------| |--------|---------|
@@ -24,9 +24,13 @@ domains. Each bar is broken into four task-status segments:
| orange-red | blocked | | orange-red | blocked |
| light grey | todo | | light grey | todo |
The left axis shows domain labels (one per group of workstreams). The `done/total` The left axis shows the `domain / repository` label once per repository group.
count is printed to the right of each bar. Workstreams with no tasks yet show The `done/total` count is printed to the right of each bar. Workstreams with no
a grey "— no tasks yet" label. 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 ### Contribution & SBOM Health
@@ -78,7 +82,9 @@ and summary.
## Data source ## Data source
Polls `GET /state/summary` every **15 seconds**. Blocking decisions are fetched Polls `GET /state/summary` every **15 seconds**. The workstream chart also polls
separately via `GET /decisions/?decision_type=pending` and only re-fetched `GET /workstreams/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`,
after a successful resolve action — this prevents the inline form from being and `GET /workstreams/workplan-index` for repository grouping, task counts, and
wiped on every poll. 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.

View File

@@ -88,17 +88,20 @@ const wsChartState = (async function*() {
while (true) { while (true) {
let wsAll = [], ok = false; let wsAll = [], ok = false;
try { try {
const [rw, rt, rto, rr] = await Promise.all([ const [rw, rt, rto, rr, rwi] = await Promise.all([
fetch(`${API}/workstreams/`), fetch(`${API}/workstreams/`),
fetch(`${API}/tasks/?limit=2000`), fetch(`${API}/tasks/?limit=2000`),
fetch(`${API}/topics/`), fetch(`${API}/topics/`),
fetch(`${API}/repos/`), fetch(`${API}/repos/`),
fetch(`${API}/workstreams/workplan-index`),
]); ]);
ok = rw.ok && rt.ok && rto.ok && rr.ok; ok = rw.ok && rt.ok && rto.ok && rr.ok;
if (ok) { if (ok) {
const [wsList, taskList, topicList, repoList] = await Promise.all([ const [wsList, taskList, topicList, repoList] = await Promise.all([
rw.json(), rt.json(), rto.json(), rr.json(), 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 topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
// Aggregate task counts per workstream // 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 === "blocked") counts[wid].blocked++;
else if (t.status === "todo") counts[wid].todo++; else if (t.status === "todo") counts[wid].todo++;
} }
wsAll = wsList.map(w => ({ wsAll = wsList.map(w => {
...w, const repo = repoMap[w.repo_id];
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", const topic = topicMap[w.topic_id];
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), 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 {} } catch {}
yield {wsAll, ok}; yield {wsAll, ok};
@@ -152,7 +165,7 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/overview");
display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}">⚠️ ${summary.error ?? ''}</div>`); display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}">⚠️ ${summary.error ?? ''}</div>`);
``` ```
## Workstreams by Domain ## Workstreams by Repository
```js ```js
// view() is the idiomatic Observable Framework reactive input: // 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). // Sort by domain, then repository, then most recently updated workstream.
// Within a domain, most recently updated workstream comes first. // The axis labels show each domain/repo group once.
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;
}
const chartWs = [..._chartWsFiltered].sort((a, b) => { const chartWs = [..._chartWsFiltered].sort((a, b) => {
const dd = (_domainLatest[b.domain] ?? 0) - (_domainLatest[a.domain] ?? 0); const domainCompare = (a.domain ?? "").localeCompare(b.domain ?? "");
if (dd !== 0) return dd; 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); 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); const _isTimeBased = !_STATUS_MODES.has(_chartMode);
function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; } 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 _yLabels = {};
const _seen = new Set(); const _seen = new Set();
for (const w of chartWs) { for (const w of chartWs) {
_yLabels[w.title] = _seen.has(w.domain) ? "" : w.domain; const group = `${w.domain} / ${w.repo_label}`;
_seen.add(w.domain); _yLabels[w.id] = _seen.has(group) ? "" : group;
_seen.add(group);
} }
const statusOrder = ["done", "in progress", "blocked", "todo"]; const statusOrder = ["done", "in progress", "blocked", "todo"];
const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"];
const _taskRows = chartWs.flatMap(w => [ const _taskRows = chartWs.flatMap(w => [
{label: w.title, status: "done", count: w.done ?? 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},
{label: w.title, status: "in progress", count: w.in_progress ?? 0}, {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},
{label: w.title, status: "blocked", count: w.blocked ?? 0}, {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},
{label: w.title, status: "todo", count: w.todo ?? 0}, {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); ]).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 ──────────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────────
if (chartWs.length === 0) { if (chartWs.length === 0) {
const _emptyMsg = { const _emptyMsg = {
@@ -292,34 +313,59 @@ if (chartWs.length === 0) {
display(Plot.plot({ display(Plot.plot({
y: { y: {
label: null, tickSize: 0, label: null, tickSize: 0,
domain: chartWs.map(w => w.title), domain: chartWs.map(w => w.id),
tickFormat: t => _yLabels[t] ?? "", tickFormat: t => _yLabels[t] ?? "",
}, },
x: {label: "Tasks", grid: true}, x: {label: "Tasks", grid: true},
color: {domain: statusOrder, range: statusColors, legend: true}, color: {domain: statusOrder, range: statusColors, legend: true},
marks: [ 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 // Title label — pushed to lower half of bar row (dy: +7) to separate from count
Plot.text(chartWs.filter(w => w.total > 0), { 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, text: d => d.title.length > 72 ? d.title.slice(0, 70) + "…" : d.title,
textAnchor: "start", fontSize: 10, fill: "#1e293b", textAnchor: "start", fontSize: 10, fill: "#1e293b",
fontWeight: d => _isTimeBased ? _wsWeight(d.status) : "normal", 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), { 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`, text: d => `${d.title.length > 48 ? d.title.slice(0, 46) + "…" : d.title} — no tasks yet`,
textAnchor: "start", fontSize: 10, fill: "#94a3b8", 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 // Count label — pushed to upper half of bar row (dy: -7) to separate from title
Plot.text(chartWs.filter(w => w.total > 0), { Plot.text(chartWs.filter(w => w.total > 0), {
y: "title", x: "total", y: "id", x: "total",
text: d => ` ${d.done}/${d.total}`, text: d => ` ${d.done}/${d.total}`,
dx: 4, dy: -7, textAnchor: "start", fontSize: 11, fill: "gray", 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]), Plot.ruleX([0]),
], ],
marginLeft: 160, marginLeft: 220,
marginRight: 70, marginRight: 70,
height: Math.max(80, chartWs.length * 44 + 50), height: Math.max(80, chartWs.length * 44 + 50),
width: 700, width: 700,

View File

@@ -9,19 +9,56 @@ import {fieldRow} from "../components/field-help.js";
```js ```js
const wsId = observable.params.id; const wsId = observable.params.id;
const raw = await fetch(`${API}/workstreams/${wsId}`) const [raw, taskRows, workplanIndex] = await Promise.all([
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) fetch(`${API}/workstreams/${wsId}`)
.catch(e => ({error: String(e)})); .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 ```js
if (raw.error) { if (raw.error) {
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`); display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
} else { } else {
const workplan = (workplanIndex.workstreams ?? {})[wsId] ?? {};
const name = raw.title || raw.slug || wsId; const name = raw.title || raw.slug || wsId;
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name; const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`); display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`);
display(html`<p style="margin-top:0"><a href="/workstreams">← Workstreams</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`); display(html`<p style="margin-top:0"><a href="/">← Overview</a> &nbsp;|&nbsp; <a href="/workstreams">← Workstreams</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
display(html`<div class="ws-summary">
<div><span>Status</span><strong>${raw.status ?? "—"}</strong></div>
<div><span>Workplan</span><strong>${workplan.filename ?? "not file-backed"}</strong></div>
<div><span>Tasks</span><strong>${taskRows.length}</strong></div>
</div>`);
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`<h2>Tasks</h2>`);
if (sortedTasks.length === 0) {
display(html`<p style="color:gray">No tasks are attached to this workstream.</p>`);
} else {
display(html`<table class="task-table">
<thead><tr><th>Status</th><th>Priority</th><th>Task</th><th>Human</th></tr></thead>
<tbody>${sortedTasks.map(t => html`<tr>
<td><span class="task-status task-status-${t.status}">${t.status}</span></td>
<td>${t.priority ?? "—"}</td>
<td><a href="/tasks/${t.id}">${t.title ?? t.id}</a></td>
<td>${t.needs_human ? "yes" : ""}</td>
</tr>`)}</tbody>
</table>`);
}
const FIELD_ORDER = [ const FIELD_ORDER = [
"id","slug","title","status","topic_id","repo_id","repo_goal_id", "id","slug","title","status","topic_id","repo_id","repo_goal_id",
@@ -39,3 +76,52 @@ if (raw.error) {
</table>`); </table>`);
} }
``` ```
<style>
.ws-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0 1.25rem;
max-width: 760px;
}
.ws-summary div {
background: var(--theme-background-alt);
border-radius: 6px;
padding: 0.75rem 0.9rem;
}
.ws-summary span {
display: block;
color: gray;
font-size: 0.72rem;
text-transform: uppercase;
margin-bottom: 0.2rem;
}
.ws-summary strong {
overflow-wrap: anywhere;
}
.task-table {
border-collapse: collapse;
width: 100%;
max-width: 900px;
margin-bottom: 1.25rem;
}
.task-table th, .task-table td {
border-bottom: 1px solid var(--theme-foreground-faint);
padding: 0.42rem 0.5rem;
text-align: left;
vertical-align: top;
}
.task-status {
border-radius: 4px;
display: inline-block;
font-size: 0.72rem;
padding: 0.12rem 0.38rem;
white-space: nowrap;
}
.task-status-done { background: #e8f5e9; color: #1b5e20; }
.task-status-in_progress { background: #e3f2fd; color: #0d47a1; }
.task-status-blocked { background: #fff3e0; color: #bf360c; }
.task-status-todo { background: #f1f5f9; color: #334155; }
.task-status-cancelled { background: #f3f4f6; color: #6b7280; }
</style>

View File

@@ -133,6 +133,11 @@ class TestWorkstreams:
assert r.status_code == 200 assert r.status_code == 200
assert len(r.json()) == 1 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 # Task tests