diff --git a/dashboard/src/components/config.js b/dashboard/src/components/config.js index 3499f94..d8f3c2d 100644 --- a/dashboard/src/components/config.js +++ b/dashboard/src/components/config.js @@ -28,10 +28,11 @@ export async function waitForVisible(ms) { export async function apiFetch(path, options = {}) { const url = path.startsWith("http") ? path : `${API}${path}`; const timeout = options.timeout ?? FETCH_TIMEOUT; + const {timeout: _timeout, ...fetchOptions} = options; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { - return await fetch(url, {...options, signal: ctrl.signal}); + return await fetch(url, {cache: "no-store", ...fetchOptions, signal: ctrl.signal}); } finally { clearTimeout(timer); } diff --git a/dashboard/src/index.md b/dashboard/src/index.md index a32574b..bd729ef 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -20,61 +20,69 @@ const pageState = (async function*() { while (true) { let summary = {}, snapshots = [], totalPkgs = 0, milestones = [], wsAll = [], ok = false; try { - 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"), + const loadJson = async (name, path, options = {}) => { + const response = await apiFetch(path, options); + if (!response.ok) throw new Error(`${name} HTTP ${response.status}`); + return response.json(); + }; + + const [ + summaryData, + snapList, + allEvents, + wsList, + taskList, + topicList, + repoList, + workplanIndex, + ] = await Promise.all([ + loadJson("summary", "/state/summary", {timeout: 20_000}), + loadJson("sbom snapshots", "/sbom/snapshots/"), + loadJson("milestones", "/progress/?event_type=milestone&limit=500"), + loadJson("workstreams", "/workstreams/"), + loadJson("tasks", "/tasks/?limit=2000"), + loadJson("topics", "/topics/"), + loadJson("repos", "/repos/"), + loadJson("workplan index", "/workstreams/workplan-index").catch(() => ({workstreams: {}})), ]); - ok = rSum.ok && rSnap.ok && rRegs.ok && rw.ok && rt.ok && rto.ok && rr.ok; - if (ok) { - 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])); - const counts = {}; - for (const t of taskList) { - const wid = t.workstream_id; - if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}; - counts[wid].total++; - if (t.status === "done") counts[wid].done++; - else if (t.status === "in_progress") counts[wid].in_progress++; - else if (t.status === "blocked") counts[wid].blocked++; - else if (t.status === "todo") counts[wid].todo++; - } - wsAll = wsList.map(w => { - const repo = repoMap[w.repo_id]; - const topic = topicMap[w.topic_id]; - const workplan = workplanMap[w.id] ?? {}; - return { - ...w, - status: normalizeWorkstreamStatus(w.status), - 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, - health_labels: workplan.health_labels ?? [], - href: `./workstreams/${w.id}`, - ...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), - }; - }); - } else { - summary = {error: "API unreachable"}; + + ok = true; + 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 workplanMap = workplanIndex.workstreams ?? {}; + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); + const counts = {}; + for (const t of taskList) { + const wid = t.workstream_id; + if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}; + counts[wid].total++; + if (t.status === "done") counts[wid].done++; + else if (t.status === "in_progress") counts[wid].in_progress++; + else if (t.status === "blocked") counts[wid].blocked++; + else if (t.status === "todo") counts[wid].todo++; } + wsAll = wsList.map(w => { + const repo = repoMap[w.repo_id]; + const topic = topicMap[w.topic_id]; + const workplan = workplanMap[w.id] ?? {}; + return { + ...w, + status: normalizeWorkstreamStatus(w.status), + 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, + health_labels: workplan.health_labels ?? [], + href: `./workstreams/${w.id}`, + ...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), + }; + }); } catch (e) { - summary = {error: "API unreachable"}; + summary = {error: `Dashboard data load failed: ${e?.message ?? String(e)}`}; } failures = ok ? 0 : failures + 1; yield {summary, snapshots, totalPkgs, milestones, wsAll, ok, ts: new Date()}; @@ -136,8 +144,8 @@ display(html`
- - + + diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index d745409..2ae6003 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -24,6 +24,7 @@ Checks: C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph + C-22 task-description-drift WARN Yes Task description/content differs between file and DB Usage: python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] @@ -83,7 +84,7 @@ except ImportError: # --------------------------------------------------------------------------- _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) -_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE) +_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) _ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES) SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES) @@ -236,16 +237,64 @@ def parse_frontmatter(text: str) -> tuple[dict, str]: return meta, parts[2] +def _clean_task_description(raw: str) -> str | None: + """Trim section chrome while preserving task markdown content.""" + lines = raw.splitlines() + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[-1].strip() in {"---", "***", "___"}: + lines.pop() + while lines and not lines[-1].strip(): + lines.pop() + while lines and lines[0].strip() in {"---", "***", "___"}: + lines.pop(0) + while lines and not lines[0].strip(): + lines.pop(0) + text = "\n".join(lines).strip() + return text or None + + def parse_task_blocks(body: str) -> list[dict]: - """Extract task blocks, injecting title from the nearest preceding ### heading.""" - headings = [(m.start(), m.group(1).strip()) for m in _HEADING_RE.finditer(body)] + """Extract task blocks, injecting title and markdown content from the task section.""" + headings = [ + (m.start(), len(m.group(1)), m.group(2).strip()) + for m in _HEADING_RE.finditer(body) + ] + task_matches = list(_TASK_BLOCK_RE.finditer(body)) results = [] - for m in _TASK_BLOCK_RE.finditer(body): + for idx, m in enumerate(task_matches): task = _parse_yaml_block(m.group(1).strip()) + prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()] + prev_heading = prev[-1] if prev else None if "title" not in task: - prev = [(pos, text) for pos, text in headings if pos < m.start()] - if prev: - task["title"] = prev[-1][1] + if prev_heading: + task["title"] = prev_heading[2] + + content_end = len(body) + if idx + 1 < len(task_matches): + content_end = min(content_end, task_matches[idx + 1].start()) + if prev_heading: + current_level = prev_heading[1] + next_peer_heading = next( + ( + pos + for pos, level, _text in headings + if pos > m.end() and level <= current_level + ), + None, + ) + if next_peer_heading is not None: + content_end = min(content_end, next_peer_heading) + else: + next_heading = next((pos for pos, _level, _text in headings if pos > m.end()), None) + if next_heading is not None: + content_end = min(content_end, next_heading) + + description = _clean_task_description(body[m.end():content_end]) + if description: + task["description"] = description results.append(task) return results @@ -900,6 +949,27 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N fixable=True, _fix_context={"task_id": t_sh_id, "status": t_status}, ) + file_description = task.get("description") + if isinstance(file_description, str): + file_description = file_description.strip() or None + else: + file_description = None + db_description = db_task.get("description") + if isinstance(db_description, str): + db_description = db_description.strip() or None + else: + db_description = None + if file_description and file_description != db_description: + report.add( + severity="WARN", check_id="C-22", + message=f"Task description drift '{t_id}': file content differs from DB (file wins)", + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_description[:120], + db_value=(db_description or "")[:120], + fixable=True, + _fix_context={"task_id": t_sh_id, "description": file_description}, + ) elif t_id: # C-11: task exists in file but not linked to DB ws_status = ws.get("status", "") @@ -1478,6 +1548,7 @@ def fix_repo( t_data = _api_post(api_base, "/tasks", { "workstream_id": new_ws_id, "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, "status": t_status, "priority": t_priority, "assignee": task.get("assignee") or None, @@ -1526,10 +1597,14 @@ def fix_repo( status = ctx["status"] result = _api_patch(api_base, f"/tasks/{task_id}", {"status": status}) - if result is not None: + if result is not None and "_error" not in result: report.fixes_applied.append( f"C-10 fixed: task {task_id[:8]}… status → {status!r}" ) + elif result is not None: + report.fixes_applied.append( + f"C-10 FAILED: task {task_id[:8]}… status → {status!r}: {result['_error']}" + ) elif issue.check_id == "C-11": ws_id = ctx["ws_id"] @@ -1555,6 +1630,7 @@ def fix_repo( t_data = _api_post(api_base, "/tasks", { "workstream_id": ws_id, "title": str(task.get("title", t_id)).strip() or t_id, + "description": task.get("description") or None, "status": t_status, "priority": t_priority, "assignee": task.get("assignee") or None, @@ -1579,6 +1655,19 @@ def fix_repo( f"C-12 fixed: orphan task {task_id[:8]}… cancelled (workstream finished)" ) + elif issue.check_id == "C-22": + task_id = ctx["task_id"] + description = ctx["description"] + result = _api_patch(api_base, f"/tasks/{task_id}", {"description": description}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-22 fixed: task {task_id[:8]}… description updated" + ) + elif result is not None: + report.fixes_applied.append( + f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}" + ) + elif issue.check_id == "C-15": # T03 — writeback: DB is ahead of file — patch file to match DB. if no_writeback: diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 7c3aee9..c0e9daa 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -145,6 +145,39 @@ class TestParseTaskBlocks: assert len(tasks) == 1 assert tasks[0]["id"] == "T01" + def test_extracts_markdown_after_task_block_as_description(self): + body = ( + "### T01 — Configure registry\n\n" + "```task\nid: T01\nstatus: todo\npriority: high\n```\n\n" + "Update `app.ini` so packages are enabled.\n\n" + "```ini\n[packages]\nENABLED = true\n```\n\n" + "**Done when:** a rendered config contains the package settings.\n\n" + "---\n\n" + "### T02 — Next task\n\n" + "```task\nid: T02\nstatus: todo\n```\n" + ) + tasks = parse_task_blocks(body) + assert tasks[0]["description"] == ( + "Update `app.ini` so packages are enabled.\n\n" + "```ini\n[packages]\nENABLED = true\n```\n\n" + "**Done when:** a rendered config contains the package settings." + ) + assert "Next task" not in tasks[0]["description"] + + def test_keeps_nested_headings_inside_task_description(self): + body = ( + "## T01\n\n" + "```task\nid: T01\nstatus: todo\n```\n\n" + "Intro.\n\n" + "### Notes\n\n" + "- Keep this nested heading in the task content.\n\n" + "## T02\n\n" + "```task\nid: T02\nstatus: todo\n```\n" + ) + tasks = parse_task_blocks(body) + assert "### Notes" in tasks[0]["description"] + assert "## T02" not in tasks[0]["description"] + # --------------------------------------------------------------------------- # get_tasks_from_workplan