fabro/apps/fabro-web/app/lib/run-phases.ts
Bryan Helmkamp 8bca376f35
fix(web): close run phase on terminal events (#427)
Run Events no longer leaves the pre-execution Initializing bar open when
a run fails before `run.running`. This addresses the waterfall symptom
in fabro-sh/fabro#426.

The phase derivation now records terminal `run.completed` / `run.failed`
events and uses them as fallback boundaries for Submitted, Pending,
Runnable, and Initializing phases. The existing `run.running` handoff
still takes precedence once execution actually starts.

Tested:
- `cd apps/fabro-web && bun test app/lib/run-phases.test.ts`
- `cd apps/fabro-web && bun run typecheck`
- `git diff --check`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
2026-05-27 10:38:32 -04:00

110 lines
3 KiB
TypeScript

import type { EventEnvelope } from "@qltysh/fabro-api-client";
export type RunPhaseKind = "submitted" | "pending" | "runnable" | "initializing";
export interface RunPhase {
kind: RunPhaseKind;
label: string;
startMs: number;
endMs: number | null;
}
const PHASE_LABEL: Record<RunPhaseKind, string> = {
submitted: "Submitted",
pending: "Pending",
runnable: "Runnable",
initializing: "Initializing",
};
// Stages own the timeline once `run.running` fires, so we stop slicing there.
export function deriveRunPhases(
events: ReadonlyArray<EventEnvelope> | undefined,
createdAtIso: string,
): RunPhase[] {
const createdMs = Date.parse(createdAtIso);
if (Number.isNaN(createdMs)) return [];
let startRequestedMs: number | null = null;
let pendingMs: number | null = null;
let runnableMs: number | null = null;
let startingMs: number | null = null;
let runningMs: number | null = null;
let terminalMs: number | null = null;
let remaining = 6;
for (const event of events ?? []) {
if (remaining === 0) break;
let target: "startRequested" | "pending" | "runnable" | "starting" | "running" | "terminal" | null = null;
switch (event.event) {
case "run.start_requested":
if (startRequestedMs == null) target = "startRequested";
break;
case "run.pending":
if (pendingMs == null) target = "pending";
break;
case "run.runnable":
if (runnableMs == null) target = "runnable";
break;
case "run.starting":
if (startingMs == null) target = "starting";
break;
case "run.running":
if (runningMs == null) target = "running";
break;
case "run.completed":
case "run.failed":
if (terminalMs == null) target = "terminal";
break;
}
if (target == null) continue;
const ms = Date.parse(event.ts);
if (Number.isNaN(ms)) continue;
switch (target) {
case "startRequested": startRequestedMs = ms; break;
case "pending": pendingMs = ms; break;
case "runnable": runnableMs = ms; break;
case "starting": startingMs = ms; break;
case "running": runningMs = ms; break;
case "terminal": terminalMs = ms; break;
}
remaining -= 1;
}
const phases: RunPhase[] = [];
phases.push({
kind: "submitted",
label: PHASE_LABEL.submitted,
startMs: createdMs,
endMs: startRequestedMs ?? pendingMs ?? runnableMs ?? startingMs ?? runningMs ?? terminalMs,
});
if (pendingMs != null) {
phases.push({
kind: "pending",
label: PHASE_LABEL.pending,
startMs: pendingMs,
endMs: runnableMs ?? startingMs ?? runningMs ?? terminalMs,
});
}
if (runnableMs != null) {
phases.push({
kind: "runnable",
label: PHASE_LABEL.runnable,
startMs: runnableMs,
endMs: startingMs ?? runningMs ?? terminalMs,
});
}
if (startingMs != null) {
phases.push({
kind: "initializing",
label: PHASE_LABEL.initializing,
startMs: startingMs,
endMs: runningMs ?? terminalMs,
});
}
return phases;
}