feat: add workplan aliases and legacy meter

Adds preferred workplan REST/event surfaces, legacy-meter telemetry and weekly review summaries, documentation/dashboard terminology updates, dashboard API loading fixes, and close-out sync for STATE-WP-0052 and STATE-WP-0054.
This commit is contained in:
2026-06-04 08:25:31 +02:00
parent 355f80b078
commit 166aedfa8d
43 changed files with 1851 additions and 145 deletions

View File

@@ -1,4 +1,65 @@
export const API = "http://127.0.0.1:8000";
export const DEFAULT_API = "http://127.0.0.1:8000";
export const API_STORAGE_KEY = "stateHubApiBase";
const API_QUERY_PARAMS = ["api_base", "apiBase"];
function cleanApiBase(value) {
if (typeof value !== "string") return null;
const cleaned = value.trim().replace(/\/+$/, "");
return cleaned || null;
}
function getStorageApiBase(storage) {
if (!storage?.getItem) return null;
try {
return cleanApiBase(storage.getItem(API_STORAGE_KEY));
} catch {
return null;
}
}
function urlFromLocation(location) {
if (!location) return null;
try {
return new URL(location.href ?? String(location));
} catch {
return null;
}
}
function getQueryApiBase(url) {
if (!url) return null;
for (const name of API_QUERY_PARAMS) {
const value = cleanApiBase(url.searchParams.get(name));
if (value) return value;
}
return null;
}
function inferApiBase(url) {
if (!url || !["http:", "https:"].includes(url.protocol)) return DEFAULT_API;
if (url.hostname === "::1" || url.hostname === "[::1]") return DEFAULT_API;
const apiUrl = new URL(url.href);
apiUrl.port = globalThis.STATE_HUB_API_PORT || "8000";
apiUrl.pathname = "";
apiUrl.search = "";
apiUrl.hash = "";
return apiUrl.origin;
}
export function resolveApiBase({
location = globalThis.location,
storage = globalThis.localStorage,
} = {}) {
const url = urlFromLocation(location);
return (
getQueryApiBase(url)
|| cleanApiBase(globalThis.STATE_HUB_API_BASE)
|| getStorageApiBase(storage)
|| inferApiBase(url)
);
}
export const API = resolveApiBase();
export const POLL = 15_000;
export const POLL_HEAVY = 60_000;
export const FETCH_TIMEOUT = 12_000;

View File

@@ -26,7 +26,7 @@ const FIELD_LINKS = {
getTitle: d => d.title,
},
workstream_id: {
apiUrl: id => `${API}/workstreams/${id}`,
apiUrl: id => `${API}/workplans/${id}`,
getUrl: (id, _d) => `/workstreams/${id}`,
getTitle: d => d.title || d.slug,
},

View File

@@ -16,7 +16,7 @@ const depState = (async function*() {
let wsMap = {}, edges = [], ok = false;
try {
const [rw, rto, rr, rd] = await Promise.all([
apiFetch("/workstreams/"),
apiFetch("/workplans/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
apiFetch("/state/deps"),

View File

@@ -63,7 +63,7 @@ Current loaders:
| File | API endpoint |
|---|---|
| `summary.json.py` | `/state/summary` |
| `workstreams.json.py` | `/workstreams/` |
| `workstreams.json.py` | `/workplans/` |
| `contributions.json.py` | `/contributions/` |
| `decisions.json.py` | `/decisions/` |
| `domains.json.py` | `/domains/` |
@@ -144,7 +144,7 @@ The dashboard has 30+ pages organised in four navigation groups:
| Page | Route | Purpose |
|---|---|---|
| Workstreams | `/workstreams` | All workstreams with Workstream Health Index |
| Workplans | `/workstreams` | All workplans with Workplan Health Index; route name remains compatibility-backed |
| Decisions | `/decisions` | Decision log with resolve-in-place form |
| Dependencies | `/dependencies` | Dependency graph explorer |
| Extensions | `/extensions` | Extension point registry |
@@ -163,8 +163,11 @@ The dashboard has 30+ pages organised in four navigation groups:
All shared components live in `src/components/` and are imported as ES modules:
### `config.js`
Exports two constants used by every live-polling page:
- `API = "http://127.0.0.1:8000"` — the FastAPI base URL
Exports shared runtime configuration used by every live-polling page:
- `API` — the FastAPI base URL. It defaults to `http://127.0.0.1:8000`
outside the browser, derives from the dashboard host in browser sessions, and
can be overridden with `?api_base=...`, `globalThis.STATE_HUB_API_BASE`, or
`localStorage.stateHubApiBase`.
- `POLL = 15_000` — polling interval in milliseconds
### `entity-modal.js`

View File

@@ -62,7 +62,7 @@ create_dependency(
Via REST:
```bash
curl -X POST http://127.0.0.1:8000/workstreams/<from_id>/dependencies/ \
curl -X POST http://127.0.0.1:8000/workplans/<from_id>/dependencies/ \
-H "Content-Type: application/json" \
-d '{"to_workstream_id": "<to_id>", "description": "..."}'
```

View File

@@ -127,7 +127,7 @@ The Goals page groups everything by domain:
Workstreams carry an optional `repo_goal_id` field. Setting it traces *why* a workstream exists — which specific repo goal it contributes to. This connection is currently recorded in the DB but is not yet visualised in the Workstreams page.
To set the link when creating a workstream via `create_workstream`, pass `repo_goal_id`. To update an existing one, use `PATCH /workstreams/{id}/` with `{"repo_goal_id": "<uuid>"}`.
To set the link when creating a workplan through the preferred API, pass `repo_goal_id`. To update an existing one, use `PATCH /workplans/{id}/` with `{"repo_goal_id": "<uuid>"}`. Legacy `create_workstream` and `/workstreams/{id}/` callers remain compatibility-supported while they are metered.
---

View File

@@ -49,7 +49,7 @@ make api # db + migrate + uvicorn (restarts if already running)
| Page | Endpoints |
|---|---|
| Overview | `/state/summary` |
| Workstreams | `/workstreams/`, `/topics/`, `/state/summary` |
| Workplans | `/workplans/`, `/topics/`, `/state/summary` |
| Decisions | `/decisions/?limit=500`, `/topics/` |
| Progress | `/progress/?limit=500` |

View File

@@ -83,8 +83,8 @@ and summary.
## Data source
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
`GET /workplans/`, `GET /tasks/?limit=2000`, `GET /topics/`, `GET /repos/`,
and `GET /workplans/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

@@ -95,5 +95,5 @@ status group.
Polls every **15 seconds**:
- `GET /tasks/?limit=500`
- `GET /workstreams/`
- `GET /workplans/`
- `GET /topics/`

View File

@@ -64,7 +64,7 @@ to this reference page.
Polls every **15 seconds**:
- `GET /tasks/?limit=500` — all tasks
- `GET /workstreams/` — for domain + title context
- `GET /workplans/` — for domain + title context
- `GET /topics/` — for domain slug resolution
- `GET /contributions/` — for third-party todos

View File

@@ -77,7 +77,7 @@ advance_workstation(entity_type="workstream", entity_id="<uuid>", target_worksta
Direct status patching still exists for bootstrap and compatibility work:
```bash
curl -X PATCH http://127.0.0.1:8000/workstreams/<uuid>/ \
curl -X PATCH http://127.0.0.1:8000/workplans/<uuid>/ \
-H "Content-Type: application/json" \
-d '{"status": "finished"}'
```

View File

@@ -108,7 +108,7 @@ create_workstream(
Via REST:
```bash
curl -X POST http://127.0.0.1:8000/workstreams/ \
curl -X POST http://127.0.0.1:8000/workplans/ \
-H "Content-Type: application/json" \
-d '{"topic_id": "<uuid>", "title": "…", "status": "ready"}'
```

View File

@@ -16,7 +16,7 @@ GET /progress/?event_type=daily_triage&limit=14
Each event carries the report under `detail.report`, with a summary and a list
of recommendations. Candidate values are resolved through
`/workstreams/workplan-index` so file-backed workplans can link to their
`/workplans/index` so file-backed workplans can link to their
workstream detail pages.
## How to read recommendations

View File

@@ -39,11 +39,11 @@ const pageState = (async function*() {
loadJson("summary", "/state/summary", {timeout: 20_000}),
loadJson("sbom snapshots", "/sbom/snapshots/"),
loadJson("milestones", "/progress/?event_type=milestone&limit=500"),
loadJson("workstreams", "/workstreams/"),
loadJson("workplans", "/workplans/"),
loadJson("tasks", "/tasks/?limit=2000"),
loadJson("topics", "/topics/"),
loadJson("repos", "/repos/"),
loadJson("workplan index", "/workstreams/workplan-index").catch(() => ({workstreams: {}})),
loadJson("workplan index", "/workplans/index").catch(() => ({workplans: {}, workstreams: {}})),
]);
ok = true;

View File

@@ -15,7 +15,7 @@ const interventionState = (async function*() {
try {
const [rt, rw, rto, rr] = await Promise.all([
apiFetch("/tasks/?limit=500"),
apiFetch("/workstreams/"),
apiFetch("/workplans/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
]);

View File

@@ -16,7 +16,7 @@ try {
fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/extension-points/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/technical-debt/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/workstreams/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/workplans/`).then(r => r.ok ? r.json() : []),
]);
} catch {}
```

View File

@@ -14,7 +14,7 @@ const taskState = (async function*() {
try {
const [rt, rw, rto, rr] = await Promise.all([
apiFetch("/tasks/?limit=500"),
apiFetch("/workstreams/"),
apiFetch("/workplans/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
]);

View File

@@ -14,7 +14,7 @@ const tdState = (async function*() {
try {
const [rt, rw, rto, rr] = await Promise.all([
apiFetch("/technical-debt/"),
apiFetch("/workstreams/"),
apiFetch("/workplans/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
]);

View File

@@ -16,7 +16,7 @@ const todoState = (async function*() {
try {
const [rt, rw, rto, rr, rc, ri] = await Promise.all([
apiFetch("/tasks/?limit=500"),
apiFetch("/workstreams/"),
apiFetch("/workplans/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
apiFetch("/contributions/"),

View File

@@ -104,7 +104,7 @@ function queueControls(row) {
}
save.onclick = () => run("saving", async () => {
const response = await apiFetch(`/execution/workstreams/${row.workstream_id}/intent`, {
const response = await apiFetch(`/execution/workplans/${row.workstream_id}/intent`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload()),

View File

@@ -1,5 +1,5 @@
---
title: Workstreams
title: Workplans
---
```js
@@ -16,7 +16,7 @@ const wsState = (async function*() {
let data = [], openWs = [], ok = false;
try {
const [rw, rt, rr, rd] = await Promise.all([
apiFetch("/workstreams/"),
apiFetch("/workplans/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
apiFetch("/state/deps"),
@@ -134,7 +134,7 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno
}).filter(Boolean);
```
# Workstreams
# Workplans
```js
import {injectTocTop} from "./components/toc-sidebar.js";
@@ -166,17 +166,17 @@ function _warnLevel(name, val) {
function _warnColor(lv) { return lv === 2 ? "#dc2626" : lv === 1 ? "#d97706" : "var(--theme-foreground-muted, #666)"; }
const _whiMetrics = [
{name: "DD", val: _DD, fmt: v => v.toFixed(2), label: "Dependency Density", desc: "Average number of dependencies per open workstream; high values indicate a tightly coupled graph that is hard to parallelise."},
{name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", label: "Blocked Ratio", desc: "Share of open workstreams currently in a blocked state; directly reduces the work that can proceed right now."},
{name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", label: "Single-Point Risk", desc: "Share of workstreams depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."},
{name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", label: "Parallel Execution Potential", desc: "Share of open workstreams with zero blocking dependencies that could start or continue immediately."},
{name: "DD", val: _DD, fmt: v => v.toFixed(2), label: "Dependency Density", desc: "Average number of dependencies per open workplan; high values indicate a tightly coupled graph that is hard to parallelise."},
{name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", label: "Blocked Ratio", desc: "Share of open workplans currently in a blocked state; directly reduces the work that can proceed right now."},
{name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", label: "Single-Point Risk", desc: "Share of workplans depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."},
{name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", label: "Parallel Execution Potential", desc: "Share of open workplans with zero blocking dependencies that could start or continue immediately."},
{name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", label: "Cross-Domain Dependency Ratio", desc: "Share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."},
];
const _whiBox = html`<div class="kpi-infobox whi-box">
<div class="kpi-infobox-title">Workstream Health</div>
<div class="kpi-infobox-title">Workplan Health</div>
${_openCount === 0
? html`<div class="kpi-row"><span class="kpi-muted">No active workstreams</span></div>`
? html`<div class="kpi-row"><span class="kpi-muted">No active workplans</span></div>`
: html`
<div class="whi-score-row">
<span class="whi-value" style="color:${_whiColor(_WHI)}">${(_WHI*100).toFixed(0)}<span class="whi-pct">%</span></span>
@@ -202,7 +202,7 @@ const _whiBox = html`<div class="kpi-infobox whi-box">
description="Domain-scoped WHI (intra-domain edges only). Open: ${d.openCount} · Blocked: ${(d.br*100).toFixed(0)}% · Runnable: ${(d.pep*100).toFixed(0)}%"
doc="/docs/workstream-health-index">${d.domain}</help-tip>
<span class="whi-domain-score" style="color:${_whiColor(d.whi)}">${(d.whi*100).toFixed(0)}%</span>
${d.cpi === 1 ? html`<help-tip style="color:#d97706;font-size:0.7rem" label="Dependency Cycle" description="A circular dependency exists within this domain — workstreams are waiting on each other and cannot all proceed." doc="/docs/workstream-health-index">⚠</help-tip>` : ""}
${d.cpi === 1 ? html`<help-tip style="color:#d97706;font-size:0.7rem" label="Dependency Cycle" description="A circular dependency exists within this domain — workplans are waiting on each other and cannot all proceed." doc="/docs/workstream-health-index">⚠</help-tip>` : ""}
</div>`)}
</div>` : ""}
`}
@@ -276,7 +276,7 @@ display(Plot.plot({
}));
```
## All Workstreams
## All Workplans
```js
display(_filtersForm);
@@ -313,7 +313,7 @@ const wsWithDeps = openWs.filter(w => {
});
if (wsWithDeps.length === 0) {
display(html`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workstreams.</p>`);
display(html`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workplans.</p>`);
} else {
display(html`<div class="dep-grid">${wsWithDeps.map(w => {
const depRows = w.depends_on.map(d =>

View File

@@ -11,13 +11,13 @@ import {statusControl, TASK_STATUSES, WORKSTREAM_STATUSES} from "../components/s
```js
const wsId = observable.params.id;
const [raw, taskRows, workplanIndex] = await Promise.all([
fetch(`${API}/workstreams/${wsId}`)
fetch(`${API}/workplans/${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`)
fetch(`${API}/workplans/index`)
.then(r => r.ok ? r.json() : {workstreams: {}})
.catch(() => ({workstreams: {}})),
]);
@@ -31,7 +31,7 @@ if (raw.error) {
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="/">← Overview</a> &nbsp;|&nbsp; <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">← Workplans</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
display(html`<div class="ws-summary">
<div><span>Status</span>${statusControl({

View File

@@ -27,7 +27,7 @@ const triageState = (async function*() {
try {
const [reportsResp, indexResp] = await Promise.all([
apiFetch("/progress/?event_type=daily_triage&limit=14"),
apiFetch("/workstreams/workplan-index"),
apiFetch("/workplans/index"),
]);
ok = reportsResp.ok && indexResp.ok;
events = reportsResp.ok ? await reportsResp.json() : [];