From cce3cbfd42ac442c1a06471cafa1811f09101a74 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Tue, 28 Jul 2026 21:49:15 +0700 Subject: [PATCH] feat: add production assurance dashboard flow --- README.md | 45 ++- bin/casan | 30 +- docs/packaging/CASAN_PACKAGING_PLAN.md | 15 +- .../EDITION_FEATURE_LIMITATION_MATRIX.md | 8 + packages/casan-control-panel/README.md | 33 +- .../backend/src/app.module.ts | 3 +- .../backend/src/ingest/ingest.controller.ts | 17 ++ .../backend/src/ingest/ingest.module.ts | 9 + .../backend/src/ingest/ingest.service.ts | 119 ++++++++ .../backend/src/reports/h6-report.html.ts | 4 +- .../backend/src/reports/h6-report.ts | 91 +++++- .../backend/src/reports/reports.controller.ts | 22 +- .../backend/src/reports/reports.module.ts | 2 + .../backend/src/reports/reports.service.ts | 41 ++- .../backend/src/reports/run-report.html.ts | 57 ++++ .../backend/src/reports/run-report.ts | 147 +++++++++ .../src/telemetry/telemetry.controller.ts | 5 + .../backend/src/telemetry/telemetry.module.ts | 1 + .../src/telemetry/telemetry.service.ts | 85 +++++- .../backend/test/ingest.test.ts | 45 +++ .../backend/test/reports.test.ts | 74 +++++ .../goals/ModelInteractionDiagram.tsx | 8 +- .../frontend/src/components/layout/Header.tsx | 5 + .../src/components/trace/AssuranceRail.tsx | 42 +++ .../src/components/trace/TraceExplorer.tsx | 8 +- .../frontend/src/lib/api.ts | 44 ++- .../frontend/src/pages/H6Report.tsx | 27 +- .../frontend/src/pages/Overview.tsx | 35 ++- .../frontend/src/pages/Runs.tsx | 2 +- .../scripts/control-plane-local.sh | 179 +++++++++++ packages/casan-devkit/casan-init.py | 166 ++++++++-- .../tests/hybrid-install-tests.sh | 2 +- .../adapters/claude-code/claude_hook.py | 4 +- .../adapters/codex/codex_hook.py | 5 +- .../schemas/agentic-lifecycle.schema.json | 25 ++ .../scripts/python/agentic_bridge.py | 283 +++++++++++++++++- .../scripts/python/report_cli.py | 158 ++++++++++ .../scripts/python/telemetry_exporter.py | 109 +++++++ .../tests/phase-agentic-bridge-tests.sh | 9 + 39 files changed, 1873 insertions(+), 91 deletions(-) create mode 100644 packages/casan-control-panel/backend/src/ingest/ingest.controller.ts create mode 100644 packages/casan-control-panel/backend/src/ingest/ingest.module.ts create mode 100644 packages/casan-control-panel/backend/src/ingest/ingest.service.ts create mode 100644 packages/casan-control-panel/backend/src/reports/run-report.html.ts create mode 100644 packages/casan-control-panel/backend/src/reports/run-report.ts create mode 100644 packages/casan-control-panel/backend/test/ingest.test.ts create mode 100644 packages/casan-control-panel/frontend/src/components/trace/AssuranceRail.tsx create mode 100755 packages/casan-control-panel/scripts/control-plane-local.sh create mode 100755 packages/casan-harness/scripts/python/report_cli.py create mode 100755 packages/casan-harness/scripts/python/telemetry_exporter.py diff --git a/README.md b/README.md index ef2da43..787be89 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Mô hình production mặc định: - Cài **DevKit một lần trên máy** để có launcher và lệnh quản trị. - Chạy `casan init` trong từng repository. -- Project mới dùng **Core level + Managed runtime + Enforce mode** nếu người +- Project mới dùng **Core edition + Managed runtime + Enforce mode** nếu người dùng không chọn khác. - Project offline, air-gapped hoặc cần tự chứa runtime có thể chọn **Vendored runtime**. @@ -22,10 +22,14 @@ hay release tooling. | Thành phần | Trạng thái | Phạm vi | |---|---|---| -| Core — Level 1 | Implemented | H1–H7 harness, hooks, policy gates, audit, evidence và CLI | -| DevKit — Level 2 | Implemented | Core + adoption tooling, domain-pack và CI template | -| Platform — Level 3 | Preview | Control Panel deploy riêng; `casan init` chỉ áp dụng nền Level 2 | -| Enterprise — Level 4 | Chưa phát hành | CLI chủ động từ chối, không giả lập tính năng | +| Core | Implemented | H1–H7 harness, hooks, policy gates, audit, evidence và CLI | +| DevKit | Implemented | Core + adoption tooling, domain-pack và CI template | +| Control Plane | Preview | Live H1–H7, H6, run history và evidence export; deploy riêng | +| Enterprise | Chưa phát hành | OIDC/KMS/WORM/HA/DR/SLA; CLI chủ động từ chối | + +Tên edition không phải maturity score. **CASAN Maturity L1–L5** là kết quả đánh +giá dựa trên evidence vận hành; cài Core không tự động có nghĩa là maturity L1, +và cài Control Plane không tự động đạt L3/L4. ## Bốn quyết định cần phân biệt @@ -34,12 +38,33 @@ CASAN tách riêng bốn khái niệm để cấu hình rõ ràng: | Quyết định | Lựa chọn | Mặc định production | |---|---|---| | Gói cài trên máy | `core`, `devkit`, `platform` | `devkit`, vì `casan init` thuộc DevKit | -| Capability của project | `--level core`, `devkit`, `platform` | `core` | +| Product edition | `--edition core`, `devkit`, `platform-preview` | `core` | | Vị trí Core runtime | `--runtime managed`, `vendored` | `managed` | | Cách thực thi policy | `--mode enforce`, `observe` | `enforce` | -`--level core` không có nghĩa Core phải nằm trong repository. Level mô tả -capability; runtime mô tả vị trí. Đây là hai quyết định độc lập. +`--edition core` không có nghĩa Core phải nằm trong repository. Edition mô tả +capability được đóng gói; runtime mô tả vị trí; maturity mô tả mức vận hành đã +được chứng minh. Đây là ba trục độc lập. `--level` vẫn được giữ như alias cũ. + +## Golden path: prompt → live assurance + +Core không export HTML trên hot path. Sau mỗi prompt, hook tự ghi trace/H6 và +trả assurance receipt. Nếu project đã enroll Control Plane, receipt có deep link +đến đúng run; nếu offline, dùng `casan report latest`. + +```bash +# Platform preview: một lệnh, tự trỏ Control Plane vào project hiện tại +casan dashboard start + +# Sau một prompt +casan report latest +casan view # mở run mới nhất +casan report export --format html # snapshot chỉ tạo khi được yêu cầu +``` + +`casan dashboard start` là convenience launcher cho local demo/evaluation. +Production triển khai Control Plane như service dùng chung và enroll project +bằng `casan init --dashboard-url https://casan.example`. ## Quick start @@ -114,7 +139,7 @@ Select clients (comma-separated) [1,2]: Nhấn Enter để dùng Managed và Claude Code + Codex. Nhập sai sẽ được hướng dẫn chọn lại. Sau khi hoàn tất, output terminal là bản tóm tắt dễ đọc gồm project, -level, runtime, mode, client, file thay đổi và next steps. +edition, maturity status, runtime, mode, client, file thay đổi và next steps. Project đã init không bị hỏi lại runtime: CASAN giữ nguyên mode hiện tại. Muốn đổi, truyền rõ `--runtime managed` hoặc `--runtime vendored`. @@ -124,7 +149,7 @@ Project đã init không bị hỏi lại runtime: CASAN giữ nguyên mode hi ```bash casan doctor casan verify-harness -casan level show +casan edition show ``` Với Codex, mở `/hooks`, review và trust đúng project hook sau lần init hoặc khi diff --git a/bin/casan b/bin/casan index f819605..2dd4343 100755 --- a/bin/casan +++ b/bin/casan @@ -75,7 +75,8 @@ Commands: init [--runtime managed|vendored] Adopt/reconfigure CASAN (interactive wizard by default) uninstall [--purge] Remove CASAN from this project (preserves user config) doctor [--client ...] Verify configured hooks, pin, adapters, and VS Code route - level Show / change the project's packaging level + edition Show / change the product edition + level Deprecated alias for edition verify-harness Verify the resolved harness matches the project pin run [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out) gate Run production checks from the project manifest @@ -85,8 +86,11 @@ Commands: project init Create an idempotent NestJS/React project shell prompt verify Verify the adopted prompt-enforcement contract prompt trace Verify that a prompt trace is H1-H7 certified + report latest [--json] Show the latest prompt assurance receipt + report export [trace] [--format] Export a trace snapshot on demand + view [trace-id] Open a trace in the enrolled Control Plane pipeline [--manifest path] Run the manifest-driven SRS→test pipeline - dashboard [port] Serve the AgentOps dashboard (Platform only) + dashboard Run the local Control Plane (Platform only) version Print version help This help @@ -120,6 +124,15 @@ case "$cmd" in exec python3 "$DEVKIT_ROOT/casan-init.py" init --level "$n" "$@" ;; *) echo "casan: usage: casan level >" >&2; exit 64 ;; esac ;; + edition) + [[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: edition requires the casan-devkit package" >&2; exit 1; } + sub="${1:-show}"; shift || true + case "$sub" in + show) exec python3 "$DEVKIT_ROOT/casan-init.py" edition --show "$@" ;; + set) name="${1:-core}"; shift || true + exec python3 "$DEVKIT_ROOT/casan-init.py" init --edition "$name" "$@" ;; + *) echo "casan: usage: casan edition >" >&2; exit 64 ;; + esac ;; run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;; gate) if [[ -z "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" \ @@ -155,11 +168,24 @@ case "$cmd" in exec bash "$BASH_DIR/prompt-enforcement-verify.sh" --root "$CASAN_APP_ROOT" --trace-id "$trace_id" "$@" ;; *) echo "casan: usage: casan prompt " >&2; exit 64 ;; esac ;; + report) + sub="${1:-latest}"; shift || true + case "$sub" in + latest) exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" latest "$@" ;; + export) exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" export "$@" ;; + *) echo "casan: usage: casan report " >&2; exit 64 ;; + esac ;; + view) + exec python3 "$HARNESS/scripts/python/report_cli.py" --root "$CASAN_APP_ROOT" view "$@" ;; pipeline) RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs" [[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; } exec node "$RUNNER" "$@" ;; dashboard) + CONTROL_PLANE_SCRIPT="$HARNESS/../casan-control-panel/scripts/control-plane-local.sh" + if [[ -f "$CONTROL_PLANE_SCRIPT" ]]; then + exec bash "$CONTROL_PLANE_SCRIPT" "$@" + fi [[ -f "$BASH_DIR/dashboard-serve.sh" ]] || { echo "casan: dashboard requires the Platform bundle" >&2 exit 1 diff --git a/docs/packaging/CASAN_PACKAGING_PLAN.md b/docs/packaging/CASAN_PACKAGING_PLAN.md index a48d433..3606e3e 100644 --- a/docs/packaging/CASAN_PACKAGING_PLAN.md +++ b/docs/packaging/CASAN_PACKAGING_PLAN.md @@ -5,17 +5,21 @@ holds (or scaffolds) all major CASAN components, and **releases are split by lev downstream project adopts only the level it needs. Single source of truth for bundle contents + maturity: [`packaging/levels.json`](../../packaging/levels.json). -## The four levels +## The four product editions -| Lvl | Package names | Status | What it is | +| Edition | Package names | Status | What it is | |---|---|:--:|---| | **1 — Core Harness** | `casan-core`, `casan-harness` | ✅ implemented | Minimal H1–H7 production runtime: security + action gates, evidence pack, audit, cost/telemetry, policy/config defaults, adapters, `bin/casan` CLI | | **2 — DevKit / Adoption Kit** | `casan-devkit`, `casan-project-kit` | ✅ implemented | Level 1 + project templates, domain-pack scaffold, Gitea workflow template, harness Dockerfile, install script, adoption/CI/domain-pack guides | | **3 — Platform Components** | `casan-platform`, `casan-control-panel` | 🟡 preview | Control Panel, Dashboard, Run History, governed chat MVP and **Evidence Pack Viewer**. Attack Battery Viewer, Gitea evidence publishing, and managed rollout are still pending. | | **4 — Enterprise / Governed Console** | `casan-enterprise`, `casan-governed-console` | 📋 future | Promotion layer requiring managed deployment, KMS/Object Lock operations, HA/DR/SLA, external review and compliance/support evidence. | -Levels are cumulative: DevKit extends Core, Platform extends DevKit, Enterprise extends -Platform. +Editions are cumulative: DevKit extends Core, Platform extends DevKit, Enterprise extends +Platform. Historical numeric packaging levels remain compatibility aliases only. + +Do not confuse editions with **CASAN Maturity L1–L5**. Maturity is an +evidence-based operational assessment. Installing an edition never grants a +maturity claim. ## Packaging principle The source hub may contain all levels, **but releases must be split**. Do NOT force a @@ -61,7 +65,8 @@ minimal. ## Who adopts what - **Governance-harness-only / BJT initial / CI gate** → `casan-core`. - **New project adopting CASAN** → `casan-devkit` (install.sh scaffolds domain + CI). -- **Want dashboards/visibility** → `casan-platform` (preview; dashboard today). +- **Want dashboards/visibility** → `casan-platform` (preview; live H1–H7, + H6 coverage and on-demand run/H6 exports today). - **Enterprise governed console** → future; building blocks (RBAC/tenant/KMS/WORM/approval) already live in core. diff --git a/docs/packaging/EDITION_FEATURE_LIMITATION_MATRIX.md b/docs/packaging/EDITION_FEATURE_LIMITATION_MATRIX.md index da97c8a..34692aa 100644 --- a/docs/packaging/EDITION_FEATURE_LIMITATION_MATRIX.md +++ b/docs/packaging/EDITION_FEATURE_LIMITATION_MATRIX.md @@ -4,6 +4,9 @@ This matrix is the customer-facing source of truth for edition claims. A check means the capability is packaged and has repository evidence; it does not imply an enterprise SLA, managed operation, or certification unless explicitly noted. +Edition and maturity are independent: edition describes shipped capability; +CASAN Maturity L1–L5 describes evidence-backed operational adoption. + | Capability | Core | DevKit | Platform Preview | Enterprise | |---|---:|---:|---:|---:| | H1–H7 harness, policy/action gates | Included | Included | Included | Building blocks only | @@ -11,6 +14,11 @@ an enterprise SLA, managed operation, or certification unless explicitly noted. | Project/domain templates and CI adoption guides | — | Included | Included | — | | Gitea CI gate template | — | Included | Included | — | | Control Panel: runs, governance, security, cost, approvals | — | — | Included | Not a shipped edition | +| Prompt assurance receipt + latest-run discovery | Included | Included | Included | Building blocks only | +| Clickable trace deep link when enrolled | Included | Included | Included | Building blocks only | +| One-command local Control Plane launcher | — | — | Included | Not a shipped edition | +| H1–H7 live assurance rail + per-run HTML/JSON export | — | — | Included | Not a shipped edition | +| Async HMAC telemetry delivery with durable local spool | Included | Included | Included | Building blocks only | | Evidence Pack Viewer | — | — | Included | Not a shipped edition | | Governed chat/operator/codegen MVP | — | — | Included, preview | Not a shipped edition | | Gitea webhook evidence publishing | — | — | Not yet available | Not available | diff --git a/packages/casan-control-panel/README.md b/packages/casan-control-panel/README.md index 38619e5..9a1f679 100644 --- a/packages/casan-control-panel/README.md +++ b/packages/casan-control-panel/README.md @@ -1,7 +1,8 @@ # CASAN Ops Console (Plan-13 Track 1/2/3/4 + Command Center) — Control Panel Real **NestJS API + React UI** that surfaces CASAN harness telemetry and governed settings -management. This is the Level-3 `casan-platform` **Control Panel** component. +management. This is the `casan-platform` **Control Plane** preview. “Platform” +is a product edition, not a CASAN Maturity L3 claim. Monitoring remains read-only ("Đọc ≠ Ghi"). Settings writes go through RBAC and the harness-owned governance CLI; the UI never writes harness files directly or bypasses a gate. @@ -11,6 +12,18 @@ frontend/ React + Vite + Tailwind + TanStack Query Ops Console + Settings/Appro ``` ## Run (local) + +Preferred golden path from an adopted project: + +```bash +casan dashboard start # starts API + UI, enrolls the local deep link +casan dashboard status +casan view # opens the most recent prompt trace +casan dashboard stop +``` + +Manual developer mode: + ```bash npm install # from repo root (picks up the workspaces) npm run console:api # NestJS API → http://127.0.0.1:3010/api/v1 @@ -33,6 +46,24 @@ Harness reports: no maturity score is hard-coded. - `/reports/h6` — UI report view with project, time-range and run filters plus source-level freshness and data-quality warnings. +- `GET /api/v1/reports/run/:traceId` and `/export?format=html|json` — complete + per-prompt H1–H7 assurance receipt with truthful H6 availability. +- `POST /api/v1/ingest/turn` — optional central ingestion. Disabled unless + `CASAN_CP_INGEST_TOKEN` is set; requests require a five-minute timestamp and + HMAC-SHA256 signature. Raw prompt/tool content keys are rejected. + +Core always writes a sanitized pending envelope locally first. Central delivery +is asynchronous and never delays or changes a prompt verdict: + +```bash +export CASAN_CONTROL_PLANE_TOKEN='use-a-secret-manager-in-production' +casan init \ + --dashboard-url https://casan.example \ + --ingest-url https://casan.example/api/v1/ingest/turn +``` + +The server receives the same secret as `CASAN_CP_INGEST_TOKEN`. The secret name, +not the secret value, is stored in project configuration. Metrics export: `GET /api/v1/metrics` provides Prometheus text exposition for aggregate freshness, run/failure/cost/token and H4/H5/action/incident counters. diff --git a/packages/casan-control-panel/backend/src/app.module.ts b/packages/casan-control-panel/backend/src/app.module.ts index 98bd9b6..8bb2887 100644 --- a/packages/casan-control-panel/backend/src/app.module.ts +++ b/packages/casan-control-panel/backend/src/app.module.ts @@ -10,9 +10,10 @@ import { GoalsModule } from './goals/goals.module.js'; import { EvidenceModule } from './evidence/evidence.module.js'; import { SessionController } from './session/session.controller.js'; import { ReportsModule } from './reports/reports.module.js'; +import { IngestModule } from './ingest/ingest.module.js'; @Module({ - imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule, EvidenceModule, ReportsModule], + imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule, EvidenceModule, ReportsModule, IngestModule], controllers: [HealthController, SessionController], }) export class AppModule {} diff --git a/packages/casan-control-panel/backend/src/ingest/ingest.controller.ts b/packages/casan-control-panel/backend/src/ingest/ingest.controller.ts new file mode 100644 index 0000000..5105998 --- /dev/null +++ b/packages/casan-control-panel/backend/src/ingest/ingest.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Headers, Inject, Post } from '@nestjs/common'; +import { ok } from '../common/api-response.js'; +import { IngestService } from './ingest.service.js'; + +@Controller('api/v1/ingest') +export class IngestController { + constructor(@Inject(IngestService) private readonly ingestService: IngestService) {} + + @Post('turn') + turn( + @Body() body: unknown, + @Headers('x-casan-timestamp') timestamp?: string, + @Headers('x-casan-signature') signature?: string, + ) { + return ok(this.ingestService.ingest(body, timestamp, signature)); + } +} diff --git a/packages/casan-control-panel/backend/src/ingest/ingest.module.ts b/packages/casan-control-panel/backend/src/ingest/ingest.module.ts new file mode 100644 index 0000000..bc2dc48 --- /dev/null +++ b/packages/casan-control-panel/backend/src/ingest/ingest.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { IngestController } from './ingest.controller.js'; +import { IngestService } from './ingest.service.js'; + +@Module({ + controllers: [IngestController], + providers: [IngestService], +}) +export class IngestModule {} diff --git a/packages/casan-control-panel/backend/src/ingest/ingest.service.ts b/packages/casan-control-panel/backend/src/ingest/ingest.service.ts new file mode 100644 index 0000000..f624af8 --- /dev/null +++ b/packages/casan-control-panel/backend/src/ingest/ingest.service.ts @@ -0,0 +1,119 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { appendFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { BadRequestException, ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { APP_ROOT, PATHS } from '../common/app-root.js'; + +type Row = Record; + +export interface IngestEnvelope { + schema_version: 1; + sent_at: string; + project_id: string; + trace_id: string; + receipt: Row; + metric: Row; + trace: Row; + events: Row[]; +} + +const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; +const FORBIDDEN_KEYS = new Set([ + 'prompt', 'raw_prompt', 'assistant_summary', 'tool_input', 'tool_output', + 'result_content', 'secret', 'password', 'authorization', +]); + +export function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const record = value as Row; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +export function expectedSignature(token: string, timestamp: string, body: unknown): string { + return `sha256=${createHmac('sha256', token).update(`${timestamp}.${stableJson(body)}`).digest('hex')}`; +} + +function containsForbiddenKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsForbiddenKey); + if (!value || typeof value !== 'object') return false; + return Object.entries(value as Row).some(([key, nested]) => ( + FORBIDDEN_KEYS.has(key.toLowerCase()) || containsForbiddenKey(nested) + )); +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function atomicJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(temporary, path); +} + +@Injectable() +export class IngestService { + ingest(raw: unknown, timestamp: string | undefined, signature: string | undefined) { + const token = process.env.CASAN_CP_INGEST_TOKEN; + if (!token) throw new ServiceUnavailableException('CASAN_INGEST_DISABLED'); + const epoch = Number(timestamp); + if (!Number.isInteger(epoch) || Math.abs(Math.floor(Date.now() / 1000) - epoch) > 300) { + throw new ForbiddenException('CASAN_INGEST_TIMESTAMP_INVALID'); + } + const expected = expectedSignature(token, String(timestamp), raw); + if (!signature || !safeEqual(signature, expected)) { + throw new ForbiddenException('CASAN_INGEST_SIGNATURE_INVALID'); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new BadRequestException('CASAN_INGEST_INVALID_ENVELOPE'); + } + const envelope = raw as Partial; + if ( + envelope.schema_version !== 1 + || typeof envelope.project_id !== 'string' + || typeof envelope.trace_id !== 'string' + || !SAFE_ID.test(envelope.project_id) + || !SAFE_ID.test(envelope.trace_id) + || !envelope.metric + || !envelope.trace + || !Array.isArray(envelope.events) + || envelope.events.length > 2000 + ) throw new BadRequestException('CASAN_INGEST_INVALID_ENVELOPE'); + if (containsForbiddenKey(envelope)) { + throw new BadRequestException('CASAN_INGEST_RAW_CONTENT_FORBIDDEN'); + } + if ( + envelope.metric.trace_id !== envelope.trace_id + || envelope.trace.trace_id !== envelope.trace_id + || envelope.events.some((event) => event.trace_id !== envelope.trace_id) + ) throw new BadRequestException('CASAN_INGEST_TRACE_MISMATCH'); + + const tracePath = join(PATHS.traceDir, `agentic-${envelope.trace_id}.json`); + if (existsSync(tracePath)) { + return { accepted: true, duplicate: true, trace_id: envelope.trace_id }; + } + + mkdirSync(dirname(PATHS.metrics), { recursive: true }); + appendFileSync(PATHS.metrics, `${JSON.stringify(envelope.metric)}\n`, 'utf8'); + atomicJson(tracePath, envelope.trace); + if (envelope.events.length > 0) { + const eventPath = join(PATHS.traceEventDir, `${envelope.trace_id}.jsonl`); + mkdirSync(dirname(eventPath), { recursive: true }); + appendFileSync(eventPath, `${envelope.events.map((event) => JSON.stringify(event)).join('\n')}\n`, 'utf8'); + } + const receiptPath = join(APP_ROOT, '.specify', 'state', 'ingested', `${envelope.trace_id}.json`); + atomicJson(receiptPath, envelope.receipt ?? {}); + return { + accepted: true, + duplicate: false, + project_id: envelope.project_id, + trace_id: envelope.trace_id, + }; + } +} diff --git a/packages/casan-control-panel/backend/src/reports/h6-report.html.ts b/packages/casan-control-panel/backend/src/reports/h6-report.html.ts index bc845b7..c8b42ef 100644 --- a/packages/casan-control-panel/backend/src/reports/h6-report.html.ts +++ b/packages/casan-control-panel/backend/src/reports/h6-report.html.ts @@ -14,7 +14,7 @@ function breakdownRows(rows: H6Breakdown[]): string { if (rows.length === 0) return 'No records in the selected scope.'; return rows.map((row) => ` ${escapeHtml(row.key)}${number(row.runs)}${number(row.failures)} - ${number(row.latency_avg_ms)} ms${number(row.tokens)}${money(row.cost_usd)} + ${number(row.latency_avg_ms)} ms${row.tokens === null ? 'Unavailable' : number(row.tokens)}${row.cost_usd === null ? 'Unavailable' : money(row.cost_usd)} `).join(''); } @@ -50,7 +50,7 @@ main{max-width:1180px;margin:0 auto;padding:42px 28px 72px}.hero{position:relati @media print{body{background:#fff}main{max-width:none;padding:0}.hero,.card,.section{box-shadow:none}.hero{background:#111827!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}.section{break-inside:avoid}.footer{margin-top:10px}}
CASAN assurance dossier · contract v${report.schema_version}

${escapeHtml(report.title)}

${scope.map((item) => `${escapeHtml(item)}`).join('')}
${escapeHtml(report.verdict)}
Generated ${escapeHtml(report.generated_at)} · ${escapeHtml(report.report_id)} · Freshness ${escapeHtml(report.freshness.status)}
-
Governed runs${number(report.summary.runs)}
Failure rate${report.summary.failure_rate_pct}%
P95 latency${number(report.summary.latency_ms.p95)} ms
Provider tokens${number(report.summary.tokens.provider_total)}
Actual provider cost${money(report.summary.cost_usd.provider_actual)}
Estimated cost${money(report.summary.cost_usd.estimated)}
Alerts${number(report.summary.alerts)}
Retries${number(report.summary.retries)}
+
Governed runs${number(report.summary.runs)}
Failure rate${report.summary.failure_rate_pct}%
P95 latency${number(report.summary.latency_ms.p95)} ms
Provider tokens${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : 'Unavailable'}
Actual provider cost${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : 'Unavailable'}
Token coverage${report.summary.coverage.token_pct}%
Cost coverage${report.summary.coverage.cost_pct}%
Retries${number(report.summary.retries)}

Verdict findings

    ${findings}

Evidence freshness

${sourceRows}
SourcePresenceStateAgeRecordsPath

Step breakdown

${breakdownRows(report.details.by_step)}
StepRunsFailuresAvg latencyTokensCost
diff --git a/packages/casan-control-panel/backend/src/reports/h6-report.ts b/packages/casan-control-panel/backend/src/reports/h6-report.ts index ffb398e..f0f99bf 100644 --- a/packages/casan-control-panel/backend/src/reports/h6-report.ts +++ b/packages/casan-control-panel/backend/src/reports/h6-report.ts @@ -37,10 +37,18 @@ export interface H6ReportSummary { failure_rate_pct: number; retries: number; latency_ms: { average: number; p50: number; p95: number; p99: number; max: number }; - tokens: { input: number; output: number; total: number; provider_total: number }; - cost_usd: { provider_actual: number; estimated: number }; + tokens: { input: number | null; output: number | null; total: number | null; provider_total: number | null }; + cost_usd: { provider_actual: number | null; estimated: number | null }; provider_calls: number; alerts: number; + coverage: { + runtime_records: number; + token_records: number; + cost_records: number; + token_pct: number; + cost_pct: number; + quality: { complete: number; partial: number; insufficient: number; unknown: number }; + }; } export interface H6Breakdown { @@ -48,8 +56,8 @@ export interface H6Breakdown { runs: number; failures: number; latency_avg_ms: number; - tokens: number; - cost_usd: number; + tokens: number | null; + cost_usd: number | null; } export interface H6ReportDetails { @@ -66,6 +74,7 @@ export type H6Report = HarnessReport; const safeFilter = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; const numberValue = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) ? value : 0; +const hasNumber = (value: unknown): boolean => typeof value === 'number' && Number.isFinite(value); const stringValue = (value: unknown, fallback: string): string => typeof value === 'string' && value.trim() ? value.trim() : fallback; const round = (value: number, digits = 2): number => Number(value.toFixed(digits)); const configuredThreshold = (name: string, fallback: number): number => { @@ -148,8 +157,13 @@ function grouped(rows: TelemetryRow[], keyOf: (row: TelemetryRow) => string): H6 runs: records.length, failures: records.filter((row) => stringValue(row.status, 'unknown') === 'failed').length, latency_avg_ms: latencies.length ? Math.round(latencies.reduce((total, value) => total + value, 0) / latencies.length) : 0, - tokens: sum(records, 'total_tokens'), - cost_usd: round(sum(records, records.some((row) => row.cost_usd !== undefined) ? 'cost_usd' : 'cost_estimate'), 6), + tokens: records.some((row) => hasNumber(row.total_tokens)) + ? sum(records, 'total_tokens') : null, + cost_usd: records.some((row) => hasNumber(row.cost_usd)) + ? round(sum(records, 'cost_usd'), 6) + : records.some((row) => hasNumber(row.cost_estimate)) + ? round(sum(records, 'cost_estimate'), 6) + : null, }; }).sort((left, right) => right.runs - left.runs || left.key.localeCompare(right.key)); } @@ -195,6 +209,29 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep const failed = metrics.filter((row) => stringValue(row.status, 'unknown') === 'failed').length; const degraded = metrics.filter((row) => stringValue(row.status, 'unknown') === 'degraded').length; const success = metrics.filter((row) => ['success', 'pass', 'passed'].includes(stringValue(row.status, 'unknown'))).length; + const providerTokenRuns = new Set(provider.filter((row) => hasNumber(row.total_tokens)).map(rowRun)); + const providerCostRuns = new Set(provider.filter((row) => hasNumber(row.cost_usd)).map(rowRun)); + const tokenRecords = metrics.filter((row) => ( + hasNumber(row.total_tokens) + || hasNumber(row.input_tokens) + || hasNumber(row.output_tokens) + || providerTokenRuns.has(rowRun(row)) + )).length; + const costRecords = metrics.filter((row) => ( + hasNumber(row.cost_estimate) + || providerCostRuns.has(rowRun(row)) + )).length; + const tokenCoverage = metrics.length ? round((tokenRecords / metrics.length) * 100, 1) : 0; + const costCoverage = metrics.length ? round((costRecords / metrics.length) * 100, 1) : 0; + const qualityCounts = { complete: 0, partial: 0, insufficient: 0, unknown: 0 }; + for (const row of metrics) { + const quality = stringValue(row.telemetry_quality, 'unknown'); + if (quality === 'complete' || quality === 'partial' || quality === 'insufficient') { + qualityCounts[quality] += 1; + } else { + qualityCounts.unknown += 1; + } + } const sourceEvidence = evidenceSources(input); const primary = sourceEvidence.find((source) => source.source === 'metrics'); const failureRate = metrics.length ? round((failed / metrics.length) * 100, 1) : 0; @@ -223,7 +260,19 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep const alertCount = alertCounts.reduce((total, entry) => total + entry.count, 0); if (alertCount > 0) findings.push({ severity: 'warning', code: 'ALERTS_PRESENT', message: `${alertCount} alert signal(s) require review.`, metric: 'alerts', value: alertCount }); if (degraded > 0) findings.push({ severity: 'warning', code: 'DEGRADED_RUNS_PRESENT', message: `${degraded} degraded run(s) are present in the selected scope.`, metric: 'degraded', value: degraded }); + if (metrics.length > 0 && (tokenCoverage < 100 || costCoverage < 100)) { + findings.push({ + severity: 'warning', + code: 'TELEMETRY_COVERAGE_GAP', + message: 'Some runs do not have reliable provider token or cost attribution.', + metric: 'token_cost_coverage_pct', + value: `${tokenCoverage}/${costCoverage}`, + threshold: '100/100', + }); + } if (provider.length === 0) warnings.push('No provider usage records matched the selected scope; token and actual-cost breakdown may be incomplete.'); + if (metrics.length > 0 && tokenCoverage < 100) warnings.push(`${tokenCoverage}% of runtime records have reliable token attribution; unavailable values remain null, never zero.`); + if (metrics.length > 0 && costCoverage < 100) warnings.push(`${costCoverage}% of runtime records have reliable cost attribution; unavailable values remain null, never zero.`); for (const source of sourceEvidence) { if (source.source === 'metrics') continue; if (!source.present) warnings.push(`Optional ${source.source} telemetry source is missing; its breakdown is unavailable.`); @@ -243,7 +292,7 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep const costSources = grouped(metrics, (row) => stringValue(row.cost_source, 'unknown')).map((entry) => ({ source: entry.key, records: entry.runs, - cost_usd: entry.cost_usd, + cost_usd: entry.cost_usd ?? 0, })); const scope: HarnessReportScope = { project: query.project, from: query.from, to: query.to, run: query.run }; @@ -272,15 +321,37 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep p99: percentile(latencies, 99), max: latencies.length ? Math.max(...latencies) : 0, }, - tokens: { input: sum(metrics, 'input_tokens'), output: sum(metrics, 'output_tokens'), total: sum(metrics, 'total_tokens'), provider_total: sum(provider, 'total_tokens') }, - cost_usd: { provider_actual: round(sum(provider, 'cost_usd'), 6), estimated: round(sum(metrics, 'cost_estimate'), 6) }, + tokens: { + input: metrics.some((row) => hasNumber(row.input_tokens)) ? sum(metrics, 'input_tokens') : null, + output: metrics.some((row) => hasNumber(row.output_tokens)) ? sum(metrics, 'output_tokens') : null, + total: metrics.some((row) => hasNumber(row.total_tokens)) ? sum(metrics, 'total_tokens') : null, + provider_total: provider.some((row) => hasNumber(row.total_tokens)) ? sum(provider, 'total_tokens') : null, + }, + cost_usd: { + provider_actual: provider.some((row) => hasNumber(row.cost_usd)) ? round(sum(provider, 'cost_usd'), 6) : null, + estimated: metrics.some((row) => hasNumber(row.cost_estimate)) ? round(sum(metrics, 'cost_estimate'), 6) : null, + }, provider_calls: provider.length, alerts: alertCount, + coverage: { + runtime_records: metrics.length, + token_records: tokenRecords, + cost_records: costRecords, + token_pct: tokenCoverage, + cost_pct: costCoverage, + quality: qualityCounts, + }, }, thresholds: { failure_rate_pct: failureThreshold, p95_latency_ms: p95Threshold, freshness_age_s: STALE_AFTER_S }, findings, evidence_sources: sourceEvidence, - data_quality: { status: !primary?.present ? 'insufficient' : warnings.length ? 'partial' : 'complete', warnings }, + data_quality: { + status: ( + !primary?.present + || (metrics.length > 0 && tokenRecords === 0 && costRecords === 0) + ) ? 'insufficient' : warnings.length ? 'partial' : 'complete', + warnings, + }, available_filters: { projects: availableProjects, runs: availableRuns }, details: { by_status: counted(metrics, (row) => stringValue(row.status, 'unknown')).map(({ key, count: countValue }) => ({ status: key, count: countValue })), diff --git a/packages/casan-control-panel/backend/src/reports/reports.controller.ts b/packages/casan-control-panel/backend/src/reports/reports.controller.ts index d349a68..9b3a82a 100644 --- a/packages/casan-control-panel/backend/src/reports/reports.controller.ts +++ b/packages/casan-control-panel/backend/src/reports/reports.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Controller, Get, Header, Inject, Query, Res } from '@nestjs/common'; +import { BadRequestException, Controller, Get, Header, Inject, Param, Query, Res } from '@nestjs/common'; import type { Response } from 'express'; import { ok } from '../common/api-response.js'; import { parseH6ReportQuery, type H6ReportQueryParams } from './h6-report.js'; @@ -34,4 +34,24 @@ export class ReportsController { response.setHeader('Content-Disposition', `attachment; filename="casan-h6-report-${stamp}.${format}"`); response.send(body); } + + @Get('run/:traceId') + run(@Param('traceId') traceId: string) { + return ok(this.reports.run(traceId)); + } + + @Get('run/:traceId/export') + @Header('Cache-Control', 'no-store') + exportRun( + @Param('traceId') traceId: string, + @Query('format') rawFormat: string | undefined, + @Res() response: Response, + ) { + const format = rawFormat ?? 'json'; + if (format !== 'json' && format !== 'html') throw new BadRequestException('RUN_REPORT_INVALID_FORMAT'); + const report = this.reports.run(traceId); + response.type(format === 'html' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8'); + response.setHeader('Content-Disposition', `attachment; filename="casan-run-${traceId}.${format}"`); + response.send(this.reports.serializeRun(report, format)); + } } diff --git a/packages/casan-control-panel/backend/src/reports/reports.module.ts b/packages/casan-control-panel/backend/src/reports/reports.module.ts index 3a1fd41..a28ce48 100644 --- a/packages/casan-control-panel/backend/src/reports/reports.module.ts +++ b/packages/casan-control-panel/backend/src/reports/reports.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ReportsController } from './reports.controller.js'; import { ReportsService } from './reports.service.js'; +import { TelemetryModule } from '../telemetry/telemetry.module.js'; @Module({ + imports: [TelemetryModule], controllers: [ReportsController], providers: [ReportsService], exports: [ReportsService], diff --git a/packages/casan-control-panel/backend/src/reports/reports.service.ts b/packages/casan-control-panel/backend/src/reports/reports.service.ts index 912ef52..a0a8568 100644 --- a/packages/casan-control-panel/backend/src/reports/reports.service.ts +++ b/packages/casan-control-panel/backend/src/reports/reports.service.ts @@ -1,12 +1,20 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { PATHS, telemetryFreshness } from '../common/app-root.js'; import { readJsonl } from '../telemetry/telemetry.reader.js'; +import { TelemetryService } from '../telemetry/telemetry.service.js'; import { buildH6Report, type H6Report, type H6ReportQuery } from './h6-report.js'; import { renderH6ReportHtml } from './h6-report.html.js'; import { HARNESS_REPORT_CATALOG } from './report.contract.js'; +import { buildRunAssuranceReport, type RunAssuranceReport } from './run-report.js'; +import { renderRunAssuranceHtml } from './run-report.html.js'; +import { APP_ROOT } from '../common/app-root.js'; @Injectable() export class ReportsService { + constructor(@Inject(TelemetryService) private readonly telemetry: TelemetryService) {} + catalog() { return { schema_version: 1, reports: HARNESS_REPORT_CATALOG }; } @@ -25,4 +33,35 @@ export class ReportsService { serializeH6(report: H6Report, format: 'json' | 'html'): string { return format === 'html' ? renderH6ReportHtml(report) : `${JSON.stringify(report, null, 2)}\n`; } + + run(traceId: string): RunAssuranceReport { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(traceId)) { + throw new NotFoundException('CASAN_RUN_NOT_FOUND'); + } + const graph = this.telemetry.traceGraph(traceId); + const traceResult = this.telemetry.run(traceId); + const metrics = readJsonl(PATHS.metrics); + const metric = [...metrics].reverse().find((row) => row.trace_id === traceId) ?? null; + let config: Record = {}; + try { + config = JSON.parse(readFileSync(join(APP_ROOT, '.casan', 'config.json'), 'utf8')) as Record; + } catch { + config = {}; + } + const report = buildRunAssuranceReport({ + traceId, + graph, + trace: traceResult.trace, + metric, + config, + }); + if (report.verdict === 'not_found') throw new NotFoundException('CASAN_RUN_NOT_FOUND'); + return report; + } + + serializeRun(report: RunAssuranceReport, format: 'json' | 'html'): string { + return format === 'html' + ? renderRunAssuranceHtml(report) + : `${JSON.stringify(report, null, 2)}\n`; + } } diff --git a/packages/casan-control-panel/backend/src/reports/run-report.html.ts b/packages/casan-control-panel/backend/src/reports/run-report.html.ts new file mode 100644 index 0000000..83a1b70 --- /dev/null +++ b/packages/casan-control-panel/backend/src/reports/run-report.html.ts @@ -0,0 +1,57 @@ +import type { RunAssuranceReport } from './run-report.js'; + +const escapeHtml = (value: unknown): string => String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const badgeClass = (status: string): string => { + if (status === 'pass' || status === 'certified') return 'pass'; + if (status === 'warning' || status === 'in_progress') return 'warn'; + if (status === 'queued' || status === 'skipped') return 'muted'; + return 'fail'; +}; + +export function renderRunAssuranceHtml(report: RunAssuranceReport): string { + const gates = report.gates.map((gate) => ` +
+
${escapeHtml(gate.title)}${escapeHtml(gate.status)}
+

${escapeHtml(gate.reason)}

+ ${escapeHtml(gate.updated_at ?? 'No timestamp')} +
Evidence fields
${escapeHtml(JSON.stringify(gate.evidence, null, 2))}
+
`).join(''); + const tokenValue = report.summary.token_usage_available ? 'Available' : 'Unavailable'; + const costValue = report.summary.cost_available ? 'Available' : 'Unavailable'; + return ` + +CASAN assurance receipt · ${escapeHtml(report.trace_id)} +
+
CASAN · evidence-backed assurance

${escapeHtml(report.verdict.replace('_', ' ').toUpperCase())}

${escapeHtml(report.trace_id)}
+
+
Harness gates${report.summary.gates_observed}/7
+
H6 quality${escapeHtml(report.summary.telemetry_quality)}
+
Token usage${tokenValue}
+
Cost${costValue}
+
Duration${report.summary.duration_ms === null ? 'Unavailable' : `${report.summary.duration_ms} ms`}
+
Tool calls${report.summary.tool_calls}
+
Failures${report.summary.failures}
+
Evidence source${report.source.trace_found && report.source.graph_found ? 'Verified' : 'Partial'}
+
+

Live assurance rail · H1 → H7

${gates}
+

Certification

Strength: ${escapeHtml(report.certification.strength ?? 'unknown')}

${escapeHtml(report.certification.reasons.join(' · ') || 'No certification reason recorded.')}

+ +
`; +} diff --git a/packages/casan-control-panel/backend/src/reports/run-report.ts b/packages/casan-control-panel/backend/src/reports/run-report.ts new file mode 100644 index 0000000..655ec44 --- /dev/null +++ b/packages/casan-control-panel/backend/src/reports/run-report.ts @@ -0,0 +1,147 @@ +export interface RunGateSnapshot { + id: string; + title: string; + description: string; + status: string; + reason: string; + updated_at: string | null; + evidence: Record; +} + +export interface RunAssuranceReport { + schema_version: 1; + report_id: string; + generated_at: string; + trace_id: string; + project: { + id: string; + edition: string | null; + maturity: { level: number | null; status: string } | null; + }; + verdict: 'certified' | 'non_certified' | 'in_progress' | 'not_found'; + certification: { + strength: string | null; + reasons: string[]; + finalized_at: string | null; + }; + summary: { + gates_observed: number; + gates_total: 7; + tool_calls: number; + failures: number; + duration_ms: number | null; + telemetry_quality: string; + token_usage_available: boolean; + cost_available: boolean; + }; + gates: RunGateSnapshot[]; + h6: Record | null; + source: { + trace_found: boolean; + graph_found: boolean; + metric_found: boolean; + }; +} + +type Row = Record; + +const stringValue = (value: unknown, fallback = ''): string => ( + typeof value === 'string' && value.trim() ? value.trim() : fallback +); +const numberValue = (value: unknown, fallback = 0): number => ( + typeof value === 'number' && Number.isFinite(value) ? value : fallback +); +const hasNumber = (value: unknown): boolean => ( + typeof value === 'number' && Number.isFinite(value) +); + +export function buildRunAssuranceReport(input: { + traceId: string; + graph: { + found: boolean; + terminal: boolean; + progress: number; + nodes: RunGateSnapshot[]; + }; + trace: Row | null; + metric: Row | null; + config: Row; + now?: Date; +}): RunAssuranceReport { + const now = input.now ?? new Date(); + const trace = input.trace ?? {}; + const metric = input.metric ?? {}; + const certified = trace.certified === true; + const traceFound = input.trace !== null; + const metricFound = input.metric !== null; + const verdict = ( + !traceFound && !input.graph.found && !metricFound ? 'not_found' + : !input.graph.terminal && !trace.finalized_at ? 'in_progress' + : certified ? 'certified' : 'non_certified' + ); + const maturity = input.config.maturity && typeof input.config.maturity === 'object' + ? input.config.maturity as Record + : null; + const duration = ( + hasNumber(metric.duration_ms) ? Number(metric.duration_ms) + : hasNumber(metric.latency_ms) ? Number(metric.latency_ms) + : null + ); + const costObject = metric.cost && typeof metric.cost === 'object' + ? metric.cost as Record + : {}; + return { + schema_version: 1, + report_id: `RUN-${input.traceId}`, + generated_at: now.toISOString(), + trace_id: input.traceId, + project: { + id: stringValue( + input.config.project_id ?? trace.project_id ?? metric.project_id, + 'unknown', + ), + edition: stringValue( + input.config.edition ?? input.config.target_level_name, + ) || null, + maturity: maturity ? { + level: hasNumber(maturity.level) ? Number(maturity.level) : null, + status: stringValue(maturity.status, 'not_assessed'), + } : null, + }, + verdict, + certification: { + strength: stringValue( + trace.certification_strength ?? metric.certification_strength, + ) || null, + reasons: Array.isArray(trace.certification_reasons) + ? trace.certification_reasons.filter( + (value): value is string => typeof value === 'string') + : [], + finalized_at: stringValue(trace.finalized_at ?? metric.finished_at) || null, + }, + summary: { + gates_observed: input.graph.progress, + gates_total: 7, + tool_calls: numberValue(trace.tool_calls ?? metric.tool_calls), + failures: numberValue(trace.failures ?? metric.failures), + duration_ms: duration, + telemetry_quality: stringValue(metric.telemetry_quality, 'unknown'), + token_usage_available: ( + hasNumber(metric.total_tokens) + || hasNumber(metric.input_tokens) + || hasNumber(metric.output_tokens) + ), + cost_available: ( + hasNumber(metric.cost_estimate) + || hasNumber(costObject.amount) + ), + }, + gates: input.graph.nodes, + h6: input.metric, + source: { + trace_found: traceFound, + graph_found: input.graph.found, + metric_found: metricFound, + }, + }; +} diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts index e9326de..0c1e859 100644 --- a/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.controller.ts @@ -16,6 +16,11 @@ export class TelemetryController { return ok(this.svc.overview()); } + @Get('project') + project() { + return ok(this.svc.projectProfile()); + } + // Production reverse proxy policy must restrict this aggregate-only endpoint // to the monitoring network / service account. @Get('metrics') diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts index 0df79c5..dd4b75e 100644 --- a/packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.module.ts @@ -5,5 +5,6 @@ import { TelemetryService } from './telemetry.service.js'; @Module({ controllers: [TelemetryController], providers: [TelemetryService], + exports: [TelemetryService], }) export class TelemetryModule {} diff --git a/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts b/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts index 759a7cf..75e28f6 100644 --- a/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts +++ b/packages/casan-control-panel/backend/src/telemetry/telemetry.service.ts @@ -59,9 +59,9 @@ const pct = (part: number, total: number) => (total > 0 ? Math.round((part / tot function gateStatus(value: unknown): HarnessGateStatus { const status = String(value ?? '').toLowerCase(); - if (['success', 'pass', 'passed', 'allow', 'allowed', 'answered'].includes(status)) return 'pass'; - if (['warn', 'warning'].includes(status)) return 'warning'; - if (['block', 'blocked', 'deny', 'denied'].includes(status)) return 'blocked'; + if (['success', 'pass', 'passed', 'allow', 'allowed', 'answered', 'opened', 'certified'].includes(status)) return 'pass'; + if (['warn', 'warning', 'degraded', 'partial', 'insufficient'].includes(status)) return 'warning'; + if (['block', 'blocked', 'deny', 'denied', 'flag', 'non_certified'].includes(status)) return 'blocked'; if (['fail', 'failed', 'error'].includes(status)) return 'error'; if (status === 'running') return 'running'; if (status === 'skipped') return 'skipped'; @@ -232,11 +232,23 @@ export class TelemetryService { const runs = metrics.length; const latencies = metrics.map((m) => num(m.latency_ms)).filter((x) => x > 0); + const latestMetric = metrics.length ? metrics[metrics.length - 1] : null; + const latestTraceId = typeof latestMetric?.trace_id === 'string' ? latestMetric.trace_id : null; + const latestTrace = latestTraceId ? readTrace(PATHS.traceDir, latestTraceId) : null; + const latestGraph = latestTraceId ? this.traceGraph(latestTraceId) : null; return { ...this.freshness(), totals: { runs, total_cost: sum(metrics, 'cost_estimate'), + token_coverage_pct: pct( + count(metrics, (m) => m.total_tokens != null || m.input_tokens != null || m.output_tokens != null), + runs, + ), + cost_coverage_pct: pct( + count(metrics, (m) => m.cost_estimate != null || (m.cost && m.cost.amount != null)), + runs, + ), avg_latency_ms: latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : 0, failures: count(metrics, (m) => m.status === 'failed'), hallucination_signals: sum(metrics, 'hallucination_signals'), @@ -260,6 +272,39 @@ export class TelemetryService { head: readHead(PATHS.auditHead), last_decision: audit.length ? audit[audit.length - 1].decision ?? null : null, }, + latest_assurance: latestTraceId && latestMetric && latestGraph ? { + trace_id: latestTraceId, + certified: latestTrace?.certified === true, + certification_strength: latestTrace?.certification_strength ?? latestMetric.certification_strength ?? null, + telemetry_quality: latestMetric.telemetry_quality ?? 'unknown', + duration_ms: latestMetric.duration_ms ?? latestMetric.latency_ms ?? null, + tool_calls: latestMetric.tool_calls ?? 0, + failures: latestMetric.failures ?? 0, + graph: latestGraph, + } : null, + }; + } + + projectProfile() { + const config = readJson(join(APP_ROOT, '.casan', 'config.json')) as Row | null; + const maturity = config?.maturity && typeof config.maturity === 'object' + ? config.maturity as Row + : null; + return { + project_id: String(config?.project_id ?? APP_ROOT.split('/').pop() ?? 'unknown'), + app_root: APP_ROOT, + edition: String(config?.edition ?? config?.target_level_name ?? 'unknown'), + edition_status: String(config?.edition_status ?? 'unknown'), + maturity: { + level: typeof maturity?.level === 'number' ? maturity.level : null, + status: String(maturity?.status ?? 'not_assessed'), + evidence: typeof maturity?.evidence === 'string' ? maturity.evidence : null, + }, + enforcement_mode: String(config?.enforcement_mode ?? 'unknown'), + integration_mode: String(config?.integration_mode ?? 'unknown'), + clients: Array.isArray(config?.clients) + ? config.clients.filter((value): value is string => typeof value === 'string') + : [], }; } @@ -351,13 +396,45 @@ export class TelemetryService { }, }); } + const gateMap: Record = { + H1: 'H1-context', + H2: 'H2-tool', + H3: 'H3-eval', + H4: 'H4-security', + H5: 'H5-governance', + H6: 'H6-agentops', + H7: 'H7-orchestration', + }; + for (const evidence of arr(legacyTrace.evidence)) { + const harnesses = String(evidence.h ?? '').split('/'); + for (const harness of harnesses) { + const mappedGate = gateMap[harness]; + if (!mappedGate) continue; + events.push({ + timestamp: String(evidence.at ?? legacyTrace.finalized_at ?? ''), + trace_id: safeId, + gate_id: mappedGate, + status: gateStatus(evidence.decision), + reason: `${String(evidence.kind ?? 'legacy-evidence')}: ${String(evidence.detail ?? '')}`, + evidence: { + kind: evidence.kind ?? null, + decision: evidence.decision ?? null, + detail: evidence.detail ?? null, + certification_strength: legacyTrace.certification_strength ?? null, + }, + }); + } + } } for (const metric of readJsonl(PATHS.metrics).filter((row) => row.trace_id === safeId)) { + const telemetryQuality = String(metric.telemetry_quality ?? ''); events.push({ timestamp: String(metric.timestamp ?? ''), trace_id: safeId, gate_id: 'H6-agentops', - status: gateStatus(metric.status), + status: ['partial', 'insufficient'].includes(telemetryQuality) + ? 'warning' + : gateStatus(metric.status), reason: String(metric.step ?? 'Legacy runtime metric'), evidence: { latency_ms: metric.latency_ms ?? null, diff --git a/packages/casan-control-panel/backend/test/ingest.test.ts b/packages/casan-control-panel/backend/test/ingest.test.ts new file mode 100644 index 0000000..5b9522f --- /dev/null +++ b/packages/casan-control-panel/backend/test/ingest.test.ts @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; +import { IngestService, expectedSignature, stableJson } from '../src/ingest/ingest.service.js'; + +test('ingest canonical JSON is stable across object key order', () => { + assert.equal(stableJson({ z: 1, a: { y: 2, b: 3 } }), '{"a":{"b":3,"y":2},"z":1}'); +}); + +test('ingest is disabled unless an HMAC secret is configured', () => { + const previous = process.env.CASAN_CP_INGEST_TOKEN; + delete process.env.CASAN_CP_INGEST_TOKEN; + try { + assert.throws(() => new IngestService().ingest({}, '0', 'none'), ServiceUnavailableException); + } finally { + if (previous === undefined) delete process.env.CASAN_CP_INGEST_TOKEN; + else process.env.CASAN_CP_INGEST_TOKEN = previous; + } +}); + +test('signed ingest rejects raw prompt/content fields before persistence', () => { + const previous = process.env.CASAN_CP_INGEST_TOKEN; + process.env.CASAN_CP_INGEST_TOKEN = 'test-only-secret'; + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = { + schema_version: 1, + sent_at: new Date().toISOString(), + project_id: 'safe-project', + trace_id: 'safe-trace', + receipt: {}, + metric: { trace_id: 'safe-trace', prompt: 'must-not-cross-boundary' }, + trace: { trace_id: 'safe-trace' }, + events: [], + }; + try { + const signature = expectedSignature('test-only-secret', timestamp, body); + assert.throws( + () => new IngestService().ingest(body, timestamp, signature), + BadRequestException, + ); + } finally { + if (previous === undefined) delete process.env.CASAN_CP_INGEST_TOKEN; + else process.env.CASAN_CP_INGEST_TOKEN = previous; + } +}); diff --git a/packages/casan-control-panel/backend/test/reports.test.ts b/packages/casan-control-panel/backend/test/reports.test.ts index b0686ec..daafc67 100644 --- a/packages/casan-control-panel/backend/test/reports.test.ts +++ b/packages/casan-control-panel/backend/test/reports.test.ts @@ -8,6 +8,8 @@ import { sourceFreshness } from '../src/common/app-root.js'; import { buildH6Report, parseH6ReportQuery, type H6ReportInput } from '../src/reports/h6-report.js'; import { renderH6ReportHtml } from '../src/reports/h6-report.html.js'; import { HARNESS_REPORT_CATALOG } from '../src/reports/report.contract.js'; +import { buildRunAssuranceReport } from '../src/reports/run-report.js'; +import { renderRunAssuranceHtml } from '../src/reports/run-report.html.js'; const NOW = new Date('2026-07-20T12:00:00.000Z'); @@ -68,6 +70,8 @@ test('H6 report filters project/time/run and aggregates measured evidence', () = assert.equal(report.summary.latency_ms.p50, 100); assert.equal(report.summary.latency_ms.p95, 9000); assert.equal(report.summary.tokens.provider_total, 28); + assert.equal(report.summary.coverage.token_pct, 100); + assert.equal(report.summary.coverage.cost_pct, 100); assert.equal(report.summary.alerts, 2); assert.deepEqual(report.details.by_alert, [ { alert: 'execution-failed', count: 1 }, @@ -105,6 +109,30 @@ test('H6 report makes stale optional sources explicit in data quality', () => { assert.ok(report.data_quality.warnings.some((warning) => warning.includes('alerts telemetry source is missing'))); }); +test('H6 report never presents missing Codex usage as zero-cost coverage', () => { + const input = fixture(); + input.metrics = [{ + timestamp: '2026-07-19T10:00:00Z', + trace_id: 'codex-null', + project_id: 'basic-design', + status: 'success', + latency_ms: 244090, + total_tokens: null, + cost_estimate: null, + telemetry_quality: 'insufficient', + }]; + input.provider = []; + const report = buildH6Report(input, parseH6ReportQuery({ run: 'codex-null' })); + assert.equal(report.summary.coverage.token_pct, 0); + assert.equal(report.summary.coverage.cost_pct, 0); + assert.equal(report.summary.tokens.total, null); + assert.equal(report.summary.cost_usd.provider_actual, null); + assert.equal(report.summary.cost_usd.estimated, null); + assert.equal(report.data_quality.status, 'insufficient'); + assert.equal(report.verdict, 'attention'); + assert.ok(report.findings.some((finding) => finding.code === 'TELEMETRY_COVERAGE_GAP')); +}); + test('HTML export is standalone, escaped and contains no hard-coded maturity score', () => { const input = fixture(); input.metrics[0].step = ''; @@ -117,3 +145,49 @@ test('HTML export is standalone, escaped and contains no hard-coded maturity sco assert.doesNotMatch(html, /Average\s+\d|\/100|218 core tests/i); assert.match(html, /No maturity score or telemetry value is hard-coded/); }); + +test('per-run assurance export carries H1-H7 and truthful H6 availability', () => { + const nodes = ['Context', 'Tool', 'Eval', 'Security', 'Governance', 'AgentOps', 'Orchestration'] + .map((title, index) => ({ + id: `H${index + 1}`, + title: `H${index + 1} · ${title}`, + description: title, + status: index === 5 ? 'warning' : 'pass', + reason: index === 5 ? 'provider usage unavailable' : 'evidence verified', + updated_at: NOW.toISOString(), + evidence: {}, + })); + const report = buildRunAssuranceReport({ + traceId: 'trace-safe', + graph: { found: true, terminal: true, progress: 7, nodes }, + trace: { + certified: true, + certification_strength: 'project_hook', + certification_reasons: ['evidence_complete'], + finalized_at: NOW.toISOString(), + tool_calls: 4, + }, + metric: { + trace_id: 'trace-safe', + telemetry_quality: 'insufficient', + duration_ms: 1200, + total_tokens: null, + cost_estimate: null, + }, + config: { + project_id: 'basic-design', + edition: 'core', + maturity: { level: null, status: 'not_assessed' }, + }, + now: NOW, + }); + assert.equal(report.verdict, 'certified'); + assert.equal(report.summary.gates_observed, 7); + assert.equal(report.summary.token_usage_available, false); + assert.equal(report.summary.cost_available, false); + const html = renderRunAssuranceHtml(report); + assert.match(html, /Live assurance rail · H1 → H7/); + assert.match(html, /Evidence fields/); + assert.match(html, /Unavailable/); + assert.doesNotMatch(html, /\$0(?:\.0+)?/); +}); diff --git a/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx b/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx index 132fe95..4a5783f 100644 --- a/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx +++ b/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx @@ -100,14 +100,14 @@ function H6Summary({ node, goal, runReport, projectReport, loading, error }: { return
Run telemetry could not be loaded here. Open the full H6 report to inspect the source status.
; } - const tokenTotal = runReport.summary.tokens.provider_total || runReport.summary.tokens.total; + const tokenTotal = runReport.summary.tokens.provider_total ?? runReport.summary.tokens.total; const actualCost = runReport.summary.cost_usd.provider_actual; - const displayedCost = actualCost || runReport.summary.cost_usd.estimated; + const displayedCost = actualCost ?? runReport.summary.cost_usd.estimated; const provider = runReport.details.by_provider.slice(0, 2).map((row) => row.key).join(', ') || 'Not attributed'; const metrics = [ ['Max runtime', formatDuration(runReport.summary.latency_ms.max)], - ['Tokens', tokenTotal.toLocaleString()], - [actualCost ? 'Actual cost' : 'Estimated cost', formatCost(displayedCost)], + ['Tokens', tokenTotal === null ? 'Unavailable' : tokenTotal.toLocaleString()], + [actualCost !== null ? 'Actual cost' : displayedCost !== null ? 'Estimated cost' : 'Cost', displayedCost === null ? 'Unavailable' : formatCost(displayedCost)], ['Retries', runReport.summary.retries.toLocaleString()], ['Provider calls', runReport.summary.provider_calls.toLocaleString()], ['Freshness', runReport.freshness.status], diff --git a/packages/casan-control-panel/frontend/src/components/layout/Header.tsx b/packages/casan-control-panel/frontend/src/components/layout/Header.tsx index 30b3fbb..744d6d1 100644 --- a/packages/casan-control-panel/frontend/src/components/layout/Header.tsx +++ b/packages/casan-control-panel/frontend/src/components/layout/Header.tsx @@ -21,6 +21,7 @@ const PAGE_COPY: Record = { export function Header() { const { data } = useQuery({ queryKey: ['health'], queryFn: health }); const { data: session } = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: 60_000 }); + const { data: project } = useQuery({ queryKey: ['project-profile'], queryFn: api.project, staleTime: 60_000 }); const { pathname } = useLocation(); const stale = data ? !data.ok : true; const page = PAGE_COPY[pathname] ?? PAGE_COPY['/']; @@ -32,6 +33,10 @@ export function Header() {

{page.title}

+
+
{project?.project_id ?? 'Discovering project…'}
+
{project?.edition ?? 'unknown'} edition · maturity {project?.maturity.level ? `L${project.maturity.level}` : 'not assessed'}
+
{data?.runs ?? '—'} governed runs
diff --git a/packages/casan-control-panel/frontend/src/components/trace/AssuranceRail.tsx b/packages/casan-control-panel/frontend/src/components/trace/AssuranceRail.tsx new file mode 100644 index 0000000..49275ed --- /dev/null +++ b/packages/casan-control-panel/frontend/src/components/trace/AssuranceRail.tsx @@ -0,0 +1,42 @@ +import type { HarnessGateStatus, HarnessTraceGraph } from '../../lib/api'; + +const TONE: Record = { + queued: 'border-slate-200 bg-slate-100 text-slate-400', + running: 'border-blue-300 bg-blue-50 text-blue-700', + pass: 'border-emerald-300 bg-emerald-50 text-emerald-700', + warning: 'border-amber-300 bg-amber-50 text-amber-700', + blocked: 'border-rose-300 bg-rose-50 text-rose-700', + error: 'border-rose-300 bg-rose-50 text-rose-700', + skipped: 'border-slate-200 bg-slate-100 text-slate-400', +}; + +const MARK: Record = { + queued: '○', + running: '●', + pass: '✓', + warning: '!', + blocked: '×', + error: '×', + skipped: '–', +}; + +export function AssuranceRail({ graph }: { graph: HarnessTraceGraph }) { + return ( +
+
+ {graph.nodes.map((node, index) => ( +
+
+
+ {node.title.split(' · ')[0]} + {MARK[node.status]} +
+
{node.title.split(' · ')[1]}
+
+ {index < graph.nodes.length - 1 &&
} +
+ ))} +
+
+ ); +} diff --git a/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx b/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx index 928a1fe..cbb8d76 100644 --- a/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx +++ b/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { api, type HarnessGateNode, type HarnessGateStatus, type HarnessTraceGraph } from '../../lib/api'; +import { api, runReportExportUrl, type HarnessGateNode, type HarnessGateStatus, type HarnessTraceGraph } from '../../lib/api'; import { Card, StatusBadge } from '../ui/Card'; const NODE_TONE: Record = { @@ -82,7 +82,11 @@ export function TraceExplorer({ traceId, onClose }: { traceId: string; onClose?: return ( Close} + right={
+ Open H6 + Export evidence + {onClose && } +
} className="overflow-hidden" >
diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index 5788265..9e72c5a 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -59,8 +59,8 @@ export interface H6Breakdown { runs: number; failures: number; latency_avg_ms: number; - tokens: number; - cost_usd: number; + tokens: number | null; + cost_usd: number | null; } export interface H6Report { @@ -87,10 +87,18 @@ export interface H6Report { failure_rate_pct: number; retries: number; latency_ms: { average: number; p50: number; p95: number; p99: number; max: number }; - tokens: { input: number; output: number; total: number; provider_total: number }; - cost_usd: { provider_actual: number; estimated: number }; + tokens: { input: number | null; output: number | null; total: number | null; provider_total: number | null }; + cost_usd: { provider_actual: number | null; estimated: number | null }; provider_calls: number; alerts: number; + coverage: { + runtime_records: number; + token_records: number; + cost_records: number; + token_pct: number; + cost_pct: number; + quality: { complete: number; partial: number; insufficient: number; unknown: number }; + }; }; thresholds: Record; findings: H6ReportFinding[]; @@ -141,11 +149,22 @@ export interface EvidencePackDetail extends EvidencePackSummary { export interface Overview extends Freshness { totals: { runs: number; total_cost: number; avg_latency_ms: number; failures: number; + token_coverage_pct: number; cost_coverage_pct: number; hallucination_signals: number; provider_tokens: number; provider_cost: number; fallback_routes: number; tool_denies: number; action_blocks: number; }; harness_signals: Record>; audit_chain: { records: number; head: string | null; last_decision: string | null }; + latest_assurance: { + trace_id: string; + certified: boolean; + certification_strength: string | null; + telemetry_quality: string; + duration_ms: number | null; + tool_calls: number; + failures: number; + graph: HarnessTraceGraph; + } | null; } export interface SettingsActor { @@ -581,8 +600,25 @@ export function h6ReportExportUrl(query: H6ReportQuery, format: 'json' | 'html') return `${base}/reports/h6/export?${params.toString()}`; } +export function runReportExportUrl(traceId: string, format: 'json' | 'html'): string { + const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'; + return `${base}/reports/run/${encodeURIComponent(traceId)}/export?format=${format}`; +} + +export interface ProjectProfile { + project_id: string; + app_root: string; + edition: string; + edition_status: string; + maturity: { level: number | null; status: string; evidence: string | null }; + enforcement_mode: string; + integration_mode: string; + clients: string[]; +} + export const api = { session: () => get('session'), + project: () => get('project'), overview: () => get('overview'), runs: (limit = 50) => get(`runs?limit=${limit}`), traceGraph: (traceId: string) => get(`runs/${encodeURIComponent(traceId)}/graph`), diff --git a/packages/casan-control-panel/frontend/src/pages/H6Report.tsx b/packages/casan-control-panel/frontend/src/pages/H6Report.tsx index b3086d1..d018ae3 100644 --- a/packages/casan-control-panel/frontend/src/pages/H6Report.tsx +++ b/packages/casan-control-panel/frontend/src/pages/H6Report.tsx @@ -31,7 +31,7 @@ function MetricCard({ label, value, sub, accent = false }: { label: string; valu return
{accent && }
{label}
-
{value}
+
9 ? 'text-base' : 'text-2xl'}`}>{value}
{sub &&
{sub}
}
; } @@ -42,7 +42,7 @@ function BreakdownTable({ rows, subject }: { rows: H6Breakdown[]; subject: strin {subject}runsfailuresavg latencytokenscost {rows.map((row) => - {row.key}{integer(row.runs)}{integer(row.failures)}{integer(row.latency_avg_ms)} ms{integer(row.tokens)}{money(row.cost_usd)} + {row.key}{integer(row.runs)}{integer(row.failures)}{integer(row.latency_avg_ms)} ms{row.tokens === null ? 'Unavailable' : integer(row.tokens)}{row.cost_usd === null ? 'Unavailable' : money(row.cost_usd)} )}{rows.length === 0 && No matching {subject.toLowerCase()} records.}
; } @@ -108,9 +108,22 @@ export function H6ReportPage() { - - - + 0 ? integer(report.data.summary.tokens.provider_total ?? report.data.summary.tokens.total ?? 0) : 'Unavailable'} + sub={`${report.data.summary.coverage.token_pct}% run coverage`} + accent + /> + + @@ -127,6 +140,10 @@ export function H6ReportPage() {
+
+
Token coverage
{report.data.summary.coverage.token_pct}%
+
Cost coverage
{report.data.summary.coverage.cost_pct}%
+
{report.data.data_quality.warnings.length > 0 ?
    {report.data.data_quality.warnings.map((warning) =>
  • {warning}
  • )}
:
All required sources are present and no estimation warning was detected.
}
Generated on demand · no hard-coded score}> diff --git a/packages/casan-control-panel/frontend/src/pages/Overview.tsx b/packages/casan-control-panel/frontend/src/pages/Overview.tsx index 26c8e4f..8e40db2 100644 --- a/packages/casan-control-panel/frontend/src/pages/Overview.tsx +++ b/packages/casan-control-panel/frontend/src/pages/Overview.tsx @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import { Card, StatTile, StatusBadge } from '../components/ui/Card'; +import { AssuranceRail } from '../components/trace/AssuranceRail'; +import { Link } from 'react-router-dom'; function SignalMark({ tone }: { tone: 'indigo' | 'emerald' | 'amber' }) { const color = { indigo: 'bg-indigo-500', emerald: 'bg-emerald-500', amber: 'bg-amber-500' }[tone]; @@ -39,10 +41,41 @@ export function Overview() {
+ {data.latest_assurance && ( +
+
+
+
Latest assurance receipt
+
+

{data.latest_assurance.certified ? 'Certified governed run' : 'Run requires review'}

+ + +
+

{data.latest_assurance.trace_id}

+
+
+ Inspect H6 + Open live trace +
+
+ +
+ {data.latest_assurance.graph.progress}/7 gates observed + {data.latest_assurance.tool_calls} tools + {data.latest_assurance.failures} failures + {data.latest_assurance.duration_ms == null ? '—' : `${Math.round(data.latest_assurance.duration_ms / 1000)}s`} duration +
+
+ )} +
- + 0 ? `$${totals.total_cost.toFixed(4)}` : 'Unavailable'} + sub={`${totals.cost_coverage_pct}% cost coverage`} + /> diff --git a/packages/casan-control-panel/frontend/src/pages/Runs.tsx b/packages/casan-control-panel/frontend/src/pages/Runs.tsx index 153dfed..f36706b 100644 --- a/packages/casan-control-panel/frontend/src/pages/Runs.tsx +++ b/packages/casan-control-panel/frontend/src/pages/Runs.tsx @@ -30,7 +30,7 @@ export function Runs() { {run.latency_ms ?? '—'} ms {run.total_tokens ?? '—'} - ${Number(run.cost_estimate ?? 0).toFixed(5)} + {run.cost_estimate == null ? Unavailable : `$${Number(run.cost_estimate).toFixed(5)}`} {run.trace_id ? (