From 41cfb82011ec795ec8d49dfa57e5d78c37c3d195 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Sat, 12 Sep 2026 14:51:52 -0700 Subject: [PATCH 1/2] Board: correct existing-data truth, hierarchy, and attention semantics Presentation-only change against the existing /api/status payload. No schema, payload, or cloud projection field is added or changed. - Put the current-work summary (Work Now), the owner queue, and lane work before aggregate productivity and release history. - Group owner-queue entries by PR into one work item with grouped reasons, one primary responsible role, and one next action. - Classify rebase, CI repair, audit fixes, and re-review as builder or orchestrator work; reserve owner attention for PRs carrying explicit permission, budget, policy, product-decision, or owner-request labels. - Render unmeasured time, cost, quality, and productivity values as "not recorded" instead of coercing them to zero, and stop unknown or unavailable states from rendering green or as pass. - Separate the gate publisher from the code-mower/gate commit status so a green publisher run cannot pass a pending verdict. The publisher is an allowlist of the canonical "Code Mower gate" workflow and its "publish Code Mower gate status" job, so an unrelated check such as security-gate stays an ordinary check. - Report observation age for replayed, aged, GitHub-unavailable, and server-cache-unconfirmed snapshots, showing the older of the observation time and board.cache.age_seconds. A snapshot with no parseable observation time is reported neutrally with no live claim and no synthetic age. Show a running campaign with no unexpired provider response deadline as last reported rather than currently running. - Keep GitHub data useful when local session inputs are absent and name which local data is unavailable. Refs #947 Co-Authored-By: Claude Opus 5 (1M context) --- docs/board-data-contract.md | 41 ++ src/code_mower/board.py | 370 ++++++++++++++++-- tests/test_board.py | 752 +++++++++++++++++++++++++++++++++++- 3 files changed, 1136 insertions(+), 27 deletions(-) diff --git a/docs/board-data-contract.md b/docs/board-data-contract.md index 2403744d..175a0822 100644 --- a/docs/board-data-contract.md +++ b/docs/board-data-contract.md @@ -397,6 +397,47 @@ When GitHub is unavailable, the owner queue returns `available: false`, an empty entry list, and a generic message. Existing local event and spend timelines can still render from local files in the same Board response. +## Presentation Rules + +The browser view derives the rules below from the payload described above. They +add no fields to any schema; they only bound what the page is allowed to assert. + +- **Hierarchy.** The current-work summary (`Work Now`), the owner queue, and + lane work are rendered before aggregate productivity and release history. +- **One work item per PR.** `owner_queue.entries[]` carries one entry per + attention reason. The page groups entries by `pr_number` into a single work + item with grouped reasons, one primary responsible role, and one next action, + so several reasons for one PR cannot inflate the owner count. +- **Role.** `blocked-audit`, `failing-check`, `rebase-needed` and `draft` are + builder work; `stale-gate` is orchestrator work. Owner attention requires an + explicit permission, budget, policy, product-decision or owner-request label + already present in the PR's own label groups; a reason that claims owner + attention without such evidence is shown as orchestrator triage. +- **Missing measurements.** A time, cost, quality or productivity value that is + not a real JSON number renders as `not recorded`. Absent values are never + coerced to zero, and an unknown or unavailable state is never green or `pass`. +- **Gate verdict.** Only the `code-mower/gate` commit status is the verdict. + The publisher is an allowlist of the canonical names that publish it — the + `Code Mower gate` workflow and its `publish Code Mower gate status` job — + compared case- and whitespace-insensitively. Those are labelled as + publishers, so a successful publisher run cannot make a pending, blocked or + unrecorded verdict look passing. An unrelated check whose name merely + contains `gate`, such as `security-gate`, is an ordinary check. +- **Observation age.** Snapshots replayed from local history, snapshots older + than ten minutes, snapshots served from a `board.cache.state` other than + `fresh`, and snapshots taken while GitHub was unavailable are shown as + `last observed ago` and may not claim that work is running now. The + age shown is the older of the observation time and `board.cache.age_seconds`. + When neither records a parseable time the page reports `observation time not + recorded` neutrally, with no `live` claim and no synthetic age. +- **Campaign liveness.** A campaign's `elapsed_seconds` is recorded provider + work time, not age, and is labelled that way. A `running` campaign with no + unexpired provider `response_deadline_at` is shown as `last reported running`. +- **Local data.** When GitHub data is fresh but local session inputs (agent + adapter cards, orchestrator lease, reviewer verdict history, reviewer spend + rows) are absent, GitHub information stays useful and the page names the + local data that is unavailable instead of rendering it as zero. + ## Agent Adapters The Board embeds `code_mower.boardAgentAdapters.v1` in `/api/status`. Agent diff --git a/src/code_mower/board.py b/src/code_mower/board.py index 64ec111b..062b5aff 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -1303,14 +1303,19 @@ def render_board_html(config: BoardConfig) -> str:
-

Supervised Pilot

-

Productivity

+ +

Work Now

Owner Queue

-

Local Orchestrator Lease

-

Agent Cards

-

Release Campaigns

+

Lane Work

+

Supervised Pilot

Open PRs

Gate Alerts

+

Agent Cards

+

Local Orchestrator Lease

+

Release Campaigns

+

Productivity

Recent Code Mower Workflows

Recent Local History

Reviewer Verdict Timeline

@@ -1353,12 +1358,260 @@ def render_board_html(config: BoardConfig) -> str: const esc = (value) => text(value).replace(/[&<>"']/g, c => ({{"&":"&","<":"<",">":">","\\"":""","'":"'"}}[c])); const put = (id, html) => document.getElementById(id).innerHTML = html; const pill = (value) => `${{esc(value)}}`; + const statePill = (value, cls) => `${{esc(value)}}`; const empty = (message) => `
${{esc(message)}}
`; const href = (value) => /^https?:\\/\\//i.test(text(value)) ? text(value) : "#"; - const stateClass = (value) => /fail|error|blocked/i.test(text(value)) ? "bad" : /warn|pending|waiting|queued|progress/i.test(text(value)) ? "warn" : "ok"; - const display = (value) => value === null || value === undefined || value === "" ? "unknown" : text(value); - const seconds = (value) => Number.isFinite(Number(value)) ? `${{Number(value).toFixed(1)}}s` : "n/a"; - const money = (value) => Number.isFinite(Number(value)) ? `$${{Number(value).toFixed(3)}}` : "n/a"; + // --- presentation truth helpers (BEGIN) --- + // Pure, DOM-free projections of the existing /api/status payload. They add + // no fields; they only stop the page from asserting more than the payload + // records. Kept self-contained so the shipped code can be executed + // directly by the tests instead of restated in Python. + const NOT_RECORDED = "not recorded"; + const GATE_CONTEXT = "code-mower/gate"; + // A snapshot this much older than now is reported by age alone, whatever + // the payload calls its source: a wedged refresh must not keep presenting + // an old observation as the current state of the world. + const STALE_OBSERVATION_SECONDS = 600; + // Only a real JSON number is a recorded measurement. Number(null), + // Number("") and Number(false) are all a finite 0, so the usual + // Number.isFinite(Number(v)) test silently reports "no data" as zero. + const measured = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null); + const display = (value) => (value === null || value === undefined || value === "" ? NOT_RECORDED : String(value)); + const seconds = (value) => {{ + const number = measured(value); + return number === null ? NOT_RECORDED : `${{number.toFixed(1)}}s`; + }}; + const money = (value) => {{ + const number = measured(value); + return number === null ? NOT_RECORDED : `$${{number.toFixed(3)}}`; + }}; + const countOf = (available, value) => (available ? String(value) : NOT_RECORDED); + // An absent, unknown or unavailable state is neutral, never green and + // never "pass". Only a state the payload actually reports as good earns + // the ok colour. + const UNKNOWN_STATE_RE = /^(unknown|unavailable|absent|none|not recorded|no data|off|n\\/a)$/i; + const stateClass = (value) => {{ + const state = text(value).trim(); + if (state === "" || UNKNOWN_STATE_RE.test(state)) return "muted"; + if (/fail|error|blocked|expired|overdue/i.test(state)) return "bad"; + if (/warn|pending|waiting|queued|progress|stale|unverified|last reported/i.test(state)) return "warn"; + return "ok"; + }}; + const parseMs = (value) => {{ + const parsed = Date.parse(text(value)); + return Number.isFinite(parsed) ? parsed : null; + }}; + const ageText = (secs) => {{ + const number = measured(secs); + if (number === null) return NOT_RECORDED; + if (number >= 3600) return `${{(number / 3600).toFixed(1)}}h`; + if (number >= 60) return `${{(number / 60).toFixed(0)}}m`; + return `${{Math.max(number, 0).toFixed(0)}}s`; + }}; + const ageSeconds = (value, nowMs) => {{ + const at = parseMs(value); + return at === null ? null : Math.max((nowMs - at) / 1000, 0); + }}; + // The `code-mower/gate` commit status is the verdict. The only thing that + // publishes it is the canonical gate workflow and its publishing job, so + // the publisher is an allowlist of those two names rather than anything + // gate-shaped: an unrelated `security-gate` check is an ordinary check, + // not a Code Mower publisher. + const GATE_PUBLISHER_NAMES = ["code mower gate", "publish code mower gate status"]; + // Case- and whitespace-insensitive comparison for payload identifiers. + const normalized = (value) => text(value).trim().toLowerCase().replace(/\\s+/g, " "); + const isGateContext = (name) => normalized(name) === GATE_CONTEXT; + const isGatePublisher = (name) => GATE_PUBLISHER_NAMES.includes(normalized(name)); + function gateVerdict(pr) {{ + const list = Array.isArray(pr?.checks) ? pr.checks : []; + const verdict = list.find(check => isGateContext(check?.name)); + if (!verdict) return {{state: NOT_RECORDED, recorded: false, class: "muted"}}; + const state = text(verdict.state).trim() || "unknown"; + return {{state, recorded: true, class: stateClass(state)}}; + }} + // Reasons the Board can raise for one work item, and the role that clears + // each one. Rebase, CI repair, audit fixes and re-review are routine lane + // work owned by the builder or the orchestrator. Owner attention is + // reserved for reasons carrying explicit permission, budget, policy, + // product-decision or owner-request evidence in the payload's own labels. + const ATTENTION_REASONS = {{ + "needs-owner": {{rank: 0, role: "owner"}}, + "blocked-audit": {{rank: 1, role: "builder"}}, + "failing-check": {{rank: 2, role: "builder"}}, + "rebase-needed": {{rank: 3, role: "builder"}}, + "stale-gate": {{rank: 4, role: "orchestrator"}}, + "draft": {{rank: 5, role: "builder"}} + }}; + const UNKNOWN_REASON = {{rank: 8, role: "orchestrator"}}; + const OWNER_EVIDENCE_RE = /^(needs-owner|owner-request|owner-decision|owner-approval|needs-permission|permission-required|needs-budget|budget-approval|needs-policy|policy-decision|needs-product-decision|product-decision)$/i; + function ownerEvidence(...sources) {{ + const names = []; + for (const source of sources) {{ + const values = Array.isArray(source) ? source : Object.values(source || {{}}).flat(); + for (const value of values) {{ + const name = text(value).trim(); + if (OWNER_EVIDENCE_RE.test(name) && !names.includes(name)) names.push(name); + }} + }} + return names; + }} + // One PR is one work item. The owner queue payload emits a separate entry + // per reason, so several reasons for the same PR are grouped here instead + // of rendering as unrelated rows and inflating the owner count. + function attentionItems(entries, prs) {{ + const byNumber = new Map(); + for (const pr of Array.isArray(prs) ? prs : []) byNumber.set(pr?.number, pr); + const items = new Map(); + for (const entry of Array.isArray(entries) ? entries : []) {{ + const number = entry?.pr_number; + const reason = ATTENTION_REASONS[entry?.kind] || UNKNOWN_REASON; + let item = items.get(number); + if (!item) {{ + const pr = byNumber.get(number) || {{}}; + item = {{ + pr_number: number, + title: entry?.title || pr.title || "", + url: entry?.url || pr.url || "", + branch: entry?.branch || pr.branch || "", + author: entry?.author || pr.author || "", + updated_at: entry?.updated_at || pr.updated_at || "", + head_sha_prefix: entry?.head_sha_prefix || "", + gate: gateVerdict(pr), + evidence: ownerEvidence(pr.labels, entry?.labels), + reasons: [], + next_action: "", + rank: UNKNOWN_REASON.rank + 1 + }}; + items.set(number, item); + }} + const kind = text(entry?.kind).trim() || "attention"; + if (!item.reasons.some(existing => existing.kind === kind)) {{ + item.reasons.push({{kind, role: reason.role, next_action: text(entry?.next_action)}}); + }} + for (const name of ownerEvidence(entry?.labels)) {{ + if (!item.evidence.includes(name)) item.evidence.push(name); + }} + if (reason.rank < item.rank) {{ + item.rank = reason.rank; + item.next_action = text(entry?.next_action); + }} + }} + return [...items.values()].map(item => {{ + // A reason only a builder or the orchestrator can clear never promotes + // to owner attention, and a reason that claims owner attention without + // explicit evidence falls back to orchestrator triage. + const claimsOwner = item.reasons.some(reason => reason.role === "owner"); + const role = claimsOwner && item.evidence.length + ? "owner" + : item.reasons.every(reason => reason.role === "orchestrator") || claimsOwner + ? "orchestrator" + : "builder"; + return {{...item, role, next_action: item.next_action || item.reasons[0]?.next_action || "inspect"}}; + }}).sort((a, b) => (a.role === b.role ? 0 : a.role === "owner" ? -1 : b.role === "owner" ? 1 : 0) + || a.rank - b.rank + || (a.pr_number ?? 0) - (b.pr_number ?? 0)); + }} + // Board snapshots can be replayed from local history, served from a cache + // the server has not confirmed, or carry no observation time at all. Each + // of those may only report what was last observed; none of them may claim + // that anything is running right now. + function observation(data, nowMs) {{ + const current = data?.productivity?.current || {{}}; + const cache = data?.board?.cache || {{}}; + const observedAt = text(current.observed_at) || text(data?.generated_at); + const observedAge = ageSeconds(observedAt, nowMs); + const cacheAge = measured(cache.age_seconds); + // Only `fresh` is a snapshot the server has confirmed as current. It + // answers a cold cache with metadata only and a stale one with the + // previous snapshot, so any other reported state -- including a future + // one this page does not know -- is serving unconfirmed data, however + // recent the embedded observation time looks. + const cacheState = normalized(cache.state); + const unconfirmed = cacheState !== "" && cacheState !== "fresh"; + // Take the older of the two recorded ages so an unconfirmed snapshot can + // never understate how old what is on screen actually is. + const age = observedAge === null + ? cacheAge + : cacheAge === null ? observedAge : Math.max(observedAge, cacheAge); + // No parseable observation time anywhere is not evidence of freshness, + // so it may not produce a "live" claim or a synthetic last-observed age. + const unknownAge = age === null; + const historical = current.source === "historical_board_snapshot" || current.historical === true; + const remoteAvailable = data?.remote?.available === true; + const aged = age !== null && age > STALE_OBSERVATION_SECONDS; + const stale = historical || aged || unconfirmed || unknownAge || !remoteAvailable; + return {{ + age_text: ageText(age), + historical, + aged, + unconfirmed, + live: !stale, + label: unknownAge + ? "observation time not recorded" + : stale ? `last observed ${{ageText(age)}} ago` : `live, observed ${{ageText(age)}} ago`, + class: unknownAge ? "muted" : stale ? "warn" : "ok", + detail: historical + ? "Replayed from the last recorded local Board snapshot; nothing here is evidence of work running now." + : unconfirmed + ? `The Board server is serving a ${{cacheState}} cached snapshot it has not confirmed; nothing here is evidence of work running now.` + : unknownAge + ? "No observation time is recorded, so this snapshot cannot be shown as current." + : remoteAvailable + ? "" + : "GitHub is unavailable, so remote counts below are last observed rather than current." + }}; + }} + // Which local-only inputs the snapshot actually carries. Fresh GitHub data + // stays useful when they are missing, but the page has to say so rather + // than render their absence as a zero. + function localSources(data) {{ + const adapters = data?.agent_adapters || {{}}; + // No adapter directory at all is "never measured", which is a different + // statement from an empty directory reporting zero live agents. + const adaptersAvailable = adapters.available !== false && adapters.path_exists === true; + const missing = []; + if (!adaptersAvailable) missing.push("agent adapter cards"); + if (text(data?.orchestrator_lease?.state) !== "active") missing.push("orchestrator lease"); + if (!(data?.timelines?.verdicts?.entries || []).length) missing.push("reviewer verdict history"); + if (!(data?.timelines?.spend?.groups || []).length) missing.push("reviewer spend rows"); + return {{ + adapters_available: adaptersAvailable, + missing, + message: missing.length + ? `Local session data unavailable: ${{missing.join(", ")}}. GitHub data above is unaffected.` + : "Local session data available." + }}; + }} + const CLAIMS_RUNNING_RE = /^(running|dispatched|in_progress)$/i; + // A campaign file records accumulated provider work time, not liveness. + // The one wall-clock signal it carries is a provider response deadline: + // once that has passed -- or was never recorded -- nothing in the payload + // shows the provider still working. + function cardLiveness(card, nowMs) {{ + const deadline = parseMs(card?.response_deadline_at); + const overdue = deadline !== null && deadline < nowMs; + const state = text(card?.state).trim() || "unknown"; + const suppressed = CLAIMS_RUNNING_RE.test(state) && (overdue || deadline === null); + return {{ + label: suppressed ? `last reported ${{state}}` : state, + overdue, + deadline_recorded: deadline !== null, + overdue_for: overdue ? ageText((nowMs - deadline) / 1000) : "", + class: suppressed || overdue ? "warn" : stateClass(state) + }}; + }} + function campaignLiveness(campaign, nowMs) {{ + const status = text(campaign?.status).trim() || "unknown"; + const cards = (Array.isArray(campaign?.cards) ? campaign.cards : []).map(card => cardLiveness(card, nowMs)); + const unverified = CLAIMS_RUNNING_RE.test(status) && !cards.some(card => card.deadline_recorded && !card.overdue); + return {{ + cards, + unverified, + label: unverified ? `last reported ${{status}}` : status, + class: unverified ? "warn" : stateClass(status) + }}; + }} + // --- presentation truth helpers (END) --- const localTime = (value) => {{ const raw = text(value); if (!raw) return ""; @@ -1371,7 +1624,25 @@ def render_board_html(config: BoardConfig) -> str: return Object.values(groups || {{}}).flat().map(pill).join(" ") || 'none'; }} function checks(list) {{ - return (list || []).map(c => `${{esc(c.name)}}=${{esc(c.state)}}`).join(", ") || 'none'; + // The canonical publishing job is marked so a green publisher run is + // never read as a green verdict. Other checks, including unrelated ones + // whose name happens to contain "gate", render normally. + return (list || []).map(c => {{ + const publisher = isGatePublisher(c.name); + const suffix = publisher ? ' (publisher job, not the verdict)' : ""; + return `${{esc(c.name)}}=${{esc(display(c.state))}}${{suffix}}`; + }}).join(", ") || 'none'; + }} + function attentionRow(item) {{ + const reasons = item.reasons.map(reason => pill(`${{reason.kind}} -> ${{reason.role}}`)).join(" "); + const evidence = item.evidence.length ? `
owner evidence: ${{esc(item.evidence.join(", "))}}
` : ""; + return `
+
#${{esc(item.pr_number)}} ${{esc(item.title)}}${{statePill(item.role, item.role === "owner" ? "warn" : "muted")}}${{statePill(`gate ${{item.gate.state}}`, item.gate.class)}}${{item.head_sha_prefix ? pill(item.head_sha_prefix) : ""}}
+
next: ${{esc(item.next_action)}}
+
reasons (${{item.reasons.length}}): ${{reasons}}
+ ${{evidence}} +
${{esc(item.branch)}} by ${{esc(item.author)}}${{item.updated_at ? ` updated ${{localTime(item.updated_at)}}` : ""}}
+
`; }} function renderLease(lease) {{ const messages = {{absent: "No orchestrator lease in this working copy.", expired: "Lease expired.", malformed: "Local lease is malformed.", unavailable: "Local lease is unavailable."}}; @@ -1409,22 +1680,39 @@ def render_board_html(config: BoardConfig) -> str: const productivityWindow = productivity.window?.local_history || {{}}; const productivitySpend = productivity.spend || {{}}; const productivityQuality = productivity.quality || {{}}; + const nowMs = Date.now(); + const remoteAvailable = data.remote?.available === true; + const obs = observation(data, nowMs); + const sources = localSources(data); + const attention = attentionItems(ownerQueue, prs); + const ownerItems = attention.filter(item => item.role === "owner"); + const laneItems = attention.filter(item => item.role !== "owner"); + const leadItem = attention[0]; put("summary", [ `
Next action${{esc(data.next_action || "inspect")}}
`, data.next_detail ? `
Detail${{esc(data.next_detail)}}
` : "", - `
GitHub${{data.remote?.available ? "available" : "unavailable"}}
`, - `
Open PRs${{prs.length}}
`, - `
Pilot${{esc(supervised.cycle_state || "off")}}
`, - `
Productivity${{esc(productivity.status || "unknown")}}
`, - `
Owner queue${{ownerQueue.length}}
`, - `
Agent cards${{agentCards.length}}
`, - `
Campaigns${{(data.release_campaigns?.campaigns || []).length}}
`, - `
Gate alerts${{alerts.length}}
` + `
Observation${{esc(obs.label)}}
`, + `
GitHub${{remoteAvailable ? "available" : "unavailable"}}
`, + `
Open PRs${{esc(countOf(remoteAvailable, prs.length))}}
`, + `
Owner decisions${{esc(countOf(remoteAvailable, ownerItems.length))}}
`, + `
Lane work${{esc(countOf(remoteAvailable, laneItems.length))}}
`, + `
Gate alerts${{esc(countOf(remoteAvailable, alerts.length))}}
`, + `
Pilot${{esc(display(supervised.cycle_state))}}
`, + `
Productivity${{esc(display(productivity.status))}}
`, + `
Agent cards${{esc(countOf(sources.adapters_available, agentCards.length))}}
`, + `
Campaigns${{(data.release_campaigns?.campaigns || []).length}}
` + ].join("")); + put("worknow", [ + leadItem + ? `
Do next: #${{esc(leadItem.pr_number)}}${{esc(leadItem.next_action)}}${{statePill(leadItem.role, leadItem.role === "owner" ? "warn" : "muted")}}${{statePill(`gate ${{leadItem.gate.state}}`, leadItem.gate.class)}}
${{esc(leadItem.title)}}
` + : `
Do next: ${{esc(data.next_action || "inspect")}}
${{data.next_detail ? `
${{esc(data.next_detail)}}
` : ""}}
`, + `
${{pill(`owner decisions ${{countOf(remoteAvailable, ownerItems.length)}}`)}}${{pill(`lane work ${{countOf(remoteAvailable, laneItems.length)}}`)}}${{pill(`open PRs ${{countOf(remoteAvailable, prs.length)}}`)}}${{statePill(obs.label, obs.class)}}
${{obs.detail ? `
${{esc(obs.detail)}}
` : ""}}
`, + `
${{esc(sources.message)}}
` ].join("")); const reviewerOutcomes = supervisedDecision.reviewer_outcomes || []; const supervisedRows = supervised.enabled ? [ `
${{esc(supervised.cycle_state || "unknown")}}${{pill(supervised.controller_mode || "dry_run")}}${{supervisedDecision.decision_state ? pill(supervisedDecision.decision_state) : ""}}
next: ${{esc(supervisedDecision.next_action || "inspect")}}
${{supervisedDecision.next_detail ? `
${{esc(supervisedDecision.next_detail)}}
` : ""}}
`, - `
${{pill(`open PRs ${{supervisedMetrics.open_pr_count ?? supervisedPRs.length}}`)}}${{pill(`ready issues ${{supervisedMetrics.ready_issue_count ?? supervisedIssues.length}}`)}}${{pill(`active lanes ${{supervisedMetrics.active_lane_count ?? 0}}`)}}${{pill(`stale ${{supervisedMetrics.stale_evidence_count ?? 0}}`)}}
`, + `
${{pill(`open PRs ${{display(supervisedMetrics.open_pr_count ?? supervisedPRs.length)}}`)}}${{pill(`ready issues ${{display(supervisedMetrics.ready_issue_count ?? supervisedIssues.length)}}`)}}${{pill(`active lanes ${{display(supervisedMetrics.active_lane_count)}}`)}}${{pill(`stale ${{display(supervisedMetrics.stale_evidence_count)}}`)}}
`, supervisedDecision.pr_number ? `
Selected PR #${{esc(supervisedDecision.pr_number)}}${{supervisedDecision.lane_id ? pill(supervisedDecision.lane_id) : ""}}${{supervisedDecision.gate_status ? pill(`gate ${{supervisedDecision.gate_status}}`) : ""}}${{supervisedDecision.author_lane_excluded ? pill("author excluded") : ""}}
${{esc(supervisedDecision.branch || "")}}${{supervisedDecision.head_sha_prefix ? ` @ ${{esc(supervisedDecision.head_sha_prefix)}}` : ""}}
` : "", supervisedDecision.issue_number ? `
Selected issue #${{esc(supervisedDecision.issue_number)}}${{supervisedDecision.lane_id ? pill(supervisedDecision.lane_id) : ""}}
` : "", reviewerOutcomes.length ? `
Reviewer Evidence
${{reviewerOutcomes.map(outcome => `${{esc(outcome.lane_id || outcome.config_lane_id)}}=${{esc(outcome.verdict)}}`).join(", ")}}
` : "", @@ -1435,6 +1723,9 @@ def render_board_html(config: BoardConfig) -> str: put("supervised", trackerRows + supervisedRows || empty(supervised.message || "No supervised pilot activity.")); const productivityRows = [ `
next: ${{esc(productivity.next_action || "inspect")}}
`, + // Aggregates are only as current as the snapshot they were computed + // from, so the observation comes before the numbers rather than after. + `
${{pill(`source ${{display(productivityCurrent.source)}}`)}}${{statePill(obs.label, obs.class)}}${{obs.historical ? statePill("historical snapshot", "warn") : ""}}
observed ${{esc(display(productivityCurrent.observed_at))}}${{obs.historical ? "; these aggregates replay the last recorded snapshot and are not evidence of work running now" : ""}}
`, `
Current
${{pill(`open PRs ${{display(productivityCurrent.open_pr_count)}}`)}}${{pill(`active lanes ${{display(productivityCurrent.active_lane_count)}}`)}}${{pill(`blocked ${{display(productivityCurrent.blocked_pr_count)}}`)}}${{pill(`owner actions ${{display(productivityCurrent.owner_action_count)}}`)}}
`, `
Throughput
${{pill(`merged ${{display(productivityMetrics.merged_pr_count)}}`)}}${{pill(`cycle ${{seconds(productivityMetrics.cycle_time_seconds)}}`)}}${{pill(`active ${{seconds(productivityMetrics.active_time_seconds)}}`)}}${{pill(`wait ${{seconds(productivityMetrics.wait_time_seconds)}}`)}}
local window ${{display(productivityWindow.start)}} to ${{display(productivityWindow.end)}} (${{seconds(productivityWindow.duration_seconds)}})
`, `
Quality
${{pill(`reviews ${{display(productivityMetrics.reviewer_run_count)}}`)}}${{pill(`PASS ${{display(productivityQuality.audit_pass_count)}}`)}}${{pill(`BLOCKED ${{display(productivityQuality.audit_blocked_count)}}`)}}${{pill(`catches ${{display(productivityQuality.reviewer_catch_count)}}`)}}${{pill(`fix rounds ${{display(productivityQuality.fix_round_count)}}`)}}
`, @@ -1442,13 +1733,24 @@ def render_board_html(config: BoardConfig) -> str: (productivity.warnings || []).length ? `
${{esc((productivity.warnings || []).slice(0, 3).join("; "))}}
` : "" ].filter(Boolean).join(""); put("productivity", productivityRows || empty("No local productivity signals yet.")); - put("owner", ownerQueue.length ? ownerQueue.map(item => `
#${{esc(item.pr_number)}} ${{esc(item.kind)}}${{pill(item.next_action)}}${{pill(item.head_sha_prefix)}}
${{esc(item.branch)}} by ${{esc(item.author)}}${{item.updated_at ? ` updated ${{localTime(item.updated_at)}}` : ""}}
`).join("") : empty(data.owner_queue?.message || "No owner queue items.")); + put("owner", ownerItems.length + ? ownerItems.map(attentionRow).join("") + : empty(remoteAvailable + ? "No PR carries explicit permission, budget, policy, product-decision or owner-request evidence." + : (data.owner_queue?.message || "GitHub unavailable; owner decisions not recorded."))); + put("lanework", laneItems.length + ? laneItems.map(attentionRow).join("") + : empty(remoteAvailable ? "No builder or orchestrator work items." : "GitHub unavailable; lane work not recorded.")); put("agents", agentCards.length ? agentCards.map(agent => `
${{esc(agent.provider)}}${{pill(agent.role)}}${{pill(agent.status)}}${{agent.stale ? pill("stale") : ""}}${{agent.lane ? pill(agent.lane) : ""}}${{agent.pr_number ? pill(`#${{agent.pr_number}}`) : ""}}
${{esc(agent.title || agent.next_action || "local agent")}}
${{esc(agent.branch || agent.repo || "")}}${{agent.pid ? ` pid=${{esc(agent.pid)}}` : ""}}${{agent.cwd ? ` cwd=${{esc(agent.cwd)}}` : ""}}${{agent.updated_at ? ` updated ${{localTime(agent.updated_at)}}` : ""}}
`).join("") : empty(data.agent_adapters?.message || "No local agent adapter cards.")); const campaignsData = data.release_campaigns || {{}}; const campaigns = campaignsData.campaigns || []; const campaignRows = campaigns.flatMap(c => {{ - const header = `
Release ${{esc(c.release_tag)}}${{pill(c.status)}}${{c.dry_run ? pill("dry-run") : pill("applied")}}${{pill(c.qualification_context)}}${{seconds(c.elapsed_seconds)}}
next: ${{esc(c.next_action)}}
`; - const cardRows = (c.cards || []).map(card => `
${{esc(card.provider)}}${{pill(card.posture || "required")}}${{esc(card.state)}}${{pill(card.environment)}}${{card.transport_verified === false ? pill("transport unverified") : ""}}${{seconds(card.elapsed_seconds)}}
next: ${{esc(card.next_action)}}
${{card.next_detail ? `
${{esc(card.next_detail)}}
` : ""}}${{card.response_deadline_at ? `
response deadline ${{localTime(card.response_deadline_at)}}
` : ""}}
`); + const live = campaignLiveness(c, nowMs); + const header = `
Release ${{esc(c.release_tag)}}${{statePill(live.label, live.class)}}${{c.dry_run ? pill("dry-run") : pill("applied")}}${{pill(c.qualification_context)}}recorded work ${{seconds(c.elapsed_seconds)}}
next: ${{esc(c.next_action)}}
${{live.unverified ? `
No unexpired provider response deadline is recorded, so this campaign is shown as last reported rather than currently running.
` : ""}}
`; + const cardRows = (c.cards || []).map((card, index) => {{ + const cardLive = live.cards[index] || cardLiveness(card, nowMs); + return `
${{esc(card.provider)}}${{pill(card.posture || "required")}}${{esc(cardLive.label)}}${{pill(card.environment)}}${{card.transport_verified === false ? pill("transport unverified") : ""}}${{cardLive.overdue ? statePill(`deadline passed ${{cardLive.overdue_for}} ago`, "warn") : ""}}recorded work ${{seconds(card.elapsed_seconds)}}
next: ${{esc(card.next_action)}}
${{card.next_detail ? `
${{esc(card.next_detail)}}
` : ""}}
${{card.response_deadline_at ? `response deadline ${{localTime(card.response_deadline_at)}}` : `response deadline ${{esc(NOT_RECORDED)}}`}}
`; + }}); return [header, ...cardRows]; }}).join(""); put("campaigns", campaignRows || empty(campaignsData.message || "No release campaigns.")); @@ -1460,11 +1762,24 @@ def render_board_html(config: BoardConfig) -> str:
next: ${{esc(pr.next_action)}}
${{pr.next_detail ? `
${{esc(pr.next_detail)}}
` : ""}} `).join("") : empty("No open pull requests.")); - put("alerts", alerts.length ? alerts.map(a => `
${{esc(a.kind)}} ${{esc(a.message)}}
`).join("") : empty("No gate alerts.")); - put("runs", runs.length ? runs.slice(0, 8).map(run => `
${{esc(run.workflow || "workflow")}}${{pill(run.conclusion || run.status || "unknown")}}
${{esc(run.branch)}}${{run.updated_at ? ` updated ${{localTime(run.updated_at)}}` : ""}}
`).join("") : empty("No recent Code Mower workflow runs.")); + put("alerts", !remoteAvailable + ? empty("GitHub unavailable; gate alerts not recorded.") + : alerts.length + ? alerts.map(a => `
${{esc(a.kind)}} ${{esc(a.message)}}
`).join("") + : empty("No gate alerts.")); + put("runs", runs.length ? runs.slice(0, 8).map(run => {{ + // A run of the workflow that publishes `code-mower/gate` succeeds when + // the publisher job finished, whatever verdict it published. Say so + // here so a green row is never read as a green gate. + // Matched on the workflow name only: the run title is the commit + // subject, and a PR that merely mentions the gate is not a publisher. + const publisher = isGatePublisher(run.workflow); + const state = run.conclusion || run.status; + return `
${{esc(run.workflow || "workflow")}}${{statePill(display(state), stateClass(state))}}${{publisher ? pill("gate publisher") : ""}}
${{publisher ? `
Publisher execution only; the ${{esc(GATE_CONTEXT)}} verdict is the commit status listed under each PR.
` : ""}}
${{esc(run.branch)}}${{run.updated_at ? ` updated ${{localTime(run.updated_at)}}` : ""}}
`; + }}).join("") : empty("No recent Code Mower workflow runs.")); put("verdicts", verdicts.length ? verdicts.map(v => `
#${{esc(v.pr_number)}} ${{esc(v.lane)}}${{pill(v.verdict)}}${{pill(v.head_sha_prefix)}}
${{localTime(v.created_at)}}
`).join("") : empty(timelines.verdicts?.message || "No local reviewer verdict history yet.")); const spendRows = [ - ...spendGroups.map(g => `
${{esc(g.lane)}}${{pill(g.verdict)}}${{pill(`${{g.runs}} runs`)}}
${{seconds(g.wall_seconds_total)}} total / ${{seconds(g.wall_seconds_avg)}} avg / ${{money(g.cost_usd_total)}} / ${{esc(g.total_tokens || 0)}} tokens
`), + ...spendGroups.map(g => `
${{esc(g.lane)}}${{pill(display(g.verdict))}}${{pill(`${{display(g.runs)}} runs`)}}
${{seconds(g.wall_seconds_total)}} total / ${{seconds(g.wall_seconds_avg)}} avg / ${{money(g.cost_usd_total)}} / ${{esc(display(g.total_tokens))}} tokens
`), spend.skipped_rows ? `
Skipped ${{esc(spend.skipped_rows)}} malformed spend row(s).
` : "", spend.filtered_rows ? `
Filtered ${{esc(spend.filtered_rows)}} spend row(s) from other repos.
` : "" ].filter(Boolean); @@ -1478,7 +1793,10 @@ def render_board_html(config: BoardConfig) -> str: put("history", events.length ? events.slice().reverse().map(event => {{ const s = event.summary || {{}}; const remote = s.remote_available ? "remote available" : "remote unavailable"; - return `
${{localTime(event.created_at)}}${{pill(remote)}}
next: ${{esc(s.next_action || "inspect")}}
PRs ${{esc(s.open_prs ?? 0)}} / alerts ${{esc(s.gate_alerts ?? 0)}} / local ${{esc((s.local_boards ?? 0) + (s.local_processes ?? 0))}}
`; + const locals = measured(s.local_boards) === null && measured(s.local_processes) === null + ? NOT_RECORDED + : String((measured(s.local_boards) ?? 0) + (measured(s.local_processes) ?? 0)); + return `
${{localTime(event.created_at)}}${{pill(remote)}}
next: ${{esc(s.next_action || "inspect")}}
PRs ${{esc(display(s.open_prs))}} / alerts ${{esc(display(s.gate_alerts))}} / local ${{esc(locals)}}
`; }}).join("") : empty(history.message || "No local board events recorded yet.")); }} let pollTimer = null; diff --git a/tests/test_board.py b/tests/test_board.py index 795f0f89..d8fce296 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -13,7 +13,7 @@ import threading import time import urllib.request -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from unittest import TestCase, skipUnless from unittest.mock import patch @@ -109,6 +109,88 @@ def _run_board_poll_script(steps: list[dict[str, object] | str | None]) -> list[ return json.loads(completed.stdout) +TRUTH_HELPERS_END = "// --- presentation truth helpers (END) ---" + +# A minimal stand-in for the pieces of the browser the shipped renderer +# touches: one element bag keyed by id, and a pinned clock so observation and +# campaign-liveness output is deterministic. +BOARD_DOM_HARNESS = """ +const NODES = {}; +const document = {getElementById: (id) => (NODES[id] = NODES[id] || {innerHTML: "", textContent: ""})}; +Date.now = () => __NOW_MS__; +__SCRIPT__ +render(JSON.parse(process.argv[1])); +renderEvents(JSON.parse(process.argv[2])); +console.log(JSON.stringify(Object.fromEntries(Object.entries(NODES).map(([id, node]) => [id, node.innerHTML || node.textContent])))); +""" + + +def _board_truth_helpers() -> str: + """Lift the shipped, DOM-free presentation helpers out of the rendered page. + + The tests execute the same JavaScript the browser gets rather than a Python + restatement of it, so a helper that is edited without its test is caught. + """ + + html = board.render_board_html(board.BoardConfig(repo="owner/repo")) + start = html.find(" const text =") + end = html.find(TRUTH_HELPERS_END) + if start < 0 or end < start: # pragma: no cover - guards the extraction + raise AssertionError("board HTML no longer exposes the presentation truth helpers") + return html[start : end + len(TRUTH_HELPERS_END)] + + +def _eval_board_truth(expression: str, *args: object) -> object: + """Evaluate one shipped helper expression against JSON arguments.""" + + script = ( + _board_truth_helpers() + + "\nconst ARGS = process.argv.slice(1).map(value => JSON.parse(value));\n" + + f"console.log(JSON.stringify({expression}));\n" + ) + completed = subprocess.run( + [shutil.which("node") or "node", "-e", script, *(json.dumps(arg) for arg in args)], + capture_output=True, + text=True, + check=True, + ) + return json.loads(completed.stdout) + + +def _render_board_dom( + payload: object, + history: object | None = None, + *, + now: datetime = NOW, +) -> dict[str, str]: + """Run the shipped ``render()``/``renderEvents()`` against a stubbed DOM.""" + + html = board.render_board_html(board.BoardConfig(repo="owner/repo")) + body = html[html.index(" ")] + # The page kicks itself off with load(); the harness supplies the payload + # directly instead of a fetch. + trimmed = body.rsplit(" load();", 1) + if len(trimmed) != 2: # pragma: no cover - guards the extraction + raise AssertionError("board HTML no longer bootstraps with load()") + script = ( + BOARD_DOM_HARNESS.replace("__NOW_MS__", str(int(now.timestamp() * 1000))) + .replace("__SCRIPT__", "".join(trimmed)) + ) + completed = subprocess.run( + [ + shutil.which("node") or "node", + "-e", + script, + json.dumps(payload), + json.dumps(history if history is not None else {"events": []}), + ], + capture_output=True, + text=True, + check=True, + ) + return json.loads(completed.stdout) + + def _completed(stdout: str, returncode: int = 0) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr="") @@ -1908,6 +1990,674 @@ def _http_status(self, port: int, path: str, headers: dict[str, str]) -> int: connection.close() +def _pr(number: int, **overrides: object) -> dict[str, object]: + pr: dict[str, object] = { + "number": number, + "title": f"PR {number}", + "url": f"https://github.com/owner/repo/pull/{number}", + "branch": f"claude/{number}", + "head_sha": "abcdef0123456789", + "author": "claude-bot", + "is_draft": False, + "merge_state": "CLEAN", + "updated_at": NOW.isoformat().replace("+00:00", "Z"), + "labels": {"builder": ["builder:claude"], "needs": [], "done": [], "blocked": []}, + "checks": [], + "stale": False, + "next_action": "wait for audit", + "next_detail": "", + } + pr.update(overrides) + return pr + + +def _status(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "generated_at": NOW.isoformat().replace("+00:00", "Z"), + "next_action": "inspect", + "next_detail": "", + "remote": { + "available": True, + "errors": [], + "pull_requests": [], + "workflow_runs": [], + "gate_health": {"status": "pass", "alerts": []}, + }, + "board": {"version": {"serving_version": "0.9.0"}}, + "owner_queue": {"available": True, "count": 0, "entries": [], "message": ""}, + "agent_adapters": {"available": True, "path_exists": False, "agents": [], "message": ""}, + "orchestrator_lease": {"state": "absent"}, + "release_campaigns": {"available": True, "campaigns": []}, + "supervised_pilot": {"enabled": False, "cycle_state": "unavailable", "message": "off"}, + "timelines": {"verdicts": {"entries": []}, "spend": {"groups": []}}, + "productivity": {"status": "warn", "current": {}, "metrics": {}, "quality": {}, "spend": {}}, + "local_boards": {"boards": []}, + "local_processes": {"processes": []}, + } + payload.update(overrides) + return payload + + +@skipUnless(shutil.which("node"), "node is required to execute the shipped board renderer") +class BoardPresentationTruthTests(TestCase): + """Issue #947: the Board may not claim more than the payload records.""" + + def test_absent_measurements_render_as_not_recorded_not_zero(self) -> None: + # Number(null), Number("") and Number(false) are all a finite 0, so a + # naive Number.isFinite check turns "never measured" into "measured + # zero". A real recorded zero must still render as zero. + self.assertEqual( + _eval_board_truth( + "[seconds(null), seconds(undefined), seconds(''), seconds('12'), seconds(false)," + " money(null), money(''), display(null), display(''), display(undefined)]" + ), + ["not recorded"] * 5 + ["not recorded"] * 5, + ) + self.assertEqual( + _eval_board_truth("[seconds(0), money(0), display(0), countOf(true, 0)]"), + ["0.0s", "$0.000", "0", "0"], + ) + # A count that could not be observed at all is not a zero count. + self.assertEqual(_eval_board_truth("countOf(false, 0)"), "not recorded") + + def test_unknown_state_never_renders_green_or_as_pass(self) -> None: + classes = _eval_board_truth( + "['', 'unknown', 'unavailable', 'none', 'not recorded', 'off'," + " 'pending', 'stale', 'unverified', 'last reported running'," + " 'failure', 'blocked', 'success', 'complete'].map(stateClass)" + ) + self.assertEqual( + classes, + ["muted"] * 6 + ["warn"] * 4 + ["bad"] * 2 + ["ok"] * 2, + ) + + def test_gate_publisher_success_cannot_pass_the_gate_verdict(self) -> None: + # The gate workflow's job publishes the `code-mower/gate` commit status. + # Its own success only means the publisher ran. + pending = _eval_board_truth( + "gateVerdict(ARGS[0])", + _pr( + 7, + checks=[ + {"name": "publish Code Mower gate status", "state": "success"}, + {"name": "code-mower/gate", "state": "pending"}, + ], + ), + ) + self.assertEqual(pending, {"state": "pending", "recorded": True, "class": "warn"}) + # With no `code-mower/gate` status at all the verdict is unrecorded, + # never inherited from the publisher run beside it. + missing = _eval_board_truth( + "gateVerdict(ARGS[0])", + _pr(7, checks=[{"name": "publish Code Mower gate status", "state": "success"}]), + ) + self.assertEqual(missing, {"state": "not recorded", "recorded": False, "class": "muted"}) + self.assertEqual( + _eval_board_truth( + "['code-mower/gate', 'Code Mower gate', 'publish Code Mower gate status', 'package']" + ".map(isGatePublisher)" + ), + [False, True, True, False], + ) + + def test_only_the_canonical_publisher_names_are_treated_as_publishers(self) -> None: + # The publisher is the workflow that posts `code-mower/gate` and its + # publishing job, matched case- and whitespace-insensitively. A check + # that merely contains "gate" belongs to somebody else. + self.assertEqual( + _eval_board_truth( + "[' code mower GATE ', 'Publish Code Mower Gate Status'].map(isGatePublisher)" + ), + [True, True], + ) + unrelated = [ + "security-gate", + "gatekeeper", + "release gate", + "quality-gate/sonar", + "code-mower/gate", + ] + self.assertEqual( + _eval_board_truth("ARGS[0].map(isGatePublisher)", unrelated), + [False] * len(unrelated), + ) + # `code-mower/gate` stays the verdict, whatever spacing or case it + # arrives in. + self.assertEqual( + _eval_board_truth("['code-mower/gate', ' CODE-MOWER/GATE '].map(isGateContext)"), + [True, True], + ) + + def test_unrelated_gate_shaped_names_render_as_ordinary_checks_and_runs(self) -> None: + nodes = _render_board_dom( + _status( + remote={ + "available": True, + "errors": [], + "pull_requests": [_pr(7, checks=[{"name": "security-gate", "state": "success"}])], + "workflow_runs": [ + { + "workflow": "security-gate", + "title": "scan", + "status": "completed", + "conclusion": "success", + "branch": "claude/7", + "url": "https://github.com/owner/repo/actions/runs/79", + } + ], + "gate_health": {"status": "pass", "alerts": []}, + } + ) + ) + + self.assertIn("security-gate=success", nodes["prs"]) + self.assertNotIn("publisher job, not the verdict", nodes["prs"]) + self.assertNotIn("gate publisher", nodes["runs"]) + self.assertNotIn("Publisher execution only", nodes["runs"]) + + def test_gate_publisher_run_is_labelled_in_the_rendered_page(self) -> None: + nodes = _render_board_dom( + _status( + remote={ + "available": True, + "errors": [], + "pull_requests": [ + _pr( + 7, + checks=[ + {"name": "publish Code Mower gate status", "state": "success"}, + {"name": "code-mower/gate", "state": "pending"}, + ], + ) + ], + "workflow_runs": [ + { + "workflow": "Code Mower gate", + "title": "publish gate", + "status": "completed", + "conclusion": "success", + "branch": "claude/7", + "url": "https://github.com/owner/repo/actions/runs/77", + } + ], + "gate_health": {"status": "pass", "alerts": []}, + }, + owner_queue={ + "available": True, + "count": 1, + "message": "", + "entries": [ + {"kind": "stale-gate", "pr_number": 7, "next_action": "rerun gate or inspect stuck audit"} + ], + }, + ) + ) + + self.assertIn("gate publisher", nodes["runs"]) + self.assertIn("Publisher execution only", nodes["runs"]) + self.assertIn("publisher job, not the verdict", nodes["prs"]) + self.assertIn("code-mower/gate=pending", nodes["prs"]) + # The verdict pill on the work item follows the commit status, not the + # green publisher run beside it. + self.assertIn('gate pending', nodes["worknow"] + nodes["lanework"]) + + def test_a_pr_titled_after_the_gate_is_not_a_publisher_run(self) -> None: + nodes = _render_board_dom( + _status( + remote={ + "available": True, + "errors": [], + "pull_requests": [], + "workflow_runs": [ + { + "workflow": "audit labeler", + "title": "Harden the gate alert wording", + "status": "completed", + "conclusion": "success", + "branch": "claude/1", + "url": "https://github.com/owner/repo/actions/runs/78", + } + ], + "gate_health": {"status": "pass", "alerts": []}, + } + ) + ) + + self.assertNotIn("gate publisher", nodes["runs"]) + + def test_one_pr_with_several_reasons_is_one_grouped_work_item(self) -> None: + prs = [_pr(7, merge_state="BEHIND", stale=True)] + entries = [ + {"kind": "failing-check", "pr_number": 7, "next_action": "fix failing check"}, + {"kind": "stale-gate", "pr_number": 7, "next_action": "rerun gate"}, + {"kind": "rebase-needed", "pr_number": 7, "next_action": "rebase/behind"}, + ] + + items = _eval_board_truth("attentionItems(ARGS[0], ARGS[1])", entries, prs) + + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["pr_number"], 7) + self.assertEqual( + [reason["kind"] for reason in items[0]["reasons"]], + ["failing-check", "stale-gate", "rebase-needed"], + ) + # One primary responsible role, and the highest-precedence reason wins + # the single next action. + self.assertEqual(items[0]["role"], "builder") + self.assertEqual(items[0]["next_action"], "fix failing check") + + def test_routine_lane_reasons_never_become_owner_attention(self) -> None: + # Rebase, CI repair, audit fixes and re-review are builder/orchestrator + # work regardless of how many of them a single PR raises. + entries = [ + {"kind": kind, "pr_number": number, "next_action": kind} + for number, kind in enumerate( + ("blocked-audit", "failing-check", "rebase-needed", "stale-gate", "draft"), start=1 + ) + ] + + items = _eval_board_truth( + "attentionItems(ARGS[0], ARGS[1])", entries, [_pr(n) for n in range(1, 6)] + ) + + self.assertEqual( + {item["reasons"][0]["kind"]: item["role"] for item in items}, + { + "blocked-audit": "builder", + "failing-check": "builder", + "rebase-needed": "builder", + "stale-gate": "orchestrator", + "draft": "builder", + }, + ) + self.assertEqual([item for item in items if item["role"] == "owner"], []) + + def test_owner_attention_requires_explicit_evidence(self) -> None: + labelled = _pr(7, labels={"needs": ["needs-owner"], "blocked": ["codex-audit-blocked"]}) + items = _eval_board_truth( + "attentionItems(ARGS[0], ARGS[1])", + [ + { + "kind": "needs-owner", + "pr_number": 7, + "next_action": "owner decision", + "labels": ["needs-owner"], + }, + {"kind": "blocked-audit", "pr_number": 7, "next_action": "fix BLOCKED audit"}, + ], + [labelled], + ) + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["role"], "owner") + self.assertEqual(items[0]["evidence"], ["needs-owner"]) + self.assertEqual(items[0]["next_action"], "owner decision") + + # A product decision is owner evidence too. + product = _eval_board_truth( + "attentionItems(ARGS[0], ARGS[1])", + [{"kind": "needs-owner", "pr_number": 8, "next_action": "owner decision"}], + [_pr(8, labels={"needs": ["product-decision"]})], + ) + self.assertEqual(product[0]["role"], "owner") + + # A row that claims owner attention with no permission, budget, policy, + # product-decision or owner-request signal anywhere in the payload is + # orchestrator triage, not an owner decision. + unbacked = _eval_board_truth( + "attentionItems(ARGS[0], ARGS[1])", + [{"kind": "needs-owner", "pr_number": 9, "next_action": "owner decision"}], + [_pr(9, labels={"needs": ["needs-codex-audit"]})], + ) + self.assertEqual(unbacked[0]["role"], "orchestrator") + + def test_grouped_work_items_split_owner_queue_from_lane_work(self) -> None: + nodes = _render_board_dom( + _status( + remote={ + "available": True, + "errors": [], + "pull_requests": [ + _pr(7, merge_state="BEHIND", stale=True), + _pr(8, labels={"needs": ["needs-owner"]}), + ], + "workflow_runs": [], + "gate_health": {"status": "pass", "alerts": []}, + }, + owner_queue={ + "available": True, + "count": 4, + "message": "", + "entries": [ + {"kind": "needs-owner", "pr_number": 8, "next_action": "owner decision", "labels": ["needs-owner"]}, + {"kind": "failing-check", "pr_number": 7, "next_action": "fix failing check"}, + {"kind": "stale-gate", "pr_number": 7, "next_action": "rerun gate"}, + {"kind": "rebase-needed", "pr_number": 7, "next_action": "rebase/behind"}, + ], + }, + ) + ) + + # Three reasons for PR #7 are one row in Lane Work, not three rows in + # the owner queue. + self.assertEqual(nodes["lanework"].count('class="row"'), 1) + self.assertIn("reasons (3):", nodes["lanework"]) + self.assertNotIn("#8", nodes["lanework"]) + self.assertEqual(nodes["owner"].count('class="row"'), 1) + self.assertIn("#8", nodes["owner"]) + self.assertIn("owner evidence: needs-owner", nodes["owner"]) + # The summary counts work items, so one PR cannot inflate the owner + # count through several reasons. + self.assertIn("Owner decisions1", nodes["summary"]) + self.assertIn("Lane work1", nodes["summary"]) + # The deterministic next step is the owner decision, stated first. + self.assertIn("Do next:", nodes["worknow"]) + self.assertIn("owner decision", nodes["worknow"]) + + def test_current_work_precedes_aggregates_and_release_history(self) -> None: + html = board.render_board_html(board.BoardConfig(repo="owner/repo")) + + order = [html.index(f">{title}<") for title in ("Work Now", "Owner Queue", "Lane Work")] + self.assertEqual(order, sorted(order)) + for later in ("Release Campaigns", "Productivity", "Recent Local History", "Spend And Latency"): + self.assertLess(html.index(">Work Now<"), html.index(f">{later}<")) + self.assertLess(html.index(">Lane Work<"), html.index(f">{later}<")) + + def test_stale_snapshot_reports_age_and_suppresses_running_claims(self) -> None: + observed = (NOW - timedelta(hours=3)).isoformat().replace("+00:00", "Z") + stale = _eval_board_truth( + "observation(ARGS[0], ARGS[1])", + _status( + productivity={ + "status": "pass", + "current": {"source": "historical_board_snapshot", "observed_at": observed, "historical": True}, + } + ), + int(NOW.timestamp() * 1000), + ) + self.assertTrue(stale["historical"]) + self.assertFalse(stale["live"]) + self.assertEqual(stale["age_text"], "3.0h") + self.assertEqual(stale["label"], "last observed 3.0h ago") + self.assertEqual(stale["class"], "warn") + self.assertIn("nothing here is evidence of work running now", stale["detail"]) + + # A live-sourced snapshot that simply stopped refreshing is stale by + # age alone, whatever the payload calls its source. + aged = _eval_board_truth( + "observation(ARGS[0], ARGS[1])", + _status(productivity={"status": "pass", "current": {"source": "live_remote", "observed_at": observed}}), + int(NOW.timestamp() * 1000), + ) + self.assertTrue(aged["aged"]) + self.assertFalse(aged["live"]) + self.assertEqual(aged["label"], "last observed 3.0h ago") + + fresh_at = (NOW - timedelta(seconds=9)).isoformat().replace("+00:00", "Z") + live = _eval_board_truth( + "observation(ARGS[0], ARGS[1])", + _status(productivity={"status": "pass", "current": {"source": "live_remote", "observed_at": fresh_at}}), + int(NOW.timestamp() * 1000), + ) + self.assertTrue(live["live"]) + self.assertEqual(live["label"], "live, observed 9s ago") + + def test_unconfirmed_server_cache_is_never_labelled_live(self) -> None: + # The server answers a cold cache with metadata only and a stale one + # with the previous snapshot. A recent observation time embedded in + # that unconfirmed snapshot is not evidence that it is current. + fresh_at = (NOW - timedelta(seconds=9)).isoformat().replace("+00:00", "Z") + payload = _status( + generated_at=fresh_at, + board={ + "version": {"serving_version": "0.9.0"}, + "cache": {"state": "stale", "age_seconds": 42.0, "refresh_in_progress": True}, + }, + productivity={"status": "pass", "current": {"source": "live_remote", "observed_at": fresh_at}}, + ) + + stale_cache = _eval_board_truth("observation(ARGS[0], ARGS[1])", payload, int(NOW.timestamp() * 1000)) + + self.assertTrue(stale_cache["unconfirmed"]) + self.assertFalse(stale_cache["live"]) + self.assertFalse(stale_cache["historical"]) + self.assertFalse(stale_cache["aged"]) + # The older of the two recorded ages is shown, so the cache age is not + # understated by the fresher embedded observation time. + self.assertEqual(stale_cache["age_text"], "42s") + self.assertEqual(stale_cache["label"], "last observed 42s ago") + self.assertEqual(stale_cache["class"], "warn") + self.assertIn("nothing here is evidence of work running now", stale_cache["detail"]) + + cold_cache = _eval_board_truth( + "observation(ARGS[0], ARGS[1])", + _status( + generated_at=fresh_at, + board={"version": {}, "cache": {"state": "cold", "age_seconds": None}}, + productivity={"status": "pass", "current": {"source": "live_remote", "observed_at": fresh_at}}, + ), + int(NOW.timestamp() * 1000), + ) + self.assertTrue(cold_cache["unconfirmed"]) + self.assertFalse(cold_cache["live"]) + + # Only `fresh` confirms the snapshot the server is serving. + confirmed = _eval_board_truth( + "observation(ARGS[0], ARGS[1])", + _status( + generated_at=fresh_at, + board={"version": {}, "cache": {"state": "fresh", "age_seconds": 9.0}}, + productivity={"status": "pass", "current": {"source": "live_remote", "observed_at": fresh_at}}, + ), + int(NOW.timestamp() * 1000), + ) + self.assertTrue(confirmed["live"]) + self.assertEqual(confirmed["label"], "live, observed 9s ago") + + nodes = _render_board_dom(payload) + self.assertIn("last observed 42s ago", nodes["summary"]) + self.assertNotIn("live, observed", nodes["summary"]) + self.assertNotIn("live, observed", nodes["worknow"]) + self.assertIn("nothing here is evidence of work running now", nodes["worknow"]) + + def test_missing_observation_time_is_neutral_and_never_live(self) -> None: + # With no parseable observation time anywhere there is nothing to date + # the snapshot by, so the page may claim neither freshness nor an age. + payload = _status(generated_at="", productivity={"status": "pass", "current": {"source": "live_remote"}}) + + unknown = _eval_board_truth("observation(ARGS[0], ARGS[1])", payload, int(NOW.timestamp() * 1000)) + + self.assertFalse(unknown["live"]) + self.assertFalse(unknown["aged"]) + self.assertFalse(unknown["unconfirmed"]) + self.assertEqual(unknown["label"], "observation time not recorded") + self.assertEqual(unknown["age_text"], "not recorded") + self.assertEqual(unknown["class"], "muted") + self.assertNotIn("live", unknown["label"]) + self.assertNotIn("ago", unknown["label"]) + self.assertIn("cannot be shown as current", unknown["detail"]) + + # An unparseable timestamp is the same case as an absent one. + garbled = _eval_board_truth( + "observation(ARGS[0], ARGS[1])", + _status(generated_at="not-a-timestamp", productivity={"current": {"observed_at": "soon"}}), + int(NOW.timestamp() * 1000), + ) + self.assertEqual(garbled["label"], "observation time not recorded") + self.assertFalse(garbled["live"]) + + nodes = _render_board_dom(payload) + self.assertIn('observation time not recorded', nodes["summary"]) + self.assertNotIn("live, observed", nodes["summary"]) + self.assertNotIn("last observed not recorded", nodes["summary"] + nodes["worknow"]) + self.assertIn("cannot be shown as current", nodes["worknow"]) + + def test_github_unavailable_renders_counts_as_not_recorded(self) -> None: + nodes = _render_board_dom( + _status( + remote={ + "available": False, + "errors": ["pull_requests: gh unavailable"], + "pull_requests": [], + "workflow_runs": [], + "gate_health": {"status": "pass", "alerts": []}, + } + ) + ) + + # No observation means no count, and an unobserved gate is not a clean + # gate. + self.assertIn("Open PRsnot recorded", nodes["summary"]) + self.assertIn("Gate alertsnot recorded", nodes["summary"]) + self.assertIn("gate alerts not recorded", nodes["alerts"]) + self.assertIn("last observed", nodes["summary"]) + + def test_fresh_github_keeps_working_when_local_data_is_absent(self) -> None: + sources = _eval_board_truth("localSources(ARGS[0])", _status()) + + self.assertFalse(sources["adapters_available"]) + self.assertEqual( + sources["missing"], + ["agent adapter cards", "orchestrator lease", "reviewer verdict history", "reviewer spend rows"], + ) + self.assertIn("Local session data unavailable", sources["message"]) + self.assertIn("GitHub data above is unaffected", sources["message"]) + + nodes = _render_board_dom( + _status( + remote={ + "available": True, + "errors": [], + "pull_requests": [_pr(7)], + "workflow_runs": [], + "gate_health": {"status": "pass", "alerts": []}, + } + ) + ) + self.assertIn("Open PRs1", nodes["summary"]) + self.assertIn("Agent cardsnot recorded", nodes["summary"]) + self.assertIn("Local session data unavailable", nodes["worknow"]) + self.assertIn("#7", nodes["prs"]) + + def test_old_running_campaign_is_reported_as_last_reported(self) -> None: + past = (NOW - timedelta(hours=6)).isoformat().replace("+00:00", "Z") + future = (NOW + timedelta(hours=1)).isoformat().replace("+00:00", "Z") + now_ms = int(NOW.timestamp() * 1000) + + overdue = _eval_board_truth( + "campaignLiveness(ARGS[0], ARGS[1])", + { + "status": "running", + "elapsed_seconds": 12.0, + "cards": [{"provider": "devin", "state": "running", "response_deadline_at": past}], + }, + now_ms, + ) + self.assertTrue(overdue["unverified"]) + self.assertEqual(overdue["label"], "last reported running") + self.assertEqual(overdue["class"], "warn") + self.assertEqual(overdue["cards"][0]["label"], "last reported running") + self.assertEqual(overdue["cards"][0]["overdue_for"], "6.0h") + + # An unexpired response deadline is live evidence, so the present-tense + # claim stands. + current = _eval_board_truth( + "campaignLiveness(ARGS[0], ARGS[1])", + { + "status": "running", + "elapsed_seconds": 12.0, + "cards": [{"provider": "devin", "state": "running", "response_deadline_at": future}], + }, + now_ms, + ) + self.assertFalse(current["unverified"]) + self.assertEqual(current["label"], "running") + + # No deadline at all is no liveness evidence either. + silent = _eval_board_truth( + "campaignLiveness(ARGS[0], ARGS[1])", + {"status": "running", "elapsed_seconds": 0.0, "cards": [{"provider": "devin", "state": "running"}]}, + now_ms, + ) + self.assertTrue(silent["unverified"]) + self.assertEqual(silent["label"], "last reported running") + # A terminal campaign is never rewritten. + complete = _eval_board_truth( + "campaignLiveness(ARGS[0], ARGS[1])", + {"status": "complete", "elapsed_seconds": 90.0, "cards": []}, + now_ms, + ) + self.assertFalse(complete["unverified"]) + self.assertEqual(complete["label"], "complete") + + def test_campaign_section_labels_elapsed_time_as_recorded_work(self) -> None: + past = (NOW - timedelta(hours=6)).isoformat().replace("+00:00", "Z") + nodes = _render_board_dom( + _status( + release_campaigns={ + "available": True, + "campaigns": [ + { + "release_tag": "v0.9.0", + "status": "running", + "dry_run": False, + "qualification_context": "release", + "elapsed_seconds": 12.0, + "next_action": "poll running providers", + "cards": [ + { + "provider": "devin", + "posture": "required", + "state": "running", + "environment": "hosted", + "elapsed_seconds": 12.0, + "response_deadline_at": past, + "next_action": "poll devin", + } + ], + } + ], + } + ) + ) + + self.assertIn("last reported running", nodes["campaigns"]) + self.assertIn("recorded work 12.0s", nodes["campaigns"]) + self.assertIn("deadline passed 6.0h ago", nodes["campaigns"]) + self.assertIn("shown as last reported rather than currently running", nodes["campaigns"]) + + def test_unmeasured_productivity_and_spend_render_as_not_recorded(self) -> None: + nodes = _render_board_dom( + _status( + productivity={ + "status": None, + "next_action": "record more board snapshots", + "current": {"source": "live_remote", "observed_at": NOW.isoformat().replace("+00:00", "Z")}, + "metrics": {"cycle_time_seconds": None, "merged_pr_count": None}, + "quality": {"audit_pass_count": None}, + "spend": {"wall_seconds": None, "cost_usd": None, "total_tokens": None}, + }, + timelines={ + "verdicts": {"entries": []}, + "spend": { + "groups": [ + {"lane": "codex", "verdict": None, "runs": 2, "wall_seconds_total": None, + "wall_seconds_avg": None, "cost_usd_total": None, "total_tokens": None} + ] + }, + }, + ), + {"events": [{"created_at": NOW.isoformat().replace("+00:00", "Z"), "summary": {"next_action": "inspect"}}]}, + ) + + self.assertIn("cycle not recorded", nodes["productivity"]) + self.assertIn("merged not recorded", nodes["productivity"]) + self.assertIn("not recorded tokens", nodes["productivity"]) + self.assertIn("Productivitynot recorded", nodes["summary"]) + self.assertIn("not recorded tokens", nodes["spend"]) + self.assertIn("PRs not recorded / alerts not recorded / local not recorded", nodes["history"]) + self.assertNotIn("$0.000", nodes["productivity"]) + + class StatusCacheTests(TestCase): """Deterministic coverage for the /api/status stale-while-refresh cache, with no live network calls.""" From 3c72bc38ffc8d5098b091167c8d0014a7f3d5460 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Sat, 12 Sep 2026 15:04:43 -0700 Subject: [PATCH 2/2] Board: read campaign response deadlines only while awaiting a response Addresses Codex audit finding codex:044b8d66dadb5a1987a8 on 41cfb82. A provider card can retain the response_deadline_at it was dispatched with after its response arrives. cardLiveness() marked any passed deadline overdue, so a terminal `complete` card warned yellow and a terminal `blocked` card was downgraded from red to yellow. Read the deadline only while a card is still awaiting a response. Of the five valid provider states in release_campaigns, `complete` and `blocked` are its TERMINAL_EVIDENCE_STATES and `unavailable` never dispatched, so only `queued` and `running` await one. Terminal and never-dispatched cards keep their own state styling and show no overdue warning. Campaign-level liveness now requires an awaiting card with an unexpired deadline, so an unexpired deadline retained by a finished card cannot verify a running campaign. Refs #947 Co-Authored-By: Claude Opus 5 (1M context) --- docs/board-data-contract.md | 9 ++- src/code_mower/board.py | 22 +++++++- tests/test_board.py | 107 ++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) diff --git a/docs/board-data-contract.md b/docs/board-data-contract.md index 175a0822..267cd625 100644 --- a/docs/board-data-contract.md +++ b/docs/board-data-contract.md @@ -431,8 +431,13 @@ add no fields to any schema; they only bound what the page is allowed to assert. When neither records a parseable time the page reports `observation time not recorded` neutrally, with no `live` claim and no synthetic age. - **Campaign liveness.** A campaign's `elapsed_seconds` is recorded provider - work time, not age, and is labelled that way. A `running` campaign with no - unexpired provider `response_deadline_at` is shown as `last reported running`. + work time, not age, and is labelled that way. A response deadline is read + only while a provider card is still awaiting a response (`queued` or + `running`): a terminal `complete` or `blocked` card and a never-dispatched + `unavailable` card can retain the deadline they were given, and that stale + timestamp neither marks them overdue nor changes their state styling. A + `running` campaign is shown as `last reported running` unless some card that + is awaiting a response has an unexpired `response_deadline_at`. - **Local data.** When GitHub data is fresh but local session inputs (agent adapter cards, orchestrator lease, reviewer verdict history, reviewer spend rows) are absent, GitHub information stays useful and the page names the diff --git a/src/code_mower/board.py b/src/code_mower/board.py index 062b5aff..3306dff1 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -1583,17 +1583,30 @@ def render_board_html(config: BoardConfig) -> str: }}; }} const CLAIMS_RUNNING_RE = /^(running|dispatched|in_progress)$/i; + // The provider states in which a card is still waiting for a response. + // Of release_campaigns' five valid provider states, `complete` and + // `blocked` are terminal qualification evidence (its + // TERMINAL_EVIDENCE_STATES) and `unavailable` never dispatched, so only + // `queued` and `running` are awaiting one. + const AWAITING_CARD_STATE_RE = /^(queued|running|dispatched|in_progress)$/i; // A campaign file records accumulated provider work time, not liveness. // The one wall-clock signal it carries is a provider response deadline: // once that has passed -- or was never recorded -- nothing in the payload // shows the provider still working. function cardLiveness(card, nowMs) {{ - const deadline = parseMs(card?.response_deadline_at); - const overdue = deadline !== null && deadline < nowMs; const state = text(card?.state).trim() || "unknown"; + const deadline = parseMs(card?.response_deadline_at); + const awaiting = AWAITING_CARD_STATE_RE.test(state); + // A card that already answered, or never dispatched, can still carry the + // deadline it was given. That retained timestamp says nothing about a + // late provider, so it must not mark the card overdue and repaint a + // terminal state -- turning a passed `complete` yellow, or downgrading a + // failed `blocked` from red to yellow. + const overdue = awaiting && deadline !== null && deadline < nowMs; const suppressed = CLAIMS_RUNNING_RE.test(state) && (overdue || deadline === null); return {{ label: suppressed ? `last reported ${{state}}` : state, + awaiting, overdue, deadline_recorded: deadline !== null, overdue_for: overdue ? ageText((nowMs - deadline) / 1000) : "", @@ -1603,7 +1616,10 @@ def render_board_html(config: BoardConfig) -> str: function campaignLiveness(campaign, nowMs) {{ const status = text(campaign?.status).trim() || "unknown"; const cards = (Array.isArray(campaign?.cards) ? campaign.cards : []).map(card => cardLiveness(card, nowMs)); - const unverified = CLAIMS_RUNNING_RE.test(status) && !cards.some(card => card.deadline_recorded && !card.overdue); + // Only a card actually awaiting a response can evidence a live campaign: + // an unexpired deadline retained by a finished card proves nothing. + const unverified = CLAIMS_RUNNING_RE.test(status) + && !cards.some(card => card.awaiting && card.deadline_recorded && !card.overdue); return {{ cards, unverified, diff --git a/tests/test_board.py b/tests/test_board.py index d8fce296..518bbd47 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -2589,6 +2589,113 @@ def test_old_running_campaign_is_reported_as_last_reported(self) -> None: self.assertFalse(complete["unverified"]) self.assertEqual(complete["label"], "complete") + def test_terminal_provider_cards_keep_their_styling_with_expired_deadlines(self) -> None: + # `complete` and `blocked` are release_campaigns' terminal evidence + # states and `unavailable` never dispatched. All three can retain the + # response deadline they were given, and that stale timestamp is not + # evidence of a late provider. + past = (NOW - timedelta(hours=6)).isoformat().replace("+00:00", "Z") + now_ms = int(NOW.timestamp() * 1000) + cards = [ + {"provider": "devin", "state": state, "response_deadline_at": past} + for state in ("complete", "blocked", "unavailable", "running") + ] + + liveness = _eval_board_truth( + "ARGS[0].map(card => cardLiveness(card, ARGS[1]))", cards, now_ms + ) + done, blocked, unavailable, control = liveness + + # A passed qualification stays green, not yellow. + self.assertEqual((done["label"], done["class"]), ("complete", "ok")) + self.assertFalse(done["awaiting"]) + self.assertFalse(done["overdue"]) + self.assertEqual(done["overdue_for"], "") + # A failed qualification stays red; it is never downgraded to yellow. + self.assertEqual((blocked["label"], blocked["class"]), ("blocked", "bad")) + self.assertFalse(blocked["overdue"]) + # A provider that never dispatched is neutral, not overdue. + self.assertEqual((unavailable["label"], unavailable["class"]), ("unavailable", "muted")) + self.assertFalse(unavailable["overdue"]) + # Nonterminal control: a card still awaiting a response past its + # deadline is still reported as overdue and last reported. + self.assertTrue(control["awaiting"]) + self.assertTrue(control["overdue"]) + self.assertEqual((control["label"], control["class"]), ("last reported running", "warn")) + self.assertEqual(control["overdue_for"], "6.0h") + + # A `queued` card is awaiting a response too, so its expired deadline + # still counts -- but queued is not a running claim, so its own label + # and state styling are untouched. + queued = _eval_board_truth( + "cardLiveness(ARGS[0], ARGS[1])", + {"provider": "devin", "state": "queued", "response_deadline_at": past}, + now_ms, + ) + self.assertTrue(queued["awaiting"]) + self.assertTrue(queued["overdue"]) + self.assertEqual(queued["label"], "queued") + + def test_finished_card_deadline_cannot_verify_a_running_campaign(self) -> None: + # The only unexpired deadline belongs to a card that already answered, + # so no card is actually awaiting a response and the campaign-level + # running claim stays unverified. + future = (NOW + timedelta(hours=1)).isoformat().replace("+00:00", "Z") + past = (NOW - timedelta(hours=6)).isoformat().replace("+00:00", "Z") + + liveness = _eval_board_truth( + "campaignLiveness(ARGS[0], ARGS[1])", + { + "status": "running", + "elapsed_seconds": 30.0, + "cards": [ + {"provider": "devin", "state": "complete", "response_deadline_at": future}, + {"provider": "cursor_cloud_agent", "state": "running", "response_deadline_at": past}, + ], + }, + int(NOW.timestamp() * 1000), + ) + + self.assertTrue(liveness["unverified"]) + self.assertEqual(liveness["label"], "last reported running") + self.assertEqual(liveness["cards"][0]["class"], "ok") + self.assertEqual(liveness["cards"][1]["label"], "last reported running") + + def test_terminal_card_renders_without_an_overdue_warning(self) -> None: + past = (NOW - timedelta(hours=6)).isoformat().replace("+00:00", "Z") + nodes = _render_board_dom( + _status( + release_campaigns={ + "available": True, + "campaigns": [ + { + "release_tag": "v0.9.0", + "status": "complete", + "dry_run": False, + "qualification_context": "release", + "elapsed_seconds": 90.0, + "next_action": "campaign complete; all providers passed", + "cards": [ + { + "provider": "devin", + "posture": "required", + "state": "complete", + "environment": "hosted", + "elapsed_seconds": 90.0, + "response_deadline_at": past, + "next_action": "none", + } + ], + } + ], + } + ) + ) + + self.assertIn('complete', nodes["campaigns"]) + self.assertNotIn("deadline passed", nodes["campaigns"]) + self.assertNotIn("last reported", nodes["campaigns"]) + def test_campaign_section_labels_elapsed_time_as_recorded_work(self) -> None: past = (NOW - timedelta(hours=6)).isoformat().replace("+00:00", "Z") nodes = _render_board_dom(