Skip to content
128 changes: 128 additions & 0 deletions src/observability/dashboard/state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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.
Expand Down Expand Up @@ -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] ──────────────────────────────────────────────────────
212 changes: 212 additions & 0 deletions src/observability/dashboard/ui/pages/command-center.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
'<div class="panel next-action-panel" id="command-next-action-panel">' +
'<div class="panel-head"><span class="panel-title">Pipeline Next Action</span><span class="panel-note mono" id="command-next-action-run"></span></div>' +
'<div class="panel-body" id="command-next-action"></div>' +
'</div>' +
'<div class="panel exec-rollup-panel" id="command-exec-rollup-panel">' +
'<div class="panel-head"><span class="panel-title">Executive Rollup</span><span class="panel-note" id="command-exec-rollup-note"></span></div>' +
'<div class="panel-body"><div class="exec-rollup-strip" id="command-exec-rollup"></div></div>' +
'</div>');
}

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 '<div class="exec-stat"><div class="exec-stat-v">' + esc(stat.value) + '</div>' +
'<div class="exec-stat-l">' + esc(stat.label) + '</div>' +
'<div class="exec-stat-s">' + esc(stat.detail) + '</div></div>';
}).join('') +
'<div class="exec-stat"><div class="exec-stat-v schema-badge mono" id="command-schema-version">' + esc(schema.value) + '</div>' +
'<div class="exec-stat-l">State schema</div>' +
'<div class="exec-stat-s">' + esc(schema.detail) + '</div></div>';
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',
'<div class="next-action">' +
'<div class="next-action-icon ' + esc(tone) + '" aria-hidden="true">&#8594;</div>' +
'<div class="next-action-main">' +
'<div class="next-action-text">' + esc(next.text || 'No recommendation available.') + '</div>' +
'<div class="feed-meta">' + meta.map(function(part) { return '<span>' + esc(part) + '</span>'; }).join('') + '</div>' +
'</div>' +
'<button class="tb-chip ' + esc(chipInfo.cls) + '" data-page="' + esc(chipInfo.page) + '" onclick="showPageFromChip(this)">' + esc(chipInfo.label) + '</button>' +
'</div>' +
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 '<div class="next-action-source stale">&#9888; 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.</div>';
}
return '<div class="next-action-source">Same recommendation the rstack-agents pipeline status CLI computes.</div>';
}
// ── end [wave:command] ────────────────────────────────────────────

function commandSummaryTitle(s, attentionItems, counts) {
var activeRunCount = (s.activeRuns || []).length;
if (attentionItems.length) {
Expand Down Expand Up @@ -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' });
}
Expand Down
Loading
Loading