From 2f1621a9b13d87e818839cf919ea227c7600b2ad Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Tue, 2 Jun 2026 22:34:32 +0530 Subject: [PATCH 1/4] feat(observability): stage-report collector + /api/run-report endpoint (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state/stage-reports.js: parse the 15 canonical stage artifacts + key deliverables (release-readiness, summary), size-capped, malformed-tolerant - GET /api/run-report?run=: one sandboxed call returns all parsed reports (reuses the run-dir resolver; traversal → 404) - runs carry a stageReports index; shipped in client state - 3 tests: parsing, index, malformed-JSON tolerance Co-Authored-By: Claude Opus 4.8 (1M context) --- src/observability/dashboard/server.js | 40 ++++++++-- .../dashboard/state/client-state.js | 1 + src/observability/dashboard/state/runs.js | 2 + .../dashboard/state/stage-reports.js | 74 +++++++++++++++++++ tests/stage-reports.test.js | 64 ++++++++++++++++ 5 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 src/observability/dashboard/state/stage-reports.js create mode 100644 tests/stage-reports.test.js diff --git a/src/observability/dashboard/server.js b/src/observability/dashboard/server.js index 17ac40b..2b4e9a6 100644 --- a/src/observability/dashboard/server.js +++ b/src/observability/dashboard/server.js @@ -8,6 +8,7 @@ import { dashboardHtml } from './ui.js'; import { studio3dHtml } from './ui/studio3d.js'; import { buildFullState, resolveDashboardApproval, toClientState } from './state/index.js'; import { sourceRoots } from './state/roots.js'; +import { collectStageReports } from './state/stage-reports.js'; // owner: RStack developed by Richardson Gunde @@ -184,6 +185,33 @@ async function handleApproval(req, res, decision) { const ARTIFACT_MAX_BYTES = 512 * 1024; const ARTIFACT_EXTENSIONS = new Set(['.md', '.json', '.jsonl', '.txt', '.yml', '.yaml']); +// Locate a run directory by id across the known project roots, rejecting any +// id that could traverse the filesystem. Returns null if not found / unsafe. +async function resolveRunDir(runId) { + if (!runId || runId.includes('/') || runId.includes('..') || runId.includes('\\')) return null; + const roots = await sourceRoots(PROJECT_ROOT, {}); + return roots + .map((root) => join(root, '.rstack', 'runs', runId)) + .find((dir) => existsSync(dir)) ?? null; +} + +async function handleRunReport(url, res) { + const sendJson = (status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + try { + const runId = url.searchParams.get('run') ?? ''; + if (!runId) return sendJson(400, { error: 'run is required' }); + const runDir = await resolveRunDir(runId); + if (!runDir) return sendJson(404, { error: 'run not found' }); + const { stages, deliverables } = await collectStageReports(runDir); + sendJson(200, { run: runId, stages, deliverables }); + } catch (err) { + sendJson(500, { error: String(err?.message) }); + } +} + async function handleArtifact(url, res) { const sendJson = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); @@ -193,12 +221,7 @@ async function handleArtifact(url, res) { const runId = url.searchParams.get('run') ?? ''; const relPath = url.searchParams.get('path') ?? ''; if (!runId || !relPath) return sendJson(400, { error: 'run and path are required' }); - if (runId.includes('/') || runId.includes('..')) return sendJson(400, { error: 'invalid run id' }); - - const roots = await sourceRoots(PROJECT_ROOT, {}); - const runDir = roots - .map((root) => join(root, '.rstack', 'runs', runId)) - .find((dir) => existsSync(dir)); + const runDir = await resolveRunDir(runId); if (!runDir) return sendJson(404, { error: 'run not found' }); const target = resolve(runDir, relPath); @@ -273,6 +296,11 @@ const server = createServer(async (req, res) => { return; } + if (url.pathname === '/api/run-report' && req.method === 'GET') { + await handleRunReport(url, res); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(dashboardHtml(PORT)); }); diff --git a/src/observability/dashboard/state/client-state.js b/src/observability/dashboard/state/client-state.js index ffa3882..8a3aee2 100644 --- a/src/observability/dashboard/state/client-state.js +++ b/src/observability/dashboard/state/client-state.js @@ -11,6 +11,7 @@ export function toClientState(state) { ts: entry.ts, task_id: entry.task_id, kind: entry.kind, status: entry.status, evidence: entry.evidence, })), artifactIndex: (run.artifactIndex ?? []).slice(0, 80), + stageReports: run.stageReports ?? [], timeline: (run.timeline ?? []).slice(0, 120), totals: run.totals ?? null, stageElapsed: run.stageElapsed ?? {}, diff --git a/src/observability/dashboard/state/runs.js b/src/observability/dashboard/state/runs.js index 0779429..8b23677 100644 --- a/src/observability/dashboard/state/runs.js +++ b/src/observability/dashboard/state/runs.js @@ -3,6 +3,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { CANONICAL_SDLC_STAGES } from '../../../core/harness/stages.js'; import { deriveRunTimeline, deriveRunTotals, deriveStageElapsed } from '../../metrics/derive.js'; +import { stageReportIndex } from './stage-reports.js'; import { readJson, readJsonlSync } from './files.js'; // owner: RStack developed by Richardson Gunde @@ -167,6 +168,7 @@ export async function getRunsForRoot(projectRoot) { evidence, approvals: Array.isArray(runApprovals) ? runApprovals : [], artifactIndex: await indexArtifacts(runDir), + stageReports: await stageReportIndex(runDir), activityTimeline: buildActivityTimeline(events), timeline: deriveRunTimeline(events, rawTasks), totals: deriveRunTotals(events), diff --git a/src/observability/dashboard/state/stage-reports.js b/src/observability/dashboard/state/stage-reports.js new file mode 100644 index 0000000..499fa93 --- /dev/null +++ b/src/observability/dashboard/state/stage-reports.js @@ -0,0 +1,74 @@ +/** + * Stage-report collector — reads the structured deliverables a run produced + * (the 15 canonical stage artifacts + key top-level reports) and returns them + * parsed, so the UI can render them as infographics. + * + * Everything here is read-only and size-capped. Path safety is the caller's + * responsibility (server validates the runId + containment before calling). + * + * owner: RStack developed by Richardson Gunde + */ + +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const MAX_REPORT_BYTES = 256 * 1024; + +// stage id → the artifact filename it writes under artifacts/stages//. +export const STAGE_ARTIFACTS = Object.freeze({ + '00-environment': 'environment_report.json', + '01-transcript': 'transcript.json', + '02-requirements': 'requirements.json', + '03-documentation': 'documentation.json', + '04-planning': 'plan.json', + '05-jira': 'jira_tickets.json', + '06-architecture': 'system_design.json', + '07-code': 'code_report.json', + '08-testing': 'test_report.json', + '09-deployment': 'deployment_report.json', + '10-summary': 'summary.json', + '11-feedback-loop': 'feedback.json', + '12-security-threat-model': 'threat_model.json', + '13-compliance-checker': 'compliance_report.json', + '14-cost-estimation': 'cost_estimate.json', +}); + +// Top-level cross-stage deliverables worth surfacing on their own. +const DELIVERABLES = Object.freeze({ + 'release-readiness': 'artifacts/release-readiness.json', + summary: 'artifacts/stages/10-summary/summary.json', +}); + +async function readCappedJson(path) { + if (!existsSync(path)) return null; + try { + const raw = await readFile(path, 'utf8'); + if (raw.length > MAX_REPORT_BYTES) return { _truncated: true, _bytes: raw.length }; + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Collect parsed stage reports for one run. + * Returns { stages: { [stageId]: data|null }, deliverables: { [key]: data|null } }. + */ +export async function collectStageReports(runDir) { + const stages = {}; + await Promise.all(Object.entries(STAGE_ARTIFACTS).map(async ([stageId, file]) => { + stages[stageId] = await readCappedJson(join(runDir, 'artifacts', 'stages', stageId, file)); + })); + const deliverables = {}; + await Promise.all(Object.entries(DELIVERABLES).map(async ([key, rel]) => { + deliverables[key] = await readCappedJson(join(runDir, rel)); + })); + return { stages, deliverables }; +} + +/** Which stage ids actually produced a report (for snapshot indexing). */ +export async function stageReportIndex(runDir) { + return Object.keys(STAGE_ARTIFACTS).filter((stageId) => + existsSync(join(runDir, 'artifacts', 'stages', stageId, STAGE_ARTIFACTS[stageId]))); +} diff --git a/tests/stage-reports.test.js b/tests/stage-reports.test.js new file mode 100644 index 0000000..9b03d06 --- /dev/null +++ b/tests/stage-reports.test.js @@ -0,0 +1,64 @@ +/** + * Tests for the stage-report collector (#60) — parses the structured + * deliverables a run produced for the Run Report page + Studio 3D panels. + * + * owner: RStack developed by Richardson Gunde + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { collectStageReports, stageReportIndex, STAGE_ARTIFACTS } from '../src/observability/dashboard/state/stage-reports.js'; + +function seedRun() { + const runDir = mkdtempSync(join(tmpdir(), 'rstack-stagerep-')); + const stagesDir = join(runDir, 'artifacts', 'stages'); + const write = (stage, file, obj) => { + mkdirSync(join(stagesDir, stage), { recursive: true }); + writeFileSync(join(stagesDir, stage, file), JSON.stringify(obj)); + }; + write('12-security-threat-model', 'threat_model.json', { + status: 'FAIL_HIGH_RISKS_FOUND', + threats: [{ severity: 'HIGH' }, { severity: 'HIGH' }, { severity: 'MEDIUM' }, { severity: 'LOW' }], + release_gate: { ready: false, reason: 'fix highs' }, + }); + write('13-compliance-checker', 'compliance_report.json', { overall_score: 63, release_gate: { ready: false, blockers: ['SOC2-CC6.1'] } }); + write('02-requirements', 'requirements.json', { functional: [1, 2, 3], user_stories: [1] }); + return runDir; +} + +test('collectStageReports parses present stage artifacts, null for missing', async () => { + const runDir = seedRun(); + const { stages, deliverables } = await collectStageReports(runDir); + + assert.equal(stages['12-security-threat-model'].threats.length, 4); + assert.equal(stages['13-compliance-checker'].overall_score, 63); + assert.equal(stages['02-requirements'].functional.length, 3); + // Stages with no artifact are null, not errors. + assert.equal(stages['07-code'], null); + assert.equal(stages['14-cost-estimation'], null); + // Every canonical stage key is present in the result. + assert.deepEqual(Object.keys(stages).sort(), Object.keys(STAGE_ARTIFACTS).sort()); + // Deliverables resolve (summary maps to the stage path). + assert.ok('release-readiness' in deliverables && 'summary' in deliverables); + + rmSync(runDir, { recursive: true, force: true }); +}); + +test('stageReportIndex lists only stages that produced a report', async () => { + const runDir = seedRun(); + const index = await stageReportIndex(runDir); + assert.deepEqual(index.sort(), ['02-requirements', '12-security-threat-model', '13-compliance-checker']); + rmSync(runDir, { recursive: true, force: true }); +}); + +test('collectStageReports tolerates malformed JSON without throwing', async () => { + const runDir = mkdtempSync(join(tmpdir(), 'rstack-stagerep-bad-')); + mkdirSync(join(runDir, 'artifacts', 'stages', '08-testing'), { recursive: true }); + writeFileSync(join(runDir, 'artifacts', 'stages', '08-testing', 'test_report.json'), '{ not valid json'); + const { stages } = await collectStageReports(runDir); + assert.equal(stages['08-testing'], null, 'malformed JSON → null, not a crash'); + rmSync(runDir, { recursive: true, force: true }); +}); From 05f6261a4c1227952b0434620f18c7d67d9f221a Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Tue, 2 Jun 2026 22:34:32 +0530 Subject: [PATCH 2/4] =?UTF-8?q?feat(dashboard):=20Run=20Report=20page=20?= =?UTF-8?q?=E2=80=94=2015=20stage=20reports=20as=20infographic=20cards=20(?= =?UTF-8?q?#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Observe page: run selector, KPI flashcard row, infographic grid - Shared card renderers: threat-severity donut, compliance/consistency gauge, test pass/fail bars, cost flashcard, requirements/planning/architecture stat-chips, release-gate badges, mini-lists - Polished motion: count-up numbers, donut/gauge fill from zero, bar fill, card fade-in + hover lift, blocked-gate pulse — all CSS/SVG, zero deps Co-Authored-By: Claude Opus 4.8 (1M context) --- src/observability/dashboard/ui/client.js | 260 ++++++++++++++++++ src/observability/dashboard/ui/pages/index.js | 13 + src/observability/dashboard/ui/styles.js | 71 +++++ 3 files changed, 344 insertions(+) diff --git a/src/observability/dashboard/ui/client.js b/src/observability/dashboard/ui/client.js index 79ea65f..8b631be 100644 --- a/src/observability/dashboard/ui/client.js +++ b/src/observability/dashboard/ui/client.js @@ -152,6 +152,7 @@ var PAGE_SUBS = { 'run-analytics': 'Wall-clock run timelines, per-stage durations and run-over-run delivery trends derived from events.jsonl.', team: 'Who is live and working right now, the people behind every run, approval and guidance, and the manager project rollup.', studio: 'The live agent studio — every stage as a workstation, the Manager narrating progress, status as glow. Click an agent for their report.', + 'run-report': 'Every stage report as an infographic — requirements, architecture, tests, security, compliance, cost, release gate — for the selected run.', 'agent-work': 'Builder and validator work grouped by project, run, stage and agent contract.', 'live-feed': 'Real-time event stream from events.jsonl plus live WebSocket refreshes.', approvals: 'Human-in-loop actions from the approval queue only.', @@ -188,6 +189,7 @@ function applyState(state) { try { renderWorkflow(scoped); } catch (err) { showErr('workflow: ' + err.message); } try { renderProjects(scoped); } catch (err) { showErr('projects: ' + err.message); } try { renderRunAnalytics(scoped); } catch (err) { showErr('run analytics: ' + err.message); } + try { renderRunReport(scoped); } catch (err) { showErr('run report: ' + err.message); } try { renderTeam(scoped); } catch (err) { showErr('team: ' + err.message); } try { renderAgentWork(scoped); } catch (err) { showErr('agent work: ' + err.message); } try { renderLiveFeed(scoped); } catch (err) { showErr('live feed: ' + err.message); } @@ -1061,6 +1063,264 @@ function closeStudioInspector() { if (panel) panel.style.display = 'none'; } +// ── Stage report infographics (issue #60) — shared by Run Report + Studio 3D ─ +var REPORT_CACHE = {}; // runId → { stages, deliverables } +var REPORT_RUN_ID = null; + +var STAGE_CARD_META = { + '00-environment': { icon: '🧰', title: 'Environment', persona: 'DevOps' }, + '01-transcript': { icon: '🎙', title: 'Transcript', persona: 'Business Analyst' }, + '02-requirements': { icon: '📋', title: 'Requirements', persona: 'Product Manager' }, + '03-documentation': { icon: '📝', title: 'Documentation', persona: 'Technical Writer' }, + '04-planning': { icon: '🗺', title: 'Planning', persona: 'Delivery Manager' }, + '05-jira': { icon: '🎫', title: 'Tickets', persona: 'Scrum Master' }, + '06-architecture': { icon: '🏛', title: 'Architecture', persona: 'Solution Architect' }, + '07-code': { icon: '⚙️', title: 'Code', persona: 'Senior Developer' }, + '08-testing': { icon: '🧪', title: 'Testing', persona: 'QA Engineer' }, + '09-deployment': { icon: '🚀', title: 'Deployment', persona: 'Release Engineer' }, + '10-summary': { icon: '📊', title: 'Summary', persona: 'Program Manager' }, + '11-feedback-loop': { icon: '🔄', title: 'Feedback Loop', persona: 'Quality Coach' }, + '12-security-threat-model': { icon: '🛡', title: 'Security', persona: 'Security Engineer' }, + '13-compliance-checker': { icon: '⚖️', title: 'Compliance', persona: 'Compliance Officer' }, + '14-cost-estimation': { icon: '💰', title: 'Cost', persona: 'FinOps Analyst' }, +}; +var STAGE_CARD_ORDER = Object.keys(STAGE_CARD_META); + +function fetchRunReport(runId) { + if (REPORT_CACHE[runId]) return Promise.resolve(REPORT_CACHE[runId]); + return fetch('/api/run-report?run=' + encodeURIComponent(runId)) + .then(function(r) { return r.json(); }) + .then(function(data) { if (!data.error) REPORT_CACHE[runId] = data; return data; }); +} + +function svgDonut(segments) { + // Arcs start collapsed (dashoffset = full) and fill in when animateReport + // sets each arc's data-dashoffset → triggers the CSS transition. + var total = segments.reduce(function(s, x) { return s + x.value; }, 0) || 1; + var R = 34, C = 2 * Math.PI * R, off = 0; + var arcs = segments.filter(function(s) { return s.value > 0; }).map(function(s) { + var len = (s.value / total) * C; + var seg = ''; + off += len; return seg; + }).join(''); + return '' + + '' + + arcs + '' + total + ''; +} + +function svgGauge(score, color) { + var pct = Math.max(0, Math.min(100, Number(score) || 0)); + var R = 34, C = Math.PI * R; + var fill = (pct / 100) * C; + // Starts empty (dasharray 0) and fills to target via animateReport. + return '' + + '' + + '' + + '' + pct + ''; +} + +function statChips(items) { + return '
' + items.map(function(it) { + return '
' + (it.n || 0) + '' + esc(it.l) + '
'; + }).join('') + '
'; +} + +function gateBadge(gate) { + if (!gate) return ''; + var ready = gate.ready === true; + var reason = gate.reason || (gate.blockers ? gate.blockers.join(', ') : ''); + return '
' + + '' + (ready ? 'Release gate: READY' : 'Release gate: BLOCKED') + + (reason ? '
' + esc(String(reason).slice(0, 160)) + '
' : '') + '
'; +} + +function miniList(title, arr, fmt) { + if (!arr || !arr.length) return ''; + return '
' + esc(title) + '
' + + arr.slice(0, 5).map(function(x) { return '
' + esc((fmt ? fmt(x) : x)).slice(0, 120) + '
'; }).join('') + '
'; +} + +function scoreColor(score) { + var s = Number(score) || 0; + return s >= 80 ? '#16a34a' : s >= 50 ? '#d97706' : '#dc2626'; +} + +function stageBody(stageId, d) { + if (!d) return '
No report produced for this stage.
'; + if (d._truncated) return '
Report too large to inline (' + Math.ceil(d._bytes / 1024) + ' KB).
'; + switch (stageId) { + case '02-requirements': + return statChips([ + { n: (d.functional || []).length, l: 'functional' }, + { n: (d.non_functional || []).length, l: 'non-functional' }, + { n: (d.user_stories || []).length, l: 'user stories' }, + { n: (d.out_of_scope || []).length, l: 'out of scope' }, + ]) + miniList('Functional', d.functional, function(r) { return (r.id ? r.id + ' — ' : '') + (r.description || r.area || ''); }); + case '04-planning': + return statChips([ + { n: (d.milestones || []).length, l: 'milestones' }, + { n: (d.tasks || []).length, l: 'tasks' }, + { n: (d.risks || []).length, l: 'risks' }, + ]) + miniList('Milestones', d.milestones, function(m) { return (m.name || m.id) + (m.target ? ' · ' + m.target : ''); }); + case '06-architecture': + var routes = (d.live_api_evidence && d.live_api_evidence.routes) || []; + return statChips([ + { n: (d.components || []).length, l: 'components' }, + { n: routes.length, l: 'API routes' }, + { n: (d.trade_offs || []).length, l: 'trade-offs' }, + ]) + miniList('Components', d.components, function(c) { return c.name + (c.responsibility ? ' — ' + c.responsibility : ''); }); + case '07-code': + return statChips([ + { n: (d.files_modified || []).length, l: 'files changed' }, + { n: (d.verification || []).length, l: 'verifications' }, + { n: (d.known_concerns || []).length, l: 'concerns' }, + ]) + miniList('Files', d.files_modified); + case '08-testing': { + var res = d.results || {}; + var passed = 0, failed = 0; + Object.keys(res).forEach(function(k) { if (res[k] && typeof res[k] === 'object') { passed += Number(res[k].passed) || 0; failed += Number(res[k].failed) || 0; } }); + var tot = passed + failed || 1; + return '
passed
' + passed + '
' + + '
failed
' + failed + '
' + + miniList('Coverage gaps', d.coverage_gaps); + } + case '09-deployment': + return '
Status' + esc(d.status || '-') + '
' + miniList('Blockers', d.blockers || d.release_constraints); + case '10-summary': + return statChips([ + { n: (d.open_risks || []).length, l: 'open risks' }, + { n: (d.not_built_or_not_done || []).length, l: 'not done' }, + { n: (d.next_steps || []).length, l: 'next steps' }, + ]) + gateBadge(d.release_gate) + miniList('Open risks', d.open_risks, function(r) { return (r.severity ? '[' + r.severity + '] ' : '') + (r.summary || r.id || ''); }); + case '11-feedback-loop': + return '
' + svgGauge(d.consistency_score, scoreColor(d.consistency_score)) + 'consistency
' + + miniList('Findings', d.traceability_findings, function(f) { return (f.requirement || '') + ' — ' + (f.status || ''); }); + case '12-security-threat-model': { + var th = d.threats || []; + var by = { HIGH: 0, MEDIUM: 0, LOW: 0 }; + th.forEach(function(t) { var sv = String(t.severity || '').toUpperCase(); if (by[sv] != null) by[sv]++; }); + return '
' + svgDonut([ + { value: by.HIGH, color: '#dc2626' }, { value: by.MEDIUM, color: '#d97706' }, { value: by.LOW, color: '#16a34a' }, + ]) + '
' + by.HIGH + ' high' + by.MEDIUM + ' med' + by.LOW + ' low
' + + gateBadge(d.release_gate); + } + case '13-compliance-checker': { + var blocked = (d.controls || []).filter(function(c) { return c.status && c.status !== 'PASS' && c.status !== 'MET'; }); + return '
' + svgGauge(d.overall_score, scoreColor(d.overall_score)) + '/ 100
' + + gateBadge(d.release_gate) + miniList('Action needed', blocked, function(c) { return (c.id || c.name) + ' — ' + (c.required_action || c.status || ''); }); + } + case '14-cost-estimation': + return '
$' + (Number(d.monthly_cost_usd) || 0) + '/ month
' + + miniList('Cost drivers', d.cost_drivers) + (d.recommendation ? '
' + esc(String(d.recommendation).slice(0, 180)) + '
' : ''); + case '00-environment': { + var tools = d.tools || {}; + var names = Object.keys(tools); + var avail = names.filter(function(n) { return tools[n] && tools[n].available; }).length; + return statChips([{ n: avail, l: 'tools ready' }, { n: names.length, l: 'detected' }]) + + '
Pipeline ready' + (d.pipeline_ready ? 'Yes' : 'No') + '
'; + } + case '01-transcript': + return statChips([ + { n: (d.goals || []).length, l: 'goals' }, + { n: (d.stakeholders || []).length, l: 'stakeholders' }, + { n: (d.open_questions || []).length, l: 'open questions' }, + ]) + miniList('Goals', d.goals); + case '03-documentation': + return statChips([{ n: (d.documents_written || []).length, l: 'docs written' }, { n: (d.known_limitations_documented || []).length, l: 'limitations' }]) + + miniList('Documents', d.documents_written); + case '05-jira': + return statChips([{ n: (d.epics || []).length, l: 'epics' }, { n: (d.issues || []).length, l: 'issues' }]) + + miniList('Epics', d.epics, function(e) { return e.title || e.id; }); + default: + return '
' + esc(JSON.stringify(d).slice(0, 200)) + '…
'; + } +} + +function stageCardHtml(stageId, d, compact) { + var meta = STAGE_CARD_META[stageId] || { icon: '•', title: stageId, persona: '' }; + var status = d && d.status ? d.status : ''; + var statusCls = /FAIL|BLOCK|NOT_/.test(status) ? 'fail' : /CONCERN|PARTIAL|WARN/.test(status) ? 'warn' : d ? 'pass' : 'idle'; + return '
' + + '
' + meta.icon + '' + + '
' + esc(meta.title) + '
' + esc(stageId) + '
' + + (status ? '' + esc(String(status).replace(/_/g, ' ')) + '' : '') + '
' + + '
' + stageBody(stageId, d) + '
'; +} + +function animateReport(container) { + if (!container) return; + requestAnimationFrame(function() { + container.classList.add('report-animate'); + // Fill donut arcs and gauges from their collapsed start to the target. + Array.prototype.forEach.call(container.querySelectorAll('.donut-arc[data-dashoffset]'), function(arc) { + arc.setAttribute('stroke-dashoffset', arc.getAttribute('data-dashoffset')); + }); + Array.prototype.forEach.call(container.querySelectorAll('.gauge-fill[data-dash]'), function(g) { + g.setAttribute('stroke-dasharray', g.getAttribute('data-dash')); + }); + }); + Array.prototype.forEach.call(container.querySelectorAll('[data-count]'), function(el) { + var target = Number(el.getAttribute('data-count')) || 0; + if (target <= 0) return; + var isMoney = el.textContent.indexOf('$') === 0; + var start = null, dur = 700; + function step(ts) { + if (start === null) start = ts; + var p = Math.min(1, (ts - start) / dur); + var val = Math.round(target * (0.5 - Math.cos(p * Math.PI) / 2)); + el.textContent = (isMoney ? '$' : '') + val; + if (p < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + }); +} + +function renderRunReport(s) { + var runs = s.runs || []; + var select = document.getElementById('report-run-select'); + if (!select) return; + if (!REPORT_RUN_ID || !runs.some(function(r) { return r.runId === REPORT_RUN_ID; })) { + REPORT_RUN_ID = runs.length ? runs[0].runId : null; + } + select.innerHTML = runs.map(function(run) { + var label = ((run.manifest && run.manifest.goal) || run.runId).slice(0, 70); + return ''; + }).join(''); + if (REPORT_RUN_ID) loadRunReport(REPORT_RUN_ID); +} + +function loadRunReport(runId) { + REPORT_RUN_ID = runId; + var run = ((STATE && STATE.runs) || []).filter(function(r) { return r.runId === runId; })[0]; + var grid = document.getElementById('report-grid'); + var kpis = document.getElementById('report-kpis'); + if (!grid || !run) return; + var totals = run.totals || {}; + var produced = run.stageReports || []; + kpis.innerHTML = + reportKpi('Status', (run.manifest && run.manifest.status) || '-', 'blue') + + reportKpi('Stages reported', produced.length + '/15', 'blue') + + reportKpi('Tasks passed', (totals.tasks_passed || 0) + '/' + ((totals.tasks_passed || 0) + (totals.tasks_failed || 0)), 'green') + + reportKpi('Quality', totals.quality_avg != null ? Math.round(totals.quality_avg * 100) + '%' : '—', 'amber') + + reportKpi('Duration', fmtDur(totals.duration_ms), 'blue'); + grid.innerHTML = '
Loading run report…
'; + fetchRunReport(runId).then(function(report) { + if (!report || report.error) { grid.innerHTML = emptyHtml('No report', report && report.error); return; } + grid.innerHTML = STAGE_CARD_ORDER.map(function(stageId) { + return stageCardHtml(stageId, report.stages[stageId], false); + }).join(''); + animateReport(grid); + }); +} + +function reportKpi(label, value, tone) { + return '
' + esc(String(value)) + '
' + esc(label) + '
'; +} + var ANALYTICS_RUN_ID = null; function renderRunAnalytics(s) { diff --git a/src/observability/dashboard/ui/pages/index.js b/src/observability/dashboard/ui/pages/index.js index e939228..ca6b9a9 100644 --- a/src/observability/dashboard/ui/pages/index.js +++ b/src/observability/dashboard/ui/pages/index.js @@ -5,6 +5,7 @@ export const pages = [ ['studio', '12', 'Studio', 'Observe'], ['workflow', '01', 'Workflow Map', 'Observe'], ['projects', '02', 'Projects & Runs', 'Observe'], + ['run-report', '13', 'Run Report', 'Observe'], ['run-analytics', '10', 'Run Analytics', 'Observe'], ['agent-work', '03', 'Agent Work', 'Observe'], ['live-feed', '04', 'Live Feed', 'Observe'], @@ -141,6 +142,18 @@ function pageBody(id) { `, projects: '
Known Projects
Run Sessions
StatusRunProjectTasksDurationCost
', + 'run-report': ` +
+
+ Run Report + +
+
+
+
+
+
+ `, 'run-analytics': `
diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js index 0bb3583..dffbd35 100644 --- a/src/observability/dashboard/ui/styles.js +++ b/src/observability/dashboard/ui/styles.js @@ -1117,4 +1117,75 @@ tr.clickable:hover td { background: #f8fbff; } } .evidence-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 12px; } .evidence-row:last-child { border-bottom: none; } + +/* ── Run Report: infographic stage cards (issue #60) ──────────────────────── */ +.report-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin-bottom: 16px; } +.report-kpi { background: var(--soft); border: 1px solid var(--line); border-top: 3px solid var(--line-strong); border-radius: 10px; padding: 12px 14px; } +.report-kpi.blue { border-top-color: var(--blue); } .report-kpi.green { border-top-color: var(--green); } .report-kpi.amber { border-top-color: var(--amber); } +.report-kpi-v { font-size: 22px; font-weight: 800; } +.report-kpi-l { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; } + +.report-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; } +.stage-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; + border-top: 3px solid var(--line-strong); opacity: 0; transform: translateY(8px); + transition: opacity 0.4s ease, transform 0.4s ease, box-shadow 0.15s ease; } +.report-animate .stage-card, .studio-stage-card.report-animate { opacity: 1; transform: none; } +.stage-card:hover { box-shadow: 0 8px 22px rgba(16,24,40,0.09); transform: translateY(-2px); } +.stage-card.pass { border-top-color: var(--green); } +.stage-card.warn { border-top-color: var(--amber); } +.stage-card.fail { border-top-color: var(--red); } +.stage-card.idle { border-top-color: var(--line); opacity: 0.55; } +.stage-card-h { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.stage-card-icon { font-size: 20px; } +.stage-card-title { font-weight: 700; font-size: 14px; } +.stage-card-persona { font-size: 10px; color: var(--faint); } +.stage-card-status { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.06em; padding: 3px 7px; border-radius: 5px; } +.stage-card-status.pass { background: #ecfdf5; color: var(--green); } +.stage-card-status.warn { background: #fffbeb; color: var(--amber); } +.stage-card-status.fail { background: #fef2f2; color: var(--red); } + +.stat-chips { display: flex; flex-wrap: wrap; gap: 8px; } +.stat-chip { background: var(--soft); border-radius: 8px; padding: 8px 12px; min-width: 64px; } +.stat-n { display: block; font-size: 20px; font-weight: 800; line-height: 1; } +.stat-l { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } + +.donut-wrap, .gauge-wrap { display: flex; align-items: center; gap: 14px; } +.donut-arc, .gauge-fill { transition: stroke-dasharray 0.9s cubic-bezier(0.22,1,0.36,1); } +.donut-center { font: 800 20px var(--font, sans-serif); fill: var(--text); } +.gauge-center { font: 800 16px sans-serif; fill: var(--text); } +.donut-legend { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--muted); } +.donut-legend i, .gauge-lab { display: inline-block; } +.donut-legend i { width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; } +.gauge-lab { font-size: 11px; color: var(--muted); } + +.bars { display: flex; flex-direction: column; gap: 8px; } +.bar-row { display: grid; grid-template-columns: 52px 1fr 36px; align-items: center; gap: 8px; font-size: 12px; } +.bar-lab { color: var(--muted); } +.bar-track { height: 10px; background: var(--soft); border-radius: 5px; overflow: hidden; } +.bar-fill { height: 100%; width: 0; border-radius: 5px; transition: width 0.9s cubic-bezier(0.22,1,0.36,1); } +.report-animate .bar-fill { width: var(--w); } +.bar-fill.pass { background: var(--green); } .bar-fill.fail { background: var(--red); } +.bar-n { text-align: right; font-weight: 700; font-size: 12px; } + +.flashcard { display: flex; align-items: baseline; gap: 8px; } +.flash-n { font-size: 32px; font-weight: 800; } +.flash-l { color: var(--muted); font-size: 13px; } + +.gate { margin-top: 10px; font-size: 11px; font-weight: 700; display: flex; flex-direction: column; gap: 4px; + padding: 8px 10px; border-radius: 8px; } +.gate.ok { background: #ecfdf5; color: var(--green); } +.gate.blocked { background: #fef2f2; color: var(--red); } +.gate-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-right: 6px; } +.gate.blocked .gate-dot { animation: gate-pulse 1.4s ease-in-out infinite; } +@keyframes gate-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.5); } 50% { box-shadow: 0 0 0 5px rgba(220,38,38,0); } } +.gate-reason { font-weight: 400; opacity: 0.85; } + +.mini-list { margin-top: 10px; } +.mini-list-h { font-size: 10px; color: var(--faint); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; } +.mini-list-i { font-size: 12px; color: var(--muted); padding: 3px 0; border-bottom: 1px dashed var(--line); } +.mini-list-i:last-child { border-bottom: none; } +.kv { display: flex; justify-content: space-between; font-size: 12px; padding: 4px 0; } +.kv-note { font-size: 11px; color: var(--muted); margin-top: 8px; font-style: italic; } + +@media (max-width: 900px) { .report-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } } `; From a64a77a20478f589320787c261a8e32adbabc56e Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Tue, 2 Jun 2026 22:34:32 +0530 Subject: [PATCH 3/4] feat(dashboard): Studio 3D agent panel shows the stage's report infographic (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking an agent now fetches /api/run-report and renders that stage's compact infographic in the side panel — threat severities + gate, compliance score, test pass/fail, cost/mo, requirement counts — so 'what they shipped' shows the real structured data. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/observability/dashboard/ui/studio3d.js | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/observability/dashboard/ui/studio3d.js b/src/observability/dashboard/ui/studio3d.js index 0331899..8536655 100644 --- a/src/observability/dashboard/ui/studio3d.js +++ b/src/observability/dashboard/ui/studio3d.js @@ -67,6 +67,18 @@ export function studio3dHtml(port) { #panel li { margin-bottom: 3px; } #panel .mono { font-family: 'JetBrains Mono', monospace; font-size: 11px; } #panel .why { background: #FFFBEB; border: 1px solid #FDE68A; border-radius: 8px; padding: 10px; font-size: 12px; } + .sr-chips { display: flex; gap: 8px; flex-wrap: wrap; } + .sr-chip { background: #F4F4F5; border-radius: 8px; padding: 7px 11px; text-align: center; } + .sr-chip b { display: block; font-size: 18px; } + .sr-chip span { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } + .sr-sev { font-size: 12px; font-weight: 600; } + .sr-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin: 0 4px 0 8px; } + .sr-score { font-size: 30px; font-weight: 800; } + .sr-score span { font-size: 14px; color: var(--muted); font-weight: 600; } + .sr-gate { margin-top: 8px; font-size: 11px; font-weight: 700; padding: 7px 9px; border-radius: 7px; } + .sr-gate.ok { background: #ECFDF5; color: #16A34A; } + .sr-gate.bad { background: #FEF2F2; color: #DC2626; animation: pulse-text 1.6s ease-in-out infinite; } + .sr-status { font-size: 12px; color: var(--muted); } #fallback { display: none; position: fixed; inset: 0; z-index: 50; background: var(--bg); align-items: center; justify-content: center; text-align: center; padding: 40px; } #fallback .card { max-width: 420px; border: 1px solid var(--line); border-left: 3px solid var(--accent); @@ -440,8 +452,65 @@ function openAgentPanel(stageId) { } if (entry.why) html += '

Why waiting

' + esc(entry.why) + '
'; if (!task && !entry.why) html += '

Status

Finished — artifacts recorded for this stage.
'; + html += '
'; document.getElementById('panel-body').innerHTML = html; document.getElementById('panel').classList.add('open'); + + // Fetch + render this stage's structured report as a compact infographic. + const run = currentRun(); + if (run) fetchStageReports(run.runId).then((report) => { + const slot = document.getElementById('stage-report-slot'); + if (!slot || !report || !report.stages) return; + const mini = stageReportMini(stageId, report.stages[stageId]); + if (mini) slot.innerHTML = '

Stage report

' + mini; + }); +} + +let REPORT_CACHE = {}; +function fetchStageReports(runId) { + if (REPORT_CACHE[runId]) return Promise.resolve(REPORT_CACHE[runId]); + return fetch('/api/run-report?run=' + encodeURIComponent(runId)) + .then((r) => r.json()).then((d) => { if (!d.error) REPORT_CACHE[runId] = d; return d; }) + .catch(() => null); +} + +// Compact infographic for the Studio 3D side panel (inline-styled, scoped here). +function stageReportMini(stageId, d) { + if (!d || d._truncated) return ''; + const chips = (items) => '
' + items.map((it) => + '
' + (it.n || 0) + '' + esc(it.l) + '
').join('') + '
'; + const gate = (g) => g ? '
' + + (g.ready ? 'Release gate: READY' : 'Release gate: BLOCKED') + '
' : ''; + switch (stageId) { + case '12-security-threat-model': { + const by = { HIGH: 0, MEDIUM: 0, LOW: 0 }; + (d.threats || []).forEach((t) => { const s = String(t.severity || '').toUpperCase(); if (by[s] != null) by[s]++; }); + return '
' + by.HIGH + ' high · ' + + '' + by.MEDIUM + ' med · ' + + '' + by.LOW + ' low
' + gate(d.release_gate); + } + case '13-compliance-checker': + return '
' + (d.overall_score || 0) + '/100
' + gate(d.release_gate); + case '08-testing': { + let p = 0, f = 0; const res = d.results || {}; + Object.keys(res).forEach((k) => { if (res[k] && typeof res[k] === 'object') { p += +res[k].passed || 0; f += +res[k].failed || 0; } }); + return chips([{ n: p, l: 'passed' }, { n: f, l: 'failed' }, { n: (d.coverage_gaps || []).length, l: 'gaps' }]); + } + case '14-cost-estimation': + return '
$' + (Number(d.monthly_cost_usd) || 0) + '/mo
'; + case '02-requirements': + return chips([{ n: (d.functional || []).length, l: 'reqs' }, { n: (d.user_stories || []).length, l: 'stories' }]); + case '06-architecture': + return chips([{ n: (d.components || []).length, l: 'components' }, { n: (d.trade_offs || []).length, l: 'trade-offs' }]); + case '10-summary': + return chips([{ n: (d.open_risks || []).length, l: 'risks' }, { n: (d.next_steps || []).length, l: 'next' }]) + gate(d.release_gate); + case '04-planning': + return chips([{ n: (d.milestones || []).length, l: 'milestones' }, { n: (d.tasks || []).length, l: 'tasks' }]); + case '07-code': + return chips([{ n: (d.files_modified || []).length, l: 'files' }, { n: (d.known_concerns || []).length, l: 'concerns' }]); + default: + return d.status ? '
' + esc(String(d.status).replace(/_/g, ' ')) + '
' : ''; + } } function openManagerPanel() { From fbedfb215d421c53de6ca5734071ae585f0c45b4 Mon Sep 17 00:00:00 2001 From: Richardson Gunde Date: Tue, 2 Jun 2026 22:35:02 +0530 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.8.0=20=E2=80=94=20CHAN?= =?UTF-8?q?GELOG=20+=20version=20bump=20for=20Run=20Report=20infographics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 +++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ab665..431f998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to RStack are documented here. Entries are user-focused: what you can now do that you couldn't before. +## [1.8.0] - 2026-06-02 + +### Added +- **See what your agents actually produced — as infographics.** A new **Run + Report** page turns every run's 15 stage deliverables into eye-catching + cards: a security threat-severity donut with the release gate, a compliance + score gauge, test pass/fail bars, a cost flashcard, requirement and + architecture counts, planning milestones, and the open-risk / release-gate + summary. Numbers count up, charts fill in, blocked gates pulse. +- **The Studio 3D agents now report their real work.** Click any agent in the + 3D studio and its panel shows that stage's infographic — threat counts, + compliance score, test results, cost — pulled live from the run. +- New `GET /api/run-report` endpoint serves a run's parsed stage reports in one + sandboxed call. + ## [1.7.1] - 2026-06-02 ### Security diff --git a/package-lock.json b/package-lock.json index fdc0d85..e8bc534 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rstack-agents", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rstack-agents", - "version": "1.7.1", + "version": "1.8.0", "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.74.1", diff --git a/package.json b/package.json index a704439..b7b8bd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rstack-agents", - "version": "1.7.1", + "version": "1.8.0", "description": "Production-ready agentic SDLC framework for Pi and coding agents — orchestrator, builder/validator teams, lifecycle state, and specialist reuse", "type": "module", "main": "src/index.js",