Orchestration

A build is more than a DAG that runs once and exits. A real deployment pipeline needs to pause for a human approval or a soak period and pick back up — possibly days later, in a different process — fan a step out over a runtime list of services or tenants, and cancel cleanly, undoing exactly the work it did. Zuke builds all three on the same primitive: a durable run whose state survives the process that started it.

Suspend & resume

A target declared with .waitsFor() is a bodyless gate: reached in the graph, it checks whether its trigger is already satisfied. If so, it passes through and dependents run in the same process. If not, the run's state is saved, the run is marked suspended, any independent branches finish, and the process exits 0 — a suspended run has not failed. A later zuke resume, possibly in a different process, re-instantiates the build and continues only the targets that haven't already succeeded. This requires a state store (auto-enables the .zuke/runs filesystem store).

// TargetBuilder:  waitsFor(configure: Configure<WaitSettings>): this
// WaitSettings (fluent):
on(trigger: WaitTrigger): this
timeout(duration: string | number): this          // "72h" or ms
onTimeout(disposition: OnTimeout): this            // default "fail"
export type OnTimeout = () => TargetBuilder | "fail" | "cancel-run";

// Triggers:
export function externalSignal(name: string): WaitTrigger
export function resumeWhen(check: () => boolean | Promise<boolean>, options?: { interval?: string | number }): WaitTrigger

Two built-in triggers: externalSignal(name) waits for a named signal delivered from outside the run (an approval, a webhook); resumeWhen(check) waits for a predicate to become true, re-evaluated on demand rather than polled continuously. A delivered signal's payload shows up on ctx.signals as { data, receivedAt }.

.timeout() bounds how long a wait may stay suspended; .onTimeout() decides what happens when it elapses — "fail" (the default), "cancel-run", or a thunk returning a target to run instead (a rollback). The canonical shape — deploy, wait for an external approval, then promote:

import { Build, externalSignal, target } from "jsr:@zuke/core";

class Deploy extends Build {
  deployToSit = target().executes(async (ctx) => {
    await applyToSit();
    await ctx.state.set({ at: "sit-7" });   // survives the suspend
  });

  awaitTesting = target()
    .dependsOn(this.deployToSit)
    .waitsFor((s) => s.on(externalSignal("testing-approved")).timeout("72h").onTimeout(() => this.rollback));

  promoteToProd = target()
    .dependsOn(this.awaitTesting)
    .executes((ctx) => {
      const approval = ctx.signals.get("testing-approved");   // payload
      promote(approval?.data);
    });

  rollback = target().executes(() => rollBackSit());
}

Resuming it once QA signs off:

zuke resume <run-id> --signal testing-approved --data '{"by":"qa"}'   # deliver a signal
zuke resume --check [<run-id>]                                        # re-check predicate/timeout; sweeps ALL suspended runs when no id
# also: --force-graph, --actor <name>

Only the durable record crosses the process boundary — a resumed run gets a fresh ctx.state and ctx.signals rebuilt from what was persisted, nothing else. Write whatever a later target needs into ctx.state before the wait, as deployToSit does above.

Resume is exactly-once: it's a compare-and-swap from suspended to running, so one resumer wins and every other — a retrying cron, a doubled webhook — gets a clean AlreadyResumedError and exits non-zero instead of running the promotion twice. Resume also re-checks that the build's graph still matches the suspended run; an added, removed, or re-wired target is a hard error pointing at --force-graph to override it deliberately. resumeWhen doesn't self-poll — Zuke re-evaluates the predicate (and any elapsed timeout) on each --check, so one sweeping cron enforces every deadline across every suspended run.

Fan-out

.forEach() runs the same ordered pipeline over a runtime list — deploy N repos, migrate N tenants — with bounded concurrency and per-item failure isolation. Items run concurrently up to concurrency; within one item, its stages still run sequentially, each depending on the one before — there's no barrier between items, so a fast item can finish its whole pipeline while a slow one is still on its first stage.

forEach<Item>(items: () => readonly Item[], factory: ForEachFactory<Item>, configure?: Configure<ForEachSettings>): this
export type ForEachFactory<Item> = (item: Item, index: number) => Record<string, TargetBuilder>;  // ordered stages; each depends on the previous

// ForEachSettings:
concurrency(limit: number): this            // clamped ≥1; default = host CPU count
continueOnItemFailure(on?: boolean): this

The canonical batch deploy — one three-stage pipeline per repo:

import { Build, parameter, target } from "jsr:@zuke/core";

class CD extends Build {
  repos = parameter("services to deploy").array().required();

  deployBatch = target().forEach(
    () => this.repos.value,                    // items thunk (string[])
    (repo) => ({                               // ordered pipeline per item
      checks: target().executes(() => checkDeployable(repo)),
      fork:   target().executes(() => forkImage(repo)),
      deploy: target().executes((ctx) => applyToSit(repo, ctx)),
    }),
    (s) => s.concurrency(3).continueOnItemFailure(),
  );
}

concurrency clamps to at least 1 and defaults to the host's CPU count. continueOnItemFailure() decides whether a failed item's siblings keep going: without it, the first item failure stops the batch; with it, a failed stage skips that item's later stages but lets other items finish. Either way the fan-out target itself fails if any item failed — add .proceedAfterFailure() if partial success should still be a passing build.

Each item is first-class for reporting: sub-targets are materialised at run time and named parent[<item>].<stage> (e.g. deployBatch[api].deploy), and each gets its own summary row and its own entry in the run record — zuke runs show gives per-item verdicts, not just a batch total. Static views (zuke --list, zuke graph) show only the single fan-out node, annotated [fan-out], since the item list isn't known until the run starts.

An .onCancel() on a fan-out sub-target runs too. Cancel re-materialises the fan-out, matches each item's sub-targets by name against the record, and runs the compensation of every item that had succeeded — or was still in-flight when the cancel landed (a mid-deploy item has partial work to undo). Items unwind before the parent's own compensation, in reverse order; a throwing item compensation is recorded and the walk continues, exactly as for an ordinary target. Each item's cleanup reads that item's own persisted state (ctx.state), and lands its own compensate entry in the audit trail (naming the runtime sub-target, e.g. deployBatch[repo-a].deploy) alongside the summary.

Nested fan-out works too: a .forEach() stage that is itself a .forEach() has its inner items' .onCancel() run, matched by the same nested parent[item].stage[inner].stage names.

Caveat: for per-item compensation to find its items, the .forEach() item list must be deterministic — cancel re-evaluates it (from the record's parameters) and matches by the same parent[item].stage names. A non-deterministic list leaves a recorded item with no re-materialised twin; its compensation is reported as skipped, never a crash.

In-flight items and out-of-process cancel. An out-of-process zuke cancel compensates an item that was still running from the run record's snapshot. The owning process aborts a live body only when it next writes state (a ctx.state.set(...) checkpoint, or a .lock() heartbeat), so a body doing one long uninterrupted command may keep running while its compensation begins — the two can briefly overlap. Have item bodies checkpoint via ctx.state.set(...) (or hold a .lock()) so a cancel propagates promptly and closes that window; an in-process cancel (Ctrl-C) has no such window, since bodies are aborted and settled before the walk runs.

Every per-item stage is a full target: it takes ctx, can read and write ctx.state, and can carry its own .timeout() and .retry().

Cancellation & compensation

Cancelling should be safe and complete: work undone, locks released, the run's record ending cancelled. A compensation target, registered with .onCancel(), runs if and only if its target succeeded, walked in reverse topological order — later work is unwound before what it was built on. This requires a state store (auto-enabled, same as suspend/resume).

onCancel(compensation: OnCancel): this
export type OnCancel = TargetBuilder | (() => TargetBuilder);  // thunk references a target declared below

Trigger a cancel from any of three places:

zuke cancel <run-id>            # cancel a persisted run from any process (accepts --actor <name>)
# Ctrl-C / SIGTERM             # cancels the run in the current process
# MCP cancel_run tool          # cancel over MCP (gated like a run tool)
class CD extends Build {
  deploy = target()
    .executes((ctx) => ctx.state.set({ slot: "sit-7" }))   // record what it did
    .onCancel(() => this.rollback);                          // …and how to undo it

  rollback = target().executes((ctx) => tearDown(ctx.state.get().slot));   // reads deploy's meta
}

The compensation body gets a normal ctx, but its ctx.state exposes the original target's persisted metadata — not its own — so rollback above reads exactly what deploy recorded at work time (ctx.state.get().slot). Persist whatever a rollback will need while the original target is still running; there's nothing left to compute it from afterward.

A live run learns about its own cancellation on its next state write — cancelling flips the record to cancelling and the owning process aborts, with in-flight $ commands getting SIGTERM via the ambient ctx.signal. Cancelling is idempotent (a terminal run is a friendly no-op) and maximal — a compensation that itself throws is recorded but doesn't stop the rest of the walk. zuke runs show <id> shows exactly what was unwound.

Lazy ordering — override orderWith(targets)

Same shape as declared ordering edges, but async and resolved per run: use it when the ordering must be loaded at run time — read a monorepo's dependency-graph.json, hit an API — rather than declared statically. Return the same [before, after] OrderingEdge pairs; they merge with any extraEdges, and share its rules (endpoints outside the run's set are ignored, cycles reported). Both are honoured by a run and by zuke cancel's reverse-order compensation walk, but — being resolved only when a run plans — neither shows in the static graph / --list views nor in generated CI.

override async orderWith(t: Map<string, Target>): Promise<OrderingEdge[]> {
  const graph = await loadDependencyGraph(); // read it at run time
  return graph.edges.flatMap(([before, after]) => {
    const from = t.get(before), to = t.get(after);
    return from && to ? [[from, to] as OrderingEdge] : [];
  });
}

Parameters for fan-out

.array() turns a parameter into a comma-separated or repeatable list — the natural feed for a forEach items thunk. It composes last, after any kind or .options() already declared, so every element is validated before any target runs:

tags     = parameter("Image tags").array();                            // string[]
workers  = parameter("Worker ids").number().array();                   // number[]; "1,x" rejected
services = parameter("Services").options("api","web","worker").array(); // each element validated
repos    = parameter("services to deploy").array().required();         // required list; feeds forEach items thunk
./zuke deploy --tags latest,canary          # ["latest","canary"]
./zuke deploy --tags latest --tags canary   # same (repeat == comma)
TAGS=latest,canary ./zuke deploy            # from env

Blank entries are dropped and an unsupplied list defaults to []. Read it inside the forEach items thunk with this.<name>.value, as repos does in the batch deploy example above.

@zuke/gh adds a githubWorkflow(...) WaitTrigger for .waitsFor() — it dispatches a workflow in another repo and suspends until that workflow's conclusion. See the @zuke/gh package for the details.

For a workflow you can't modify, .correlate("created-window") gives a best-effort correlation: it claims the workflow_dispatch run created just after dispatch, and fails loudly if two candidates share that window. .discoveryTimeout(...) (default ~one minute) fast-fails if no run is identified, so a workflow that never surfaces doesn't eat the whole .timeout() — the deadline is measured from the persisted dispatch time, so it holds across a suspend/resume.