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.