# `orchestrator/step-runner` ## Purpose `runStep(...)` is the orchestrator's per-step engine. It bundles every cross-cutting concern that fires around an action handler invocation — `in_flight` transition, `saga.step.requested` emission, request-context minting, idempotency wrap, optional timeout, result persistence, `saga.step.completed` / `saga.step.failed` emission, and step-duration histogram recording — into a single async call. The orchestrator's main loop is therefore short enough to fit comfortably inside the ~250-line soft limit while every step-level concern lives here. The runner intentionally returns a structured outcome rather than throwing on action-handler failure. The caller wants to capture the error and trigger compensation; an exception path would force the orchestrator into try/catch noise around every step invocation. ## Features - Single async entry point — wraps every cross-cutting concern. - Idempotency wrap via `SagaIdempotencyWrapper`; honours the participant's `idempotency: "manual"` opt-out. - Optional per-action `timeoutMs` — fires `SagaError(SAGA.STEP_TIMEOUT)` when the handler exceeds the budget. - Step-duration histogram emitted on both success and failure paths. - Persistence updates funnel through `SagaPersistence`'s state-machine-guarded transitions. ## Usage: Basic The runner is internal — most callers consume it indirectly via `createSagaOrchestrator`. A consumer that wants to drive one step manually (e.g. a CLI that replays a stuck step) can invoke it directly: ```ts import { runStep } from "@suaveplan/api-saga/orchestrator/step-runner"; const outcome = await runStep({ instance, stepRow, registration, principal: null, persistence, emitter, idempotency, instruments, requestContextFactory, nowMs: () => Date.now(), }); if (outcome.outcome === "completed") { console.log(outcome.output); } ``` ## Usage: Advanced A test that wants to assert the step-runner's behaviour against a fake participant + in-memory persistence + capturing event bus can inject the runner directly without standing up a full orchestrator: ```ts import { runStep } from "@suaveplan/api-saga"; const outcome = await runStep({ instance, stepRow, registration: { plugin: "p", action: "a", handler: async () => ({ ok: true }), idempotency: "manual", }, principal: null, persistence, emitter, idempotency, instruments, requestContextFactory: () => testCtx, nowMs: () => 1000, }); ``` ## API Reference - `runStep(args): Promise` — single entry point. - `args`: - `instance: SagaInstanceRow` — the saga we're stepping. - `stepRow: SagaStepRow` — the persisted step. - `registration: SagaParticipantRegistration` — bound handlers. - `principal: Principal | null` — the saga initiator. - `persistence, emitter, idempotency, instruments, requestContextFactory, nowMs` — injected dependencies. - `StepRunOutcome` — `{ outcome: "completed", output } | { outcome: "failed", code, message, cause }`. ## Implementation Notes The optional timeout wrap uses raw `setTimeout` + a `settled` guard rather than `Promise.race` because `Promise.race` against a slow handler leaks the handler — the handler keeps running in the background even after the race resolves. The `settled` pattern clears the timer once the handler's promise resolves, AND ignores late handler resolution once the timeout has fired. The handler itself still completes (we can't cancel external work) but the orchestrator no longer waits on it. The runner emits a `step_duration_ms` histogram entry on BOTH the success and failure paths so the operator can dashboard "failed step duration" — without the failed-path entry the histogram would implicitly only describe successful steps.