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>(); + +export class ReportPresenter { + async call({ + environment, + key, + period = DEFAULT_PERIOD, + }: { + environment: AuthenticatedEnvironment; + key: string; + period?: string; + }): Promise { + 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 = { + 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 = { + 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 = { + // 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 = { + // 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 = { + 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 = { + 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 = { + 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 = { + "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 = { + // 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 = { 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(); + 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 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; +}; + +/** 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 = { 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 = { + "✓": "🟢", + "⚠": "🟡", + "✕": "🔴", + "○": "⚪", +}; + +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 { + 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 { + 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 " 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 "(14–16h)"). 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. *