Files
triggerdotdev--trigger.dev/pr-4327.diff
T

3487 lines
140 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/.changeset/report-health.md b/.changeset/report-health.md
new file mode 100644
index 00000000000..92dc9d6588f
--- /dev/null
+++ b/.changeset/report-health.md
@@ -0,0 +1,8 @@
+---
+"@trigger.dev/core": patch
+"trigger.dev": patch
+---
+
+Add the `get_report` MCP tool, a `trigger report` CLI command, and `GET /api/v1/reports/:key`, starting with the `health` report: a deterministic verdict on whether work is flowing, whether the runs that start are healthy, and whether telemetry is fresh — rendered as text with unicode sparklines (coloured in a terminal). Also adds a `report` MCP prompt, surfaced as a slash command in hosts that support prompts.
+
+Also: `trigger mcp` now always starts the server — the install wizard is gated behind `trigger mcp --install`. A TTY previously launched the wizard, so MCP hosts spawning the server over a PTY timed out.
diff --git a/apps/webapp/app/presenters/v3/reports/ReportPresenter.server.ts b/apps/webapp/app/presenters/v3/reports/ReportPresenter.server.ts
new file mode 100644
index 00000000000..1393fd611df
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/ReportPresenter.server.ts
@@ -0,0 +1,49 @@
+/**
+ * Thin orchestrator: load -> interpret -> return the generic ReportViewModel. No SQL or render
+ * here — data access lives in each report's `load`, meaning in `interpret`, presentation in the
+ * renderers, and the catalog of reports in `report-registry.ts`.
+ *
+ * `call` takes a resolved AuthenticatedEnvironment and is transport-independent (Seam B, §7):
+ * any future surface (MCP Resource, etc.) is just another caller of this same method.
+ */
+
+import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
+import { REPORT_REGISTRY } from "./report-registry";
+import { type ReportViewModel } from "./report-view-model";
+
+const DEFAULT_PERIOD = "1h";
+
+/**
+ * Single-flight: collapse concurrent identical requests (same report/env/period) into one
+ * computation. A report fires up to ~9 ClickHouse queries and MCP/CLI clients easily call it
+ * several times at once — without this, N callers each launch the full query set and pile onto
+ * the per-project query-concurrency limit. Keyed per (key, env, period); entry drops on settle.
+ */
+const inFlight = new Map<string, Promise<ReportViewModel | undefined>>();
+
+export class ReportPresenter {
+ async call({
+ environment,
+ key,
+ period = DEFAULT_PERIOD,
+ }: {
+ environment: AuthenticatedEnvironment;
+ key: string;
+ period?: string;
+ }): Promise<ReportViewModel | undefined> {
+ const loader = REPORT_REGISTRY[key];
+ if (!loader) return undefined;
+
+ const flightKey = `${key} ${environment.id} ${period}`;
+ const existing = inFlight.get(flightKey);
+ if (existing) return existing;
+
+ const promise = (async () => {
+ const input = await loader.load(environment, period);
+ return loader.interpret(input);
+ })().finally(() => inFlight.delete(flightKey));
+
+ inFlight.set(flightKey, promise);
+ return promise;
+ }
+}
diff --git a/apps/webapp/app/presenters/v3/reports/health/execution.ts b/apps/webapp/app/presenters/v3/reports/health/execution.ts
new file mode 100644
index 00000000000..12d19cef603
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/health/execution.ts
@@ -0,0 +1,55 @@
+/**
+ * EXECUTION analyzer: "are the runs that DO start completing OK?" — failures + durations only.
+ * Its "read:" line answers whether the flow problem is a code problem.
+ */
+
+import { isOk, maxSeverity, type Finding, type Metric } from "../report-view-model";
+import { HEALTH_THRESHOLDS, metricById, type HealthInput } from "./health-core";
+
+export const EXECUTION_METRIC_IDS = ["failures", "dur_p95"];
+
+export function interpretExecution(metrics: Metric[], input: HealthInput): Finding {
+ const exec = EXECUTION_METRIC_IDS.map((id) => metricById(metrics, id));
+ const severity = maxSeverity(...exec.map((m) => m.severity));
+ const failures = metricById(metrics, "failures");
+
+ if (isOk(severity)) {
+ return { type: "execution", severity, reason: "healthy", metricIds: EXECUTION_METRIC_IDS };
+ }
+
+ const reason = !isOk(failures.severity) ? "failures_up" : "slow_runs";
+ const recommendation =
+ reason === "failures_up"
+ ? { code: "review_failing_tasks", link: "runs_failed" }
+ : { code: "review_slow_runs", link: "runs" };
+
+ // Lazy attribution when it owns >= minShare of failures.
+ let attribution: Finding["attribution"];
+ const fb = input.failureBreakdown;
+ if (fb && fb.share >= HEALTH_THRESHOLDS.attribution.minShare) {
+ attribution = { dim: "task", key: fb.task, share: fb.share, of: "failures" };
+ }
+
+ return {
+ type: "execution",
+ severity,
+ reason,
+ metricIds: EXECUTION_METRIC_IDS,
+ recommendation,
+ attribution,
+ };
+}
+
+/** Flow causes that provably CAN'T be user code, so a healthy execution reads "not a code problem".
+ * dequeue_stall is platform-side (capacity free but nothing dequeuing). Trigger spike/surge are
+ * excluded: a code path fanning out task.trigger can BE the cause, so we only state the runs that
+ * execute are fine — never the global "not a code problem". */
+const NOT_A_CODE_PROBLEM_CAUSES = new Set(["dequeue_stall"]);
+
+export function buildExecutionRead(execution: Finding, flow: Finding): string {
+ if (execution.reason === "unknown") return "data_stale";
+ if (isOk(execution.severity)) {
+ return NOT_A_CODE_PROBLEM_CAUSES.has(flow.reason) ? "not_a_code_problem" : "runs_are_fine";
+ }
+ return "failures_elevated";
+}
diff --git a/apps/webapp/app/presenters/v3/reports/health/flow.ts b/apps/webapp/app/presenters/v3/reports/health/flow.ts
new file mode 100644
index 00000000000..a531a4aeb11
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/health/flow.ts
@@ -0,0 +1,304 @@
+/**
+ * FLOW analyzer: "is work flowing, and if not, WHY?" Diagnosed by a CAUSE TREE — `flow.reason`
+ * names why work is backing up (env_limit_saturation, dequeue_stall, …), selected from flow's
+ * evidence, falling back to v1 symptom reasons when no discriminator fires. Evidence quantities
+ * carry no severity of their own — they only select the cause.
+ */
+
+import {
+ anomalyWindow,
+ isOk,
+ maxSeverity,
+ type Exclusion,
+ type Finding,
+ type Metric,
+ type Observation,
+ type Recommendation,
+ type Severity,
+} from "../report-view-model";
+import {
+ HEALTH_THRESHOLDS,
+ isPendingIncreasing,
+ mean,
+ metricById,
+ type HealthInput,
+} from "./health-core";
+
+export const FLOW_METRIC_IDS = ["start_latency_p95", "pending", "throughput"];
+
+/** One row of the declarative cause table — everything a cause defines about itself. */
+type CauseSpec = {
+ reason: string;
+ metricIds: string[]; // real metric rows, causal order
+ drivingMetricId: string; // series for the anomaly window
+ annotationCode?: string; // set on the driving metric
+ exclusions: Exclusion[]; // ruled-out causes ("not your code")
+ observations: Observation[]; // supporting facts ("runs are completing at ~X/min")
+ recommendation: Recommendation;
+ usesAttribution: boolean; // append the worst-queue attribution line
+};
+
+export function interpretFlow(metrics: Metric[], input: HealthInput): Finding {
+ const t = HEALTH_THRESHOLDS.flowCause;
+ const ev = input.flowEvidence;
+ const flowMetrics = FLOW_METRIC_IDS.map((id) => metricById(metrics, id));
+ const severity = maxSeverity(...flowMetrics.map((m) => m.severity));
+
+ if (isOk(severity)) {
+ return { type: "flow", severity, reason: "healthy", metricIds: FLOW_METRIC_IDS };
+ }
+
+ // Discriminators (evidence only, no own severity).
+ const pendingIncreasing = isPendingIncreasing(input.pending.series);
+ const latencyElevated = !isOk(metricById(metrics, "start_latency_p95").severity);
+ // Concurrency causes need real running-capacity evidence — without it runningShare is a
+ // meaningless 0 and would falsely select dequeue_stall on the snapshot path (#1).
+ const hasConcurrencyEvidence = ev.envLimit > 0 && ev.runningSeries.length > 0;
+ const runningShare = hasConcurrencyEvidence ? mean(ev.runningSeries) / ev.envLimit : 1;
+ const pinnedShare = hasConcurrencyEvidence
+ ? ev.runningSeries.filter((r) => r >= t.pinnedLevel * ev.envLimit).length /
+ ev.runningSeries.length
+ : 0;
+ const pinned = pinnedShare >= t.pinnedShare;
+ const hasTriggerBaseline = input.throughput.normalTriggeredPerMin > 0;
+ const triggeredMult = hasTriggerBaseline
+ ? input.throughput.triggeredPerMin / input.throughput.normalTriggeredPerMin
+ : 0;
+ // No baseline: a multiplier can't be computed, so an absolute rate selects "new volume".
+ const triggerSurge = !hasTriggerBaseline && input.throughput.triggeredPerMin >= t.surgePerMin;
+
+ const donePerMin = input.throughput.donePerMin;
+ const net = donePerMin - input.throughput.triggeredPerMin;
+ // Exclusions must be PROVEN, not assumed. "not your code" needs healthy execution; "limits
+ // aren't the bottleneck" needs no env-pin AND no queue throttling; the workers/spike ones
+ // state a measured fact (rate) rather than a global "everything's fine" claim.
+ const executionHealthy =
+ isOk(metricById(metrics, "failures").severity) && isOk(metricById(metrics, "dur_p95").severity);
+ const queueThrottled = ev.throttledShare >= t.throttledShare;
+ const configHealthy = !pinned && !queueThrottled;
+ // A trigger spike/surge is only the CAUSE of a backup when work is actually piling up:
+ // completions falling behind (net < 0) AND the backlog trending up. Without this a spike that
+ // drains fine would still be blamed while its read says "queue fills faster than it drains".
+ const triggerBacklog = net < 0 && pendingIncreasing;
+
+ // First discriminator that fires wins (fixed priority). dequeue_stall is a last-resort
+ // "it's on our side" cause — so a known config bottleneck (queue throttling) must rule it
+ // out first, else throttled-but-idle-capacity is misread as a platform stall (#1).
+ let spec: CauseSpec;
+ if (
+ hasConcurrencyEvidence &&
+ !queueThrottled &&
+ runningShare < t.stallRunningShare &&
+ pendingIncreasing &&
+ latencyElevated
+ ) {
+ spec = {
+ reason: "dequeue_stall",
+ metricIds: ["concurrency", "pending", "start_latency_p95"],
+ drivingMetricId: "concurrency",
+ annotationCode: "idle_share",
+ exclusions: [
+ ...(executionHealthy ? [{ code: "not_your_code" }] : []),
+ ...(configHealthy ? [{ code: "not_your_config" }] : []),
+ ],
+ observations: [],
+ recommendation: { code: "check_platform_status", link: "status" },
+ usesAttribution: false,
+ };
+ } else if (pinned && pendingIncreasing) {
+ spec = {
+ reason: "env_limit_saturation",
+ metricIds: ["concurrency", "pending", "start_latency_p95"],
+ drivingMetricId: "concurrency",
+ annotationCode: "pinned_minutes",
+ exclusions: [],
+ // States a measured fact (runs ARE completing at {rate}/min) — evidence the workers aren't
+ // dead. An observation, not an exclusion: it doesn't claim it's the limit, nor "keeps pace".
+ observations:
+ donePerMin > 0 ? [{ code: "not_workers_platform", evidence: { donePerMin } }] : [],
+ recommendation: { code: "raise_env_limit", link: "concurrency" },
+ usesAttribution: true,
+ };
+ } else if (ev.throttledShare >= t.throttledShare && !pinned) {
+ spec = {
+ reason: "queue_limit_throttling",
+ metricIds: ["throttled", "pending"],
+ drivingMetricId: "throttled",
+ annotationCode: "throttled_minutes",
+ // Justified: we're in the not-pinned branch, so the env limit isn't the bottleneck.
+ exclusions: [{ code: "not_env_limit" }],
+ observations: [],
+ recommendation: { code: "raise_queue_limit", link: "queue" },
+ usesAttribution: true,
+ };
+ } else if (triggeredMult >= t.spikeMult && triggerBacklog) {
+ spec = {
+ reason: "trigger_spike",
+ metricIds: ["triggered", "pending", "start_latency_p95"],
+ drivingMetricId: "triggered",
+ annotationCode: "spike_mult",
+ exclusions: [],
+ // Only the proven fact — the runs that DO start execute fine. An observation, NOT the
+ // exclusion "not your code": healthy execution doesn't prove the code isn't the one
+ // triggering the flood (e.g. a deploy that fans out task.trigger in a loop).
+ observations: executionHealthy ? [{ code: "execution_healthy" }] : [],
+ recommendation: { code: "review_trigger_source", link: "runs" },
+ usesAttribution: false,
+ };
+ } else if (triggerSurge && triggerBacklog) {
+ spec = {
+ reason: "trigger_surge",
+ metricIds: ["triggered", "pending", "start_latency_p95"],
+ drivingMetricId: "triggered",
+ annotationCode: "surge_rate",
+ exclusions: [],
+ // Same as a spike: only the proven fact, without ruling out the trigger-producing code.
+ observations: executionHealthy ? [{ code: "execution_healthy" }] : [],
+ recommendation: { code: "review_trigger_source", link: "runs" },
+ usesAttribution: false,
+ };
+ } else {
+ // Fallback — v1 symptom reasons by dominant metric.
+ return fallbackFlow(flowMetrics, severity);
+ }
+
+ return assembleFlowCause(spec, metrics, input, severity);
+}
+
+/** Build a flow Finding from a cause spec: annotation, anomaly window, attribution, evidence. */
+function assembleFlowCause(
+ spec: CauseSpec,
+ metrics: Metric[],
+ input: HealthInput,
+ severity: Severity
+): Finding {
+ const t = HEALTH_THRESHOLDS;
+ const driving = metricById(metrics, spec.drivingMetricId);
+
+ // Anomaly window from the driving series. env_limit_saturation breaches ABOVE
+ // (concurrency pinned at the limit); dequeue_stall breaches BELOW (capacity idle).
+ // NOTE: runningSeries is at native env_metrics resolution (not resampled), so the "(last N
+ // min)" figure assumes those buckets are uniform and cover the resolved window. env_metrics
+ // are emitted on a fixed cadence, so that holds; a gappy/partial window could skew the minutes.
+ let aw: Finding["anomalyWindow"];
+ if (spec.reason === "env_limit_saturation" || spec.reason === "dequeue_stall") {
+ const below = spec.reason === "dequeue_stall";
+ const threshold = below
+ ? t.flowCause.stallRunningShare * input.flowEvidence.envLimit
+ : t.flowCause.pinnedLevel * input.flowEvidence.envLimit;
+ aw = anomalyWindow(input.flowEvidence.runningSeries, threshold, input.windowMinutes, { below });
+ }
+
+ // Annotation on the driving metric (a fact, not an invented number).
+ if (spec.annotationCode) {
+ const value =
+ spec.annotationCode === "pinned_minutes"
+ ? (aw?.minutes ?? 0)
+ : spec.annotationCode === "idle_share"
+ ? // mean running over the window ("N running of {limit}").
+ Math.round(mean(input.flowEvidence.runningSeries))
+ : spec.annotationCode === "spike_mult"
+ ? Math.round(
+ input.throughput.normalTriggeredPerMin > 0
+ ? input.throughput.triggeredPerMin / input.throughput.normalTriggeredPerMin
+ : 0
+ )
+ : spec.annotationCode === "throttled_minutes"
+ ? Math.round(input.flowEvidence.throttledShare * input.windowMinutes)
+ : spec.annotationCode === "surge_rate"
+ ? Math.round(input.throughput.triggeredPerMin)
+ : Math.round(driving.value);
+ driving.annotation = { code: spec.annotationCode, value };
+ }
+
+ // Attribution — only when a queue owns >= minShare of the problem.
+ let attribution: Finding["attribution"];
+ const wq = input.flowEvidence.worstQueue;
+ if (spec.usesAttribution && wq && wq.share >= t.attribution.minShare) {
+ attribution = { dim: "queue", key: wq.name, share: wq.share, of: "pending" };
+ }
+
+ // Append "nothing dead-lettered" ONLY on a measured zero (dlqDelta === 0); null means
+ // unmeasured (snapshot path) — no observation without evidence. It's a supporting fact, not a
+ // ruled-out cause, so it joins observations.
+ const observations =
+ input.flowEvidence.dlqDelta === 0
+ ? [...spec.observations, { code: "nothing_dead_lettered", evidence: { dlq: 0 } }]
+ : spec.observations;
+
+ return {
+ type: "flow",
+ severity,
+ reason: spec.reason,
+ metricIds: spec.metricIds,
+ recommendation: spec.recommendation,
+ anomalyWindow: aw,
+ attribution,
+ exclusions: spec.exclusions,
+ observations,
+ };
+}
+
+/** v1 symptom fallback when no cause discriminator fires. */
+function fallbackFlow(flowMetrics: Metric[], severity: Severity): Finding {
+ const firstOff = flowMetrics.find((m) => !isOk(m.severity));
+ const reason =
+ firstOff?.id === "start_latency_p95"
+ ? "start_latency"
+ : firstOff?.id === "pending"
+ ? "backlog"
+ : firstOff?.id === "throughput"
+ ? "throughput_lag"
+ : "degraded";
+ const recommendation =
+ reason === "start_latency"
+ ? { code: "review_start_latency", link: "queue_latency" }
+ : reason === "backlog"
+ ? { code: "check_queue_health", link: "queues" }
+ : reason === "throughput_lag"
+ ? { code: "check_worker_availability", link: "queues" }
+ : undefined;
+ return {
+ type: "flow",
+ severity,
+ reason,
+ metricIds: FLOW_METRIC_IDS,
+ recommendation,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Flow severity policy + the causal "read:" line.
+// ---------------------------------------------------------------------------
+
+export function applyFlowPolicy(
+ flow: Finding,
+ execution: Finding,
+ isDrainable: boolean,
+ telemetryStale: boolean
+): Finding {
+ if (flow.severity !== "crit") return flow;
+ // Downgrade a drainable crit to warn only when execution is fine and telemetry isn't stale.
+ // Unknown/lagging freshness must NOT block this (it's not a signal that anything's wrong).
+ const severity: Severity =
+ isOk(execution.severity) && !telemetryStale && isDrainable ? "warn" : "crit";
+ return { ...flow, severity };
+}
+
+const CAUSE_READS: Record<string, string> = {
+ dequeue_stall: "capacity_free_not_dequeuing",
+ env_limit_saturation: "saturation_chain",
+ queue_limit_throttling: "queue_throttle_chain",
+ trigger_spike: "spike_chain",
+ trigger_surge: "surge_chain",
+};
+
+export function buildFlowRead(flow: Finding, executionOk: boolean, livenessFresh: boolean): string {
+ if (flow.reason === "unknown") return "data_stale"; // stale-guarded — no causal read
+ if (isOk(flow.severity)) return "starting_normally";
+ if (CAUSE_READS[flow.reason]) return CAUSE_READS[flow.reason];
+ // fallback symptoms (v1 logic)
+ if (executionOk && livenessFresh) return "lag_while_triggering_normal";
+ if (!executionOk) return "lag_and_failures";
+ return "degraded_generic";
+}
diff --git a/apps/webapp/app/presenters/v3/reports/health/health-core.ts b/apps/webapp/app/presenters/v3/reports/health/health-core.ts
new file mode 100644
index 00000000000..78fb1d9fbef
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/health/health-core.ts
@@ -0,0 +1,271 @@
+/**
+ * Health report FOUNDATION shared by the three analyzers (flow / execution / liveness):
+ * the input shape, tunable thresholds, small helpers, and `buildMetrics` (numbers +
+ * per-metric severity). No verdict logic here — that lives in the per-analyzer modules.
+ */
+
+import { classifySeverity, delta, type Metric, type Severity } from "../report-view-model";
+
+export type HealthInput = {
+ scope: string;
+ period: string;
+ baselineLabel: string;
+ generatedAt: string;
+ /** live window length in minutes — for anomaly-window / annotation math. */
+ windowMinutes: number;
+ /** provenance; drives caveat text, not logic. */
+ flowSource: "snapshot+runs" | "queue_metrics_v1";
+ /**
+ * now = live env-level depth; normal = 7d baseline (omitted on the snapshot path, which has
+ * no real 7d pending baseline — so we never mislabel a live-window average as "7d normal");
+ * series measured (v2) or estimated (v1).
+ */
+ pending: { now: number; normal?: number; series: number[]; estimated: boolean };
+ startLatency: { p95Ms: number; normalP95Ms: number; series: number[] };
+ throughput: { donePerMin: number; triggeredPerMin: number; normalTriggeredPerMin: number };
+ failures: { rate: number; normalRate: number; series: number[] };
+ duration: { p95Ms: number; normalP95Ms: number };
+ /** Age of the freshest telemetry (ms). null = no signal to assess -> freshness unknown. */
+ liveness: { telemetryAgeMs: number | null };
+ /**
+ * Flow's cause-tree discriminators (no own severity) — from env_metrics rows + one
+ * queue_metrics GROUP BY. Empty-ish when unavailable (cause tree falls back to v1).
+ */
+ flowEvidence: {
+ runningSeries: number[];
+ envLimit: number;
+ throttledShare: number;
+ worstQueue: { name: string; share: number } | null;
+ /** runs dead-lettered in the window: 0 = measured none, null = unmeasured (snapshot). */
+ dlqDelta: number | null;
+ };
+ /** Lazy — loaded only when execution degrades (attribution line). */
+ failureBreakdown?: { task: string; share: number; region?: string };
+};
+
+/** Tunable defaults — first-guess; tune against prod once wired. */
+export const HEALTH_THRESHOLDS = {
+ // `floor` = absolute warn/crit used when there's no usable baseline (normal 0/undefined),
+ // so a spike from a zero baseline (e.g. a never-failing env) isn't classified healthy.
+ startLatency: { warnMult: 3, critMult: 10, floor: { warn: 30_000, crit: 120_000 } },
+ pending: { warnMult: 2, critMult: 10, floor: { warn: 500, crit: 5_000 } },
+ failures: {
+ warnMult: 2,
+ critMult: 4,
+ floorRate: 0.005,
+ floor: { warn: 0.02, crit: 0.05 },
+ },
+ duration: { warnMult: 1.5, critMult: 3, floor: { warn: 60_000, crit: 600_000 } },
+ liveness: { freshMs: 60_000, staleMs: 300_000 },
+ flowPolicy: { drainCritMinutes: 60 },
+ flowCause: {
+ stallRunningShare: 0.3, // dequeue_stall: running/limit below this while pending grows
+ pinnedLevel: 0.95, // a bucket counts as "pinned" when running >= 95% of limit
+ pinnedShare: 0.5, // env_limit_saturation: >= half the window's buckets pinned
+ throttledShare: 0.25, // queue_limit_throttling: >= a quarter of the window throttled
+ spikeMult: 3, // trigger_spike: triggered/min >= 3x the 7d-normal rate
+ // trigger_surge: with NO usable baseline (normal 0), a multiplier is meaningless, so an
+ // absolute floor picks the "new volume" cause instead of dropping to the v1 fallback.
+ surgePerMin: 100,
+ },
+ attribution: { minShare: 0.5 }, // name a queue/task/region only when it owns >= half the problem
+};
+
+// ---------------------------------------------------------------------------
+// Helpers.
+// ---------------------------------------------------------------------------
+
+export const mean = (xs: number[]) =>
+ xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length;
+
+/** Deterministic "trending up": mean of the last third vs the first third (direction only). */
+export function isPendingIncreasing(series: number[]): boolean {
+ if (series.length < 2) return false;
+ const third = Math.max(1, Math.floor(series.length / 3));
+ return mean(series.slice(-third)) > mean(series.slice(0, third));
+}
+
+function multiplierSeverity(
+ value: number,
+ normal: number | undefined,
+ warnMult: number,
+ critMult: number,
+ floor?: { warn: number; crit: number }
+): Severity {
+ if (normal === undefined || !Number.isFinite(normal) || normal === 0) {
+ // No usable baseline: fall back to an absolute floor so a spike isn't a false green.
+ return floor ? classifySeverity(value, floor) : "ok";
+ }
+ return classifySeverity(value / normal, { warn: warnMult, crit: critMult });
+}
+
+/** Look up a metric by id; throws if absent (buildMetrics guarantees the standard set exists). */
+export function metricById(metrics: Metric[], id: string): Metric {
+ const m = metrics.find((x) => x.id === id);
+ if (!m) throw new Error(`health: missing metric ${id}`);
+ return m;
+}
+
+// ---------------------------------------------------------------------------
+// buildMetrics: numbers + per-metric severity. The standard six carry severity; the
+// flow-evidence metrics (concurrency, throttled, triggered) are evidence only (severity ok)
+// and exist so a cause's metricIds resolve.
+// ---------------------------------------------------------------------------
+
+export function buildMetrics(input: HealthInput): Metric[] {
+ const t = HEALTH_THRESHOLDS;
+ const ev = input.flowEvidence;
+
+ const startLatency: Metric = {
+ id: "start_latency_p95",
+ value: input.startLatency.p95Ms,
+ unit: "ms",
+ aggregation: "p95",
+ normal: input.startLatency.normalP95Ms,
+ delta: delta(input.startLatency.p95Ms, input.startLatency.normalP95Ms),
+ series: { points: input.startLatency.series, kind: "measured" },
+ severity: multiplierSeverity(
+ input.startLatency.p95Ms,
+ input.startLatency.normalP95Ms,
+ t.startLatency.warnMult,
+ t.startLatency.critMult,
+ t.startLatency.floor
+ ),
+ };
+
+ const pending: Metric = {
+ id: "pending",
+ value: input.pending.now,
+ unit: "count",
+ normal: input.pending.normal,
+ delta: delta(input.pending.now, input.pending.normal),
+ series: {
+ points: input.pending.series,
+ kind: input.pending.estimated ? "estimated" : "measured",
+ },
+ severity: multiplierSeverity(
+ input.pending.now,
+ input.pending.normal,
+ t.pending.warnMult,
+ t.pending.critMult,
+ t.pending.floor
+ ),
+ };
+
+ const net = input.throughput.donePerMin - input.throughput.triggeredPerMin;
+ const throughput: Metric = {
+ id: "throughput",
+ value: net,
+ unit: "perMin",
+ aggregation: "rate",
+ breakdown: { done: input.throughput.donePerMin, triggered: input.throughput.triggeredPerMin },
+ severity: net < 0 && isPendingIncreasing(input.pending.series) ? "warn" : "ok",
+ };
+
+ const failureSeverity: Severity =
+ input.failures.rate < t.failures.floorRate
+ ? "ok"
+ : multiplierSeverity(
+ input.failures.rate,
+ input.failures.normalRate,
+ t.failures.warnMult,
+ t.failures.critMult,
+ t.failures.floor
+ );
+ const failures: Metric = {
+ id: "failures",
+ value: input.failures.rate,
+ unit: "ratio",
+ aggregation: "ratio",
+ normal: input.failures.normalRate,
+ delta: delta(input.failures.rate, input.failures.normalRate),
+ series: { points: input.failures.series, kind: "measured" },
+ severity: failureSeverity,
+ };
+
+ const durP95: Metric = {
+ id: "dur_p95",
+ value: input.duration.p95Ms,
+ unit: "ms",
+ aggregation: "p95",
+ normal: input.duration.normalP95Ms,
+ delta: delta(input.duration.p95Ms, input.duration.normalP95Ms),
+ severity: multiplierSeverity(
+ input.duration.p95Ms,
+ input.duration.normalP95Ms,
+ t.duration.warnMult,
+ t.duration.critMult,
+ t.duration.floor
+ ),
+ };
+
+ const ageMs = input.liveness.telemetryAgeMs;
+ const livenessSeverity: Severity =
+ ageMs === null // no signal at all (brand-new/quiet env) — genuinely unknown, so NEUTRAL (ok):
+ ? "ok" // a fine-but-idle env must not surface as a yellow verdict, and it never
+ : // trust-guards. "lagging" (below) IS a real warn; "unknown" is not.
+ ageMs > t.liveness.staleMs
+ ? "crit"
+ : ageMs > t.liveness.freshMs
+ ? "warn"
+ : "ok";
+ const liveness: Metric = {
+ id: "liveness",
+ // No signal -> value 0 is a placeholder, NOT a real "0ms fresh". `availability: "unknown"`
+ // says so, so a structured consumer never reads the 0 as freshness (the finding reason
+ // also carries "freshness_unknown"). A finite number keeps the JSON VM valid (no Infinity).
+ value: ageMs ?? 0,
+ availability: ageMs === null ? "unknown" : "measured",
+ unit: "ms",
+ severity: livenessSeverity,
+ };
+
+ // Flow-evidence metrics (severity ok — evidence, not a verdict).
+ const concurrency: Metric = {
+ id: "concurrency",
+ value: ev.runningSeries.length > 0 ? ev.runningSeries[ev.runningSeries.length - 1] : 0,
+ unit: "count",
+ breakdown: { limit: ev.envLimit },
+ series: { points: ev.runningSeries, kind: "measured" },
+ severity: "ok",
+ };
+ const throttled: Metric = {
+ id: "throttled",
+ value: ev.throttledShare,
+ unit: "ratio",
+ severity: "ok",
+ };
+ const triggered: Metric = {
+ id: "triggered",
+ value: input.throughput.triggeredPerMin,
+ unit: "perMin",
+ normal: input.throughput.normalTriggeredPerMin,
+ delta: delta(input.throughput.triggeredPerMin, input.throughput.normalTriggeredPerMin),
+ severity: "ok",
+ };
+
+ return [
+ startLatency,
+ pending,
+ throughput,
+ failures,
+ durP95,
+ liveness,
+ concurrency,
+ throttled,
+ triggered,
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Drain computation — shared by the flow policy (flow.ts) and the footer (health.ts).
+// ---------------------------------------------------------------------------
+
+export function computeDrain(input: HealthInput): { drainMinutes: number; isDrainable: boolean } {
+ const donePerMin = input.throughput.donePerMin;
+ const drainMinutes = donePerMin === 0 ? Number.POSITIVE_INFINITY : input.pending.now / donePerMin;
+ return {
+ drainMinutes,
+ isDrainable: drainMinutes < HEALTH_THRESHOLDS.flowPolicy.drainCritMinutes,
+ };
+}
diff --git a/apps/webapp/app/presenters/v3/reports/health/health-data.ts b/apps/webapp/app/presenters/v3/reports/health/health-data.ts
new file mode 100644
index 00000000000..8b79d5c3a21
Binary files /dev/null and b/apps/webapp/app/presenters/v3/reports/health/health-data.ts differ
diff --git a/apps/webapp/app/presenters/v3/reports/health/health-messages.ts b/apps/webapp/app/presenters/v3/reports/health/health-messages.ts
new file mode 100644
index 00000000000..888b2b54b73
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/health/health-messages.ts
@@ -0,0 +1,170 @@
+/**
+ * The `health` report's PROSE — the single place its codes resolve to strings. Registered
+ * under the "health" title so the generic renderer can look it up by `vm.title` without ever
+ * importing health vocabulary. A future report (Cost, Regression) ships its own catalog the
+ * same way; `report-messages.ts` stays report-agnostic infrastructure.
+ *
+ * Some strings carry {tokens} (e.g. {age}, {rate}) the renderer fills from the finding's
+ * metrics / evidence — meaning lives here, numbers stay facts.
+ */
+
+import { registerReportMessages, type ReportMessages } from "../report-messages";
+import { type ReasonCode, type Severity } from "../report-view-model";
+
+/** Metric id -> expanded display label. */
+const METRIC_LABELS: Record<string, string> = {
+ start_latency_p95: "start latency",
+ pending: "pending",
+ throughput: "throughput",
+ failures: "failures",
+ dur_p95: "p95 dur",
+ liveness: "liveness",
+ concurrency: "concurrency",
+ throttled: "throttled",
+ triggered: "triggered",
+};
+
+/**
+ * Finding headline — keyed by `${findingType}/${reason}`. Degraded = the cause,
+ * healthy = reassurance. `@expanded` variants show a healthy finding expanded
+ * (e.g. execution while flow is degraded).
+ */
+const FINDING_REASONS: Record<string, string> = {
+ // flow — causes
+ "flow/env_limit_saturation": "at your env concurrency limit",
+ "flow/dequeue_stall": "capacity is free but nothing is dequeuing",
+ "flow/queue_limit_throttling": "a queue is throttling at its own limit",
+ "flow/trigger_spike": "a trigger spike is backing up the queue",
+ "flow/trigger_surge": "a surge of new triggers is backing up the queue",
+ // flow — fallback symptoms (v1)
+ "flow/start_latency": "runs are slow to start",
+ "flow/backlog": "backlog is growing",
+ "flow/throughput_lag": "completion is falling behind triggers",
+ "flow/degraded": "flow is degraded",
+ // flow — healthy (collapsed)
+ "flow/healthy": "starting normally",
+ // execution
+ "execution/failures_up": "runs are failing more than usual",
+ "execution/slow_runs": "runs are slower than usual",
+ "execution/degraded": "execution is degraded",
+ "execution/unknown": "execution can't be assessed — the telemetry is stale",
+ "flow/unknown": "flow can't be assessed — the telemetry is stale",
+ "execution/healthy": "completing normally", // collapsed
+ "execution/healthy@expanded": "the runs that DO start are fine",
+ // liveness = telemetry freshness ({age} filled by the renderer)
+ "liveness/fresh": "fresh — telemetry current, updated {age} ago",
+ "liveness/lagging": "lagging — telemetry last updated {age} ago",
+ "liveness/stale": "stale — no telemetry in {age}",
+ "liveness/freshness_unknown": "freshness unknown — no telemetry signal to check",
+};
+
+/** The "read:" causal-chain line — keyed by code. */
+const READS: Record<string, string> = {
+ // cause chains
+ saturation_chain: "limit saturated → incoming work exceeds capacity → backlog grows",
+ capacity_free_not_dequeuing: "capacity is free but work isn't being picked up — on our side",
+ queue_throttle_chain: "queue at its limit → its runs wait → backlog grows",
+ spike_chain: "triggers jumped {mult}× → queue fills faster than it drains",
+ surge_chain: "new triggers arriving with no prior baseline → queue fills faster than it drains",
+ // fallback symptoms (v1)
+ starting_normally: "runs are starting on time",
+ lag_while_triggering_normal: "triggering normally, but starts lag → work is backing up",
+ lag_and_failures: "runs are lagging AND failing — check the code path",
+ degraded_generic: "flow is degraded",
+ not_a_code_problem: "NOT a code problem",
+ runs_are_fine: "runs are completing normally",
+ failures_elevated: "failures are elevated — check the code path",
+ data_stale: "data is stale — the verdict cannot be trusted",
+};
+
+/** Exclusion (ruled-out cause) — rendered under `read:`. {tokens} filled from evidence. */
+const EXCLUSIONS: Record<string, string> = {
+ not_env_limit: "env concurrency limit is not the bottleneck",
+ not_your_code: "not your code — failures and durations normal",
+ not_your_config: "not your config — limits aren't the bottleneck",
+};
+
+/** Observation (supporting fact, not a ruled-out cause) — rendered under `read:` after exclusions. */
+const OBSERVATIONS: Record<string, string> = {
+ not_workers_platform: "runs are completing at ~{rate}/min",
+ execution_healthy: "runs that start are completing normally",
+ nothing_dead_lettered: "nothing dead-lettered",
+};
+
+/** Metric annotation shown on a cause line INSTEAD of "(normal ~x)". {tokens} filled by the renderer. */
+const ANNOTATIONS: Record<string, string> = {
+ pinned_minutes: "pinned {value} of last {window} min",
+ idle_share: "idle — {value} running of {limit}",
+ throttled_minutes: "throttled {value} of last {window} min",
+ spike_mult: "{value}× the normal rate",
+ surge_rate: "{value}/min, no prior baseline",
+};
+
+/** Headline statement — keyed by `${findingType}/${severity}`. */
+const STATEMENTS: Record<string, string> = {
+ "flow/ok": "Flow healthy",
+ "flow/warn": "Flow slowing",
+ "flow/crit": "Flow stalled",
+ "execution/ok": "Execution healthy",
+ "execution/warn": "Execution degraded",
+ "execution/crit": "Execution failing",
+ "liveness/ok": "data fresh",
+ "liveness/warn": "data lagging",
+ "liveness/crit": "data stale",
+};
+
+/** Recommendation / footer codes -> calm, jargon-free action text. */
+const ACTIONS: Record<string, string> = {
+ // Review = open concrete data · Check = system state · Raise = a settings change
+ review_start_latency: "Review start latency",
+ review_failing_tasks: "Review failing tasks",
+ review_slow_runs: "Review slow runs",
+ review_trigger_source: "Review what's triggering the spike",
+ check_queue_health: "Check queue health",
+ check_worker_availability: "Check worker availability",
+ check_control_plane: "Check control plane",
+ check_platform_status: "Check status.trigger.dev — no action needed on yours",
+ raise_env_limit: "Raise the env concurrency limit",
+ raise_queue_limit: "Raise the queue's concurrency limit",
+ do_nothing_drains: "or do nothing — backlog drains in ~{value} min once triggers ease",
+ region_failover: "region move? ask your agent — depends on your failover setup",
+ nothing_to_do: "nothing to do",
+};
+
+function findingReason(
+ findingType: string,
+ reason: ReasonCode,
+ opts?: { expanded?: boolean }
+): string {
+ if (opts?.expanded) {
+ const expanded = FINDING_REASONS[`${findingType}/${reason}@expanded`];
+ if (expanded) return expanded;
+ }
+ return FINDING_REASONS[`${findingType}/${reason}`] ?? reason;
+}
+
+function statementMessage(findingType: string, severity: Severity, reason?: ReasonCode): string {
+ // Stale telemetry makes a CH-derived verdict untrustworthy — say so, don't show a severity.
+ if (reason === "unknown") {
+ const label = findingType.charAt(0).toUpperCase() + findingType.slice(1);
+ return `${label} unknown — data stale`;
+ }
+ // No freshness signal is NOT "data lagging" (a real severity) — it's genuinely unknown.
+ if (findingType === "liveness" && reason === "freshness_unknown") {
+ return "data freshness unknown";
+ }
+ return STATEMENTS[`${findingType}/${severity}`] ?? `${findingType} ${severity}`;
+}
+
+export const healthMessages: ReportMessages = {
+ metricLabel: (id) => METRIC_LABELS[id] ?? id,
+ findingReason,
+ readMessage: (code) => READS[code] ?? code,
+ exclusionMessage: (code) => EXCLUSIONS[code] ?? code,
+ observationMessage: (code) => OBSERVATIONS[code] ?? code,
+ annotationMessage: (code) => ANNOTATIONS[code] ?? code,
+ statementMessage,
+ actionMessage: (code) => ACTIONS[code] ?? code,
+};
+
+registerReportMessages("health", healthMessages);
diff --git a/apps/webapp/app/presenters/v3/reports/health/health.ts b/apps/webapp/app/presenters/v3/reports/health/health.ts
new file mode 100644
index 00000000000..08beae3894c
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/health/health.ts
@@ -0,0 +1,268 @@
+/**
+ * The `health` report — ORCHESTRATOR + public entry. Two layers:
+ * `assessHealth()` data -> HealthAssessment (all health reasoning)
+ * `toReportViewModel()` HealthAssessment -> generic ReportViewModel (pure packaging)
+ * `interpret()` is the thin composition of the two.
+ *
+ * The reasoning is split into three independent analyzers, each returning a Finding:
+ * flow.ts · execution.ts · liveness.ts (foundation in health-core.ts)
+ * This module only wires them: build metrics -> run the three -> flow policy -> stale guard
+ * -> reads -> summary/footer. PURE — no IO/clock/LLM/formatting.
+ */
+
+import {
+ isOk,
+ maxSeverity,
+ type Finding,
+ type FooterEntry,
+ type Metric,
+ type ReportViewModel,
+ type Severity,
+ type SummaryStatement,
+} from "../report-view-model";
+import { buildMetrics, computeDrain, HEALTH_THRESHOLDS, type HealthInput } from "./health-core";
+import { buildExecutionRead, interpretExecution } from "./execution";
+import { applyFlowPolicy, buildFlowRead, interpretFlow } from "./flow";
+import { interpretLiveness } from "./liveness";
+// Registers the "health" message catalog (side effect) so the renderer resolves this report's
+// codes. Kept here — the health report's entry module — so loading it always registers its prose.
+import "./health-messages";
+
+// Re-exported so the data layer + tests keep a single import path (`./health`).
+export { HEALTH_THRESHOLDS, isPendingIncreasing, type HealthInput } from "./health-core";
+
+// ---------------------------------------------------------------------------
+// Stale-telemetry trust guard. When telemetry is genuinely stale, both flow and execution are
+// CH-derived and untrustworthy: mark them unknown and strip everything that would advise action
+// off stale data (recommendation, attribution, exclusions, observations, hedge, anomaly window).
+// ---------------------------------------------------------------------------
+
+function applyStaleGuard(
+ flow: Finding,
+ execution: Finding,
+ telemetryStale: boolean
+): { flow: Finding; execution: Finding; treatedCrit: boolean } {
+ if (!telemetryStale) return { flow, execution, treatedCrit: false };
+ // Force crit so severity is consistent across summary / section glyph / JSON, and strip the
+ // ACTIONABLE causal fields so no surface advises off stale data. Raw metrics/evidence stay in
+ // the VM for diagnostics, flagged informational-only by `facts.trustworthy: false`.
+ const untrust = (f: Finding): Finding => ({
+ ...f,
+ severity: "crit",
+ reason: "unknown",
+ recommendation: undefined,
+ attribution: undefined,
+ exclusions: undefined,
+ observations: undefined,
+ hedge: undefined,
+ anomalyWindow: undefined,
+ });
+ return { flow: untrust(flow), execution: untrust(execution), treatedCrit: true };
+}
+
+// ---------------------------------------------------------------------------
+// Summary.
+// ---------------------------------------------------------------------------
+
+function aggregateSummary(
+ flow: Finding,
+ execution: Finding,
+ liveness: Finding,
+ executionTreatedCrit: boolean
+): { severity: Severity; statements: SummaryStatement[] } {
+ const executionSummarySeverity: Severity = executionTreatedCrit ? "crit" : execution.severity;
+ const severity = maxSeverity(flow.severity, executionSummarySeverity, liveness.severity);
+ const statements: SummaryStatement[] = [
+ {
+ findingType: "flow",
+ severity: flow.severity,
+ reason: flow.reason === "unknown" ? "unknown" : undefined,
+ },
+ {
+ findingType: "execution",
+ severity: executionSummarySeverity,
+ reason: execution.reason === "unknown" ? "unknown" : undefined,
+ },
+ {
+ findingType: "liveness",
+ severity: liveness.severity,
+ reason: liveness.reason === "freshness_unknown" ? "freshness_unknown" : undefined,
+ },
+ ];
+ return { severity, statements };
+}
+
+// ---------------------------------------------------------------------------
+// Footer (dominant action + do-nothing option) + links.
+// ---------------------------------------------------------------------------
+
+const SEV_RANK: Record<Severity, number> = { ok: 0, warn: 1, crit: 2 };
+
+function dominantFinding(findings: Finding[]): Finding | undefined {
+ let best: Finding | undefined;
+ for (const f of findings) {
+ if (!f.recommendation) continue;
+ if (!best || SEV_RANK[f.severity] > SEV_RANK[best.severity]) best = f;
+ }
+ return best;
+}
+
+function buildFooter(
+ findings: Finding[],
+ drain: { drainMinutes: number; isDrainable: boolean },
+ telemetryStale: boolean
+): FooterEntry[] {
+ // Stale telemetry: the only trustworthy action is to check the pipeline itself.
+ if (telemetryStale) {
+ const liveness = findings.find((f) => f.type === "liveness");
+ return liveness?.recommendation
+ ? [{ code: liveness.recommendation.code, link: liveness.recommendation.link }]
+ : [{ code: "nothing_to_do" }];
+ }
+
+ const dominant = dominantFinding(findings);
+ if (!dominant?.recommendation) return [{ code: "nothing_to_do" }];
+
+ const footer: FooterEntry[] = [
+ { code: dominant.recommendation.code, link: dominant.recommendation.link },
+ ];
+
+ // Second entry: do-nothing when the backlog drains, or the region-move hedge for a
+ // dequeue stall (the one place it stays plausible).
+ if (dominant.type === "flow") {
+ if (drain.isDrainable && Number.isFinite(drain.drainMinutes)) {
+ footer.push({ code: "do_nothing_drains", value: Math.round(drain.drainMinutes * 10) / 10 });
+ } else if (dominant.reason === "dequeue_stall") {
+ footer.push({ code: "region_failover" });
+ }
+ }
+ return footer;
+}
+
+function collectLinks(findings: Finding[]): ReportViewModel["links"] {
+ const keys = new Set<string>();
+ for (const finding of findings) {
+ if (finding.recommendation?.link) keys.add(finding.recommendation.link);
+ if (finding.hedge?.link) keys.add(finding.hedge.link);
+ }
+ return [...keys].map((key) => ({ key, label: key, url: "" }));
+}
+
+// ---------------------------------------------------------------------------
+// Domain layer: HealthAssessment — the health verdict (flow / execution / liveness findings,
+// their causes + recommendations, and the derived state). All health semantics live here; it
+// knows nothing about how a report is presented. `toReportViewModel` maps it into the generic,
+// report-agnostic ReportViewModel — so the VM stays reusable for future reports (each has its
+// own <domain>Assessment + mapper; the renderer/VM primitives are shared).
+// ---------------------------------------------------------------------------
+
+export type HealthAssessment = {
+ /** header, carried through from the input. */
+ scope: string;
+ period: string;
+ baselineLabel: string;
+ generatedAt: string;
+ windowMinutes: number;
+ /** finalized findings (post-policy, post-stale-guard, with reads built). */
+ flow: Finding;
+ execution: Finding;
+ liveness: Finding;
+ metrics: Metric[];
+ /** derived domain state the presentation layer needs (footer / summary / trust). */
+ drain: { drainMinutes: number; isDrainable: boolean };
+ telemetryStale: boolean;
+ executionTreatedCrit: boolean;
+ /** structured payload for agents (already carries the trust marker). */
+ facts: Record<string, unknown>;
+};
+
+/** Data -> domain verdict. Pure: no IO/clock/LLM/formatting — just health reasoning. */
+export function assessHealth(input: HealthInput): HealthAssessment {
+ const metrics = buildMetrics(input);
+ const drain = computeDrain(input);
+
+ let flow = interpretFlow(metrics, input);
+ const executionRaw = interpretExecution(metrics, input);
+ const liveness = interpretLiveness(metrics, input);
+
+ // Telemetry freshness as an explicit state so "unknown" (no signal) is never conflated with
+ // "lagging" (a real severity). Only GENUINE staleness trust-guards the CH-derived verdicts.
+ const ageMs = input.liveness.telemetryAgeMs;
+ const telemetryStale = ageMs !== null && ageMs > HEALTH_THRESHOLDS.liveness.staleMs;
+
+ flow = applyFlowPolicy(flow, executionRaw, drain.isDrainable, telemetryStale);
+
+ // Stale telemetry: flow AND execution are untrustworthy -> mark both unknown and strip their
+ // actions/attribution/exclusions so nothing advises off stale data.
+ const guarded = applyStaleGuard(flow, executionRaw, telemetryStale);
+ flow = guarded.flow;
+ const execution = guarded.execution;
+
+ // interpretFlow mutates the shared metrics array (e.g. sets concurrency.annotation "pinned 40
+ // of last 60 min") BEFORE the guard runs. The renderers hide it for an unknown finding, but
+ // format=json would still leak that stale-derived narrative — so strip metric annotations too
+ // (the twin of the stripped anomaly window). Raw values stay, flagged by facts.trustworthy.
+ if (telemetryStale) {
+ for (const m of metrics) m.annotation = undefined;
+ }
+
+ // Reads built last, from the finalized findings.
+ flow.read = buildFlowRead(flow, isOk(execution.severity), !telemetryStale);
+ execution.read = buildExecutionRead(execution, flow);
+
+ return {
+ scope: input.scope,
+ period: input.period,
+ baselineLabel: input.baselineLabel,
+ generatedAt: input.generatedAt,
+ windowMinutes: input.windowMinutes,
+ flow,
+ execution,
+ liveness,
+ metrics,
+ drain,
+ telemetryStale,
+ executionTreatedCrit: guarded.treatedCrit,
+ facts: {
+ // Trust marker for structured consumers. The metrics/evidence stay (useful for pipeline
+ // diagnostics), but when telemetry is stale they're informational-only: an agent must not
+ // act on them (e.g. raise concurrency off a stale backlog). The human renderer is already
+ // guarded via the "unknown" finding; this is the same guarantee for JSON.
+ trustworthy: !telemetryStale,
+ staleReason: telemetryStale ? "telemetry_stale" : undefined,
+ flowSource: input.flowSource,
+ pendingEstimated: input.pending.estimated,
+ throughput: input.throughput,
+ flowEvidence: input.flowEvidence,
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Presentation mapping: HealthAssessment -> generic ReportViewModel. Pure packaging only —
+// no health reasoning here (summary/footer/links are derived from the findings).
+// ---------------------------------------------------------------------------
+
+function toReportViewModel(a: HealthAssessment): ReportViewModel {
+ const findings = [a.flow, a.execution, a.liveness];
+ return {
+ title: "health",
+ scope: a.scope,
+ period: a.period,
+ baselineLabel: a.baselineLabel,
+ generatedAt: a.generatedAt,
+ windowMinutes: a.windowMinutes,
+ summary: aggregateSummary(a.flow, a.execution, a.liveness, a.executionTreatedCrit),
+ findings,
+ metrics: a.metrics,
+ facts: a.facts,
+ links: collectLinks(findings),
+ // Stale telemetry -> footer points at the control plane, not a CH-derived action.
+ footer: buildFooter(findings, a.drain, a.telemetryStale),
+ };
+}
+
+/** Public entry: data -> generic report. Thin composition of the domain + presentation layers. */
+export function interpret(input: HealthInput): ReportViewModel {
+ return toReportViewModel(assessHealth(input));
+}
diff --git a/apps/webapp/app/presenters/v3/reports/health/liveness.ts b/apps/webapp/app/presenters/v3/reports/health/liveness.ts
new file mode 100644
index 00000000000..4c3e313fdaa
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/health/liveness.ts
@@ -0,0 +1,27 @@
+/**
+ * LIVENESS analyzer: telemetry FRESHNESS (age of the freshest signal), NOT "last completion".
+ * A null age is genuinely unknown (neutral), never a warning; only real staleness is crit.
+ */
+
+import { type Finding, type Metric } from "../report-view-model";
+import { metricById, type HealthInput } from "./health-core";
+
+export function interpretLiveness(metrics: Metric[], input: HealthInput): Finding {
+ const liveness = metricById(metrics, "liveness");
+ const unknown = input.liveness.telemetryAgeMs === null;
+ const reason = unknown
+ ? "freshness_unknown"
+ : liveness.severity === "ok"
+ ? "fresh"
+ : liveness.severity === "warn"
+ ? "lagging"
+ : "stale";
+ return {
+ type: "liveness",
+ severity: liveness.severity,
+ reason,
+ metricIds: ["liveness"],
+ recommendation:
+ liveness.severity === "crit" ? { code: "check_control_plane", link: "status" } : undefined,
+ };
+}
diff --git a/apps/webapp/app/presenters/v3/reports/renderMarkdown.ts b/apps/webapp/app/presenters/v3/reports/renderMarkdown.ts
new file mode 100644
index 00000000000..0eb82ab47f6
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/renderMarkdown.ts
@@ -0,0 +1,522 @@
+/**
+ * GENERIC renderer: ReportViewModel -> monospace markdown. Severity-driven disclosure:
+ * a degraded finding expands into evidence in causal order; a healthy finding collapses
+ * to one `✓` line. Owns ALL presentation (formatting, glyphs, sparklines, spacing, {token}
+ * substitution) and resolves the VM's codes -> strings via the report's registered message
+ * catalog, looked up by `vm.title` — so it holds NO report vocabulary itself.
+ *
+ * Knows NOTHING about health — walks summary -> findings -> metrics generically.
+ */
+
+import { reportMessages, type ReportMessages } from "./report-messages";
+import {
+ type Finding,
+ type FooterEntry,
+ type Metric,
+ type ReportViewModel,
+ type Severity,
+ type Unit,
+} from "./report-view-model";
+
+const BARS = "▁▂▃▄▅▆▇█";
+const MINUS = ""; // U+2212
+
+const SEVERITY_GLYPH: Record<Severity, string> = { ok: "✓", warn: "⚠", crit: "✕" };
+
+/**
+ * A NEUTRAL marker for a state that's genuinely unknown, not good/bad — e.g. liveness with no
+ * telemetry signal. It doesn't affect the aggregate severity (that stays driven by real findings),
+ * but it must not read as a confident green "✓", so it gets its own glyph.
+ */
+const NEUTRAL_GLYPH = "○";
+
+/**
+ * Markdown-only status colour. Chat hosts render neither ANSI nor HTML, so swapping
+ * the glyphs for traffic-light circles is the one colour cue they get — one emoji per
+ * marker (neutral -> white). ANSI keeps the crisp ✓/⚠/✕/○, so this applies ONLY on markdown.
+ */
+const MARKDOWN_STATUS_EMOJI: Record<string, string> = {
+ "✓": "🟢",
+ "⚠": "🟡",
+ "✕": "🔴",
+ "○": "⚪",
+};
+
+function toMarkdownEmoji(text: string): string {
+ return text.replace(/[✓⚠✕○]/g, (g) => MARKDOWN_STATUS_EMOJI[g] ?? g);
+}
+
+/** Glyph for a finding/statement: neutral for a genuinely-unknown freshness, else severity-driven. */
+function statusGlyph(severity: Severity, reason?: string): string {
+ return reason === "freshness_unknown" ? NEUTRAL_GLYPH : SEVERITY_GLYPH[severity];
+}
+
+/** Evidence lines (metric rows + attribution) shown for a degraded section. */
+const EVIDENCE_CAP = 4;
+
+/** Column where the header's scope·period·baseline starts (mirrors the mockup). */
+const HEADER_COL = 22;
+
+/** Gap between aligned columns in an evidence block. */
+const COL_GAP = 3;
+
+/** Section labels pad to this so their glyph/content aligns vertically. */
+const SECTION_LABEL_WIDTH = 9; // "EXECUTION"
+const SECTION_GAP = 3;
+
+/** Section label padded so every section's ✓ / cause text starts at the same column. */
+function sectionLabel(type: string): string {
+ return `${type.toUpperCase().padEnd(SECTION_LABEL_WIDTH)}${" ".repeat(SECTION_GAP)}`;
+}
+
+// ---------------------------------------------------------------------------
+// Formatters.
+// ---------------------------------------------------------------------------
+
+/** All sparklines render at this fixed width so they align in a column. */
+const SPARK_WIDTH = 8;
+
+/** Resample to exactly `width` points (bucket-average) so every sparkline aligns. */
+function resampleToWidth(points: number[], width: number): number[] {
+ if (points.length === 0) return [];
+ if (points.length === width) return points;
+ if (points.length < width) {
+ // stretch: nearest-sample up to width (keeps shape).
+ return Array.from({ length: width }, (_, i) => points[Math.floor((i * points.length) / width)]);
+ }
+ const out: number[] = [];
+ const stride = points.length / width;
+ for (let i = 0; i < width; i++) {
+ const slice = points.slice(
+ Math.floor(i * stride),
+ Math.max(Math.floor((i + 1) * stride), Math.floor(i * stride) + 1)
+ );
+ out.push(slice.reduce((a, b) => a + b, 0) / slice.length);
+ }
+ return out;
+}
+
+export function sparklineFromSeries(points: number[]): string {
+ if (points.length === 0) return "";
+ const p = resampleToWidth(points, SPARK_WIDTH);
+ const min = Math.min(...p);
+ const max = Math.max(...p);
+ if (max === min) return BARS[0].repeat(SPARK_WIDTH);
+ return p.map((v) => BARS[Math.round(((v - min) / (max - min)) * (BARS.length - 1))]).join("");
+}
+
+function fmtCount(n: number): string {
+ return Math.round(n).toLocaleString("en-US");
+}
+
+function fmtDuration(ms: number): string {
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ const s = ms / 1000;
+ if (s < 60) return Number.isInteger(s) ? `${s}s` : `${s.toFixed(1)}s`;
+ const m = s / 60;
+ return Number.isInteger(m) ? `${m}m` : `${m.toFixed(1)}m`;
+}
+
+function fmtPct(ratio: number): string {
+ return `${(ratio * 100).toFixed(1)}%`;
+}
+
+function fmtRate(n: number): string {
+ return `${fmtCount(n)}/min`;
+}
+
+function fmtSignedRate(net: number): string {
+ const sign = net < 0 ? MINUS : net > 0 ? "+" : "";
+ return `${sign}${fmtCount(Math.abs(net))}/min`;
+}
+
+function fmtValue(value: number, unit: Unit): string {
+ switch (unit) {
+ case "ms":
+ return fmtDuration(value);
+ case "count":
+ return fmtCount(value);
+ case "ratio":
+ return fmtPct(value);
+ case "perMin":
+ return fmtRate(value);
+ }
+}
+
+function fill(template: string, tokens: Record<string, string | number | undefined>): string {
+ return template.replace(/\{(\w+)\}/g, (_, k) => {
+ const v = tokens[k];
+ return v === undefined ? `{${k}}` : String(v);
+ });
+}
+
+function metricById(metrics: Metric[], id: string): Metric | undefined {
+ return metrics.find((m) => m.id === id);
+}
+
+// ---------------------------------------------------------------------------
+// Metric line (expanded evidence).
+// ---------------------------------------------------------------------------
+
+function deltaSegment(metric: Metric): string {
+ if (metric.severity === "ok" || !metric.delta || metric.delta.dir === "flat") return "";
+ const arrow = metric.delta.dir === "up" ? "↑" : "↓";
+ // "up" shows the multiplier when we have it ("↑ 16×"). "down" is arrow-only: a
+ // drop rounds to 0×/1×, meaningless — the arrow already says "below normal".
+ if (metric.delta.dir === "down") return arrow;
+ return metric.delta.mult === undefined ? arrow : `${arrow} ${metric.delta.mult}×`;
+}
+
+function annotationSegment(metric: Metric, vm: ReportViewModel): string {
+ if (!metric.annotation) return "";
+ const value = String(metric.annotation.value ?? "");
+ return fill(reportMessages(vm.title).annotationMessage(metric.annotation.code), {
+ value,
+ window: vm.windowMinutes,
+ limit: metric.breakdown?.limit ?? "",
+ });
+}
+
+function metricValueText(metric: Metric, msg: ReportMessages): string {
+ // concurrency etc. carry a limit -> "running/limit".
+ if (metric.unit === "count" && metric.breakdown?.limit !== undefined) {
+ return `${fmtCount(metric.value)}/${fmtCount(metric.breakdown.limit)}`;
+ }
+ // composite throughput -> "done vs triggered -> net".
+ if (metric.unit === "perMin" && metric.breakdown?.done !== undefined) {
+ const { done, triggered } = metric.breakdown;
+ return `${fmtRate(done)} done vs ${fmtRate(triggered)} triggered → net ${fmtSignedRate(metric.value)}`;
+ }
+ const showAgg =
+ metric.aggregation === "p95" && !msg.metricLabel(metric.id).toLowerCase().startsWith("p95");
+ return showAgg
+ ? `p95 ${fmtValue(metric.value, metric.unit)}`
+ : fmtValue(metric.value, metric.unit);
+}
+
+/** Column widths for a section's evidence block, so value/delta/spark align down the page. */
+type Cols = { label: number; value: number; delta: number };
+
+function isComposite(metric: Metric): boolean {
+ return metric.unit === "perMin" && metric.breakdown?.done !== undefined;
+}
+
+function computeColumns(rows: Metric[], vm: ReportViewModel, extraLabels: string[]): Cols {
+ const msg = reportMessages(vm.title);
+ const labelLens = [
+ ...rows.map((m) => msg.metricLabel(m.id).length),
+ ...extraLabels.map((l) => l.length),
+ ];
+ const valueLens = rows.filter((m) => !isComposite(m)).map((m) => metricValueText(m, msg).length);
+ const deltaLens = rows
+ .filter((m) => !isComposite(m) && !annotationSegment(m, vm))
+ .map((m) => deltaSegment(m).length);
+ return {
+ label: Math.max(0, ...labelLens),
+ value: Math.max(0, ...valueLens),
+ delta: Math.max(0, ...deltaLens),
+ };
+}
+
+function renderMetricRow(metric: Metric, cols: Cols, vm: ReportViewModel): string {
+ const msg = reportMessages(vm.title);
+ const gap = " ".repeat(COL_GAP);
+ const label = msg.metricLabel(metric.id).padEnd(cols.label);
+ const value = metricValueText(metric, msg);
+
+ // composite throughput: label + value only (its own grammar).
+ if (isComposite(metric)) return ` ${label}${gap}${value}`;
+
+ const spark =
+ metric.series && metric.series.points.length > 0
+ ? sparklineFromSeries(metric.series.points)
+ : "";
+ const annotation = annotationSegment(metric, vm);
+ const delta = annotation ? "" : deltaSegment(metric); // cause line carries an annotation, not a delta
+ const trailing = annotation
+ ? annotation
+ : metric.normal !== undefined
+ ? `(normal ~${fmtValue(metric.normal, metric.unit)})`
+ : metric.series?.kind === "estimated"
+ ? "(estimated)" // proxy trend (e.g. snapshot backlog) — flag it so it isn't read as measured
+ : "";
+
+ // Fixed columns: label · value · delta · SPARK · trailing. The fixed-width spark
+ // column keeps every spark and the trailing (normal / annotation) aligned.
+ let line = ` ${label}${gap}${value.padEnd(cols.value)}`;
+ if (cols.delta > 0) line += `${gap}${delta.padEnd(cols.delta)}`;
+ line += `${gap}${(spark || "").padEnd(SPARK_WIDTH)}`;
+ if (trailing) line += `${gap}${trailing}`;
+ return line.replace(/\s+$/, "");
+}
+
+// ---------------------------------------------------------------------------
+// Compact facts (collapsed / semi-expanded healthy sections).
+// ---------------------------------------------------------------------------
+
+function compactFact(metric: Metric): string | undefined {
+ switch (metric.id) {
+ case "pending":
+ return `pending ${fmtCount(metric.value)}${metric.normal !== undefined ? ` (normal ~${fmtCount(metric.normal)})` : ""}`;
+ case "start_latency_p95":
+ return `starts p95 ${fmtDuration(metric.value)}`;
+ case "failures":
+ return `failures ${fmtPct(metric.value)}${metric.normal !== undefined ? ` (normal ~${fmtPct(metric.normal)})` : ""}`;
+ case "dur_p95":
+ return metric.severity === "ok"
+ ? "durations normal"
+ : `durations p95 ${fmtDuration(metric.value)}`;
+ default:
+ return undefined; // throughput / evidence metrics get no collapsed fact
+ }
+}
+
+/** Reassuring facts read consequence-first: depth/failures before latency/duration. */
+const COMPACT_ORDER = ["pending", "failures", "start_latency_p95", "dur_p95"];
+
+function compactFacts(finding: Finding, metrics: Metric[]): string {
+ return finding.metricIds
+ .map((id) => metricById(metrics, id))
+ .filter((m): m is Metric => m !== undefined)
+ .slice()
+ .sort((a, b) => COMPACT_ORDER.indexOf(a.id) - COMPACT_ORDER.indexOf(b.id))
+ .map(compactFact)
+ .filter((s): s is string => s !== undefined)
+ .join(" · ");
+}
+
+// ---------------------------------------------------------------------------
+// Read / exclusion / attribution / window.
+// ---------------------------------------------------------------------------
+
+function readTokens(_finding: Finding, metrics: Metric[]): Record<string, string | number> {
+ const triggered = metricById(metrics, "triggered");
+ return {
+ mult: triggered?.delta?.mult ?? "",
+ };
+}
+
+function windowSuffix(finding: Finding): string {
+ const aw = finding.anomalyWindow;
+ if (!aw) return "";
+ return aw.touchesEnd ? ` (last ${aw.minutes} min)` : ` (${aw.minutes} min window)`;
+}
+
+function attributionLine(finding: Finding, cols: Cols): string | undefined {
+ const a = finding.attribution;
+ if (!a) return undefined;
+ const label = `worst ${a.dim}`.padEnd(cols.label);
+ return ` ${label}${" ".repeat(COL_GAP)}${a.key} — ${Math.round(a.share * 100)}% of ${a.of}`;
+}
+
+// ---------------------------------------------------------------------------
+// Sections.
+// ---------------------------------------------------------------------------
+
+function renderDegradedSection(finding: Finding, vm: ReportViewModel): string[] {
+ const msg = reportMessages(vm.title);
+ const rows = finding.metricIds
+ .map((id) => metricById(vm.metrics, id))
+ .filter((m): m is Metric => m !== undefined);
+
+ const extraLabels = finding.attribution ? [`worst ${finding.attribution.dim}`] : [];
+ const cols = computeColumns(rows, vm, extraLabels);
+
+ const evidence: string[] = rows.map((m) => renderMetricRow(m, cols, vm));
+ const attr = attributionLine(finding, cols);
+ if (attr) evidence.push(attr);
+
+ // One blank line between evidence rows so the block breathes.
+ const spaced = evidence.slice(0, EVIDENCE_CAP).flatMap((l, i) => (i === 0 ? [l] : ["", l]));
+
+ const lines = [
+ // Lead with the glyph so the cause text lines up with the healthy sections' "✓ …".
+ `${sectionLabel(finding.type)}${SEVERITY_GLYPH[finding.severity]} ${msg.findingReason(finding.type, finding.reason)}${windowSuffix(finding)}`,
+ "", // blank line between the header and its evidence (matches the section/footer spacing)
+ ...spaced,
+ ];
+
+ if (finding.read) {
+ lines.push(
+ "",
+ ` read: ${fill(msg.readMessage(finding.read), readTokens(finding, vm.metrics))}`
+ );
+ // Exclusions ("not your code") first, then supporting observations ("runs completing at ~X/min").
+ for (const excl of finding.exclusions ?? []) {
+ lines.push(
+ ` ${fill(msg.exclusionMessage(excl.code), { rate: fmtCount(excl.evidence?.donePerMin ?? 0) })}`
+ );
+ }
+ for (const obs of finding.observations ?? []) {
+ lines.push(
+ ` ${fill(msg.observationMessage(obs.code), { rate: fmtCount(obs.evidence?.donePerMin ?? 0) })}`
+ );
+ }
+ }
+ return lines;
+}
+
+function renderHealthyExecutionExpanded(finding: Finding, vm: ReportViewModel): string[] {
+ const msg = reportMessages(vm.title);
+ const lines = [
+ `${sectionLabel(finding.type)}${SEVERITY_GLYPH.ok} ${msg.findingReason(finding.type, finding.reason, { expanded: true })}`,
+ "", // blank line between the header and its facts (matches the section/footer spacing)
+ ` ${compactFacts(finding, vm.metrics)}`,
+ ];
+ if (finding.read) lines.push(` read: ${msg.readMessage(finding.read)}`);
+ return lines;
+}
+
+function renderCollapsedSection(finding: Finding, vm: ReportViewModel): string[] {
+ const msg = reportMessages(vm.title);
+ const headline = `${sectionLabel(finding.type)}${SEVERITY_GLYPH.ok} ${msg.findingReason(finding.type, finding.reason)}`;
+ const facts = compactFacts(finding, vm.metrics);
+ // Healthy sections stay on one line; the facts are short.
+ return facts ? [`${headline} — ${facts}`] : [headline];
+}
+
+function renderLivenessLine(finding: Finding, metrics: Metric[], msg: ReportMessages): string {
+ const metric = metricById(metrics, finding.metricIds[0]);
+ const ageMs = metric?.value;
+ const age = ageMs !== undefined && Number.isFinite(ageMs) ? fmtDuration(ageMs) : "unknown";
+ const reason = msg.findingReason(finding.type, finding.reason).replace("{age}", age);
+ return `${sectionLabel(finding.type)}${statusGlyph(finding.severity, finding.reason)} ${reason}`;
+}
+
+function renderFooter(footer: FooterEntry[], msg: ReportMessages): string[] {
+ return footer.map((entry, i) => {
+ const text = fill(msg.actionMessage(entry.code), { value: entry.value, min: entry.value });
+ return i === 0 ? `→ ${text}` : ` ${text}`;
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Top-level render.
+// ---------------------------------------------------------------------------
+
+/**
+ * The plain monochrome layout (✓/⚠/✕) shared by every surface. Colour renderers paint
+ * THIS via `paintReport`; markdown swaps the glyphs for emoji. Internal so the three
+ * public renderers can't drift.
+ */
+function renderReportPlain(vm: ReportViewModel): string {
+ const msg = reportMessages(vm.title);
+ const lines: string[] = [];
+
+ // header: "/report <title>" padded to a column, then scope · period · baseline.
+ const left = `/report ${vm.title}`;
+ const right = [vm.scope, vm.period, vm.baselineLabel].filter(Boolean).join(" · ");
+ lines.push(`${left.padEnd(HEADER_COL)}${right}`, "");
+
+ // Each statement carries its OWN glyph — one leading glyph would read as if it
+ // applied only to the first statement (e.g. "✕ Flow healthy"). Per-statement is clear.
+ const verdict = vm.summary.statements
+ .map(
+ (s) =>
+ `${statusGlyph(s.severity, s.reason)} ${msg.statementMessage(s.findingType, s.severity, s.reason)}`
+ )
+ .join(" · ");
+ lines.push(verdict, "");
+
+ const flowDegraded = vm.findings.some((f) => f.type === "flow" && f.severity !== "ok");
+
+ for (const finding of vm.findings) {
+ if (finding.type === "liveness") {
+ lines.push(renderLivenessLine(finding, vm.metrics, msg), "");
+ } else if (finding.reason === "unknown") {
+ // stale-data guard: no ✓ or facts computed from a silent feed. The guard forces crit,
+ // so the glyph reads from severity (consistent with the summary + JSON).
+ lines.push(
+ `${sectionLabel(finding.type)}${SEVERITY_GLYPH[finding.severity]} ${msg.findingReason(finding.type, finding.reason)}`,
+ ""
+ );
+ } else if (finding.severity !== "ok") {
+ lines.push(...renderDegradedSection(finding, vm), "");
+ } else if (finding.type === "execution" && flowDegraded) {
+ lines.push(...renderHealthyExecutionExpanded(finding, vm), "");
+ } else {
+ lines.push(...renderCollapsedSection(finding, vm), "");
+ }
+ }
+
+ lines.push(...renderFooter(vm.footer, msg));
+
+ return lines.join("\n").replace(/\n+$/, "\n");
+}
+
+/**
+ * Markdown surface (agents / chat). The plain layout with severity glyphs swapped for
+ * status emoji — the only decoration markdown gets.
+ */
+export function renderReportMarkdown(vm: ReportViewModel): string {
+ return toMarkdownEmoji(renderReportPlain(vm));
+}
+
+// ---------------------------------------------------------------------------
+// ANSI colour renderer (terminal, e.g. `trigger report`). Colourises the SAME
+// plain layout as a post-pass via `paintReport`, so terminal output can't drift.
+// ---------------------------------------------------------------------------
+
+const SPARK_LOW = "▁▂▃▄▅";
+const SPARK_HIGH = "▆▇█";
+
+/** A surface's colour functions. `low`/`high` paint the two sparkline tones. */
+type Paint = {
+ escape: (s: string) => string;
+ green: (s: string) => string;
+ amber: (s: string) => string;
+ red: (s: string) => string;
+ grey: (s: string) => string;
+ low: (s: string) => string;
+ high: (s: string) => string;
+};
+
+function paintSpark(run: string, p: Paint): string {
+ return [...run]
+ .map((ch) => (SPARK_HIGH.includes(ch) ? p.high(ch) : SPARK_LOW.includes(ch) ? p.low(ch) : ch))
+ .join("");
+}
+
+/** Colourise the plain report by role. Detection reads the RAW line; wrapping the escaped line. */
+function paintReport(text: string, p: Paint): string {
+ return text
+ .split("\n")
+ .map((raw, i) => {
+ const line = p.escape(raw);
+ // header: grey the right column (scope · period · baseline).
+ if (i === 0) {
+ return line.replace(/^(\/report \S+\s{2,})(.+)$/, (_, a, b) => a + p.grey(b));
+ }
+ // whole-line secondary: read: chain, its exclusion lines, do-nothing footer.
+ if (/^\s*read:/.test(raw) || /^\s{6,}\S/.test(raw)) return p.grey(line);
+ if (/^\s{2}(or do nothing|open |Check status)/.test(raw)) return p.grey(line);
+
+ let l = line;
+ l = l.replace(/[▁▂▃▄▅▆▇█]+/g, (m) => paintSpark(m, p)); // two-tone sparkline
+ l = l
+ .replace(/✓/g, p.green("✓"))
+ .replace(/⚠/g, p.amber("⚠"))
+ .replace(/✕/g, p.red("✕"))
+ .replace(/○/g, p.grey("○")); // neutral (unknown) marker
+ l = l.replace(/↑ ?\d+×|↑/g, (m) => p.amber(m)).replace(/↓ ?\d+×|↓/g, (m) => p.green(m));
+ l = l.replace(/\(last \d+ min\)|\(\d+ min window\)/g, (m) => p.amber(m)); // anomaly window
+ l = l.replace(/\(normal ~[^)]*\)|\(estimated\)/g, (m) => p.grey(m)); // baseline/estimate is context
+ l = l.replace(/^(\s+worst \w+\s+)(.+?)( — )/, (_, a, k, b) => a + p.green(k) + b); // attribution key (may contain spaces)
+ return l;
+ })
+ .join("\n");
+}
+
+const ANSI_PAINT: Paint = {
+ escape: (s) => s,
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
+ amber: (s) => `\x1b[33m${s}\x1b[0m`,
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
+ grey: (s) => `\x1b[90m${s}\x1b[0m`,
+ low: (s) => `\x1b[32m${s}\x1b[0m`,
+ high: (s) => `\x1b[33m${s}\x1b[0m`,
+};
+
+export function renderReportAnsi(vm: ReportViewModel): string {
+ return paintReport(renderReportPlain(vm), ANSI_PAINT);
+}
diff --git a/apps/webapp/app/presenters/v3/reports/report-messages.ts b/apps/webapp/app/presenters/v3/reports/report-messages.ts
new file mode 100644
index 00000000000..2d90ea016ed
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/report-messages.ts
@@ -0,0 +1,39 @@
+/**
+ * Report-agnostic message infrastructure. Prose lives in each report's OWN catalog (e.g.
+ * `health/health-messages.ts`); this file only defines the resolver surface + a registry so the
+ * generic renderer can turn a VM's codes into strings by looking the catalog up via `vm.title`.
+ * No report vocabulary here — that would re-couple the renderer to a specific report.
+ */
+
+import { type ReasonCode, type Severity } from "./report-view-model";
+
+/**
+ * The resolver surface a report provides. Every renderer resolves a VM's codes through this;
+ * strings may carry {tokens} (e.g. {age}, {rate}) that the renderer fills from evidence.
+ */
+export type ReportMessages = {
+ metricLabel(id: string): string;
+ findingReason(findingType: string, reason: ReasonCode, opts?: { expanded?: boolean }): string;
+ readMessage(code: ReasonCode): string;
+ exclusionMessage(code: ReasonCode): string;
+ observationMessage(code: ReasonCode): string;
+ annotationMessage(code: ReasonCode): string;
+ statementMessage(findingType: string, severity: Severity, reason?: ReasonCode): string;
+ actionMessage(code: ReasonCode): string;
+};
+
+const catalogs = new Map<string, ReportMessages>();
+
+/** Register a report's catalog under its title (e.g. "health"). Called for its side effect. */
+export function registerReportMessages(title: string, messages: ReportMessages): void {
+ catalogs.set(title, messages);
+}
+
+/** Look up a report's catalog by `vm.title`. Throws if the report never registered one. */
+export function reportMessages(title: string): ReportMessages {
+ const messages = catalogs.get(title);
+ if (!messages) {
+ throw new Error(`report-messages: no catalog registered for report "${title}"`);
+ }
+ return messages;
+}
diff --git a/apps/webapp/app/presenters/v3/reports/report-registry.ts b/apps/webapp/app/presenters/v3/reports/report-registry.ts
new file mode 100644
index 00000000000..a0209dcfeda
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/report-registry.ts
@@ -0,0 +1,37 @@
+/**
+ * The report catalog: which reports exist and how each loads + interprets its data. Keyed by
+ * report name so cost/regression/errors drop in later as new `{ load, interpret }` entries with
+ * no changes to the VM, renderers, route, tool, or presenter.
+ *
+ * Deliberately separate from `ReportPresenter` — the presenter only orchestrates (look up a
+ * loader by key, run it, single-flight); knowing WHICH reports exist is a distinct concern.
+ */
+
+import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
+import { interpret as interpretHealth } from "./health/health";
+import { loadHealthInput } from "./health/health-data";
+import { type ReportViewModel } from "./report-view-model";
+
+export type ReportLoader<TInput> = {
+ load: (env: AuthenticatedEnvironment, period: string) => Promise<TInput>;
+ interpret: (input: TInput) => ReportViewModel;
+};
+
+function defineReport<TInput>(loader: ReportLoader<TInput>): ReportLoader<unknown> {
+ return loader as ReportLoader<unknown>;
+}
+
+export const REPORT_REGISTRY: Record<string, ReportLoader<unknown>> = {
+ health: defineReport({
+ load: (env, period) => loadHealthInput(env, period),
+ interpret: interpretHealth,
+ }),
+};
+
+export const REPORT_KEYS = Object.keys(REPORT_REGISTRY);
+
+export function isReportKey(key: string): boolean {
+ // Object.hasOwn, not `in`: `in` matches prototype keys ("toString", "__proto__"),
+ // which would pass the route guard and then 500 in the loader.
+ return Object.hasOwn(REPORT_REGISTRY, key);
+}
diff --git a/apps/webapp/app/presenters/v3/reports/report-view-model.ts b/apps/webapp/app/presenters/v3/reports/report-view-model.ts
new file mode 100644
index 00000000000..95ede3a24ca
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/reports/report-view-model.ts
@@ -0,0 +1,226 @@
+/**
+ * Generic, render-agnostic contract for a Report. Semantic, not a UI tree: numbers +
+ * what they mean (codes, severities, units, series), never formatted strings or layout.
+ * Every renderer consumes this and owns presentation. Reasons are codes;
+ * `report-messages.ts` resolves them -> strings, so phrasing lives in one place.
+ *
+ * No React/DOM/IO. Report-agnostic — `health` is just one interpreter that emits it.
+ */
+
+export type Severity = "ok" | "warn" | "crit";
+
+export type Unit = "ms" | "count" | "ratio" | "perMin";
+
+/** A code resolved to a human string by `report-messages.ts`. */
+export type ReasonCode = string;
+
+/** A key into `ReportViewModel.links`, so a recommendation can point at a URL. */
+export type LinkKey = string;
+
+export type Delta = {
+ dir: "up" | "down" | "flat";
+ /** rounded value/normal multiplier; renderer decides whether to print "6×". */
+ mult?: number;
+};
+
+export type MetricSeries = {
+ points: number[];
+ /** "estimated" = a proxy (e.g. pending backlog), shown informational-only. */
+ kind: "measured" | "estimated";
+};
+
+export type Metric = {
+ /** CODE, e.g. "start_latency_p95" — messages map -> label "start latency". */
+ id: string;
+ value: number;
+ unit: Unit;
+ aggregation?: "p95" | "rate" | "ratio" | "count";
+ /** baseline; renderer formats "(normal ~7s)". */
+ normal?: number;
+ delta?: Delta;
+ series?: MetricSeries;
+ /** named sub-values for composite metrics (e.g. throughput { done, triggered }). */
+ breakdown?: Record<string, number>;
+ /** shown on a cause line INSTEAD of "(normal ~x)", e.g. "pinned 40 of last 60 min". */
+ annotation?: { code: ReasonCode; value?: number };
+ /**
+ * Whether `value` is a real measurement. "unknown" = there was no signal, so `value` is a
+ * placeholder (e.g. liveness age 0) that a structured consumer must NOT read as a real 0 —
+ * the finding's reason carries the "unknown" meaning. Absent = measured (the common case).
+ */
+ availability?: "measured" | "unknown";
+ severity: Severity;
+};
+
+export type Recommendation = {
+ code: ReasonCode;
+ link?: LinkKey;
+};
+
+/** A footer line: an action, or the "do nothing" option (carries value). */
+export type FooterEntry = {
+ code: ReasonCode;
+ link?: LinkKey;
+ /** a computed fact (e.g. drainMinutes), never invented. */
+ value?: number;
+};
+
+/** A ruled-out cause + its evidence, e.g. "not your code" (never emitted without evidence). */
+export type Exclusion = {
+ code: ReasonCode;
+ evidence?: Record<string, number>;
+};
+
+/**
+ * A supporting fact backing the verdict — a measured observation, NOT a ruled-out cause,
+ * e.g. "runs are completing at ~820/min". Kept separate from `Exclusion` so the two aren't
+ * conflated (an exclusion answers "what it ISN'T"; an observation states "what IS true").
+ */
+export type Observation = {
+ code: ReasonCode;
+ evidence?: Record<string, number>;
+};
+
+export type Finding = {
+ /** "flow" | "execution" | "liveness" | future "infrastructure" | "billing" */
+ type: string;
+ severity: Severity;
+ /** CODE for the state/cause, e.g. "env_limit_saturation" | "healthy". */
+ reason: ReasonCode;
+ /** CODE for the "read:" line. Built last, may span findings. */
+ read?: ReasonCode;
+ /** metric ids this finding covers, in causal order when degraded. */
+ metricIds: string[];
+ /** ONE primary action. */
+ recommendation?: Recommendation;
+ /** optional parenthetical — same shape as recommendation. */
+ hedge?: Recommendation;
+ /** contiguous breach window of the driving metric -> "(last 40 min)". */
+ anomalyWindow?: { minutes: number; touchesEnd: boolean };
+ /**
+ * which dimension/key owns the problem, only when share >= threshold. `of` is the
+ * denominator label the renderer prints (e.g. "pending" for flow, "failures" for execution)
+ * — so it never mislabels a failures share as "% of pending".
+ */
+ attribution?: { dim: string; key: string; share: number; of: string };
+ /** ruled-out causes + evidence — rendered under the `read:` line ("not your code …"). */
+ exclusions?: Exclusion[];
+ /** supporting facts + evidence — rendered under the `read:` line after the exclusions. */
+ observations?: Observation[];
+};
+
+export type SummaryStatement = {
+ findingType: string;
+ severity: Severity;
+ /**
+ * Normally the statement renders from (findingType, severity). Exceptions carry a reason:
+ * stale telemetry marks flow AND execution "unknown" -> "Flow/Execution unknown — data stale";
+ * liveness with no signal is "freshness_unknown" -> "data freshness unknown".
+ */
+ reason?: ReasonCode;
+};
+
+export type ReportLink = {
+ key: LinkKey;
+ label: string;
+ url: string;
+};
+
+/** a.k.a. ReportDocument — report-agnostic, render-agnostic. */
+export type ReportViewModel = {
+ /** "health" | "cost" | … */
+ title: string;
+ /** "prod" */
+ scope: string;
+ /** "last 1h" */
+ period: string;
+ /** "vs your 7d normal" */
+ baselineLabel?: string;
+ /** ISO string — passed in, never read from the clock inside interpret. */
+ generatedAt: string;
+ /** live window length in minutes — lets the renderer say "of last 60 min". */
+ windowMinutes: number;
+
+ summary: {
+ severity: Severity;
+ statements: SummaryStatement[];
+ };
+ findings: Finding[];
+ metrics: Metric[];
+ /** dense structured payload for agents. */
+ facts: Record<string, unknown>;
+ links: ReportLink[];
+ /** dominant finding's action + optional "do nothing" option. Max two entries. */
+ footer: FooterEntry[];
+};
+
+// ---------------------------------------------------------------------------
+// Interpret-side helpers (produce VM fields, no prose, no IO).
+// ---------------------------------------------------------------------------
+
+/** Direction + rounded multiplier of `value` against a `normal` baseline. */
+export function delta(value: number, normal: number | undefined): Delta {
+ if (normal === undefined || !Number.isFinite(normal) || normal === 0) {
+ return { dir: "flat" };
+ }
+ const diff = value - normal;
+ const dir = diff > 0 ? "up" : diff < 0 ? "down" : "flat";
+ return { dir, mult: Math.round(value / normal) };
+}
+
+/** Severity of a scalar `x` against ascending warn/crit thresholds. */
+export function classifySeverity(x: number, t: { warn: number; crit: number }): Severity {
+ if (x >= t.crit) return "crit";
+ if (x >= t.warn) return "warn";
+ return "ok";
+}
+
+const SEVERITY_RANK: Record<Severity, number> = { ok: 0, warn: 1, crit: 2 };
+
+export function maxSeverity(...severities: Severity[]): Severity {
+ return severities.reduce<Severity>(
+ (acc, s) => (SEVERITY_RANK[s] > SEVERITY_RANK[acc] ? s : acc),
+ "ok"
+ );
+}
+
+export function isOk(severity: Severity): boolean {
+ return severity === "ok";
+}
+
+/**
+ * Trailing contiguous run of buckets that breach `threshold`, in minutes over
+ * `windowMinutes`. Default breach is at/over threshold (ABOVE, e.g. concurrency
+ * pinned at the limit); `below: true` counts at/under (BELOW, e.g. running capacity
+ * idle under a stall floor). `touchesEnd` = the run reaches the latest bucket
+ * ("(last 40 min)" vs mid-window "(1416h)"). Undefined when nothing breaches.
+ */
+export function anomalyWindow(
+ series: number[],
+ threshold: number,
+ windowMinutes: number,
+ options?: { below?: boolean }
+): { minutes: number; touchesEnd: boolean } | undefined {
+ if (series.length === 0) return undefined;
+ const perBucket = windowMinutes / series.length;
+ const breaches = options?.below ? (v: number) => v <= threshold : (v: number) => v >= threshold;
+
+ // longest breaching run + whether any run touches the end.
+ let longest = 0;
+ let current = 0;
+ let touchesEnd = false;
+ for (let i = 0; i < series.length; i++) {
+ if (breaches(series[i])) {
+ current++;
+ longest = Math.max(longest, current);
+ if (i === series.length - 1) touchesEnd = true;
+ } else {
+ current = 0;
+ }
+ }
+ if (longest === 0) return undefined;
+ // If the run reaches the latest bucket, use the TRAILING length so "(last X min)" is
+ // accurate — a longer mid-window run must not inflate it.
+ const runBuckets = touchesEnd ? current : longest;
+ return { minutes: Math.round(runBuckets * perBucket), touchesEnd };
+}
diff --git a/apps/webapp/app/routes/api.v1.reports.$key.ts b/apps/webapp/app/routes/api.v1.reports.$key.ts
new file mode 100644
index 00000000000..bcb504458cf
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.reports.$key.ts
@@ -0,0 +1,93 @@
+import { json } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { ReportPresenter } from "~/presenters/v3/reports/ReportPresenter.server";
+import { isReportKey, REPORT_KEYS } from "~/presenters/v3/reports/report-registry";
+import { renderReportAnsi, renderReportMarkdown } from "~/presenters/v3/reports/renderMarkdown";
+import { logger } from "~/services/logger.server";
+import { createLoaderApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server";
+
+const ParamsSchema = z.object({
+ key: z.string(),
+});
+
+/**
+ * The query tables the reports read. Authorize per-table (like api.v1.query.ts) rather than
+ * the permissive `{ type: "query", id: "all" }`: a JWT must be scoped to every table a report
+ * touches, so a token scoped to only some tables can't fetch a report that reads others. This
+ * is the union across reports; `health` reads all three.
+ */
+const REPORT_QUERY_TABLES = ["runs", "env_metrics", "queue_metrics"] as const;
+
+/** Canonical shorthand ("1h" / "30m" / "7d") with an upper bound, so the public API rejects
+ * garbage and absurd ranges (e.g. "999999999d") itself rather than relying on downstream clip. */
+const UNIT_MS: Record<string, number> = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 };
+const MAX_PERIOD_MS = 90 * UNIT_MS.d;
+const PeriodSchema = z
+ .string()
+ .regex(/^[1-9]\d*[smhdw]$/, "period must be a shorthand like '1h', '30m', or '7d'")
+ .refine(
+ (p) => Number(p.slice(0, -1)) * UNIT_MS[p.slice(-1)] <= MAX_PERIOD_MS,
+ "period is too large (max 90d)"
+ );
+
+const SearchParamsSchema = z.object({
+ period: PeriodSchema.optional(),
+ // markdown (default) for CLI/MCP · json (the raw VM) for web · ansi for a colour terminal.
+ format: z.enum(["markdown", "json", "ansi"]).default("markdown"),
+});
+
+export const loader = createLoaderApiRoute(
+ {
+ params: ParamsSchema,
+ searchParams: SearchParamsSchema,
+ // The MCP `get_report` tool calls this with a scoped JWT (read:query), so JWT auth
+ // must be allowed — same as api.v1.query.ts.
+ allowJWT: true,
+ findResource: async () => 1, // dummy — report key validated in the handler
+ authorization: {
+ action: "read",
+ // Per-table, not `id: "all"`: a JWT must be scoped to every query table the report reads.
+ resource: () => everyResource(REPORT_QUERY_TABLES.map((id) => ({ type: "query", id }))),
+ },
+ },
+ async ({ params, searchParams, authentication }) => {
+ if (!isReportKey(params.key)) {
+ return json(
+ { error: `Unknown report "${params.key}". Available: ${REPORT_KEYS.join(", ")}.` },
+ { status: 404 }
+ );
+ }
+
+ try {
+ const presenter = new ReportPresenter();
+ const vm = await presenter.call({
+ environment: authentication.environment,
+ key: params.key,
+ period: searchParams.period,
+ });
+
+ if (!vm) {
+ return json({ error: `Unknown report "${params.key}".` }, { status: 404 });
+ }
+
+ if (searchParams.format === "json") {
+ return json(vm, { status: 200 });
+ }
+
+ if (searchParams.format === "ansi") {
+ return new Response(renderReportAnsi(vm), {
+ status: 200,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+
+ return new Response(renderReportMarkdown(vm), {
+ status: 200,
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
+ });
+ } catch (error) {
+ logger.error("Failed to render report", { error, key: params.key });
+ return json({ error: "Something went wrong, please try again." }, { status: 500 });
+ }
+ }
+);
diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts
index 70af40ec89f..fb1bf11a72e 100644
--- a/apps/webapp/app/services/queryService.server.ts
+++ b/apps/webapp/app/services/queryService.server.ts
@@ -115,6 +115,18 @@ export type ExecuteQueryResult<T> =
}
| { success: false; error: Error };
+/** Own-property flag tagged on the transient "query concurrency exceeded" rejection (retryable). */
+const QUERY_CONCURRENCY_REJECTION_FLAG = "__queryConcurrencyRejection";
+
+/** True for the transient concurrency-limit rejection — a stable signal callers can retry on. */
+export function isQueryConcurrencyRejection(error: unknown): boolean {
+ return (
+ typeof error === "object" &&
+ error !== null &&
+ (error as Record<string, unknown>)[QUERY_CONCURRENCY_REJECTION_FLAG] === true
+ );
+}
+
const INTERVAL_UNIT_SECONDS: Record<TimeBucketInterval["unit"], number> = {
SECOND: 1,
MINUTE: 60,
@@ -204,7 +216,11 @@ export async function executeQuery<TOut extends z.ZodSchema>(
acquireResult.reason === "key_limit"
? `You've exceeded your query concurrency of ${orgLimit} for this project. Please try again later.`
: "We're experiencing a lot of queries at the moment. Please try again later.";
- return { success: false, error: new QueryError(errorMessage, { query: options.query }) };
+ const error = new QueryError(errorMessage, { query: options.query });
+ // Stable marker so callers can retry on a transient concurrency rejection without
+ // matching the message text (which is free to change).
+ Object.assign(error, { [QUERY_CONCURRENCY_REJECTION_FLAG]: true });
+ return { success: false, error };
}
// Detect which table the query targets to determine the time column
diff --git a/apps/webapp/test/reportHealth.test.ts b/apps/webapp/test/reportHealth.test.ts
new file mode 100644
index 00000000000..22f940ffbdd
--- /dev/null
+++ b/apps/webapp/test/reportHealth.test.ts
@@ -0,0 +1,511 @@
+import { describe, expect, it } from "vitest";
+import { renderReportAnsi, renderReportMarkdown } from "~/presenters/v3/reports/renderMarkdown";
+import {
+ interpret,
+ isPendingIncreasing,
+ type HealthInput,
+} from "~/presenters/v3/reports/health/health";
+
+/** Golden A — degraded: env concurrency-limit saturation, backlog drains. */
+const INPUT_A: HealthInput = {
+ scope: "prod",
+ period: "last 1h",
+ baselineLabel: "vs your 7d normal",
+ generatedAt: "2026-07-20T12:00:00.000Z",
+ windowMinutes: 60,
+ flowSource: "queue_metrics_v1",
+ pending: { now: 1910, normal: 120, series: [120, 300, 700, 1200, 1600, 1910], estimated: false },
+ startLatency: {
+ p95Ms: 42000,
+ normalP95Ms: 7000,
+ series: [7000, 12000, 20000, 30000, 38000, 42000],
+ },
+ throughput: { donePerMin: 820, triggeredPerMin: 1150, normalTriggeredPerMin: 1100 },
+ failures: { rate: 0.013, normalRate: 0.011, series: [0.011, 0.011, 0.012, 0.013] },
+ duration: { p95Ms: 1200, normalP95Ms: 1180 },
+ liveness: { telemetryAgeMs: 4000 },
+ flowEvidence: {
+ runningSeries: [60, 80, 90, 100, 100, 100, 100, 100, 100],
+ envLimit: 100,
+ throttledShare: 0.1,
+ worstQueue: { name: "email-sends", share: 0.82 },
+ dlqDelta: 0,
+ },
+};
+
+/** Golden B — everything healthy. */
+const INPUT_B: HealthInput = {
+ scope: "prod",
+ period: "last 1h",
+ baselineLabel: "vs your 7d normal",
+ generatedAt: "2026-07-20T12:00:00.000Z",
+ windowMinutes: 60,
+ flowSource: "queue_metrics_v1",
+ pending: { now: 84, normal: 120, series: [110, 96, 88, 90, 84], estimated: false },
+ startLatency: { p95Ms: 6000, normalP95Ms: 7000, series: [6500, 6200, 6000, 5900, 6000] },
+ throughput: { donePerMin: 1000, triggeredPerMin: 1000, normalTriggeredPerMin: 1000 },
+ failures: { rate: 0.009, normalRate: 0.011, series: [0.01, 0.009, 0.009] },
+ duration: { p95Ms: 1100, normalP95Ms: 1180 },
+ liveness: { telemetryAgeMs: 2000 },
+ flowEvidence: {
+ runningSeries: [40, 45, 50, 48, 44],
+ envLimit: 100,
+ throttledShare: 0,
+ worstQueue: null,
+ dlqDelta: 0,
+ },
+};
+
+describe("health cause tree (Golden A — env limit saturation)", () => {
+ const vm = interpret(INPUT_A);
+ const flow = vm.findings.find((f) => f.type === "flow")!;
+
+ it("selects the env-limit-saturation cause + evidence", () => {
+ expect(flow.reason).toBe("env_limit_saturation");
+ expect(flow.severity).toBe("warn"); // crit raw, warn by the drainable policy
+ expect(flow.anomalyWindow).toEqual({ minutes: 40, touchesEnd: true });
+ expect(flow.attribution).toEqual({
+ dim: "queue",
+ key: "email-sends",
+ share: 0.82,
+ of: "pending",
+ });
+ expect(flow.exclusions).toEqual([]); // env-limit saturation rules nothing out...
+ expect(flow.observations).toEqual([
+ // ...it states supporting facts instead.
+ { code: "not_workers_platform", evidence: { donePerMin: 820 } },
+ { code: "nothing_dead_lettered", evidence: { dlq: 0 } },
+ ]);
+ expect(flow.read).toBe("saturation_chain");
+ const concurrency = vm.metrics.find((m) => m.id === "concurrency")!;
+ expect(concurrency.annotation).toEqual({ code: "pinned_minutes", value: 40 });
+ });
+
+ it("footer = raise limit + do-nothing (drains)", () => {
+ expect(vm.footer).toEqual([
+ { code: "raise_env_limit", link: "concurrency" },
+ { code: "do_nothing_drains", value: 2.3 },
+ ]);
+ });
+
+ it("renders", () => {
+ expect(renderReportMarkdown(vm)).toMatchInlineSnapshot(`
+ "/report health prod · last 1h · vs your 7d normal
+
+ 🟡 Flow slowing · 🟢 Execution healthy · 🟢 data fresh
+
+ FLOW 🟡 at your env concurrency limit (last 40 min)
+
+ concurrency 100/100 ▁▅▆█████ pinned 40 of last 60 min
+
+ pending 1,910 ↑ 16× ▁▁▂▃▅▅▇█ (normal ~120)
+
+ start latency p95 42s ↑ 6× ▁▁▂▄▆▆▇█ (normal ~7s)
+
+ worst queue email-sends — 82% of pending
+
+ read: limit saturated → incoming work exceeds capacity → backlog grows
+ runs are completing at ~820/min
+ nothing dead-lettered
+
+ EXECUTION 🟢 the runs that DO start are fine
+
+ failures 1.3% (normal ~1.1%) · durations normal
+ read: runs are completing normally
+
+ LIVENESS 🟢 fresh — telemetry current, updated 4s ago
+
+ → Raise the env concurrency limit
+ or do nothing — backlog drains in ~2.3 min once triggers ease"
+ `);
+ });
+});
+
+describe("health (Golden B — healthy)", () => {
+ const vm = interpret(INPUT_B);
+
+ it("all findings healthy, footer nothing-to-do", () => {
+ expect(vm.summary.severity).toBe("ok");
+ expect(vm.findings.map((f) => f.severity)).toEqual(["ok", "ok", "ok"]);
+ expect(vm.footer).toEqual([{ code: "nothing_to_do" }]);
+ });
+
+ it("renders", () => {
+ expect(renderReportMarkdown(vm)).toMatchInlineSnapshot(`
+ "/report health prod · last 1h · vs your 7d normal
+
+ 🟢 Flow healthy · 🟢 Execution healthy · 🟢 data fresh
+
+ FLOW 🟢 starting normally — pending 84 (normal ~120) · starts p95 6s
+
+ EXECUTION 🟢 completing normally — failures 0.9% (normal ~1.1%) · durations normal
+
+ LIVENESS 🟢 fresh — telemetry current, updated 2s ago
+
+ → nothing to do"
+ `);
+ });
+});
+
+describe("snapshot fallback path flags the estimated backlog trend", () => {
+ // No queue-metrics evidence -> the backlog series is an estimate (finished-vs-triggered proxy).
+ const snapshot: HealthInput = {
+ ...INPUT_B,
+ flowSource: "snapshot+runs",
+ pending: { now: 6000, series: [1000, 3000, 6000], estimated: true }, // normal omitted on snapshot
+ flowEvidence: {
+ runningSeries: [],
+ envLimit: 0,
+ throttledShare: 0,
+ worstQueue: null,
+ dlqDelta: null,
+ },
+ };
+
+ it("renders an (estimated) caveat on the proxy series, not a bare sparkline", () => {
+ const vm = interpret(snapshot);
+ expect(vm.findings.find((f) => f.type === "flow")!.reason).toBe("backlog");
+ const md = renderReportMarkdown(vm);
+ expect(md).toContain("(estimated)"); // the human surface signals the trend is a proxy
+ });
+});
+
+describe("liveness trust guard (telemetry freshness)", () => {
+ it("stale telemetry forces execution unknown + crit summary + control-plane footer", () => {
+ const stale: HealthInput = { ...INPUT_A, liveness: { telemetryAgeMs: 600_000 } };
+ const vm = interpret(stale);
+ const execution = vm.findings.find((f) => f.type === "execution")!;
+ expect(execution.reason).toBe("unknown");
+ expect(execution.read).toBe("data_stale");
+ expect(vm.summary.severity).toBe("crit");
+ // #6: footer points at the pipeline, not "raise the env limit" off stale data.
+ expect(vm.footer).toEqual([{ code: "check_control_plane", link: "status" }]);
+ });
+
+ it("no freshness signal is 'unknown', NOT stale — it does not trust-guard execution", () => {
+ const unknown: HealthInput = {
+ ...INPUT_A,
+ // healthy execution so we can see the guard did NOT fire.
+ failures: { rate: 0.009, normalRate: 0.011, series: [0.009] },
+ liveness: { telemetryAgeMs: null },
+ };
+ const vm = interpret(unknown);
+ const execution = vm.findings.find((f) => f.type === "execution")!;
+ const liveness = vm.findings.find((f) => f.type === "liveness")!;
+ expect(liveness.reason).toBe("freshness_unknown");
+ expect(liveness.severity).toBe("ok"); // no signal is NEUTRAL, not a warning
+ expect(execution.reason).not.toBe("unknown");
+ });
+
+ it("a healthy but idle env (no telemetry signal) reads overall green, not yellow", () => {
+ // Golden B is all-healthy; drop its telemetry signal -> the verdict must stay ok, since
+ // "freshness unknown" is neutral and must not drag a fine env into a yellow report.
+ const vm = interpret({ ...INPUT_B, liveness: { telemetryAgeMs: null } });
+ expect(vm.summary.severity).toBe("ok");
+ expect(vm.findings.find((f) => f.type === "liveness")!.reason).toBe("freshness_unknown");
+ // ...but the marker is NEUTRAL (⚪), not a confident green — the state is genuinely unknown.
+ const md = renderReportMarkdown(vm);
+ expect(md).toContain("⚪");
+ expect(md).not.toContain("🟡");
+ });
+});
+
+describe("isPendingIncreasing", () => {
+ it("detects a positive trend", () => {
+ expect(isPendingIncreasing([120, 300, 700, 1200, 1600, 1910])).toBe(true);
+ expect(isPendingIncreasing([110, 96, 88, 90, 84])).toBe(false);
+ });
+});
+
+/**
+ * Fixed-priority cause tree: the first discriminator that fires wins. Each case starts
+ * from Golden A and overrides ONLY flow evidence so the intended discriminator matches
+ * (Golden A itself covers env_limit_saturation).
+ */
+describe("flow cause tree — cause selection per discriminator", () => {
+ const withFlow = (
+ flowEvidence: Partial<HealthInput["flowEvidence"]>,
+ throughput?: Partial<HealthInput["throughput"]>
+ ): HealthInput => ({
+ ...INPUT_A,
+ throughput: { ...INPUT_A.throughput, ...throughput },
+ flowEvidence: { ...INPUT_A.flowEvidence, ...flowEvidence },
+ });
+
+ const flowReason = (input: HealthInput) =>
+ interpret(input).findings.find((f) => f.type === "flow")!.reason;
+
+ it("env_limit_saturation — concurrency pinned at the env limit", () => {
+ expect(flowReason(INPUT_A)).toBe("env_limit_saturation");
+ });
+
+ it("dequeue_stall — capacity idle while the backlog grows", () => {
+ // running far below the limit (0.1) with a rising backlog + elevated latency.
+ expect(flowReason(withFlow({ runningSeries: Array(9).fill(10) }))).toBe("dequeue_stall");
+ });
+
+ it("queue_limit_throttling — throttled, but the env limit is not the bottleneck", () => {
+ expect(flowReason(withFlow({ runningSeries: Array(9).fill(50), throttledShare: 0.5 }))).toBe(
+ "queue_limit_throttling"
+ );
+ });
+
+ it("selects queue throttling over dequeue stall when both shapes match", () => {
+ // low running (would look like a stall) BUT the queue is throttling — the known config
+ // bottleneck must win, not "it's on our side".
+ expect(flowReason(withFlow({ runningSeries: Array(9).fill(10), throttledShare: 0.5 }))).toBe(
+ "queue_limit_throttling"
+ );
+ });
+
+ it("trigger_spike — triggers >= 3x the normal rate", () => {
+ expect(
+ flowReason(
+ withFlow(
+ { runningSeries: Array(9).fill(50), throttledShare: 0 },
+ { triggeredPerMin: 3300, normalTriggeredPerMin: 1100 }
+ )
+ )
+ ).toBe("trigger_spike");
+ });
+
+ it("trigger_surge — new volume with no baseline (multiplier can't be computed)", () => {
+ // normal 0 makes a multiplier meaningless, so an absolute rate selects "new volume"
+ // instead of dropping to the v1 fallback (a spike from a zero baseline was invisible before).
+ const input = withFlow(
+ { runningSeries: Array(9).fill(50), throttledShare: 0 },
+ { triggeredPerMin: 5000, normalTriggeredPerMin: 0 }
+ );
+ expect(flowReason(input)).toBe("trigger_surge");
+ const triggered = interpret(input).metrics.find((m) => m.id === "triggered")!;
+ expect(triggered.annotation).toEqual({ code: "surge_rate", value: 5000 });
+ });
+
+ it("does not select trigger_spike when completions keep pace and pending falls", () => {
+ // 3× the normal trigger rate, but the backlog is draining (net >= 0, pending falling) — so the
+ // spike is NOT the cause of degradation (elevated latency is). Blaming it would contradict
+ // its own "queue fills faster than it drains" read.
+ const input: HealthInput = {
+ ...INPUT_A,
+ pending: { now: 400, normal: 1000, series: [500, 450, 400], estimated: false },
+ throughput: { donePerMin: 3300, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 },
+ flowEvidence: {
+ ...INPUT_A.flowEvidence,
+ runningSeries: Array(9).fill(50),
+ throttledShare: 0,
+ },
+ };
+ expect(flowReason(input)).not.toBe("trigger_spike");
+ expect(flowReason(input)).toBe("start_latency"); // falls through to the v1 symptom
+ });
+
+ it("does not select trigger_surge when new volume is draining", () => {
+ // No baseline + high volume, but completions outpace triggers and the backlog falls — not a backup.
+ const input: HealthInput = {
+ ...INPUT_A,
+ pending: { now: 400, normal: 1000, series: [500, 450, 400], estimated: false },
+ throughput: { donePerMin: 6000, triggeredPerMin: 5000, normalTriggeredPerMin: 0 },
+ flowEvidence: {
+ ...INPUT_A.flowEvidence,
+ runningSeries: Array(9).fill(50),
+ throttledShare: 0,
+ },
+ };
+ expect(flowReason(input)).not.toBe("trigger_surge");
+ });
+
+ it("fallback — degraded with no discriminator -> v1 symptom (start latency)", () => {
+ expect(flowReason(withFlow({ runningSeries: Array(9).fill(50), throttledShare: 0 }))).toBe(
+ "start_latency"
+ );
+ });
+});
+
+describe("env_limit_saturation read does not claim a start lag that isn't there", () => {
+ // Pinned concurrency + rising backlog, but start latency is still healthy — saturation can grow
+ // a backlog before p95 latency crosses its threshold, so the read must not assert "starts lag".
+ const input: HealthInput = {
+ ...INPUT_A,
+ startLatency: { p95Ms: 6000, normalP95Ms: 7000, series: [6000, 6100, 6000, 5900, 6000] },
+ flowEvidence: { ...INPUT_A.flowEvidence, runningSeries: Array(9).fill(100) },
+ };
+ const vm = interpret(input);
+ const flow = vm.findings.find((f) => f.type === "flow")!;
+
+ it("still selects env_limit_saturation with a latency-free read", () => {
+ expect(flow.reason).toBe("env_limit_saturation");
+ expect(flow.read).toBe("saturation_chain");
+ expect(vm.metrics.find((m) => m.id === "start_latency_p95")!.severity).toBe("ok");
+ const md = renderReportMarkdown(vm);
+ expect(md).toContain("incoming work exceeds capacity");
+ expect(md).not.toContain("starts lag");
+ });
+});
+
+describe("trigger spike does not exonerate user code", () => {
+ const spike = interpret({
+ ...INPUT_A,
+ throughput: { donePerMin: 820, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 },
+ flowEvidence: { ...INPUT_A.flowEvidence, runningSeries: Array(9).fill(50), throttledShare: 0 },
+ });
+
+ it("reports execution is healthy but never claims 'NOT a code problem'", () => {
+ expect(spike.findings.find((f) => f.type === "flow")!.reason).toBe("trigger_spike");
+ const md = renderReportMarkdown(spike);
+ expect(md).toContain("runs that start are completing normally"); // flow exclusion (proven fact)
+ expect(md).not.toContain("NOT a code problem"); // a code path may BE flooding the queue
+ });
+
+ it("dequeue_stall (platform-side) still reads 'NOT a code problem'", () => {
+ const stall = interpret({
+ ...INPUT_A,
+ flowEvidence: { ...INPUT_A.flowEvidence, runningSeries: Array(9).fill(10) },
+ });
+ expect(stall.findings.find((f) => f.type === "flow")!.reason).toBe("dequeue_stall");
+ expect(renderReportMarkdown(stall)).toContain("NOT a code problem");
+ });
+});
+
+describe("ANSI render (terminal)", () => {
+ const ansi = renderReportAnsi(interpret(INPUT_A));
+
+ it("uses glyphs + ANSI colour, never the markdown status emoji", () => {
+ expect(ansi).toMatch(/\x1b\[\d+m/); // an ANSI SGR colour code
+ expect(ansi).toMatch(/[✓⚠✕]/); // severity glyphs (not emoji)
+ expect(ansi).not.toMatch(/[🟢🟡🔴]/u); // emoji are the markdown surface only
+ });
+});
+
+describe("exclusions are proven, not assumed", () => {
+ const withFlow = (
+ flowEvidence: Partial<HealthInput["flowEvidence"]>,
+ over: Partial<HealthInput> = {}
+ ): HealthInput => ({
+ ...INPUT_A,
+ ...over,
+ flowEvidence: { ...INPUT_A.flowEvidence, ...flowEvidence },
+ });
+ const exclusionCodes = (input: HealthInput) =>
+ (interpret(input).findings.find((f) => f.type === "flow")!.exclusions ?? []).map((e) => e.code);
+ const observationCodes = (input: HealthInput) =>
+ (interpret(input).findings.find((f) => f.type === "flow")!.observations ?? []).map(
+ (o) => o.code
+ );
+
+ it("dequeue_stall claims not-your-code AND not-your-config (both proven: healthy exec, no pin, no throttle)", () => {
+ // dequeue_stall only fires with no env-pin and no throttling, so "limits aren't the
+ // bottleneck" is genuinely proven here (a throttled shape selects queue_limit_throttling).
+ const codes = exclusionCodes(withFlow({ runningSeries: Array(9).fill(10) }));
+ expect(codes).toContain("not_your_code");
+ expect(codes).toContain("not_your_config");
+ });
+
+ it("trigger_spike observes healthy execution without ruling out user code", () => {
+ // Backing-up spike (net < 0, pending rising) with healthy execution -> "execution_healthy" as
+ // an OBSERVATION, NOT the exclusion "not_your_code": a code path fanning out task.trigger could
+ // BE the cause of the spike, so it must not be ruled out.
+ const healthyInput = withFlow(
+ { runningSeries: Array(9).fill(50), throttledShare: 0 },
+ { throughput: { donePerMin: 820, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 } }
+ );
+ expect(observationCodes(healthyInput)).toContain("execution_healthy");
+ expect(exclusionCodes(healthyInput)).not.toContain("not_your_code");
+
+ // Execution failing -> can't even observe that execution is healthy.
+ const degradedInput = withFlow(
+ { runningSeries: Array(9).fill(50), throttledShare: 0 },
+ {
+ throughput: { donePerMin: 820, triggeredPerMin: 3300, normalTriggeredPerMin: 1100 },
+ failures: { rate: 0.2, normalRate: 0.01, series: [0.2] },
+ }
+ );
+ expect(observationCodes(degradedInput)).not.toContain("execution_healthy");
+ });
+});
+
+describe("stale-telemetry trust guard covers flow (not just execution)", () => {
+ const stale = interpret({ ...INPUT_A, liveness: { telemetryAgeMs: 600_000 } });
+ const flow = stale.findings.find((f) => f.type === "flow")!;
+ const execution = stale.findings.find((f) => f.type === "execution")!;
+
+ it("marks flow unknown + crit and strips its action / attribution / exclusions / anomaly window", () => {
+ expect(flow.reason).toBe("unknown");
+ expect(flow.severity).toBe("crit"); // consistent across summary / section glyph / JSON
+ expect(flow.recommendation).toBeUndefined();
+ expect(flow.attribution).toBeUndefined();
+ expect(flow.exclusions).toBeUndefined();
+ expect(flow.observations).toBeUndefined();
+ expect(flow.anomalyWindow).toBeUndefined(); // no stale causal evidence left in the VM
+ });
+
+ it("marks execution unknown + crit too", () => {
+ expect(execution.reason).toBe("unknown");
+ expect(execution.severity).toBe("crit");
+ });
+
+ it("strips stale-derived metric annotations so format=json can't leak them", () => {
+ // Golden A sets concurrency.annotation ("pinned 40 of last 60 min") before the guard runs;
+ // a stale feed must not surface that narrative on the raw JSON metrics.
+ expect(stale.metrics.every((m) => m.annotation === undefined)).toBe(true);
+ });
+
+ it("renders both sections red as unknown, with no stale causal verdict", () => {
+ // The unknown headline already says "data stale"; there's no `read:` line to render (it would
+ // just repeat that), and no stale causal evidence (anomaly window) survives.
+ const md = renderReportMarkdown(stale);
+ expect(md).toContain("🔴 Flow unknown — data stale");
+ expect(md).toContain("🔴 flow can't be assessed");
+ expect(md).not.toContain("(last 40 min)"); // anomaly window gone
+ });
+
+ it("drops the CH-derived link from the VM when telemetry is stale", () => {
+ // flow's "concurrency" link is gone; only liveness' control-plane link may remain.
+ expect(stale.links.map((l) => l.key)).not.toContain("concurrency");
+ });
+
+ it("flags the structured facts informational-only so an agent won't act on stale numbers", () => {
+ expect(stale.facts).toMatchObject({ trustworthy: false, staleReason: "telemetry_stale" });
+ // fresh input is trustworthy.
+ expect(interpret(INPUT_A).facts).toMatchObject({ trustworthy: true });
+ });
+});
+
+describe("freshness unknown is distinct from lagging", () => {
+ it("renders 'data freshness unknown' in the summary, not 'data lagging'", () => {
+ const md = renderReportMarkdown(interpret({ ...INPUT_A, liveness: { telemetryAgeMs: null } }));
+ expect(md).toContain("data freshness unknown");
+ expect(md).not.toContain("data lagging");
+ });
+
+ it("does not change the flow severity policy (drainable crit still downgrades to warn)", () => {
+ const fresh = interpret(INPUT_A).findings.find((f) => f.type === "flow")!;
+ const unknown = interpret({ ...INPUT_A, liveness: { telemetryAgeMs: null } }).findings.find(
+ (f) => f.type === "flow"
+ )!;
+ expect(unknown.severity).toBe(fresh.severity); // warn, unaffected by unknown freshness
+ });
+
+ it("marks the liveness metric availability 'unknown' so value 0 isn't read as fresh", () => {
+ const unknown = interpret({ ...INPUT_A, liveness: { telemetryAgeMs: null } });
+ const metric = unknown.metrics.find((m) => m.id === "liveness")!;
+ expect(metric.availability).toBe("unknown");
+ expect(interpret(INPUT_A).metrics.find((m) => m.id === "liveness")!.availability).toBe(
+ "measured"
+ );
+ });
+});
+
+describe("zero baseline is not a false green (absolute floors)", () => {
+ it("pending spiking from a 0 baseline is not healthy", () => {
+ const vm = interpret({
+ ...INPUT_B,
+ pending: { now: 6000, normal: 0, series: [0, 100, 6000], estimated: false },
+ });
+ expect(vm.findings.find((f) => f.type === "flow")!.severity).not.toBe("ok");
+ });
+
+ it("failures spiking from a 0% baseline is not healthy", () => {
+ const vm = interpret({ ...INPUT_B, failures: { rate: 0.1, normalRate: 0, series: [0.1] } });
+ expect(vm.findings.find((f) => f.type === "execution")!.severity).not.toBe("ok");
+ });
+});
diff --git a/apps/webapp/test/reportHealthData.test.ts b/apps/webapp/test/reportHealthData.test.ts
new file mode 100644
index 00000000000..ae29f7a3700
--- /dev/null
+++ b/apps/webapp/test/reportHealthData.test.ts
@@ -0,0 +1,242 @@
+import { describe, expect, it } from "vitest";
+import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
+import {
+ type HealthDeps,
+ type HealthQueryRunner,
+ loadHealthInput,
+} from "~/presenters/v3/reports/health/health-data";
+
+/**
+ * Exercises `loadHealthInput`'s ORCHESTRATION through its query seam (`HealthDeps`):
+ * source selection, snapshot fallback (empty + throw), dlq parsing, window-from-timeRange.
+ * The runner is injected at the IO boundary — SQL/TRQL translation and CH aggregation are
+ * tested by the query service and the `@internal/clickhouse` MV tests.
+ */
+
+const NOW = new Date("2026-07-22T12:00:00.000Z");
+
+const fakeEnv = {
+ id: "env_1",
+ slug: "prod",
+ organization: { id: "org_1" },
+ project: { id: "proj_1" },
+} as unknown as AuthenticatedEnvironment;
+
+type Rows = Record<string, unknown>[];
+
+/** Canned rows per query kind; the resolved timeRange is keyed off the period (7d = baseline). */
+function makeDeps(opts: {
+ runs: Rows;
+ runsSeries?: Rows;
+ envSeries?: Rows;
+ envScalar?: Rows;
+ dlqTotal?: Rows;
+ worst?: Rows;
+ liveWindowMin?: number;
+ pendingNow?: number;
+ throwOnEnv?: boolean;
+ redisThrows?: boolean;
+}): HealthDeps {
+ const rangeFor = (period: string) => {
+ const min = period === "7d" ? 7 * 1440 : (opts.liveWindowMin ?? 60);
+ return { from: new Date(NOW.getTime() - min * 60_000), to: NOW };
+ };
+ const runQuery: HealthQueryRunner = async (_env, query, period) => {
+ const timeRange = rangeFor(period);
+ const wrap = (rows: Rows = []) => ({ rows, timeRange });
+ const isEnv = query.includes("FROM env_metrics");
+ if (isEnv && opts.throwOnEnv) throw new Error("env_metrics unavailable");
+ if (query.includes("dlq_total")) return wrap(opts.dlqTotal);
+ if (isEnv && query.includes("timeBucket")) return wrap(opts.envSeries);
+ if (isEnv) return wrap(opts.envScalar ?? [{}]);
+ if (query.includes("FROM queue_metrics")) return wrap(opts.worst);
+ if (query.includes("task_identifier")) return wrap([]);
+ if (query.includes("FROM runs") && query.includes("timeBucket")) return wrap(opts.runsSeries);
+ return wrap(opts.runs); // runs scalar (live + baseline)
+ };
+ const lengthOfEnvQueue = opts.redisThrows
+ ? async () => {
+ throw new Error("redis unavailable");
+ }
+ : async () => opts.pendingNow ?? 0;
+ return { runQuery, lengthOfEnvQueue };
+}
+
+const RUNS_SCALAR: Rows = [
+ {
+ start_latency_p95: 7000,
+ dur_p95: 1000,
+ failures: 1,
+ completed: 100,
+ triggered: 120,
+ last_activity: "2026-07-22 11:59:58",
+ },
+];
+
+describe("loadHealthInput — orchestration (query seam)", () => {
+ it("measured path: queue_metrics source, real pending, parsed dlq, window from timeRange", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ envSeries: [
+ { t: "a", queued: 100, running: 50, throttled: 0, wait_p95: 5000 },
+ { t: "b", queued: 300, running: 60, throttled: 1, wait_p95: 9000 },
+ ],
+ envScalar: [{ wait_p95: 9000, avg_queued: 200, env_limit: 100 }],
+ dlqTotal: [{ dlq_total: 0 }],
+ worst: [
+ { name: "email-sends", latest_queued: 82 },
+ { name: "other", latest_queued: 18 },
+ ],
+ pendingNow: 1910,
+ liveWindowMin: 60,
+ })
+ );
+
+ expect(input.flowSource).toBe("queue_metrics_v1");
+ expect(input.pending.estimated).toBe(false);
+ expect(input.pending.now).toBe(1910);
+ expect(input.flowEvidence.dlqDelta).toBe(0);
+ expect(input.flowEvidence.worstQueue).toEqual({ name: "email-sends", share: 0.82 });
+ expect(input.windowMinutes).toBe(60);
+ expect(input.throughput.triggeredPerMin).toBeCloseTo(120 / 60);
+ });
+
+ it("dlq best-effort query returns nothing -> dlqDelta is null (unmeasured, no false 'nothing dead-lettered')", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 100 }],
+ envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100 }],
+ dlqTotal: [],
+ })
+ );
+
+ expect(input.flowSource).toBe("queue_metrics_v1");
+ expect(input.flowEvidence.dlqDelta).toBeNull();
+ });
+
+ it("no measured env rows -> snapshot fallback (estimated backlog)", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ runsSeries: [{ t: "a", triggered: 10, completed: 8, start_latency_p95: 3000, failures: 0 }],
+ envSeries: [], // pipeline hasn't populated env_metrics for this env yet
+ })
+ );
+
+ expect(input.flowSource).toBe("snapshot+runs");
+ expect(input.pending.estimated).toBe(true);
+ });
+
+ it("env_metrics query throws -> snapshot fallback, never a 500 (bug-2 guard)", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ runsSeries: [{ t: "a", triggered: 10, completed: 8, start_latency_p95: 3000, failures: 0 }],
+ throwOnEnv: true,
+ })
+ );
+
+ expect(input.flowSource).toBe("snapshot+runs");
+ expect(input.pending.estimated).toBe(true);
+ });
+
+ it("windowMinutes comes from the resolved (clipped) timeRange, not the period string", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 100 }],
+ envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100 }],
+ dlqTotal: [{ dlq_total: 3 }],
+ liveWindowMin: 45,
+ })
+ );
+
+ expect(input.windowMinutes).toBe(45);
+ expect(input.flowEvidence.dlqDelta).toBe(3);
+ });
+
+ it("telemetry age is measured from the newest ROW, not the query window end (= NOW)", async () => {
+ const OLD = "2026-07-22 11:00:00"; // 60 min before NOW, though the query window ends at NOW
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: [{ ...RUNS_SCALAR[0], last_activity: OLD }],
+ envSeries: [{ t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 100 }],
+ envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100, last_bucket: OLD }],
+ })
+ );
+ // ~60 min from the row timestamp, NOT ~0 from timeRange.to
+ expect(input.liveness.telemetryAgeMs).toBeGreaterThan(50 * 60_000);
+ });
+
+ it("terminal runs (failed/canceled) do not accumulate as snapshot backlog", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ // every triggered run also FINISHED this bucket (some failed) -> proxy stays flat at 0
+ runsSeries: [
+ {
+ t: "a",
+ triggered: 50,
+ completed: 30,
+ finished: 50,
+ start_latency_p95: 1000,
+ failures: 20,
+ },
+ {
+ t: "b",
+ triggered: 40,
+ completed: 25,
+ finished: 40,
+ start_latency_p95: 1000,
+ failures: 15,
+ },
+ ],
+ envSeries: [], // force the snapshot path
+ })
+ );
+ expect(input.flowSource).toBe("snapshot+runs");
+ expect(Math.max(...input.pending.series)).toBe(0);
+ });
+
+ it("Redis rejection falls back to the latest measured queued, not a confident 0", async () => {
+ const input = await loadHealthInput(
+ fakeEnv,
+ "1h",
+ NOW,
+ makeDeps({
+ runs: RUNS_SCALAR,
+ envSeries: [
+ { t: "a", queued: 10, running: 5, throttled: 0, wait_p95: 100 },
+ { t: "b", queued: 900, running: 5, throttled: 0, wait_p95: 100 },
+ ],
+ envScalar: [{ wait_p95: 100, avg_queued: 8, env_limit: 100 }],
+ redisThrows: true,
+ })
+ );
+ expect(input.flowSource).toBe("queue_metrics_v1");
+ expect(input.pending.now).toBe(900);
+ });
+});
diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts
index d01062440e9..bebb090debf 100644
--- a/packages/cli-v3/src/apiClient.ts
+++ b/packages/cli-v3/src/apiClient.ts
@@ -368,6 +368,49 @@ export class CliApiClient {
);
}
+ /**
+ * Fetch a server-rendered report (text + sparkline). Thin pass-through. `format`:
+ * "markdown" (agents/chat) or "ansi" (terminal). Uses this client's env API key.
+ */
+ async getReport(
+ key: string,
+ options?: { period?: string; format?: "markdown" | "ansi" }
+ ): Promise<string> {
+ if (!this.accessToken) {
+ throw new Error("getReport: No access token");
+ }
+
+ const searchParams = new URLSearchParams({ format: options?.format ?? "markdown" });
+ if (options?.period) {
+ searchParams.set("period", options.period);
+ }
+
+ const response = await fetch(
+ `${this.apiURL}/api/v1/reports/${encodeURIComponent(key)}?${searchParams.toString()}`,
+ {
+ method: "GET",
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ let bodySnippet = "";
+ try {
+ const text = (await response.text()).trim();
+ bodySnippet = text.length > 500 ? `${text.slice(0, 500)}…` : text;
+ } catch {
+ // best-effort; ignore
+ }
+ throw new Error(
+ `Failed to fetch report "${key}": ${response.status} ${response.statusText}${
+ bodySnippet ? ` — ${bodySnippet}` : ""
+ }`
+ );
+ }
+
+ return response.text();
+ }
+
async importEnvVars(
projectRef: string,
slug: string,
diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts
index e9012296553..fc3958fb1d6 100644
--- a/packages/cli-v3/src/cli/index.ts
+++ b/packages/cli-v3/src/cli/index.ts
@@ -14,6 +14,7 @@ import { configureUpdateCommand } from "../commands/update.js";
import { configureWhoamiCommand } from "../commands/whoami.js";
import { configureMintTokenCommand } from "../commands/mint-token.js";
import { configureMcpCommand } from "../commands/mcp.js";
+import { configureReportCommand } from "../commands/report.js";
import { COMMAND_NAME } from "../consts.js";
import { VERSION } from "../version.js";
import { installExitHandler } from "./common.js";
@@ -42,6 +43,7 @@ configureUpdateCommand(program);
configurePreviewCommand(program);
configureAnalyzeCommand(program);
configureMcpCommand(program);
+configureReportCommand(program);
configureInstallMcpCommand(program);
configureSkillsCommand(program);
diff --git a/packages/cli-v3/src/commands/mcp.ts b/packages/cli-v3/src/commands/mcp.ts
index 7f94e40a3af..cc5d951eb24 100644
--- a/packages/cli-v3/src/commands/mcp.ts
+++ b/packages/cli-v3/src/commands/mcp.ts
@@ -10,6 +10,7 @@ import { serverMetadata } from "../mcp/config.js";
import { McpContext } from "../mcp/context.js";
import { toMcpContextOptions } from "../mcp/contextOptions.js";
import { FileLogger } from "../mcp/logger.js";
+import { registerPrompts } from "../mcp/prompts.js";
import { registerTools } from "../mcp/tools.js";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
@@ -21,6 +22,7 @@ const McpCommandOptions = CommonCommandOptions.extend({
logFile: z.string().optional(),
devOnly: z.boolean().default(false),
readonly: z.boolean().default(false),
+ install: z.boolean().default(false),
});
export type McpCommandOptions = z.infer<typeof McpCommandOptions>;
@@ -40,6 +42,10 @@ export function configureMcpCommand(program: Command) {
"Run in read-only mode. Write tools (deploy, trigger_task, cancel_run) are hidden from the AI."
)
.option("--log-file <log file>", "The file to log to")
+ .option(
+ "--install",
+ "Run the interactive install wizard instead of starting the server. Bare `mcp` always starts the server (so clients that spawn it over a PTY don't get stuck in the wizard)."
+ )
).action(async (options) => {
wrapCommandAction("mcp", McpCommandOptions, options, async (opts) => {
await mcpCommand(opts);
@@ -48,7 +54,11 @@ export function configureMcpCommand(program: Command) {
}
export async function mcpCommand(options: McpCommandOptions) {
- if (process.stdout.isTTY) {
+ // The install wizard runs ONLY when explicitly requested (`trigger mcp --install`).
+ // Bare `trigger mcp` always starts the server — MCP hosts (e.g. Claude Code) spawn it
+ // over a PTY, so `process.stdout.isTTY` is true even though no human is there; gating
+ // the wizard on isTTY made the server never start and the client time out.
+ if (options.install) {
await printStandloneInitialBanner(true, options.profile);
intro("Welcome to the Trigger.dev MCP server install wizard 🧙");
@@ -98,6 +108,7 @@ export async function mcpCommand(options: McpCommandOptions) {
const context = new McpContext(server, toMcpContextOptions(options, fileLogger));
registerTools(context);
+ registerPrompts(context);
await server.connect(transport);
}
diff --git a/packages/cli-v3/src/commands/report.ts b/packages/cli-v3/src/commands/report.ts
new file mode 100644
index 00000000000..9fb46ac8801
--- /dev/null
+++ b/packages/cli-v3/src/commands/report.ts
@@ -0,0 +1,111 @@
+import { tryCatch } from "@trigger.dev/core/utils";
+import type { Command } from "commander";
+import { z } from "zod";
+import {
+ CommonCommandOptions,
+ commonOptions,
+ handleTelemetry,
+ wrapCommandAction,
+} from "../cli/common.js";
+import { loadConfig } from "../config.js";
+import { ReportPeriodSchema } from "../mcp/schemas.js";
+import { getProjectClient } from "../utilities/session.js";
+import { login } from "./login.js";
+
+const ReportOptions = CommonCommandOptions.extend({
+ config: z.string().optional(),
+ projectRef: z.string().optional(),
+ env: z.enum(["dev", "staging", "prod", "preview", "production"]).default("prod"),
+ branch: z.string().optional(),
+ period: ReportPeriodSchema.optional(),
+});
+
+type ReportOptions = z.infer<typeof ReportOptions>;
+
+export function configureReportCommand(program: Command) {
+ return commonOptions(
+ program
+ .command("report")
+ .description(
+ "Print an interpreted report for an environment. Currently: 'health' — is work flowing, is it your code, is the telemetry fresh."
+ )
+ .argument("[key]", "The report to render", "health")
+ .option("-c, --config <config file>", "The name of the config file")
+ .option(
+ "-p, --project-ref <project ref>",
+ "The project ref. Required if there is no config file"
+ )
+ .option("-e, --env <env>", "The environment (dev, staging, prod, preview)", "prod")
+ .option("-b, --branch <branch>", "The preview branch when using --env preview")
+ .option("--period <period>", "Time window, e.g. 1h (default), 24h, 7d")
+ ).action(async (key, options) => {
+ await handleTelemetry(async () => {
+ await reportCommand(key, options);
+ });
+ });
+}
+
+async function reportCommand(key: string, options: unknown) {
+ return await wrapCommandAction("report", ReportOptions, options, async (opts: ReportOptions) => {
+ await _reportCommand(key, opts);
+ });
+}
+
+async function _reportCommand(key: string, options: ReportOptions) {
+ // Clean output: no banner, just the report (so it can be piped).
+ const authorization = await login({
+ embedded: true,
+ defaultApiUrl: options.apiUrl,
+ profile: options.profile,
+ silent: true,
+ });
+
+ if (!authorization.ok) {
+ throw new Error(`You must login first. Use the \`login\` CLI command.`);
+ }
+
+ const resolvedConfig = await loadConfig({
+ overrides: { project: options.projectRef },
+ configFile: options.config,
+ });
+
+ const env = options.env === "production" ? "prod" : options.env;
+ if (env === "preview" && !options.branch) {
+ throw new Error("Missing branch for the preview environment.");
+ }
+
+ const projectClient = await getProjectClient({
+ accessToken: authorization.auth.accessToken,
+ apiUrl: authorization.auth.apiUrl,
+ projectRef: resolvedConfig.project,
+ env,
+ branch: options.branch,
+ profile: options.profile,
+ });
+
+ if (!projectClient) {
+ throw new Error("Failed to get project client");
+ }
+
+ // Colour in a real terminal; plain markdown when piped. NO_COLOR / FORCE_COLOR follow the
+ // de-facto supports-color spec: NO_COLOR (any value) OR FORCE_COLOR=0 disables outright;
+ // FORCE_COLOR set to anything else force-enables. Both win over isTTY (true even for
+ // PTY-spawned agents — the mcp trap).
+ const forceColor = process.env.FORCE_COLOR;
+ const disabled = "NO_COLOR" in process.env || forceColor === "0";
+ const useColor = disabled
+ ? false
+ : forceColor !== undefined
+ ? true
+ : Boolean(process.stdout.isTTY);
+ const format = useColor ? "ansi" : "markdown";
+ const [error, report] = await tryCatch(
+ projectClient.client.getReport(key, { period: options.period, format })
+ );
+
+ if (error) {
+ throw error;
+ }
+
+ process.stdout.write(report.endsWith("\n") ? report : `${report}\n`);
+}
diff --git a/packages/cli-v3/src/mcp/config.ts b/packages/cli-v3/src/mcp/config.ts
index 31b5c6ec4dd..227b0c5506e 100644
--- a/packages/cli-v3/src/mcp/config.ts
+++ b/packages/cli-v3/src/mcp/config.ts
@@ -136,6 +136,12 @@ export const toolsMetadata = {
description:
"Execute a single widget query from a built-in dashboard. Use list_dashboards first to see available dashboards, widget IDs, and their queries. Supports time period and scope options.",
},
+ get_report: {
+ name: "get_report",
+ title: "Get Report",
+ description:
+ "Render an interpreted report (an answered question, not a raw panel) as text + sparklines. The server computes the verdict deterministically. Currently: 'health' — whether work is flowing, whether the runs that start are healthy, and whether the telemetry is fresh, with a headline verdict and a suggested next action.",
+ },
whoami: {
name: "whoami",
title: "Who Am I",
diff --git a/packages/cli-v3/src/mcp/prompts.ts b/packages/cli-v3/src/mcp/prompts.ts
new file mode 100644
index 00000000000..6beb6398eae
--- /dev/null
+++ b/packages/cli-v3/src/mcp/prompts.ts
@@ -0,0 +1,64 @@
+import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
+import { z } from "zod";
+import type { McpContext } from "./context.js";
+import { GetReportInput } from "./schemas.js";
+
+// Derived from the tool's schema so completion can't drift from what get_report accepts.
+// `environment` has a `.default(...)`, so read its enum through `removeDefault()`.
+const REPORT_KEYS = GetReportInput.shape.key.options;
+const ENVIRONMENTS = GetReportInput.shape.environment.removeDefault().options;
+
+/**
+ * MCP prompts surface as slash commands in hosts that support them (Claude Code renders
+ * this as /mcp__trigger__report), so `/report health` is a real command — no per-project
+ * files needed.
+ */
+export function registerPrompts(context: McpContext) {
+ context.server.registerPrompt(
+ "report",
+ {
+ title: "Report",
+ description:
+ "Render an interpreted report for an environment. Currently: 'health' — is work flowing, is it your code, is the data fresh.",
+ argsSchema: {
+ key: completable(z.string().optional(), (value) =>
+ REPORT_KEYS.filter((k) => k.startsWith(value ?? ""))
+ ),
+ environment: completable(z.string().optional(), (value) =>
+ ENVIRONMENTS.filter((e) => e.startsWith(value ?? ""))
+ ),
+ // Plain string on purpose: MCP prompt args must be simple string schemas (the SDK
+ // introspects them as such), and this only forwards into get_report, which validates
+ // the period with the shared ReportPeriodSchema. Don't swap in a refined schema here.
+ period: z.string().optional(),
+ },
+ },
+ async ({ key, environment, period }) => {
+ const reportKey = key || "health";
+ const env = environment || (context.options.devOnly ? "dev" : "prod");
+
+ const args = [`key: "${reportKey}"`, `environment: "${env}"`];
+ if (period) {
+ args.push(`period: "${period}"`);
+ }
+
+ return {
+ messages: [
+ {
+ role: "user" as const,
+ content: {
+ type: "text" as const,
+ text: `Fetch the ${reportKey} report by calling the get_report tool with { ${args.join(
+ ", "
+ )} } (add projectRef if this workspace has more than one Trigger.dev project).
+
+Show the returned report to the user EXACTLY as-is, inside a fenced code block: it is monospace-aligned with unicode sparklines, so do not paraphrase, reformat, translate, or trim whitespace.
+
+After the block, add at most two sentences of your own — and only if the report's recommended action intersects with something you know about this project (for example, where the relevant configuration lives). Otherwise add nothing.`,
+ },
+ },
+ ],
+ };
+ }
+ );
+}
diff --git a/packages/cli-v3/src/mcp/schemas.ts b/packages/cli-v3/src/mcp/schemas.ts
index 01aa9577cb3..64a03438028 100644
--- a/packages/cli-v3/src/mcp/schemas.ts
+++ b/packages/cli-v3/src/mcp/schemas.ts
@@ -271,6 +271,50 @@ export const ListDashboardsInput = CommonProjectsInput.pick({
export type ListDashboardsInput = z.output<typeof ListDashboardsInput>;
+/**
+ * Shared period validation for the report surfaces (MCP tool + `trigger report` CLI), so they
+ * reject garbage/absurd ranges consistently client-side instead of only at the HTTP API. The
+ * webapp route (`api.v1.reports.$key.ts`) mirrors this regex + bound as the authoritative
+ * security boundary — it can't import from the CLI, so the two are kept intentionally in sync.
+ */
+const PERIOD_UNIT_MS: Record<string, number> = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 };
+const MAX_PERIOD_MS = 90 * 864e5; // 90d
+export const ReportPeriodSchema = z
+ .string()
+ .regex(/^[1-9]\d*[smhdw]$/, "period must be a shorthand like '1h', '30m', or '7d'")
+ .refine(
+ // The regex guarantees the last char is a known unit; `?? 0` just satisfies the type checker.
+ (p) => Number(p.slice(0, -1)) * (PERIOD_UNIT_MS[p.slice(-1)] ?? 0) <= MAX_PERIOD_MS,
+ "period is too large (max 90d)"
+ );
+
+// `environment` inherits CommonProjectsInput's `.default("dev")` — intentional: the MCP server
+// is dev-centric (often `--dev-only`), so an unspecified env reports on dev. The `trigger report`
+// CLI defaults to prod instead (a manual prod check). Agents should pass `environment` explicitly.
+export const GetReportInput = CommonProjectsInput.pick({
+ projectRef: true,
+ configPath: true,
+ environment: true,
+ branch: true,
+}).extend({
+ key: z
+ .enum(["health"])
+ .describe(
+ "The report to render. 'health' answers 'is work flowing, and is a problem my code or the platform?' with an interpreted verdict (flow / execution / liveness)."
+ ),
+ period: ReportPeriodSchema.optional().describe(
+ "Time period shorthand for the live window, e.g. '1h' (default), '7d'."
+ ),
+ color: z
+ .boolean()
+ .optional()
+ .describe(
+ "Return the report as ANSI-coloured text instead of markdown. Only renders in hosts that display ANSI in tool output."
+ ),
+});
+
+export type GetReportInput = z.output<typeof GetReportInput>;
+
export const RunDashboardQueryInput = CommonProjectsInput.extend({
dashboardKey: z
.string()
diff --git a/packages/cli-v3/src/mcp/tools.ts b/packages/cli-v3/src/mcp/tools.ts
index 1fe7be6256a..8fa9eabf6f8 100644
--- a/packages/cli-v3/src/mcp/tools.ts
+++ b/packages/cli-v3/src/mcp/tools.ts
@@ -12,6 +12,7 @@ import { startDevServerTool, stopDevServerTool, devServerStatusTool } from "./to
import { listPreviewBranchesTool } from "./tools/previewBranches.js";
import { listProfilesTool, switchProfileTool, whoamiTool } from "./tools/profiles.js";
import { getQuerySchemaTool, queryTool } from "./tools/query.js";
+import { getReportTool } from "./tools/report.js";
import {
cancelRunTool,
getRunDetailsTool,
@@ -89,6 +90,7 @@ export function registerTools(context: McpContext) {
startAgentChatTool,
sendAgentMessageTool,
closeAgentChatTool,
+ getReportTool,
];
for (const tool of tools) {
@@ -112,7 +114,9 @@ export function registerTools(context: McpContext) {
},
async (input, extra) => {
try {
- return tool.handler(input, { ...extra, ctx: context });
+ // await so a rejected async handler (e.g. a network failure in get_report) is caught
+ // here and wrapped, not surfaced as an unhandled rejection.
+ return await tool.handler(input, { ...extra, ctx: context });
} catch (error) {
return respondWithError(error);
}
diff --git a/packages/cli-v3/src/mcp/tools/report.ts b/packages/cli-v3/src/mcp/tools/report.ts
new file mode 100644
index 00000000000..d24847f64da
--- /dev/null
+++ b/packages/cli-v3/src/mcp/tools/report.ts
@@ -0,0 +1,45 @@
+import { toolsMetadata } from "../config.js";
+import { GetReportInput } from "../schemas.js";
+import { respondWithError, toolHandler } from "../utils.js";
+
+/**
+ * `get_report` fetches a server-rendered report (verdict + text + sparklines). Plain
+ * markdown by default, or ANSI when `color` is set (for hosts that display escapes in
+ * tool output). Read-only; the handler enforces devOnly.
+ */
+export const getReportTool = {
+ name: toolsMetadata.get_report.name,
+ title: toolsMetadata.get_report.title,
+ description: toolsMetadata.get_report.description,
+ inputSchema: GetReportInput.shape,
+ handler: toolHandler(GetReportInput.shape, async (input, { ctx }) => {
+ ctx.logger?.log("calling get_report", { input });
+
+ if (ctx.options.devOnly && input.environment !== "dev") {
+ return respondWithError(
+ `This MCP server is only available for the dev environment. You tried to access the ${input.environment} environment. Remove the --dev-only flag to access other environments.`
+ );
+ }
+
+ const projectRef = await ctx.getProjectRef({
+ projectRef: input.projectRef,
+ cwd: input.configPath,
+ });
+
+ const apiClient = await ctx.getApiClient({
+ projectRef,
+ environment: input.environment,
+ scopes: ["read:query"],
+ branch: input.branch,
+ });
+
+ const text = await apiClient.getReport(input.key, {
+ period: input.period,
+ format: input.color ? "ansi" : "markdown",
+ });
+
+ return {
+ content: [{ type: "text" as const, text }],
+ };
+ }),
+};
diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts
index deda5d01f9a..2e2b60d1d37 100644
--- a/packages/core/src/v3/apiClient/index.ts
+++ b/packages/core/src/v3/apiClient/index.ts
@@ -1938,6 +1938,39 @@ export class ApiClient {
);
}
+ /**
+ * Fetch a server-rendered report (text + sparkline). Thin pass-through — the string
+ * is ready to display. `format`: "markdown" (default, agents/chat) or "ansi" (terminal).
+ */
+ async getReport(
+ key: string,
+ options?: { period?: string; format?: "markdown" | "ansi" }
+ ): Promise<string> {
+ const searchParams = new URLSearchParams({ format: options?.format ?? "markdown" });
+ if (options?.period) {
+ searchParams.set("period", options.period);
+ }
+
+ const response = await fetch(
+ `${this.baseUrl}/api/v1/reports/${encodeURIComponent(key)}?${searchParams.toString()}`,
+ {
+ method: "GET",
+ headers: this.#getHeaders(false),
+ }
+ );
+
+ if (!response.ok) {
+ const bodySnippet = await readBodySnippet(response);
+ throw new Error(
+ `Failed to fetch report "${key}": ${response.status} ${response.statusText}${
+ bodySnippet ? ` — ${bodySnippet}` : ""
+ }`
+ );
+ }
+
+ return response.text();
+ }
+
#getHeaders(spanParentAsLink: boolean, additionalHeaders?: Record<string, string | undefined>) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -2415,6 +2448,22 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+/**
+ * Best-effort read of a (likely error) response body for inclusion in a thrown Error.
+ * Never throws, and truncates so we don't dump a huge HTML page into an error message.
+ */
+async function readBodySnippet(response: Response, maxLength = 500): Promise<string> {
+ try {
+ const text = (await response.text()).trim();
+ if (!text) {
+ return "";
+ }
+ return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
+ } catch {
+ return "";
+ }
+}
+
/**
* Safely cancels a ReadableStream, handling the case where it might be locked.
*