Skip to content

Background jobs

Some work takes minutes. Transcribing an hour of audio, say. That is too long to keep a caller on the line, so it goes in a workflow: a function whose progress is recorded step by step, so the work survives the process it started in. Restart a sixty-segment job at segment 41 and the first forty are not re-done.

A workflow body is an ordinary exported async function of its input and a WorkflowContext. There is no directive and no build step of its own:

workflows/transcribe.ts
import type { WorkflowContext } from "@alexkroman1/aai";
declare function fetchAudio(recording: string): Promise<Uint8Array>;
declare function transcribe(audio: Uint8Array): Promise<string>;
export async function transcribeFlow(
input: { recording: string },
ctx: WorkflowContext,
) {
const audio = await ctx.step("fetch", () => fetchAudio(input.recording));
const text = await ctx.step("transcribe", () => transcribe(audio));
return { text };
}

ctx.step(name, fn) runs fn once and records what it returned.

The body is re-run from the top on every resume. Each step that already finished returns its recorded value instead of running again. That replay is what makes a run survive a restart, and it is why the real work goes inside a step: the whole Node runtime is available in there — fetch, a model call, a database — and none of it is available in the body.

A step is recorded under its name plus the number of times this run has already reached that name: transcribe#0, transcribe#1, and so on, counted per name. Two rules follow from that.

mapConcurrent(items, limit, fn) from @alexkroman1/aai/step runs a fan-out with a ceiling on how many are in flight at once. A fan-out of any size stays one literal name: sixty segments are sixty records under transcribeSegment, which is exactly what the occurrence counter is for.

So do not build a name per item. ctx.step(`segment-${index}`, …) looks like the careful version and is the bug.