Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/observability/dashboard/state/layers.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,24 @@ export function buildDiagnostics(runs, roots, indexMeta = null) {
missingValidationCount: fullyParsedTasks.filter((task) => !task.validation).length,
// Data integrity (#82): damaged run files recorded during the state build,
// one entry per corrupt file so operators see WHAT is broken, not zeros.
integrity: fullyParsed.flatMap((run) => (run.integrity ?? []).map((issue) => ({ runId: run.runId, ...issue }))),
integrityErrorCount: fullyParsed.reduce((total, run) => total + (run.integrity?.length ?? 0), 0),
// #497: index-served runs carry only the has_integrity_errors boolean
// (INDEX_VERSION 5, the #316 parity fix) — the detail array is not
// persisted, so a damaged run served from the rollup index used to
// contribute nothing and Diagnostics read a false zero. Count and name
// such runs; per-file detail honestly states it needs a full parse.
// Persisting the detail rides the next INDEX_VERSION bump (#497).
integrity: [
...fullyParsed.flatMap((run) => (run.integrity ?? []).map((issue) => ({ runId: run.runId, ...issue }))),
...(runs ?? [])
.filter((run) => run.fromIndex && run.hasIntegrityErrors)
.map((run) => ({
runId: run.runId,
file: 'rollup index entry',
error: 'run reports damaged files — open the run for per-file detail (not persisted to the index)',
})),
],
integrityErrorCount: fullyParsed.reduce((total, run) => total + (run.integrity?.length ?? 0), 0)
+ (runs ?? []).filter((run) => run.fromIndex && run.hasIntegrityErrors).length,
// Rollup index freshness + retention visibility for the Diagnostics page.
index: indexMeta,
};
Expand Down
15 changes: 13 additions & 2 deletions src/observability/dashboard/state/overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ const STATUS = {
QUEUED: 'not_started',
};

function focusRun(runs) {
function focusRun(runs, readiness) {
const candidates = runs ?? [];
// #498: the hero cites the readiness verdict, so when its first blocker
// names a run in scope, that run drives the stage rail — otherwise the
// rail can render a DIFFERENT run's stages beside the verdict and read as
// the dashboard disagreeing with itself.
const citedRunId = readiness?.blockers?.[0]?.sourceRef?.runId ?? null;
const cited = citedRunId ? candidates.find((run) => run.runId === citedRunId) : null;
if (cited) return cited;
return candidates.find((run) => run.derivedStatus === 'active')
?? candidates.find((run) => run.pipelineRollup)
?? candidates[0]
Expand Down Expand Up @@ -132,8 +139,8 @@ function actionCount(state) {
* Readiness remains server-owned; this projection never derives a verdict.
*/
export function buildOverviewProjection(state) {
const run = focusRun(state.runs);
const readiness = state.readiness ?? { status: 'unknown', coverage: {}, blockers: [] };
const run = focusRun(state.runs, readiness);
const stale = Boolean(run?.pipelineRollup?.stale);
const title = run ? (readiness.summary ?? 'Delivery outcome is not available.') : 'No delivery run has been evaluated.';
const summary = readiness.summary ?? null;
Expand All @@ -150,6 +157,10 @@ export function buildOverviewProjection(state) {
rationale: summary && summary !== title ? summary : null,
nextAction: pipelineAction(run, { ...readiness, actions: state.actions ?? [] }),
stages: run ? CANONICAL_SDLC_STAGES.map((stage) => stageView(run, stage)) : [],
// #498: the rail names the run whose stages it shows — at aggregate
// scope the verdict and the rail otherwise read as two different runs
// with no explanation. Pages render this label, they never re-derive it.
stagesSource: run ? { runId: run.runId, goal: run.manifest?.goal ?? run.goal ?? run.runId } : null,
actionCount: state.actions
? state.actions.filter((action) => !['approved', 'rejected', 'consumed', 'resolved', 'expired'].includes(action.status)).length
: actionCount(state),
Expand Down
25 changes: 23 additions & 2 deletions src/observability/dashboard/state/run-workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,31 @@ import { buildOverviewProjection } from './overview.js';

// owner: RStack developed by Richardson Gunde

// #496: events-derived timelines cap at the same depth the rollup index
// keeps for persisted timelines (rollup-index.js slices to 120).
const EVENTS_TIMELINE_CAP = 120;

function readableTimeline(run) {
return [...(run.timeline ?? []), ...(run.activityTimeline ?? [])]
const derived = [...(run.timeline ?? []), ...(run.activityTimeline ?? [])]
.filter((item) => item && item.ts)
.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
if (derived.length) return { items: derived, source: 'persisted' };
// #496: legacy/fixture runs and index entries predating the derived
// timeline fields still carry raw events — a populated events.jsonl must
// never read as "no recorded timeline events". Minimal projection, capped
// to the most recent entries, labeled with its source (the same honesty
// convention as the 3D Studio's "events.jsonl projection" timeline).
const events = (run.events ?? [])
.filter((event) => event && event.ts)
.map((event) => ({
ts: event.ts,
type: event.type ?? 'event',
task_id: event.task_id ?? null,
stage_id: event.stage_id ?? null,
}))
.sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
.slice(-EVENTS_TIMELINE_CAP);
return { items: events, source: events.length ? 'events' : 'none' };
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

function workItems(run) {
Expand Down Expand Up @@ -113,7 +134,7 @@ export function buildRunWorkspace(run, context = {}) {
sections: {
summary: { available: true },
work: { available: work.length > 0, items: work },
timeline: { available: timeline.length > 0, items: timeline },
timeline: { available: timeline.items.length > 0, items: timeline.items, source: timeline.source },
artifacts: { available: artifacts.length > 0, items: artifacts, stageReports: run.stageReports ?? [] },
metrics: {
available: metricsProvenance !== 'unavailable',
Expand Down
15 changes: 15 additions & 0 deletions src/observability/dashboard/ui/pages/command-center.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,23 @@ function overviewStageIcon(state) {
return ({ passed: '✓', failed: '!', blocked: '×', in_progress: '→', not_started: '○', unknown: '?' })[state] || '?';
}

// #498: rail source label keeps the goal to one readable line.
var PROOF_SOURCE_GOAL_CHARS = 54;

function renderOverviewProofRail(s) {
var stages = (s.overview && s.overview.stages) || [];
// #498: the rail names the run whose stages it shows — server-owned via
// overview.stagesSource, so an aggregate scope can never render one run's
// stages beside another run's verdict without saying so.
var stagesSource = (s.overview && s.overview.stagesSource) || null;
var sourceEl = document.getElementById('overview-proof-source');
if (sourceEl) {
var label = stagesSource
? 'Stage proof from ' + String(stagesSource.goal || stagesSource.runId).slice(0, PROOF_SOURCE_GOAL_CHARS) + ' · ' + stagesSource.runId
: '';
setText('overview-proof-source', label);
sourceEl.hidden = !label;
}
if (!stages.length) {
setHTML('overview-proof-rail', '<li class="overview-proof-empty"><strong>No stage proof yet.</strong><span>Start a run and canonical stage evidence will appear here.</span></li>');
return;
Expand Down
1 change: 1 addition & 0 deletions src/observability/dashboard/ui/pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function pageBody(id) {
<div>
<div class="overview-eyebrow">Goal → stage → proof → action</div>
<h3 id="overview-proof-title">Proof Rail</h3>
<span class="muted" id="overview-proof-source" hidden></span>
</div>
<button class="tb-chip" onclick="showPage('traceability')">Open all evidence</button>
</div>
Expand Down
7 changes: 6 additions & 1 deletion src/observability/dashboard/ui/pages/run-workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ function renderRunWorkspaceTimeline(section) {
setHTML('run-workspace-timeline', emptyHtml('Timeline unavailable', 'This run has no recorded timeline events.'));
return;
}
setHTML('run-workspace-timeline', '<div class="run-workspace-timeline">' + section.items.map(function(item) {
// #496: an events-derived timeline names its source — same convention as
// the 3D Studio's "events.jsonl projection" label.
var sourceNote = section.source === 'events'
? '<div class="kv-note">Derived from events.jsonl — this run has no persisted timeline.</div>'
: '';
setHTML('run-workspace-timeline', sourceNote + '<div class="run-workspace-timeline">' + section.items.map(function(item) {
return '<div class="run-workspace-event"><time datetime="' + esc(item.ts) + '">' + timeHtml(item.ts) + '</time>' +
'<div><strong>' + esc(String(item.type || 'event').replaceAll('_', ' ')) + '</strong>' +
'<small>' + esc(item.task_id || item.stage_id || item.detail || 'Run event') + '</small></div></div>';
Expand Down
70 changes: 70 additions & 0 deletions tests/diagnostics-integrity-497.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* #497 — the Diagnostics integrity signal is never a false zero.
*
* buildDiagnostics aggregated integrity detail over fully-parsed runs only;
* index-served runs carry the has_integrity_errors boolean (persisted at
* INDEX_VERSION 5 by the #316 parity fix) but not the integrity[] detail
* array, so a damaged run served from the rollup index contributed nothing —
* "Data integrity errors: 0" with a damaged run in plain view. Index-served
* damage now counts and names its run; per-file detail honestly states it
* needs a full parse. Full detail persistence rides the next INDEX_VERSION
* bump (noted on the issue).
*
* owner: RStack developed by Richardson Gunde
*/

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { buildDiagnostics } from '../src/observability/dashboard/state/layers.js';
import { buildFullState } from '../src/observability/dashboard/state/index.js';
import { fixtureMalformedRun } from './helpers/dashboard-fixtures.js';

test('index-served damaged runs count and are named — never a false zero (#497)', () => {
const diagnostics = buildDiagnostics([
{ runId: 'run-parsed-clean', fromIndex: false, tasks: [], events: [], integrity: [] },
{ runId: 'run-parsed-damaged', fromIndex: false, tasks: [], events: [], integrity: [{ file: 'decisions.json', error: 'invalid JSON' }] },
{ runId: 'run-indexed-damaged', fromIndex: true, hasIntegrityErrors: true, tasks: [], events: [] },
{ runId: 'run-indexed-clean', fromIndex: true, hasIntegrityErrors: false, tasks: [], events: [] },
], ['/tmp/root']);

assert.equal(diagnostics.integrityErrorCount, 2,
'one parsed detail entry + one index-served damaged run');
const indexed = diagnostics.integrity.find((issue) => issue.runId === 'run-indexed-damaged');
assert.ok(indexed, 'the index-served damaged run is named in the problem list');
assert.match(indexed.error, /full parse|detail/i,
'the entry says honestly that per-file detail needs a full parse');
const parsed = diagnostics.integrity.find((issue) => issue.runId === 'run-parsed-damaged');
assert.equal(parsed.file, 'decisions.json', 'fully-parsed detail is unchanged');
});

test('fully-parsed damaged runs never double-count via their boolean (#497)', () => {
const diagnostics = buildDiagnostics([
{ runId: 'run-parsed-damaged', fromIndex: false, hasIntegrityErrors: true, tasks: [], events: [], integrity: [{ file: 'events.jsonl', error: 'truncated' }] },
], ['/tmp/root']);
assert.equal(diagnostics.integrityErrorCount, 1, 'the detail entry is the only count');
});

test('a damaged run stays visible in Diagnostics when served from the rollup index (#497)', async (t) => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'rstack-integrity-497-')));
t.after(() => rmSync(root, { recursive: true, force: true }));
await fixtureMalformedRun(root);

// First build parses fully and persists the rollup index; the second build
// serves the (stale, inactive) damaged run from that index — the exact
// path that reported zero.
const first = await buildFullState(root, { includeRegistry: false });
assert.ok(first.diagnostics.integrityErrorCount >= 1, 'full parse sees the damage');

const second = await buildFullState(root, { includeRegistry: false });
const served = second.runs.find((run) => run.runId === 'run-fx-damaged');
assert.ok(served, 'the damaged run is in scope');
if (!served.fromIndex) t.skip('run was fully parsed again — index path not exercised in this environment');
assert.ok(second.diagnostics.integrityErrorCount >= 1,
Comment on lines +62 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not skip the index-served assertion.

This test can pass without exercising the rollup-index path. Make the fixture deterministic, then require served.fromIndex before asserting the Diagnostics result.

Proposed test change
-  if (!served.fromIndex) t.skip('run was fully parsed again — index path not exercised in this environment');
+  assert.equal(served.fromIndex, true,
+    'the second build must serve the damaged run from the rollup index');

As per coding guidelines, “Run regression tests against the real enforcement path where possible.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const second = await buildFullState(root, { includeRegistry: false });
const served = second.runs.find((run) => run.runId === 'run-fx-damaged');
assert.ok(served, 'the damaged run is in scope');
if (!served.fromIndex) t.skip('run was fully parsed again — index path not exercised in this environment');
assert.ok(second.diagnostics.integrityErrorCount >= 1,
const second = await buildFullState(root, { includeRegistry: false });
const served = second.runs.find((run) => run.runId === 'run-fx-damaged');
assert.ok(served, 'the damaged run is in scope');
assert.equal(served.fromIndex, true,
'the second build must serve the damaged run from the rollup index');
assert.ok(second.diagnostics.integrityErrorCount >= 1,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/diagnostics-integrity-497.test.js` around lines 62 - 66, Update the
test around buildFullState and the run-fx-damaged lookup to make the fixture
deterministically use the rollup-index path, then assert that served.fromIndex
is present instead of conditionally calling t.skip. Keep the subsequent
integrityErrorCount assertion so the test always validates Diagnostics through
the indexed serving path.

Source: Coding guidelines

'index-served damage still counts — zero would be a false green');
assert.ok(second.diagnostics.integrity.some((issue) => issue.runId === 'run-fx-damaged'),
'and the run is named');
});
87 changes: 87 additions & 0 deletions tests/overview-proof-rail-498.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* #498 — the Proof Rail names the run it reflects and follows the verdict.
*
* At all-runs scope the Command Center hero cited one run's readiness
* verdict ("blocked, coverage 85%") while the Proof Rail silently rendered a
* DIFFERENT run's stages — 15 unqualified "Not Started" beside a non-zero
* coverage verdict read as the dashboard disagreeing with itself. The
* overview projection now (a) prefers the run the verdict's first blocker
* cites when choosing the rail's focus run, and (b) exposes stagesSource so
* the rail labels which run it shows. Single-run scope keeps its selection.
*
* owner: RStack developed by Richardson Gunde
*/

import test from 'node:test';
import assert from 'node:assert/strict';

import { buildOverviewProjection } from '../src/observability/dashboard/state/overview.js';
import { commandCenterScript } from '../src/observability/dashboard/ui/pages/command-center.js';

function run(runId, goal, extras = {}) {
return {
runId,
projectRoot: '/tmp/p',
manifest: { goal },
tasks: [],
...extras,
};
}

test('the rail follows the run the verdict blocker cites at aggregate scope (#498)', () => {
const projection = buildOverviewProjection({
runs: [
run('run-stale', 'Stale fixture', { pipelineRollup: { stale: false } }),
run('run-blocked', 'Blocked fixture'),
],
readiness: {
status: 'blocked',
summary: 'Release is blocked by 6 source-backed conditions.',
blockers: [{
type: 'approval',
label: 'Pending approvals',
detail: 'guardrail-override:07-code awaits a manager',
sourceRef: { kind: 'approval_audit', path: '.rstack/runs/run-blocked/approvals.json', runId: 'run-blocked' },
}],
coverage: { percent: 85 },
},
});
assert.equal(projection.focusRunId, 'run-blocked',
'the rail reflects the run the verdict is talking about');
assert.deepEqual(projection.stagesSource, { runId: 'run-blocked', goal: 'Blocked fixture' },
'and the projection names the rail’s source run for the label');
});

test('without blockers the existing focus selection is preserved (#498)', () => {
const projection = buildOverviewProjection({
runs: [
run('run-active', 'Active work', { derivedStatus: 'active' }),
run('run-other', 'Other'),
],
readiness: { status: 'ready', summary: 'All checks passed.', blockers: [], coverage: { percent: 100 } },
});
assert.equal(projection.focusRunId, 'run-active', 'active-run preference unchanged');
assert.equal(projection.stagesSource.runId, 'run-active');
});

test('a blocker citing an out-of-scope run falls back to the existing selection (#498)', () => {
const projection = buildOverviewProjection({
runs: [run('run-only', 'Only run')],
readiness: {
status: 'blocked',
summary: 'Blocked.',
blockers: [{ type: 'approval', label: 'x', detail: 'y', sourceRef: { runId: 'run-vanished' } }],
},
});
assert.equal(projection.focusRunId, 'run-only', 'never follows a citation outside the scope');
});

test('no runs means no stages source — nothing invented (#498)', () => {
const projection = buildOverviewProjection({ runs: [], readiness: { status: 'unknown', blockers: [] } });
assert.equal(projection.stagesSource, null);
});

test('the Command Center renders the rail source label from the projection (#498)', () => {
assert.match(commandCenterScript, /stagesSource/,
'the rail header consumes the server-owned stagesSource label');
});
71 changes: 71 additions & 0 deletions tests/run-workspace-timeline-496.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* #496 — the Run Workspace timeline falls back to events.jsonl.
*
* The timeline section read only the derived timeline collections
* (run.timeline / run.activityTimeline, populated for index-served runs
* since #296). Runs without them — legacy runs, fixture-shaped runs, index
* entries predating the field — declared "no recorded timeline events" while
* events.jsonl sat populated in the same snapshot. The projection now
* derives a minimal timeline from events when the derived collections are
* empty, labeled with its source; the honest empty state remains only when
* both sources are empty.
*
* owner: RStack developed by Richardson Gunde
*/

import test from 'node:test';
import assert from 'node:assert/strict';

import { buildRunWorkspace } from '../src/observability/dashboard/state/run-workspace.js';
import { commandCenterScript } from '../src/observability/dashboard/ui/pages/command-center.js';
import { runWorkspaceScript } from '../src/observability/dashboard/ui/pages/run-workspace.js';

function baseRun(overrides = {}) {
return {
runId: 'run-fx-blocked',
projectRoot: '/tmp/p',
manifest: { goal: 'Blocked fixture', status: 'IN_PROGRESS' },
tasks: [{ id: '07-code', title: 'Code', status: 'BLOCKED', stage_id: '07-code' }],
events: [
{ ts: '2026-07-01T01:00:00.000Z', type: 'task_started', task_id: '07-code' },
{ ts: '2026-07-01T02:00:00.000Z', type: 'task_validated', task_id: '07-code', status: 'FAIL' },
{ ts: '2026-07-01T03:00:00.000Z', type: 'task_started', task_id: '07-code' },
{ ts: '2026-07-01T04:00:00.000Z', type: 'guardrail_triggered', task_id: '07-code' },
{ ts: '2026-07-01T04:00:00.000Z', type: 'approval_gate_blocked', task_id: '07-code' },
],
approvals: [],
...overrides,
};
}

test('a run with events but no derived timeline gets an events-sourced timeline (#496)', () => {
const workspace = buildRunWorkspace(baseRun());
const timeline = workspace.sections.timeline;
assert.equal(timeline.available, true, 'the timeline is available');
assert.equal(timeline.items.length, 5, 'every event becomes an entry');
assert.equal(timeline.source, 'events', 'and the section names its fallback source');
assert.equal(timeline.items[0].ts, '2026-07-01T01:00:00.000Z', 'entries stay time-ordered');
assert.equal(timeline.items[0].type, 'task_started');
assert.equal(timeline.items[0].task_id, '07-code');
});

test('a derived timeline keeps precedence — no event double-merge (#496)', () => {
const workspace = buildRunWorkspace(baseRun({
timeline: [{ ts: '2026-07-01T05:00:00.000Z', type: 'stage_completed', stage_id: '07-code' }],
}));
const timeline = workspace.sections.timeline;
assert.equal(timeline.available, true);
assert.equal(timeline.items.length, 1, 'derived entries only — events never merge in beside them');
assert.equal(timeline.source, 'persisted');
});

test('a run with neither derived timeline nor events keeps the honest empty state (#496)', () => {
const workspace = buildRunWorkspace(baseRun({ events: [] }));
const timeline = workspace.sections.timeline;
assert.equal(timeline.available, false, 'no invented timeline');
});

test('the workspace page renders the events-fallback source label (#496)', () => {
const bundle = commandCenterScript + runWorkspaceScript;
assert.match(bundle, /events\.jsonl/, 'the renderer names the fallback source in the timeline section');
});
Comment on lines +41 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Test the rendered source note and the cap boundary.

Line 70 matches events.jsonl in the explanatory comment in runWorkspaceScript. The test still passes if the section.source === 'events' branch is removed.

Run the renderer through the dashboard browser or DOM regression path. Assert that the note appears for source: 'events' and does not appear for source: 'persisted'. Add a case with 121 ordered events and assert that only the newest 120 entries remain.

As per coding guidelines, “Run regression tests against the real enforcement path where possible, including cross-process or browser behavior when those are the affected boundaries.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/run-workspace-timeline-496.test.js` around lines 41 - 71, The current
renderer test only matches the string "events.jsonl" in the bundle but does not
verify it appears in the rendered output when source is 'events' and omits it
when source is 'persisted'. Add a test that renders the workspace through the
DOM regression path to verify the source note actually displays for source
'events' but not for source 'persisted'. Additionally, add a test case with 121
ordered events to verify that only the newest 120 entries are retained after
capping, ensuring the boundary enforcement is tested through the real rendering
path rather than unit assertions alone.

Source: Coding guidelines

Loading