diff --git a/src/observability/alerts/engine.js b/src/observability/alerts/engine.js index a4d7238b..3701217a 100644 --- a/src/observability/alerts/engine.js +++ b/src/observability/alerts/engine.js @@ -9,6 +9,22 @@ export const DEFAULT_ALERT_THRESHOLDS = Object.freeze({ stalledRunMinutes: 30, // run with no events for this long }); +// #491: human-scale duration for alert copy — "45m", "1h 30m", "29d 11h", +// never an unbounded minute dump like "42431 min". The studio3d freshness +// chip (ui/studio3d/model.js formatSnapshotAge) mirrors this shape; keep the +// two in step if the format ever changes. +const MS_PER_MINUTE = 60_000; +const MINUTES_PER_HOUR = 60; +const HOURS_PER_DAY = 24; + +export function humanizeDurationMs(ms) { + const minutes = Math.floor(Math.max(0, Number(ms) || 0) / MS_PER_MINUTE); + if (minutes < MINUTES_PER_HOUR) return `${minutes}m`; + const hours = Math.floor(minutes / MINUTES_PER_HOUR); + if (hours < HOURS_PER_DAY) return `${hours}h ${minutes % MINUTES_PER_HOUR}m`; + return `${Math.floor(hours / HOURS_PER_DAY)}d ${hours % HOURS_PER_DAY}h`; +} + export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) { const alerts = []; const now = Date.now(); @@ -26,7 +42,7 @@ export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) { level: cost >= thresholds.costPerRunUsd * 2 ? 'critical' : 'warn', type: 'cost_threshold', title: 'Run cost threshold exceeded', - detail: `Run ${run.runId.slice(-12)} cost $${cost.toFixed(4)} (limit $${thresholds.costPerRunUsd})`, + detail: `Run ${run.runId} cost $${cost.toFixed(4)} (limit $${thresholds.costPerRunUsd})`, runId: run.runId, ts: now, }); @@ -67,7 +83,7 @@ export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) { level: 'warn', type: 'stalled_run', title: 'Run may be stalled', - detail: `No activity in ${run.runId.slice(-12)} for ${Math.round((now - lastTs) / 60000)} min`, + detail: `No activity in ${run.runId} for ${humanizeDurationMs(now - lastTs)}`, runId: run.runId, ts: now, }); diff --git a/src/observability/dashboard/state/overview.js b/src/observability/dashboard/state/overview.js index 238a9a21..aa65a153 100644 --- a/src/observability/dashboard/state/overview.js +++ b/src/observability/dashboard/state/overview.js @@ -135,12 +135,19 @@ export function buildOverviewProjection(state) { const run = focusRun(state.runs); const readiness = state.readiness ?? { status: 'unknown', coverage: {}, blockers: [] }; 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; return { focusRunId: run?.runId ?? null, goal: run?.manifest?.goal ?? run?.goal ?? null, outcome: readiness.status ?? 'unknown', - title: run ? (readiness.summary ?? 'Delivery outcome is not available.') : 'No delivery run has been evaluated.', + title, + // #493: the hero sub-line exists only when it adds information — with a + // focused run the title already IS the readiness summary, so echoing the + // summary as rationale rendered the same sentence twice. Server-owned: + // pages render `rationale`, they never re-derive it. + rationale: summary && summary !== title ? summary : null, nextAction: pipelineAction(run, { ...readiness, actions: state.actions ?? [] }), stages: run ? CANONICAL_SDLC_STAGES.map((stage) => stageView(run, stage)) : [], actionCount: state.actions diff --git a/src/observability/dashboard/ui/pages/command-center.js b/src/observability/dashboard/ui/pages/command-center.js index 881ce8e6..e3f57d6d 100644 --- a/src/observability/dashboard/ui/pages/command-center.js +++ b/src/observability/dashboard/ui/pages/command-center.js @@ -106,7 +106,13 @@ function renderOverviewDecisionSurface(s) { setClass('overview-state', 'overview-state ' + status); setText('overview-goal', overview.goal || (noRun ? 'No active run' : 'Goal unavailable')); setText('overview-outcome-title', title); - setText('overview-rationale', readiness.summary || title); + // #493: the rationale is server-owned and null when it would repeat the + // title — never re-derive it from readiness.summary (that fallback painted + // the same sentence twice). + var rationale = overview.rationale || ''; + setText('overview-rationale', rationale); + var rationaleEl = document.getElementById('overview-rationale'); + if (rationaleEl) rationaleEl.hidden = !rationale; setText('overview-coverage', overviewCoverageText(readiness.coverage || overview.coverage)); setText('overview-evaluated-at', overviewTime(overview.evaluatedAt || readiness.evaluatedAt)); setText('overview-action-count', String(overview.actionCount || 0)); diff --git a/src/observability/dashboard/ui/studio3d/model.js b/src/observability/dashboard/ui/studio3d/model.js index 5e350213..490fe9dd 100644 --- a/src/observability/dashboard/ui/studio3d/model.js +++ b/src/observability/dashboard/ui/studio3d/model.js @@ -33,13 +33,24 @@ export function statusLabel(value) { return status.charAt(0).toUpperCase() + status.slice(1); } +const MS_PER_MINUTE = 60_000; +const MINUTES_PER_HOUR = 60; +const HOURS_PER_DAY = 24; + export function formatSnapshotAge(freshness) { if (!freshness?.observed_at) return 'Snapshot time unavailable'; const age = Number(freshness.age_ms); if (!Number.isFinite(age)) return `Observed ${freshness.observed_at}`; if (age < 1_000) return 'Observed just now'; if (age < 60_000) return `Observed ${Math.floor(age / 1_000)}s ago`; - return `Observed ${Math.floor(age / 60_000)}m ago`; + // #491: hours and days tiers — a month-old snapshot must never render as + // an unbounded "42433m ago". Same shape as alerts/engine.js + // humanizeDurationMs; keep the two in step if the format ever changes. + const minutes = Math.floor(age / MS_PER_MINUTE); + if (minutes < MINUTES_PER_HOUR) return `Observed ${minutes}m ago`; + const hours = Math.floor(minutes / MINUTES_PER_HOUR); + if (hours < HOURS_PER_DAY) return `Observed ${hours}h ${minutes % MINUTES_PER_HOUR}m ago`; + return `Observed ${Math.floor(hours / HOURS_PER_DAY)}d ${hours % HOURS_PER_DAY}h ago`; } export function entityRef(kind, id) { diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js index 74cdd796..26d72290 100644 --- a/src/observability/dashboard/ui/styles.js +++ b/src/observability/dashboard/ui/styles.js @@ -17,6 +17,11 @@ export const styles = ` --ink: #111827; } * { box-sizing: border-box; } +/* #492: the hidden attribute must always win. Author display rules on a + class (e.g. .run-workspace-empty's display:grid) otherwise beat the UA's + [hidden] { display:none } and the element stays visible despite + el.hidden = true. Same global guard as studio3d/styles.css. */ +[hidden] { display: none !important; } html, body { margin: 0; height: 100%; } body { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; diff --git a/tests/browser/dashboard-492-workspace-empty.test.js b/tests/browser/dashboard-492-workspace-empty.test.js new file mode 100644 index 00000000..5d206ac9 --- /dev/null +++ b/tests/browser/dashboard-492-workspace-empty.test.js @@ -0,0 +1,136 @@ +/** + * #492 — with a run scoped, the Run Workspace "Select one run" empty prompt + * + * must actually disappear — the `hidden` attribute was defeated by the + * class's own `display:grid` (author styles beat the UA [hidden] rule). + * + * Same harness as dashboard-96: real server on an ephemeral port, canonical + * fixtures, real Chromium via playwright-core; skips cleanly with no browser. + * + * owner: RStack developed by Richardson Gunde + */ + +/* global document, window */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixtures.js'; + +const require = createRequire(import.meta.url); +const HERE = dirname(fileURLToPath(import.meta.url)); +const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', 'server.js'); + +let chromium = null; +try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ } + +// Named per the repo's magic-number convention (PR #490). +const SERVER_BOOT_TIMEOUT_MS = 15_000; +const STATE_LOAD_TIMEOUT_MS = 10_000; + +function startServer(root) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], { + cwd: root, + env: { + ...process.env, + RSTACK_APPROVAL_TOKEN: undefined, + RSTACK_DASHBOARD_READ_TOKEN: undefined, + RSTACK_TLS_CERT: undefined, + RSTACK_TLS_KEY: undefined, + RSTACK_BUSINESS_PORT: undefined, + RSTACK_PROJECT_ROOT: undefined, + RSTACK_NO_BROWSER: '1', + RSTACK_REGISTRY_DIR: join(root, '.registry'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let settled = false; + const timer = setTimeout(() => { + if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); } + }, SERVER_BOOT_TIMEOUT_MS); + child.stdout.on('data', (chunk) => { + out += chunk; + const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/); + if (match && !settled) { + settled = true; + clearTimeout(timer); + resolvePromise({ baseUrl: `http://127.0.0.1:${match[1]}`, stop: () => child.kill('SIGKILL') }); + } + }); + child.stderr.on('data', (chunk) => { out += chunk; }); + child.on('exit', () => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(new Error(`server exited\n${out}`)); } }); + }); +} + +let browser = null; +test.before(async () => { + if (!chromium) return; + try { browser = await chromium.launch({ headless: true }); } catch { browser = null; } +}); +test.after(async () => { if (browser) await browser.close(); }); + +function browserTest(name, seed, body) { + test(name, async (t) => { + if (!browser) { t.skip('Chromium not installed — run `npx playwright-core install chromium`'); return; } + const root = mkdtempSync(join(tmpdir(), 'rstack-browser-display-')); + let server = null; + const context = await browser.newContext(); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (err) => pageErrors.push(String(err))); + try { + await seed(root); + server = await startServer(root); + await body({ page, server, t }); + assert.deepEqual(pageErrors, [], 'no uncaught page errors'); + } finally { + await context.close(); + if (server) server.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); +} + +const seedRichProject = async (root) => { await fixtureReadyRun(root); await fixtureBlockedRun(root); }; + +async function gotoDashboard(page, server) { + await page.goto(server.baseUrl, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => window.STATE != null, null, { timeout: STATE_LOAD_TIMEOUT_MS }); +} + +browserTest('scoping a run hides the Run Workspace empty prompt for real (#492)', seedRichProject, async ({ page, server }) => { + await gotoDashboard(page, server); + await page.evaluate(() => { + const buttons = [...document.querySelectorAll('nav button[data-page="run-workspace"]')]; + (buttons.find((b) => b.offsetParent !== null) || buttons[0]).click(); + }); + // Scope to the first concrete run via the topbar select (the real user path). + await page.selectOption('#scope-run', { index: 1 }); + await page.waitForFunction(() => { + const content = document.getElementById('run-workspace-content'); + return content && !content.hidden; + }, null, { timeout: 5000 }); + + const visibility = await page.evaluate(() => { + const rect = (el) => el ? el.getBoundingClientRect() : null; + const empty = document.getElementById('run-workspace-empty'); + const emptyRect = rect(empty); + return { + emptyHiddenAttr: Boolean(empty && empty.hidden), + emptyVisiblyRendered: Boolean(emptyRect && emptyRect.height > 0 && emptyRect.width > 0), + contentVisible: Boolean(document.getElementById('run-workspace-content') && !document.getElementById('run-workspace-content').hidden), + }; + }); + assert.equal(visibility.contentVisible, true, 'the scoped workspace is open'); + assert.equal(visibility.emptyHiddenAttr, true, 'the empty prompt carries the hidden attribute'); + assert.equal(visibility.emptyVisiblyRendered, false, + 'the hidden empty prompt takes no space — [hidden] must not be defeated by the class display rule'); +}); diff --git a/tests/browser/dashboard-493-hero.test.js b/tests/browser/dashboard-493-hero.test.js new file mode 100644 index 00000000..10643a5a --- /dev/null +++ b/tests/browser/dashboard-493-hero.test.js @@ -0,0 +1,124 @@ +/** + * #493 — the Command Center hero must never render the same sentence as + * + * both headline and rationale. + * + * Same harness as dashboard-96: real server on an ephemeral port, canonical + * fixtures, real Chromium via playwright-core; skips cleanly with no browser. + * + * owner: RStack developed by Richardson Gunde + */ + +/* global document, window */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixtures.js'; + +const require = createRequire(import.meta.url); +const HERE = dirname(fileURLToPath(import.meta.url)); +const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', 'server.js'); + +let chromium = null; +try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ } + +// Named per the repo's magic-number convention (PR #490). +const SERVER_BOOT_TIMEOUT_MS = 15_000; +const STATE_LOAD_TIMEOUT_MS = 10_000; + +function startServer(root) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], { + cwd: root, + env: { + ...process.env, + RSTACK_APPROVAL_TOKEN: undefined, + RSTACK_DASHBOARD_READ_TOKEN: undefined, + RSTACK_TLS_CERT: undefined, + RSTACK_TLS_KEY: undefined, + RSTACK_BUSINESS_PORT: undefined, + RSTACK_PROJECT_ROOT: undefined, + RSTACK_NO_BROWSER: '1', + RSTACK_REGISTRY_DIR: join(root, '.registry'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let settled = false; + const timer = setTimeout(() => { + if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); } + }, SERVER_BOOT_TIMEOUT_MS); + child.stdout.on('data', (chunk) => { + out += chunk; + const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/); + if (match && !settled) { + settled = true; + clearTimeout(timer); + resolvePromise({ baseUrl: `http://127.0.0.1:${match[1]}`, stop: () => child.kill('SIGKILL') }); + } + }); + child.stderr.on('data', (chunk) => { out += chunk; }); + child.on('exit', () => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(new Error(`server exited\n${out}`)); } }); + }); +} + +let browser = null; +test.before(async () => { + if (!chromium) return; + try { browser = await chromium.launch({ headless: true }); } catch { browser = null; } +}); +test.after(async () => { if (browser) await browser.close(); }); + +function browserTest(name, seed, body) { + test(name, async (t) => { + if (!browser) { t.skip('Chromium not installed — run `npx playwright-core install chromium`'); return; } + const root = mkdtempSync(join(tmpdir(), 'rstack-browser-display-')); + let server = null; + const context = await browser.newContext(); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (err) => pageErrors.push(String(err))); + try { + await seed(root); + server = await startServer(root); + await body({ page, server, t }); + assert.deepEqual(pageErrors, [], 'no uncaught page errors'); + } finally { + await context.close(); + if (server) server.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); +} + +const seedRichProject = async (root) => { await fixtureReadyRun(root); await fixtureBlockedRun(root); }; + +async function gotoDashboard(page, server) { + await page.goto(server.baseUrl, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => window.STATE != null, null, { timeout: STATE_LOAD_TIMEOUT_MS }); +} + +browserTest('the Command Center hero never repeats its headline as the rationale (#493)', seedRichProject, async ({ page, server }) => { + await gotoDashboard(page, server); + const hero = await page.evaluate(() => { + const norm = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : ''); + const rationale = document.getElementById('overview-rationale'); + return { + title: norm(document.getElementById('overview-outcome-title')), + rationale: norm(rationale), + rationaleHidden: Boolean(rationale && rationale.hidden), + }; + }); + assert.ok(hero.title.length > 0, 'the hero has a headline'); + if (!hero.rationaleHidden && hero.rationale.length > 0) { + assert.notEqual(hero.rationale, hero.title, + 'a visible rationale must say something the headline does not'); + } +}); diff --git a/tests/hub-alerts-copy-491.test.js b/tests/hub-alerts-copy-491.test.js new file mode 100644 index 00000000..1d200022 --- /dev/null +++ b/tests/hub-alerts-copy-491.test.js @@ -0,0 +1,75 @@ +/** + * #491 — alert copy names the COMPLETE run id (never a slice(-12) mangle like + * "n-fx-blocked") and humanizes durations ("29d 11h", never "42431 min"). + * The studio3d freshness chip shares the same duration shape. + * + * owner: RStack developed by Richardson Gunde + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { evaluateAlerts } from '../src/observability/alerts/engine.js'; +import { formatSnapshotAge } from '../src/observability/dashboard/ui/studio3d/model.js'; + +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +// A realistic run id — longer than 12 chars, so any slice(-12) mangle shows. +const LONG_RUN_ID = '2026-07-01T00-00-00-000Z-billing-portal'; + +function stalledState(runId, stalledForMs) { + return { + runs: [{ + runId, + manifest: { goal: 'g' }, + events: [{ ts: new Date(Date.now() - stalledForMs).toISOString(), type: 'task_started' }], + }], + }; +} + +test('stalled-run alert names the complete run id and humanizes a multi-day stall (#491)', () => { + const alerts = evaluateAlerts(stalledState(LONG_RUN_ID, 29 * DAY + 11 * HOUR)); + const stalled = alerts.find((alert) => alert.type === 'stalled_run'); + assert.ok(stalled, 'a stalled_run alert fires'); + assert.ok(stalled.detail.includes(LONG_RUN_ID), + `detail names the complete run id — got: ${stalled.detail}`); + assert.doesNotMatch(stalled.detail, /\d{3,} ?min\b/, + 'no raw minute dumps like "42431 min"'); + assert.match(stalled.detail, /\b29d \d+h\b/, 'multi-day stalls read as days + hours'); +}); + +test('stalled-run alert humanizes an hours-scale stall (#491)', () => { + const alerts = evaluateAlerts(stalledState(LONG_RUN_ID, 90 * MIN)); + const stalled = alerts.find((alert) => alert.type === 'stalled_run'); + assert.ok(stalled, 'a stalled_run alert fires'); + assert.match(stalled.detail, /\b1h 30m\b/, '90 minutes reads as 1h 30m'); +}); + +test('stalled-run alert keeps plain minutes below an hour (#491)', () => { + const alerts = evaluateAlerts(stalledState(LONG_RUN_ID, 45 * MIN)); + const stalled = alerts.find((alert) => alert.type === 'stalled_run'); + assert.ok(stalled, 'a stalled_run alert fires'); + assert.match(stalled.detail, /\b45m\b/, 'sub-hour stalls read as minutes'); +}); + +test('cost alert names the complete run id — the second slice(-12) call site (#491)', () => { + const alerts = evaluateAlerts({ + runs: [{ runId: LONG_RUN_ID, manifest: {}, metrics: { cumulative_cost_usd: 9.99 }, events: [] }], + }); + const cost = alerts.find((alert) => alert.type === 'cost_threshold'); + assert.ok(cost, 'a cost alert fires'); + assert.ok(cost.detail.includes(LONG_RUN_ID), + `cost detail names the complete run id — got: ${cost.detail}`); +}); + +test('studio3d snapshot age humanizes hours and days, never unbounded minutes (#491)', () => { + const at = '2026-07-01T00:00:00.000Z'; + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 45 * 1000 }), 'Observed 45s ago'); + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 45 * MIN }), 'Observed 45m ago'); + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 90 * MIN }), 'Observed 1h 30m ago'); + assert.equal(formatSnapshotAge({ observed_at: at, age_ms: 29 * DAY + 11 * HOUR }), 'Observed 29d 11h ago'); + assert.doesNotMatch(formatSnapshotAge({ observed_at: at, age_ms: 42_433 * MIN }), /\d{3,}m ago/, + 'no unbounded minute chips like "42433m ago"'); +}); diff --git a/tests/hub-overview-rationale-493.test.js b/tests/hub-overview-rationale-493.test.js new file mode 100644 index 00000000..a3b8eb79 --- /dev/null +++ b/tests/hub-overview-rationale-493.test.js @@ -0,0 +1,41 @@ +/** + * #493 — the overview projection owns a `rationale` that is null whenever it + * would merely repeat the title, so the Command Center hero can never render + * the same sentence twice. + * + * 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'; + +const READY_SUMMARY = 'Release is blocked by 6 source-backed conditions.'; + +test('overview rationale is null when it would repeat the title (#493)', () => { + const projection = buildOverviewProjection({ + runs: [{ runId: 'run-1', manifest: { goal: 'g' } }], + readiness: { status: 'blocked', summary: READY_SUMMARY, blockers: [], coverage: {} }, + }); + assert.equal(projection.title, READY_SUMMARY, 'title carries the readiness summary'); + assert.equal(projection.rationale, null, 'rationale must not duplicate the title'); +}); + +test('overview rationale keeps a summary that genuinely adds information (#493)', () => { + const projection = buildOverviewProjection({ + runs: [], + readiness: { status: 'unknown', summary: 'No governed run has produced evidence yet.', blockers: [], coverage: {} }, + }); + assert.equal(projection.title, 'No delivery run has been evaluated.'); + assert.equal(projection.rationale, 'No governed run has produced evidence yet.', + 'a distinct summary stays as the rationale'); +}); + +test('overview rationale is null when readiness has no summary (#493)', () => { + const projection = buildOverviewProjection({ + runs: [{ runId: 'run-1', manifest: { goal: 'g' } }], + readiness: { status: 'unknown', blockers: [], coverage: {} }, + }); + assert.equal(projection.rationale, null, 'no invented rationale'); +});