diff --git a/src/observability/dashboard/state/index.js b/src/observability/dashboard/state/index.js index 7d10fc83..d6989d4a 100644 --- a/src/observability/dashboard/state/index.js +++ b/src/observability/dashboard/state/index.js @@ -14,6 +14,11 @@ import { buildPeople, buildPresence } from './people.js'; import { buildBusinessFlexState } from './business-flex.js'; import { buildDecisionState } from './decisions.js'; import { validateProjectConfigs } from '../../../core/harness/config-validation.js'; +// [wave:command] imports — pipeline rollup enrichment (#94 / #156 / #215) +import { readPipelineState, buildPipelineState } from '../../../core/harness/pipeline-state.js'; +import { recommendPipelineAction } from '../../../commands/pipeline.js'; +import { runDirectory } from '../../../core/harness/runs.js'; +import { readJson } from './files.js'; export { toClientState } from './client-state.js'; export { resolveApprovalAcrossRoots } from './approvals.js'; @@ -33,6 +38,9 @@ export async function buildFullState(projectRoot, options = {}) { getAllApprovals(roots), ]); + // [wave:command] Compact pipeline-state rollup per run (#94/#156/#215). + await attachPipelineRollups(runs); + // run.totals already prefers persisted cumulative metrics (#83) and falls // back to event recompute for legacy runs; the metrics.json chain here is // the last resort for index entries without totals. @@ -143,3 +151,123 @@ function buildBlockedGateAlerts(blockedGates) { ts: gate.ts, })); } + +// ── [wave:command] Pipeline rollup enrichment (#94 / #156 / #215) ─────────── +// Attaches `run.pipelineRollup` — a compact summary of the run's +// pipeline-state.json — so the Command Center shows the SAME next-action the +// `rstack-agents pipeline status` CLI computes: recommendPipelineAction is +// reused verbatim, never re-implemented client-side (no second brain). +// +// Read path only: a persisted pipeline-state.json is preferred (one small +// read); fully-parsed runs without one are summarized in memory via +// buildPipelineState — the dashboard never writes run state. Index-served +// lite runs without a persisted rollup stay null, and the UI shows an honest +// "no pipeline state recorded" instead of a guess. Best-effort by contract: +// any per-run failure yields null, never a broken snapshot. + +const ROLLUP_FAILED_STATUSES = new Set(['FAIL', 'FAILED', 'ERROR', 'BLOCKED']); +const ROLLUP_PASSED_STATUSES = new Set(['PASS', 'PASSED', 'SUCCESS', 'SUCCEEDED', 'DONE', 'COMPLETED']); + +async function attachPipelineRollups(runs) { + await Promise.all((runs ?? []).map(async (run) => { + try { + let state = await readPipelineState(run.projectRoot, run.runId); + if (!state && !run.fromIndex) state = await buildPipelineState(run.projectRoot, run.runId); + run.pipelineRollup = state ? compactPipelineRollup(state, run.events ?? []) : null; + } catch { + run.pipelineRollup = null; + } + // Index-served lite runs rebuild a synthetic manifest that drops + // schema_version (#82 stamps it, #156 renders it) — restore it with one + // tiny manifest read so migration state stays observable for every run. + if (run.fromIndex && run.manifest && run.manifest.schema_version === undefined) { + const manifest = await readJson(join(runDirectory(run.projectRoot, run.runId), 'manifest.json'), null); + if (manifest?.schema_version !== undefined) run.manifest.schema_version = manifest.schema_version; + } + })); +} + +// Mirrors recommendPipelineAction's deterministic priority order +// (approvals → failed → active → pending → complete) to CLASSIFY the action +// for chip routing; the sentence itself always comes from +// recommendPipelineAction so the two can never disagree on substance. +function classifyNextAction(state) { + const none = { kind: 'unknown', stage_id: null, task_id: null, artifact: null }; + if (!state || !Array.isArray(state.stages)) return none; + const blocker = (state.approval_blockers ?? [])[0]; + if (blocker) return { kind: 'approval', stage_id: blocker.stage_id ?? null, task_id: null, artifact: blocker.artifact ?? null }; + const failed = state.stages.find((stage) => ROLLUP_FAILED_STATUSES.has(stage.status)); + if (failed) { + const kind = failed.retry_state === 'exhausted' ? 'guardrail_blocked' + : failed.retry_state === 'retryable' ? 'retry' : 'failed'; + return { kind, stage_id: failed.id, task_id: (failed.task_ids ?? [])[0] ?? null, artifact: null }; + } + if (state.current?.stage_id) { + return { kind: 'active', stage_id: state.current.stage_id, task_id: state.current.task_id ?? null, artifact: null }; + } + const pending = state.stages.find((stage) => stage.status === 'PENDING'); + if (pending) return { kind: 'pending', stage_id: pending.id, task_id: null, artifact: null }; + if (state.stages.length > 0 && state.stages.every((stage) => ROLLUP_PASSED_STATUSES.has(stage.status))) { + return { kind: 'complete', stage_id: null, task_id: null, artifact: null }; + } + return none; +} + +function compactPipelineRollup(state, events) { + const next = classifyNextAction(state); + const loop = state.goal_loop ?? {}; + // Last goal verdict: prefer the rollup's last_evaluation.status; the BLE-4 + // goal evaluator emits `recommendation` on the pinned goal_evaluated event, + // so fall back to that before giving up. + const lastGoalEvent = [...(events ?? [])].reverse() + .find((event) => String(event?.type ?? event?.kind ?? '') === 'goal_evaluated') ?? null; + const lastVerdict = loop.last_evaluation?.status ?? lastGoalEvent?.status ?? lastGoalEvent?.recommendation ?? null; + // Freshness (#218 review): a persisted pipeline-state.json can lag the live + // event stream on an active run. `generated_at` stamps when the state was + // computed; any event newer than it means the next-action below is behind + // live data. Detected from data already in the snapshot — no extra read — + // so the hero card can say so rather than present a stale recommendation as + // live ("never let stale data look live"). + const generatedAt = state.generated_at ?? null; + const eventsBehind = generatedAt + ? (events ?? []).filter((event) => String(event?.ts ?? event?.timestamp ?? '') > String(generatedAt)).length + : 0; + return { + schema_version: state.schema_version ?? null, + status: state.pipeline?.status ?? 'UNKNOWN', + stages_total: state.pipeline?.stages_total ?? 0, + stages_passed: state.pipeline?.stages_passed ?? 0, + stages_failed: state.pipeline?.stages_failed ?? 0, + generated_at: generatedAt, + stale: eventsBehind > 0, + events_behind: eventsBehind, + next_action: { ...next, text: recommendPipelineAction(state) }, + approval_blockers: (state.approval_blockers ?? []).length, + retries: { + total: state.retries?.total ?? 0, + scheduled: state.retries?.scheduled ?? 0, + exhausted: state.retries?.exhausted ?? 0, + human_required: state.retries?.human_required ?? 0, + }, + context_pressure: { + total: state.context_pressure?.total ?? 0, + by_source: state.context_pressure?.by_source ?? {}, + }, + checkpoints: { + total: state.checkpoints?.total ?? 0, + before_saved: state.checkpoints?.before_saved ?? 0, + after_saved: state.checkpoints?.after_saved ?? 0, + reverted: state.checkpoints?.reverted ?? 0, + }, + goal_loop: { + total: loop.total ?? 0, + iterations: loop.iterations ?? 0, + active: (loop.total ?? 0) > 0 && !loop.stopped_on, + stopped_on: loop.stopped_on ?? null, + last_verdict: lastVerdict, + criteria_met: lastGoalEvent?.criteria_met ?? null, + criteria_total: lastGoalEvent?.criteria_total ?? null, + }, + }; +} +// ── end [wave:command] ────────────────────────────────────────────────────── diff --git a/src/observability/dashboard/ui/pages/command-center.js b/src/observability/dashboard/ui/pages/command-center.js index 8ef9b5c7..a6256fb6 100644 --- a/src/observability/dashboard/ui/pages/command-center.js +++ b/src/observability/dashboard/ui/pages/command-center.js @@ -23,6 +23,9 @@ function renderCommand(s) { setText('command-status-chip', hasAttention ? 'Needs review' : activeRunCount ? 'Live work running' : 'All clear'); setClass('command-status-chip', 'command-status ' + (hasAttention ? 'warn' : activeRunCount ? 'active' : 'ok')); renderExecutiveMissionBrief(s); + ensureCommandWavePanels(); + renderCommandExecRollup(s); + renderCommandNextAction(s); setText('kpi-projects', projects.length); setText('kpi-projects-s', (s.sourceRoots || []).length + ' source roots tracked'); @@ -92,6 +95,182 @@ function renderExecutiveMissionBrief(s) { }).join('')); } +// ── [wave:command] Executive rollup + pipeline next action (#94 / #156) ── +// The page skeleton (ui/pages/index.js) is shared across parallel page work, +// so this module owns its new DOM: panels are injected once, then re-rendered +// on every snapshot like everything else. +function ensureCommandWavePanels() { + if (document.getElementById('command-exec-rollup-panel')) return; + var page = document.getElementById('page-command'); + if (!page) return; + var anchor = page.querySelector('.executive-grid'); + if (!anchor) return; + anchor.insertAdjacentHTML('afterend', + '
' + + '
Pipeline Next Action
' + + '
' + + '
' + + '
' + + '
Executive Rollup
' + + '
' + + '
'); +} + +function fmtCompactCount(value) { + value = Number(value) || 0; + if (value >= 1000000) return (Math.round(value / 100000) / 10) + 'M'; + if (value >= 1000) return (Math.round(value / 100) / 10) + 'k'; + return String(value); +} + +function runSpend(run) { + return Number((run.totals && run.totals.cost_usd) || (run.metrics && run.metrics.cumulative_cost_usd) || 0); +} + +function runTokens(run) { + if (run.totals && run.totals.tokens) return Number(run.totals.tokens) || 0; + var metricTokens = run.metrics && run.metrics.cumulative_tokens; + if (metricTokens && typeof metricTokens === 'object') return Number(metricTokens.total) || 0; + return Number(metricTokens) || 0; +} + +// The 30-second global-exec read (#94): runs by status, spend, tokens, +// task pass-rate, open decisions — computed from the SCOPED snapshot so the +// strip follows the project/run selector. Plus schema-version visibility +// (#156): rollup + manifest schema versions for the selected run. +function renderCommandExecRollup(s) { + var runs = s.runs || []; + var statusCounts = { active: 0, done: 0, stalled: 0, ended: 0, idle: 0 }; + var spend = 0; + var tokens = 0; + runs.forEach(function(run) { + var status = run.derivedStatus || 'idle'; + if (statusCounts[status] === undefined) statusCounts[status] = 0; + statusCounts[status] += 1; + spend += runSpend(run); + tokens += runTokens(run); + }); + var counts = taskStatusCounts(allTasks(s)); + var finished = counts.PASS + counts.FAIL; + var passRate = finished ? Math.round(counts.PASS / finished * 100) + '%' : '—'; + var openDecisions = (s.decisions && s.decisions.totals && s.decisions.totals.pending) || 0; + + setText('command-exec-rollup-note', runs.length + ' run' + (runs.length === 1 ? '' : 's') + ' in scope'); + + var runsDetail = statusCounts.active + ' active · ' + statusCounts.done + ' done' + + (statusCounts.stalled ? ' · ' + statusCounts.stalled + ' stalled' : ''); + var stats = [ + { value: runs.length, label: 'Runs', detail: runsDetail }, + { value: '$' + spend.toFixed(2), label: 'Spend so far', detail: 'AI cost across runs in scope' }, + { value: tokens ? fmtCompactCount(tokens) : '—', label: 'Tokens', detail: tokens ? 'input + output, all runs' : 'no token telemetry recorded' }, + { value: passRate, label: 'Task pass rate', detail: finished ? counts.PASS + ' of ' + finished + ' finished tasks passed' : 'no finished tasks yet' }, + { value: openDecisions, label: 'Open decisions', detail: openDecisions ? 'waiting in the Decision Queue' : 'nothing waiting on a human call' } + ]; + var schema = execSchemaBadge(runs); + var html = stats.map(function(stat) { + return '
' + esc(stat.value) + '
' + + '
' + esc(stat.label) + '
' + + '
' + esc(stat.detail) + '
'; + }).join('') + + '
' + esc(schema.value) + '
' + + '
State schema
' + + '
' + esc(schema.detail) + '
'; + setHTML('command-exec-rollup', html); +} + +function execSchemaBadge(runs) { + if (runs.length !== 1) { + return { value: '—', detail: 'select a single run to see its schema versions' }; + } + var run = runs[0]; + var rollupVersion = (run.pipelineRollup && run.pipelineRollup.schema_version) || null; + var manifestVersion = (run.manifest && run.manifest.schema_version) || null; + return { + value: 'rollup v' + (rollupVersion || '?') + ' · manifest v' + (manifestVersion || '?'), + detail: (rollupVersion && manifestVersion) ? 'pipeline-state.json + manifest.json schema versions' : 'unstamped files show v?' + }; +} + +// Which run does the exec care about right now? The scoped run if one is +// selected; otherwise the newest active run; otherwise the newest run that +// has pipeline state at all. Runs arrive newest-first from the state layer. +function commandFocusRun(s) { + var runs = s.runs || []; + if (!runs.length) return null; + if (runs.length === 1) return runs[0]; + var withRollup = runs.filter(function(run) { return run.pipelineRollup; }); + var active = withRollup.filter(function(run) { return run.derivedStatus === 'active'; }); + return active[0] || withRollup[0] || runs[0]; +} + +function showPageFromChip(el) { + showPage(el.getAttribute('data-page')); +} + +function nextActionChip(kind) { + if (kind === 'approval') return { page: 'approvals', label: 'Review in Approvals', cls: 'warn' }; + if (kind === 'guardrail_blocked' || kind === 'failed') return { page: 'alerts-guardrails', label: 'Open Alerts & Guardrails', cls: 'danger' }; + if (kind === 'retry') return { page: 'alerts-guardrails', label: 'Watch in Alerts & Guardrails', cls: 'warn' }; + if (kind === 'complete') return { page: 'run-report', label: 'Open Run Report', cls: '' }; + if (kind === 'active' || kind === 'pending') return { page: 'workflow', label: 'View Workflow Map', cls: '' }; + return { page: 'diagnostics', label: 'Open Diagnostics', cls: '' }; +} + +// #156: the deterministic next-action from the pipeline-state rollup — the +// exact sentence the "rstack-agents pipeline status" CLI prints, rendered for +// the selected run scope with a chip that jumps to the tab holding the action. +function renderCommandNextAction(s) { + var run = commandFocusRun(s); + if (!run) { + setText('command-next-action-run', ''); + setHTML('command-next-action', emptyHtml('No runs loaded yet', 'Start an RStack run and the pipeline recommendation appears here.')); + return; + } + setText('command-next-action-run', (run.runId || '').slice(-24)); + var rollup = run.pipelineRollup; + if (!rollup || !rollup.next_action) { + setHTML('command-next-action', emptyHtml('No pipeline state recorded for this run', + 'Run "rstack-agents pipeline status --regenerate" to build pipeline-state.json from the run artifacts.')); + return; + } + var next = rollup.next_action; + var chipInfo = nextActionChip(next.kind); + var tone = next.kind === 'guardrail_blocked' || next.kind === 'failed' ? 'danger' + : next.kind === 'approval' || next.kind === 'retry' ? 'warn' + : next.kind === 'complete' ? 'ok' : 'info'; + var meta = []; + if (next.stage_id) meta.push('stage ' + next.stage_id); + if (next.task_id) meta.push('task ' + next.task_id); + if (next.artifact) meta.push(next.artifact); + meta.push('pipeline ' + (rollup.status || 'UNKNOWN')); + meta.push(rollup.stages_passed + '/' + rollup.stages_total + ' stages passed'); + setHTML('command-next-action', + '
' + + '' + + '
' + + '
' + esc(next.text || 'No recommendation available.') + '
' + + '
' + meta.map(function(part) { return '' + esc(part) + ''; }).join('') + '
' + + '
' + + '' + + '
' + + nextActionSourceHtml(rollup)); +} + +// The hero states ITS OWN freshness, not the page's global "updated" chip: a +// persisted pipeline-state.json that lags the live event stream must not be +// presented as the current recommendation (#218 review — never let stale data +// look live). Fresh → CLI-parity line; stale → an honest regenerate hint. +function nextActionSourceHtml(rollup) { + if (rollup && rollup.stale) { + var behind = rollup.events_behind || 0; + return '
⚠ From the last saved pipeline-state.json — ' + + behind + ' newer event' + (behind === 1 ? '' : 's') + ' since it was computed. ' + + 'Run "rstack-agents pipeline status --regenerate" for the live recommendation.
'; + } + return '
Same recommendation the rstack-agents pipeline status CLI computes.
'; +} +// ── end [wave:command] ──────────────────────────────────────────── + function commandSummaryTitle(s, attentionItems, counts) { var activeRunCount = (s.activeRuns || []).length; if (attentionItems.length) { @@ -143,6 +322,39 @@ function commandAttentionItems(s, counts) { if (counts.BLOCKED) { items.push({ level: 'danger', value: counts.BLOCKED, title: 'Guardrail-blocked tasks', detail: 'Attempt budget exhausted. Approve the guardrail-override entry in Approvals to allow exactly one more attempt.', meta: 'Guardrails' }); } + // [wave:command] July harness signals (#215): context pressure + goal loop + // from the per-run pipeline-state rollup. Runs without a rollup contribute + // nothing — no fabricated zeros. + var pressureTotal = 0; + var goalLoops = []; + runs.forEach(function(run) { + var rollup = run.pipelineRollup; + if (!rollup) return; + pressureTotal += (rollup.context_pressure && rollup.context_pressure.total) || 0; + if (rollup.goal_loop && rollup.goal_loop.active) goalLoops.push({ runId: run.runId, loop: rollup.goal_loop }); + }); + if (pressureTotal) { + items.push({ + level: 'warn', + value: pressureTotal, + title: 'Context pressure: ' + pressureTotal + ' warning' + (pressureTotal === 1 ? '' : 's') + ' — long-loop quality risk', + detail: 'Prompts or memory blocks crossed configured size thresholds. Detect-only signal: nothing was pruned or truncated.', + meta: 'Context' + }); + } + goalLoops.slice(0, 3).forEach(function(entry) { + var loop = entry.loop; + var verdict = loop.last_verdict ? 'last verdict ' + loop.last_verdict : 'no verdict yet'; + var criteria = loop.criteria_total ? ', ' + loop.criteria_met + '/' + loop.criteria_total + ' criteria met' : ''; + items.push({ + level: 'info', + value: loop.iterations, + title: 'Goal loop running — iteration ' + loop.iterations, + detail: verdict + criteria + ' (' + entry.runId.slice(-24) + ').', + meta: 'Goal loop' + }); + }); + // end [wave:command] if (missingValidation) { items.push({ level: 'warn', value: missingValidation, title: 'Missing validations', detail: 'Agent work that does not yet have validation.json proof attached.', meta: 'Proof' }); } diff --git a/src/observability/dashboard/ui/pages/decisions.js b/src/observability/dashboard/ui/pages/decisions.js index 66d854f8..5d3df29d 100644 --- a/src/observability/dashboard/ui/pages/decisions.js +++ b/src/observability/dashboard/ui/pages/decisions.js @@ -26,11 +26,75 @@ function renderDecisions(s) { var r = run.readiness || {}; return '
' + esc(run.goal || run.runId) + '
' + esc(r.message || 'Definition-of-Ready status') + '
' + esc(run.profile || '') + '' + esc(r.mode || '') + 'score ' + esc(r.score || 0) + '
' + pill(r.status || 'PASS') + '
'; }).join('') || emptyHtml('No readiness data', 'Run sdlc_dor_check or rstack-agents dor after starting an RStack run.')); + ensureDecisionLogPanel(); + renderDecisionLog(decisions); } +// ── [wave:command] Decision Log (#94) ────────────────────────────── +// Beyond the pending queue: the chronological record of decisions a human +// already made — who resolved or waived it, when, what the call was, and +// which run it governs. The panel is injected by this module (the page +// skeleton in ui/pages/index.js is shared across parallel page work). +function ensureDecisionLogPanel() { + if (document.getElementById('decisions-log-panel')) return; + var page = document.getElementById('page-decisions'); + if (!page) return; + var grid = page.querySelector('.grid-2'); + if (!grid) return; + grid.insertAdjacentHTML('afterend', + '
' + + '
Decision Log
' + + '
' + + '
'); +} + +function decisionResolvedTs(decision) { + return decision.resolved_at || decision.updated_at || decision.created_at || ''; +} + +function renderDecisionLog(decisions) { + var resolved = decisions.filter(function(item) { + var status = item.decision.status; + return status === 'resolved' || status === 'waived'; + }).sort(function(a, b) { + return decisionResolvedTs(b.decision).localeCompare(decisionResolvedTs(a.decision)); + }); + setText('decisions-log-count', resolved.length + ' resolved or waived'); + if (!resolved.length) { + setHTML('decisions-log', emptyHtml('No resolved decisions yet', + 'When someone resolves or waives a Decision Queue item, the who / when / what lands here as the audit trail.')); + return; + } + setHTML('decisions-log', resolved.slice(0, 60).map(decisionLogRowHtml).join('')); +} + +function decisionLogRowHtml(item) { + var d = item.decision; + var who = d.resolved_by || 'unrecorded decider'; + var verb = d.status === 'waived' ? 'Waived' : 'Resolved'; + var outcome = d.resolution + ? 'Decision: ' + d.resolution + : (d.status === 'waived' ? 'Waived without a recorded resolution.' : 'No resolution text recorded.'); + return '
' + + '
' + esc(fmtTime(decisionResolvedTs(d)) || '-') + '
' + + '
' + + '
' + esc(d.decision_id + ' — ' + d.question) + '
' + + '
' + esc(outcome) + '
' + + '
' + + '' + esc(verb + ' by ' + who) + '' + + 'impact: ' + esc(d.impact || 'scope') + '' + + 'gated stage ' + esc(d.required_before_stage || '-') + '' + + '' + + '
' + + '
' + + pill(d.status === 'waived' ? 'warn' : 'pass', d.status) + + '
'; +} +// ── end [wave:command] ───────────────────────────────────────────── + registerPage('decisions', { errLabel: 'decisions', - sub: 'Decision Queue and Definition-of-Ready status from decisions.json, dor-report.json and readiness.json.', + sub: 'Decision Queue, Decision Log and Definition-of-Ready status from decisions.json, dor-report.json and readiness.json.', render: renderDecisions }); `; diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js index 8fffa1a9..6bef94f0 100644 --- a/src/observability/dashboard/ui/styles.js +++ b/src/observability/dashboard/ui/styles.js @@ -1270,6 +1270,32 @@ tr.clickable:hover td { background: #f8fbff; } @media (max-width: 900px) { .report-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +/* [wave:command] Command Center next-action + exec rollup, Decision Log (#94/#156/#215) */ +.next-action-panel, .exec-rollup-panel { margin-bottom: 16px; } +.next-action { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 14px; } +.next-action-icon { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; + justify-content: center; font-size: 18px; font-weight: 800; flex-shrink: 0; } +.next-action-icon.ok { background: #f0fdf4; color: var(--green); } +.next-action-icon.warn { background: #fff7ed; color: var(--amber); } +.next-action-icon.danger { background: #fff5f5; color: var(--red); } +.next-action-icon.info { background: #eff6ff; color: var(--blue); } +.next-action-text { font-weight: 700; line-height: 1.4; } +.next-action-source { margin-top: 10px; font-size: 11px; color: var(--faint); font-style: italic; } +.next-action-source.stale { color: var(--amber, #b7791f); font-style: normal; font-weight: 600; } +.exec-rollup-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } +.exec-stat { border: 1px solid var(--line); background: #fff; border-radius: 10px; padding: 10px 12px; } +.exec-stat-v { font-size: 22px; font-weight: 800; line-height: 1.2; } +.exec-stat-l { margin-top: 2px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .07em; } +.exec-stat-s { margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.35; } +.exec-stat-v.schema-badge { font-size: 13px; padding-top: 5px; } +.decision-log-row { display: grid; grid-template-columns: 110px 1fr auto; gap: 12px; align-items: start; + padding: 10px 0; border-bottom: 1px dashed var(--line); } +.decision-log-row:last-child { border-bottom: none; } +.decision-log-when { color: var(--muted); font-size: 11px; padding-top: 2px; } +.decision-log-main .feed-meta { margin-top: 4px; } +@media (max-width: 900px) { .next-action, .decision-log-row { grid-template-columns: 1fr; } } +/* end [wave:command] */ + /* [wave:ops] — ops panels: retry state, guardrail depth, context pressure, audit rejections */ .feed-icon.info { color: var(--blue); background: #eff6ff; } .ops-meta { color: var(--amber); font-weight: 700; } diff --git a/tests/dashboard-command-pages.test.js b/tests/dashboard-command-pages.test.js new file mode 100644 index 00000000..642a1e9d --- /dev/null +++ b/tests/dashboard-command-pages.test.js @@ -0,0 +1,243 @@ +/** + * Command Center + Decisions page wave (#94 / #156 / #215 slice). + * + * Pins three contracts: + * 1. State layer: every run in the snapshot carries `pipelineRollup` — a + * compact pipeline-state summary whose next-action TEXT comes from + * recommendPipelineAction (the `pipeline status` CLI brain), never a + * client-side re-implementation. Missing/unbuildable state yields null, + * not a broken snapshot. + * 2. Client modules: the Command Center renders the next-action card, the + * executive rollup strip (with schema-version badge) and the new + * context-pressure / goal-loop attention signals; the Decisions page + * renders the resolved/waived Decision Log. All inside the compiled + * bundle (no-build-step stance). + * 3. Honest empty states: no fabricated data when the rollup or decisions + * are absent. + * + * owner: RStack developed by Richardson Gunde + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { buildFullState, toClientState } from '../src/observability/dashboard/state/index.js'; +import { recommendPipelineAction } from '../src/commands/pipeline.js'; +import { readPipelineState, writePipelineState } from '../src/core/harness/pipeline-state.js'; +import { clientScript } from '../src/observability/dashboard/ui/client.js'; +import { commandCenterScript } from '../src/observability/dashboard/ui/pages/command-center.js'; +import { decisionsScript } from '../src/observability/dashboard/ui/pages/decisions.js'; + +const RUN_ID = '2026-07-06T12-00-00-000Z-command-fixture'; + +function jsonl(events) { + return events.map((event) => JSON.stringify(event)).join('\n') + '\n'; +} + +// Fixture-shaped run: goal loop iteration 1 with a RETRY verdict, one +// context_pressure_warning, a BLOCKED testing task with guardrail_triggered, +// checkpoints, and a schema_version 2 manifest — the July harness signals. +function seedProject() { + const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-command-pages-')); + const runDir = join(projectRoot, '.rstack', 'runs', RUN_ID); + mkdirSync(runDir, { recursive: true }); + writeFileSync(join(runDir, 'manifest.json'), JSON.stringify({ + run_id: RUN_ID, + schema_version: 2, + goal: 'Command pages fixture', + status: 'IN_PROGRESS', + created_at: '2026-07-06T12:00:00.000Z', + })); + writeFileSync(join(runDir, 'tasks.json'), JSON.stringify({ + tasks: [ + { id: '003-architecture', status: 'PASS', stage_artifacts: [{ stage_id: '06-architecture' }] }, + { id: '004-implementation', status: 'IN_PROGRESS', stage_artifacts: [{ stage_id: '07-code' }] }, + { id: '005-testing', status: 'BLOCKED', stage_artifacts: [{ stage_id: '08-testing' }] }, + ], + })); + writeFileSync(join(runDir, 'metrics.json'), JSON.stringify({ + cumulative_cost_usd: 4.87, + cumulative_tokens: { input: 1420000, output: 312000, total: 1732000 }, + stage_status: { '06-architecture': 'PASS', '07-code': 'IN_PROGRESS', '08-testing': 'BLOCKED' }, + })); + writeFileSync(join(runDir, 'events.jsonl'), jsonl([ + { ts: '2026-07-06T12:01:00.000Z', type: 'task_started', task_id: '003-architecture' }, + { ts: '2026-07-06T12:01:05.000Z', type: 'stage_checkpoint_before_saved', stage_id: '06-architecture', task_id: '003-architecture' }, + { ts: '2026-07-06T12:10:00.000Z', type: 'task_validated', task_id: '003-architecture', status: 'PASS' }, + { ts: '2026-07-06T12:11:00.000Z', type: 'loop_iteration_started', iteration: 1, goal_id: 'fixture-goal' }, + { ts: '2026-07-06T12:13:00.000Z', type: 'context_pressure_warning', source: 'memory_summary', metric: 'chars', size: 61000, threshold: 40000, task_id: '004-implementation' }, + { ts: '2026-07-06T12:31:00.000Z', type: 'task_retry_scheduled', task_id: '005-testing', attempt: 1 }, + { ts: '2026-07-06T12:35:01.000Z', type: 'guardrail_triggered', task_id: '005-testing', limit_name: 'maxTaskAttempts' }, + { ts: '2026-07-06T12:40:00.000Z', type: 'goal_evaluated', iteration: 1, recommendation: 'RETRY', criteria_met: 1, criteria_total: 2 }, + ])); + writeFileSync(join(runDir, 'decisions.json'), JSON.stringify({ + run_id: RUN_ID, + decisions: [ + { decision_id: 'DEC-001', question: 'Database choice?', impact: 'architecture', required_before_stage: '06-architecture', status: 'resolved', resolution: 'PostgreSQL 16', resolved_by: 'Richardson', resolved_at: '2026-07-06T12:04:00.000Z' }, + { decision_id: 'DEC-002', question: 'Require SSO for pilot?', impact: 'security', required_before_stage: '09-deployment', status: 'waived', resolved_by: 'Richardson', resolved_at: '2026-07-06T12:15:00.000Z' }, + { decision_id: 'DEC-003', question: 'Cloud region?', impact: 'budget', required_before_stage: '09-deployment', status: 'pending' }, + ], + })); + return projectRoot; +} + +test('runs carry a pipelineRollup whose next-action text IS the CLI recommendation', async () => { + const projectRoot = seedProject(); + const state = await buildFullState(projectRoot, { includeRegistry: false }); + const run = state.runs.find((entry) => entry.runId === RUN_ID); + assert.ok(run, 'fixture run present in snapshot'); + const rollup = run.pipelineRollup; + assert.ok(rollup, 'fully-parsed run gets a pipeline rollup even without a persisted pipeline-state.json'); + + // The rollup schema version + status flow through for #156 visibility. + assert.equal(rollup.schema_version, 1); + assert.equal(run.manifest.schema_version, 2); + assert.equal(rollup.status, 'IN_PROGRESS'); + + // No second brain: the sentence must be exactly what `pipeline status` + // would print for the same pipeline-state document. + const pipelineState = await readPipelineState(projectRoot, RUN_ID, { regenerateIfMissing: true }); + assert.equal(rollup.next_action.text, recommendPipelineAction(pipelineState)); + // Priority classification mirrors the same order: the BLOCKED testing stage + // with a scheduled retry classifies as 'retry' (what the CLI recommends). + assert.equal(rollup.next_action.kind, 'retry'); + assert.equal(rollup.next_action.stage_id, '08-testing'); + assert.equal(rollup.next_action.task_id, '005-testing'); + + // July signals (#215): context pressure + active goal loop with the BLE-4 + // recommendation as the verdict (goal_evaluated emits `recommendation`). + assert.equal(rollup.context_pressure.total, 1); + assert.deepEqual(rollup.context_pressure.by_source, { memory_summary: 1 }); + assert.equal(rollup.goal_loop.active, true); + assert.equal(rollup.goal_loop.iterations, 1); + assert.equal(rollup.goal_loop.last_verdict, 'RETRY'); + assert.equal(rollup.goal_loop.criteria_met, 1); + assert.equal(rollup.goal_loop.criteria_total, 2); + assert.equal(rollup.checkpoints.before_saved, 1); + + // The rollup survives into the client payload (the page renders from it). + const client = toClientState(state); + const clientRun = client.runs.find((entry) => entry.runId === RUN_ID); + assert.ok(clientRun.pipelineRollup, 'pipelineRollup reaches the client snapshot'); + assert.equal(clientRun.pipelineRollup.next_action.text, rollup.next_action.text); +}); + +test('the next-action rollup flags itself stale when live events outrun the saved pipeline-state.json', async () => { + const projectRoot = seedProject(); + // Operator ran `pipeline status` at 12:20; the run has emitted events since + // (12:31, 12:35:01, 12:40). The saved recommendation is behind live data. + await writePipelineState(projectRoot, RUN_ID, { generatedAt: '2026-07-06T12:20:00.000Z' }); + const stale = (await buildFullState(projectRoot, { includeRegistry: false })) + .runs.find((entry) => entry.runId === RUN_ID).pipelineRollup; + assert.equal(stale.generated_at, '2026-07-06T12:20:00.000Z'); + assert.equal(stale.stale, true, 'three events postdate the saved state'); + assert.equal(stale.events_behind, 3); + + // Re-saving AFTER the newest event makes it fresh again — no false alarm. + // (Writing the file changes the run-dir signature, forcing a re-parse, so + // the second snapshot reads the new state rather than the cached lite run.) + await writePipelineState(projectRoot, RUN_ID, { generatedAt: '2026-07-06T13:00:00.000Z' }); + const fresh = (await buildFullState(projectRoot, { includeRegistry: false })) + .runs.find((entry) => entry.runId === RUN_ID).pipelineRollup; + assert.equal(fresh.stale, false); + assert.equal(fresh.events_behind, 0); +}); + +test('a pending approval outranks everything in the next-action classification', async () => { + const projectRoot = seedProject(); + const runDir = join(projectRoot, '.rstack', 'runs', RUN_ID); + writeFileSync(join(runDir, 'approvals.json'), JSON.stringify([ + { id: 'app-1', artifact: 'deploy-approval.md', stage_id: '09-deployment', status: 'PENDING' }, + ])); + const state = await buildFullState(projectRoot, { includeRegistry: false }); + const rollup = state.runs.find((entry) => entry.runId === RUN_ID).pipelineRollup; + assert.equal(rollup.next_action.kind, 'approval'); + assert.equal(rollup.next_action.artifact, 'deploy-approval.md'); + assert.match(rollup.next_action.text, /Resolve the pending approval for deploy-approval\.md/); + assert.equal(rollup.approval_blockers, 1); +}); + +test('index-served lite runs keep the persisted rollup and manifest schema_version', async () => { + const projectRoot = seedProject(); + // Persist the rollup first (the operator flow: pipeline status --regenerate), + // THEN let the rollup index cache the run — writing afterwards would change + // the run-dir signature and force a re-parse, hiding the lite-run path. + await readPipelineState(projectRoot, RUN_ID, { regenerateIfMissing: true }); + await buildFullState(projectRoot, { includeRegistry: false }); // parses + writes .rstack/index.json + const second = await buildFullState(projectRoot, { includeRegistry: false }); + const run = second.runs.find((entry) => entry.runId === RUN_ID); + assert.equal(run.fromIndex, true, 'stalled fixture run is index-served on the second snapshot'); + assert.ok(run.pipelineRollup, 'lite run reads the persisted pipeline-state.json'); + assert.equal(run.pipelineRollup.schema_version, 1); + assert.equal(run.pipelineRollup.next_action.kind, 'retry'); + assert.equal(run.manifest.schema_version, 2, 'manifest schema_version restored for lite runs (#156)'); +}); + +test('a run without usable pipeline state gets pipelineRollup null, never a fabricated one', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-command-empty-')); + const runDir = join(projectRoot, '.rstack', 'runs', 'run-bare'); + mkdirSync(runDir, { recursive: true }); + writeFileSync(join(runDir, 'manifest.json'), JSON.stringify({ run_id: 'run-bare', goal: 'Bare run', status: 'IN_PROGRESS' })); + // Corrupt persisted rollup: readPipelineState tolerates it as fallback null, + // and the in-memory build still summarizes from the run artifacts — the + // snapshot must never throw either way. + writeFileSync(join(runDir, 'pipeline-state.json'), '{ truncated'); + const state = await buildFullState(projectRoot, { includeRegistry: false }); + const run = state.runs.find((entry) => entry.runId === 'run-bare'); + assert.ok(run, 'bare run still in snapshot'); + // Either an honest in-memory rollup (all-pending stages) or null — but the + // corrupt file must not leak garbage into next_action. + if (run.pipelineRollup) { + assert.equal(typeof run.pipelineRollup.next_action.text, 'string'); + assert.equal(run.pipelineRollup.context_pressure.total, 0); + assert.equal(run.pipelineRollup.goal_loop.active, false); + } +}); + +test('command-center module renders next-action card, exec rollup and new attention signals', () => { + // The page module owns its injected DOM — ids the renderer targets. + for (const id of ['command-next-action-panel', 'command-next-action', 'command-exec-rollup', 'command-schema-version']) { + assert.ok(commandCenterScript.includes(id), `command-center module carries #${id}`); + } + // Next-action chip routes to the tab that holds the action. + assert.match(commandCenterScript, /nextActionChip/); + assert.match(commandCenterScript, /'approvals'/); + assert.match(commandCenterScript, /'alerts-guardrails'/); + // #215 attention rows: context pressure with the long-loop wording; goal + // loop with iteration + last verdict. + assert.match(commandCenterScript, /long-loop quality risk/); + assert.match(commandCenterScript, /Goal loop running — iteration/); + assert.match(commandCenterScript, /last_verdict/); + // Schema-version badge (#156) and honest empty states. + assert.match(commandCenterScript, /rollup v/); + assert.match(commandCenterScript, /No pipeline state recorded for this run/); + assert.match(commandCenterScript, /pipeline status --regenerate/); + assert.match(commandCenterScript, /No runs loaded yet/); + // Freshness honesty (#218 review): the hero states its OWN staleness rather + // than inheriting the page's global "updated" chip. + assert.match(commandCenterScript, /nextActionSourceHtml/); + assert.match(commandCenterScript, /From the last saved pipeline-state\.json/); + assert.match(commandCenterScript, /newer event/); +}); + +test('decisions module renders the resolved/waived Decision Log with who/when/impact', () => { + assert.ok(decisionsScript.includes('decisions-log-panel'), 'log panel injected by the module'); + assert.match(decisionsScript, /Decision Log/); + assert.match(decisionsScript, /resolved.*waived|waived.*resolved/s); + assert.match(decisionsScript, /resolved_by/); + assert.match(decisionsScript, /resolved_at/); + assert.match(decisionsScript, /impact: /); + assert.match(decisionsScript, /openDrawerRow\(this\)/); + assert.match(decisionsScript, /No resolved decisions yet/); +}); + +test('wave additions keep the assembled bundle compiling and inline-script safe', () => { + const bundle = clientScript(3008); + assert.doesNotThrow(() => new Function(bundle), 'bundle with wave:command additions must compile'); + assert.ok(!bundle.includes('