diff --git a/docs/board-data-contract.md b/docs/board-data-contract.md index 2403744d..267cd625 100644 --- a/docs/board-data-contract.md +++ b/docs/board-data-contract.md @@ -397,6 +397,52 @@ 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 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 + 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..3306dff1 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,276 @@ 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; + // 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 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) : "", + 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)); + // 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, + 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 +1640,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 +1696,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 +1739,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 +1749,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 +1778,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 +1809,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..518bbd47 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,781 @@ 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_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( + _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."""