Overview shows workstreams by repo now and allows drilldown

This commit is contained in:
2026-05-02 12:01:45 +02:00
parent 43fcd7e8a7
commit c48f26dd1f
6 changed files with 291 additions and 43 deletions

View File

@@ -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,

View File

@@ -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.

View File

@@ -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`<div class="warning" style="display:${summary.error ? '' : 'none'}">⚠️ ${summary.error ?? ''}</div>`);
```
## 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,

View File

@@ -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`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
} 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`<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 = [
"id","slug","title","status","topic_id","repo_id","repo_goal_id",
@@ -39,3 +76,52 @@ if (raw.error) {
</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 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

View File

@@ -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.