feat(dashboard): poll optimisation — T4, T5, T6

T4: workstreams.md and dependencies.md now call /state/deps instead of the
    full /state/summary — removes 2 heavy 10-table queries per 60 s cycle.

T5: index.md's 4 independent polling loops (summaryState, sbomSnapState,
    regsState, wsChartState) consolidated into a single pageState generator
    with one Promise.all batch and a shared backoff counter.

T6: config.js gains waitForVisible(ms) — pauses polling entirely while the
    tab is hidden and fires immediately on visibilitychange.  pollDelay()
    simplified (hidden-tab POLL_HIDDEN logic removed).  All 16 polling pages
    migrated from await sleep(pollDelay(...)) to await waitForVisible(pollDelay(...)).

CUST-WP-0039 complete — all 6 tasks done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 17:58:18 +02:00
parent 1603085792
commit 41ce4ede2c
19 changed files with 115 additions and 159 deletions

View File

@@ -3,7 +3,7 @@ title: Capability Requests
---
```js
import {API, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
const POLL = 30_000;
```
@@ -20,7 +20,7 @@ const reqState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL, failures}));
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```
@@ -210,7 +210,7 @@ const catalogState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield data;
await sleep(pollDelay({ok, base: POLL, failures}));
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```

View File

@@ -1,19 +1,30 @@
export const API = "http://127.0.0.1:8000";
export const POLL = 15_000;
export const POLL_HEAVY = 60_000;
export const POLL_HIDDEN = 120_000;
export const FETCH_TIMEOUT = 12_000;
export function pollDelay({ok = true, base = POLL, failures = 0} = {}) {
const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
const failureDelay = ok ? base : Math.min(base * 2 ** Math.min(failures, 4), 300_000);
return hidden ? Math.max(failureDelay, POLL_HIDDEN) : failureDelay;
return ok ? base : Math.min(base * 2 ** Math.min(failures, 4), 300_000);
}
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Waits `ms` if the tab is visible; pauses until the tab becomes visible if hidden,
// then returns immediately so the next poll fires as soon as the user returns.
export async function waitForVisible(ms) {
if (typeof document === "undefined") return sleep(ms);
if (document.visibilityState === "visible") return sleep(ms);
return new Promise(resolve => {
const handler = () => {
document.removeEventListener("visibilitychange", handler);
resolve();
};
document.addEventListener("visibilitychange", handler);
});
}
export async function apiFetch(path, options = {}) {
const url = path.startsWith("http") ? path : `${API}${path}`;
const timeout = options.timeout ?? FETCH_TIMEOUT;

View File

@@ -3,7 +3,7 @@ title: Contributions
---
```js
import {API, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
const POLL = 30_000;
```
@@ -20,7 +20,7 @@ const contribState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL, failures}));
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Decisions
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -46,7 +46,7 @@ const decState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,26 +3,27 @@ title: Dependencies
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
// Fetch workstreams + topics + summary (summary carries dep edges on open_workstreams)
// Fetch workstreams + topics + dep edges; /state/deps replaces the heavier
// /state/summary which was only used here to extract dependency edges.
const depState = (async function*() {
let failures = 0;
while (true) {
let wsMap = {}, edges = [], ok = false;
try {
const [rw, rto, rr, rs] = await Promise.all([
const [rw, rto, rr, rd] = await Promise.all([
apiFetch("/workstreams/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
apiFetch("/state/summary", {timeout: 20_000}),
apiFetch("/state/deps"),
]);
ok = rw.ok && rto.ok && rr.ok && rs.ok;
ok = rw.ok && rto.ok && rr.ok && rd.ok;
if (ok) {
const [wsList, topicList, repoList, summary] = await Promise.all([
rw.json(), rto.json(), rr.json(), rs.json(),
const [wsList, topicList, repoList, depsList] = await Promise.all([
rw.json(), rto.json(), rr.json(), rd.json(),
]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
@@ -31,17 +32,16 @@ const depState = (async function*() {
// Prefer repo→domain (GEMS primary); fall back to topic→domain
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
}]));
// Build directed edge list from open_workstreams depends_on arrays
for (const ow of (summary.open_workstreams ?? [])) {
for (const depId of (ow.depends_on ?? [])) {
edges.push({from_id: ow.id, to_id: depId});
for (const ow of depsList) {
for (const depStub of (ow.depends_on ?? [])) {
edges.push({from_id: ow.id, to_id: depStub});
}
}
}
} catch {}
failures = ok ? 0 : failures + 1;
yield {wsMap, edges, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Domains
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -23,7 +23,7 @@ const domainsState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {domains, repos, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Extension Points
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -39,7 +39,7 @@ const epState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Goals
---
```js
import {API, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
const POLL = 20_000;
```
@@ -28,7 +28,7 @@ const goalsState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {domains, domainGoals, repoGoals, repos, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL, failures}));
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Agent Inbox
---
```js
import {API, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -19,7 +19,7 @@ const inboxState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {messages, ok, ts: new Date()};
await sleep(pollDelay({ok, failures}));
await waitForVisible(pollDelay({ok, failures}));
}
})();
```

View File

@@ -3,117 +3,39 @@ title: Overview
---
```js
import {API, POLL, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
// Live polling — yields {data, ok, ts}; backs off when the API is slow/offline.
const summaryState = (async function*() {
// Single polling loop — fetches all data in one Promise.all batch, backs off uniformly.
const pageState = (async function*() {
let failures = 0;
while (true) {
let data, ok = false;
let summary = {}, snapshots = [], totalPkgs = 0, milestones = [], wsAll = [], ok = false;
try {
const r = await apiFetch("/state/summary", {timeout: 20_000});
ok = r.ok;
data = ok ? await r.json() : {error: `HTTP ${r.status}`};
} catch (e) {
data = {error: "API unreachable"};
}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```
```js
const summary = summaryState.data ?? {};
const _ok = summaryState.ok ?? false;
const _ts = summaryState.ts;
const totals = summary.totals ?? {};
const ws = totals.workstreams ?? {};
const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {};
```
```js
// Blocking decisions — fetched once on load, refreshed only after a resolve action.
// Kept separate from the summary poll so in-progress form inputs aren't wiped every 15 s.
const blockingDecisions = Mutable([]);
const refreshDecisions = async () => {
const r = await fetch(`${API}/decisions/?decision_type=pending`).catch(() => null);
const all = r?.ok ? await r.json() : [];
blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status));
};
refreshDecisions();
```
```js
// SBOM snapshots — repo coverage and total package count
const sbomSnapState = (async function*() {
let failures = 0;
while (true) {
let snapshots = [], totalPkgs = 0, ok = false;
try {
const r = await apiFetch("/sbom/snapshots/");
ok = r.ok;
if (r.ok) {
snapshots = await r.json();
totalPkgs = snapshots.reduce((s, sn) => s + (sn.entry_count ?? 0), 0);
}
} catch {}
failures = ok ? 0 : failures + 1;
yield {snapshots, totalPkgs};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```
```js
// Registered projects — milestone events tagged with registration
const regsState = (async function*() {
let failures = 0;
while (true) {
let rows = [], ok = false;
try {
const r = await apiFetch("/progress/?event_type=milestone&limit=500");
ok = r.ok;
if (r.ok) {
const all = await r.json();
rows = all.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
}
} catch {}
failures = ok ? 0 : failures + 1;
yield rows;
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```
```js
// All-workstreams + all-tasks poll — drives the multi-mode chart
const wsChartState = (async function*() {
let failures = 0;
while (true) {
let wsAll = [], ok = false;
try {
const [rw, rt, rto, rr, rwi] = await Promise.all([
const [rSum, rSnap, rRegs, rw, rt, rto, rr, rwi] = await Promise.all([
apiFetch("/state/summary", {timeout: 20_000}),
apiFetch("/sbom/snapshots/"),
apiFetch("/progress/?event_type=milestone&limit=500"),
apiFetch("/workstreams/"),
apiFetch("/tasks/?limit=2000"),
apiFetch("/topics/"),
apiFetch("/repos/"),
apiFetch("/workstreams/workplan-index"),
]);
ok = rw.ok && rt.ok && rto.ok && rr.ok;
ok = rSum.ok && rSnap.ok && rRegs.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 [summaryData, snapList, allEvents, wsList, taskList, topicList, repoList] = await Promise.all([
rSum.json(), rSnap.json(), rRegs.json(), rw.json(), rt.json(), rto.json(), rr.json(),
]);
summary = summaryData;
snapshots = snapList;
totalPkgs = snapshots.reduce((s, sn) => s + (sn.entry_count ?? 0), 0);
milestones = allEvents.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
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
const counts = {};
for (const t of taskList) {
const wid = t.workstream_id;
@@ -139,17 +61,40 @@ const wsChartState = (async function*() {
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
};
});
} else {
summary = {error: "API unreachable"};
}
} catch (e) {
summary = {error: "API unreachable"};
}
} catch {}
failures = ok ? 0 : failures + 1;
yield {wsAll, ok};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
yield {summary, snapshots, totalPkgs, milestones, wsAll, ok, ts: new Date()};
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```
```js
const wsAll = wsChartState.wsAll ?? [];
const summary = pageState.summary ?? {};
const _ok = pageState.ok ?? false;
const _ts = pageState.ts;
const totals = summary.totals ?? {};
const ws = totals.workstreams ?? {};
const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {};
const wsAll = pageState.wsAll ?? [];
```
```js
// Blocking decisions — fetched once on load, refreshed only after a resolve action.
// Kept separate from the main poll so in-progress form inputs aren't wiped every 60 s.
const blockingDecisions = Mutable([]);
const refreshDecisions = async () => {
const r = await fetch(`${API}/decisions/?decision_type=pending`).catch(() => null);
const all = r?.ok ? await r.json() : [];
blockingDecisions.value = all.filter(d => ["open", "escalated"].includes(d.status));
};
refreshDecisions();
```
# Custodian State Hub
@@ -390,8 +335,8 @@ const contribCounts = summary.contribution_counts ?? {};
const licenceRisk = summary.licence_risk_count ?? 0;
const totalContribs = ["br","fr","ep","upr"].reduce((s, t) => s + (contribCounts[t] ?? 0), 0);
const needsFollowUp = (contribCounts["submitted"] ?? 0) + (contribCounts["acknowledged"] ?? 0);
const sbomSnaps = sbomSnapState.snapshots ?? [];
const totalPkgs = sbomSnapState.totalPkgs ?? 0;
const sbomSnaps = pageState.snapshots ?? [];
const totalPkgs = pageState.totalPkgs ?? 0;
display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem">
<a class="card card-link" href="./contributions">
@@ -508,7 +453,7 @@ if (nextSteps.length === 0) {
## Registered Projects
```js
const regs = regsState ?? [];
const regs = pageState.milestones ?? [];
if (regs.length === 0) {
display(html`<p style="color:gray">No projects registered yet. Run <code>custodian register-project</code> inside a repo.</p>`);
} else {
@@ -523,7 +468,7 @@ if (regs.length === 0) {
```js
// Registered domains with no workstreams yet — show a getting-started hint
const regs = regsState ?? [];
const regs = pageState.milestones ?? [];
const registeredDomains = new Set(regs.map(e => e.detail?.domain).filter(Boolean));
const emptyRegistered = (summary.topics ?? []).filter(t =>
registeredDomains.has(t.domain_slug) && (t.workstreams ?? []).length === 0

View File

@@ -3,7 +3,7 @@ title: Interventions
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -39,7 +39,7 @@ const interventionState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {tasks, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Progress
---
```js
import {POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -22,7 +22,7 @@ const progState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, tokenEvents, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Tasks
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -36,7 +36,7 @@ const taskState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Technical Debt
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -39,7 +39,7 @@ const tdState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Todo
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
const THIS_REPO = "the-custodian";
```
@@ -45,7 +45,7 @@ const todoState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {tasks, contribs, improvements, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: Token Cost
---
```js
import {apiFetch, pollDelay, sleep} from "./components/config.js";
import {apiFetch, pollDelay, waitForVisible} from "./components/config.js";
import {refCell} from "./components/ref-cell.js";
const POLL = 60_000;
```
@@ -37,7 +37,7 @@ const tokenState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {byRepo, events, wsMap, taskMap, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL, failures}));
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ title: UI Feedback
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
@@ -65,7 +65,7 @@ const feedbackState = (async function*() {
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,25 +3,26 @@ title: Workstreams
---
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, sleep} from "./components/config.js";
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
```
```js
// Fetch workstreams + topics + summary (for dep graph) in parallel
// Fetch workstreams + topics + dep edges in parallel; /state/deps replaces the
// heavier /state/summary which was only used here to extract dependency edges.
const wsState = (async function*() {
let failures = 0;
while (true) {
let data = [], openWs = [], ok = false;
try {
const [rw, rt, rr, rs] = await Promise.all([
const [rw, rt, rr, rd] = await Promise.all([
apiFetch("/workstreams/"),
apiFetch("/topics/"),
apiFetch("/repos/"),
apiFetch("/state/summary", {timeout: 20_000}),
apiFetch("/state/deps"),
]);
ok = rw.ok && rt.ok && rr.ok && rs.ok;
ok = rw.ok && rt.ok && rr.ok && rd.ok;
if (ok) {
const [wsList, topicList, repoList, summary] = await Promise.all([rw.json(), rt.json(), rr.json(), rs.json()]);
const [wsList, topicList, repoList, depsList] = await Promise.all([rw.json(), rt.json(), rr.json(), rd.json()]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
data = wsList.map(w => ({
@@ -29,13 +30,12 @@ const wsState = (async function*() {
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
topic_title: topicMap[w.topic_id]?.title ?? "—",
}));
// open_workstreams from summary carry depends_on / blocks lists
openWs = summary.open_workstreams ?? [];
openWs = depsList;
}
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, openWs, ok, ts: new Date()};
await sleep(pollDelay({ok, base: POLL_HEAVY, failures}));
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
}
})();
```

View File

@@ -3,7 +3,7 @@ id: CUST-WP-0039
type: workplan
title: "Dashboard Poll Optimization"
domain: custodian
status: active
status: done
owner: custodian
topic_slug: custodian
created: "2026-05-11"
@@ -122,7 +122,7 @@ Schema: `WorkstreamDepStub` already exists in `api/schemas/workstream_dependency
```task
id: CUST-WP-0039-T4
status: todo
status: done
priority: medium
depends_on: [CUST-WP-0039-T3]
state_hub_task_id: "b80dce9c-b1ef-4606-9460-5100d6f58bce"
@@ -143,7 +143,7 @@ Changes:
```task
id: CUST-WP-0039-T5
status: todo
status: done
priority: medium
state_hub_task_id: "7c2d5e01-9de5-48ad-aa0b-a37cf5332ad9"
```
@@ -169,7 +169,7 @@ Approach: single `pageState` generator that yields a flat object with all fields
```task
id: CUST-WP-0039-T6
status: todo
status: done
priority: low
state_hub_task_id: "31b6a353-040a-4f87-b2f1-1deab5cf6191"
```