/** * ctx.step — the unit of work inside a handler. * * A step logs its start, its outcome with elapsed time, and is the boundary * for retries and timeouts. When it fails, the error names the step — the * operator reads "step \"attach listener rule\" failed", not a stack frame. * * Deliberately local for now: the structured events a step emits are the * seam where catalog tracing plugs in later (the same pipeline the CLI's * `service workflow exec` reports to), without changing a single handler. */ import { dim, elapsed, red } from "../term"; import type { ActionLogger } from "./context"; import { silentNarrative, type Narrative } from "./narrative"; export interface StepOptions { /** Additional attempts after the first failure (default 0 — fail fast). */ retries?: number; /** Per-attempt time budget; exceeded attempts count as failures. */ timeoutMs?: number; } /** The work of a step. Its argument narrates the step on the trace (what happened, why, links, outputs). */ export type StepWork = (step: Narrative) => Promise | T; export interface StepFn { (name: string, run: StepWork): Promise; (name: string, opts: StepOptions, run: StepWork): Promise; } const withTimeout = async ( run: () => Promise | T, ms?: number ): Promise => { if (!ms) return run(); let timer: ReturnType | undefined; try { return await Promise.race([ Promise.resolve().then(run), new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`timed out after ${elapsed(ms)}`)), ms ); }), ]); } finally { clearTimeout(timer); } }; const isNamedError = ( err: unknown ): err is Error & { code: string; retryable: boolean } => err instanceof Error && typeof (err as { code?: unknown }).code === "string" && typeof (err as { retryable?: unknown }).retryable === "boolean"; export function createStepRunner(log: ActionLogger): StepFn { return (async ( name: string, optsOrRun: StepOptions | StepWork, maybeRun?: StepWork ): Promise => { const opts: StepOptions = typeof optsOrRun === "function" ? {} : optsOrRun; const run = (typeof optsOrRun === "function" ? optsOrRun : maybeRun)!; const attempts = 1 + Math.max(0, opts.retries ?? 0); log.step(name); const startedAt = Date.now(); let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { try { // Without tracing the narrator is silent; with it, dispatch hands the // traced one in through `run` itself. const result = await withTimeout(() => run(silentNarrative), opts.timeoutMs); log.detail(dim(`done in ${elapsed(Date.now() - startedAt)}`)); return result; } catch (err) { lastError = err; if (attempt < attempts) { log.detail( `${red(`attempt ${attempt}/${attempts} failed`)} ${dim( `— ${err instanceof Error ? err.message : String(err)}; retrying` )}` ); } } } const message = lastError instanceof Error ? lastError.message : String(lastError); log.fail( `${name} ${dim(`(${elapsed(Date.now() - startedAt)})`)} — ${message}` ); // A named action error (code + retryable) keeps its name: the step is // already in the log line above, and the code is what the platform, the // trace and the tests read. if (isNamedError(lastError)) throw lastError; throw new Error(`step "${name}" failed: ${message}`, { cause: lastError }); }) as StepFn; }