Run context
Every target body may take one argument, ctx: TargetContext —
a typed handle to the run it's executing in. A zero-arg () => …
body is still valid; take ctx only when you need it. It carries
run identity, a cancellation signal, this target's durable state, what every
other target in the run has done, any external signal payloads the run has
received, and whether this is a dry run.
The shape of ctx
export interface TargetContext {
readonly runId: string; // stable for every target in the run (crypto.randomUUID())
readonly target: string; // dotted name of the executing target
readonly signal: AbortSignal; // aborted when the run is cancelled
readonly state: TargetStateHandle; // this target's durable metadata
stateOf(target: string): TargetStateHandle; // another target's state handle
outcomeOf(target: string): TargetOutcomeView | undefined; // what another target did
outcomes(): ReadonlyMap<string, TargetOutcomeView>; // every outcome settled so far
plan(): RunPlan; // the run's planned shape — see below
readonly signals: ReadonlyMap<string, SignalRecord>; // external signal payloads
readonly dryRun: boolean; // true under a dry run (bodies don't execute)
}
export type TargetFn = (ctx: TargetContext) => unknown | Promise<unknown>;
// An effect body gets the same context plus what it needs to be re-driven safely.
export interface EffectContext extends TargetContext {
readonly effect: string; // the effect's declared name
readonly redriven: boolean; // true when a previous attempt already committed its intent
} runId is stable for every target in the run — the same UUID
from start to finish, useful for correlating logs. target is
the dotted name of the target currently executing.
import { Build, target } from "jsr:@zuke/core";
class Deploy extends Build {
ship = target().executes(async (ctx) => {
console.log(`run ${ctx.runId} · target ${ctx.target}`);
await $`terraform apply`; // SIGTERM'd if the run is cancelled (ambient signal)
await ctx.state.set({ slot: "sit-7" });
});
} ctx.state
ctx.state is this target's own durable metadata —
a small JSON record it can write to and read back, including across a
suspend/resume boundary. Set
merges a patch (it doesn't replace the whole record); get reads the
current value synchronously. ctx.stateOf(target) reaches a
different target's state handle — typically a dependency's,
letting a later target read what an earlier one recorded.
export interface TargetStateHandle {
set(patch: Record<string, JsonValue>): Promise<void>; // merge a JSON patch, awaits write
get(): Record<string, JsonValue>; // read current metadata
}
ship = target().executes(async (ctx) => {
await ctx.state.set({ slot: "sit-7" }); // merge — doesn't clobber earlier keys
const meta = ctx.state.get(); // { slot: "sit-7", ... }
});
promote = target()
.dependsOn(this.ship)
.executes((ctx) => {
const shipped = ctx.stateOf("ship").get(); // read a dependency's state
console.log(shipped.slot);
});
With no state store configured, ctx.state is an in-memory
no-op: reads and writes are consistent within the run, but nothing
persists once the process exits. Opt into a real store to make it
durable — see Durable run state for the store
precedence, the record shape, and the zuke runs CLI.
Reading what the rest of the run did
ctx.outcomeOf(name) reports another target's status in this
run, and ctx.outcomes() returns all of them keyed by target
name. The status is the run record's
vocabulary — succeeded, failed,
skipped, waiting, running — so a
target whose body ran and one served from the incremental cache both read
succeeded, which is the distinction the record keeps.
import { Build, target } from "jsr:@zuke/core";
class Ci extends Build {
unit = target().proceedAfterFailure().executes(() => {});
docs = target().onlyWhen(() => false).executes(() => {});
report = target().dependsOn(this.unit, this.docs).always().executes((ctx) => {
const skipped = [...ctx.outcomes()]
.filter(([, outcome]) => outcome.status === "skipped")
.map(([name]) => name);
console.log(`skipped: ${skipped.join(", ")}`);
console.log(`unit: ${ctx.outcomeOf("unit")?.status}`); // "succeeded" | "failed" | …
});
}
This is what makes an aggregate target — a required status check over a set
of checks, some of which are allowed to fail — expressible:
.dependsOn(...) alone can't say "run whatever happened", so
pair .always() on the reporter with
.proceedAfterFailure() on the checks.
- A target that has not settled has no outcome —
undefinedrather than a placeholder. That includes the target doing the asking, and any sibling still running concurrently. Depend on what you intend to read. - Outcomes survive a resume. The process that resumes never re-runs what already succeeded, and still reports it — those come from the durable run record.
- A store is not required. A run with no state store answers the same way for everything settled in that process.
The run's shape — ctx.plan()
ctx.plan() answers what the run set out to do: which
targets it planned, in execution order, and which targets must finish before
a given one starts. It is the seam for a body whose work depends on what else
was asked for.
class Ci extends Build {
build = target().executes((ctx) => {
// Only worth signing the artifact if this run is going to deploy it.
if (ctx.plan().includes("deploy")) console.log("signing");
});
deploy = target().dependsOn(this.build).executes(() => {});
} zuke build prints nothing; zuke deploy signs. Same
body, same build — the plan describes this run, not the
class.
A condition reads the same view, so a target can gate on the graph rather than only on the environment. Gate on a target you do not depend on — a hard dependency is in the plan whenever its dependent is, so gating on one is a condition that can never be false:
class Ci extends Build {
unit = target().executes(() => {});
// Only worth publishing coverage when the run is also going to deploy.
coverage = target()
.dependsOn(this.unit)
.onlyWhen((ctx) => ctx.plan().includes("deploy"))
.executes(() => {});
deploy = target().dependsOn(this.unit).executes(() => {});
}
Receiving the context is optional: an existing
.onlyWhen(() => …) keeps working, since a zero-argument
function is assignable to the one-parameter type. A condition gets a
narrower context than a body — target and
plan() only. A
.whenSkipped("skip-dependencies") condition is evaluated
before the run starts, to decide what the run prunes, and at that
point the run's identity, its state handles, and its cancellation signal do
not exist yet.
The plan is not the outcome. ctx.plan() says
what the run planned, as this process resolved it;
ctx.outcomeOf(name) says what became of a
target once it settled. A planned target can still be skipped — by a
condition, by --affected, or by an operator's
forced outcome — and a run that fails
early never reaches its later targets at all. So
plan().includes("deploy") means "deploy is part
of this run", never "deploy will execute". Ask
outcomeOf for that.
There is a concrete reason the plan refuses to answer "will it run". A
whenSkipped("skip-dependencies") condition is evaluated
twice: once up front, to decide what the run prunes, and
again when the scheduler reaches the target. A "will it run" view would
answer differently at those two moments — and at the first one it would be
circular, since that very condition is one of the things deciding the
answer. Reporting the planned graph gives one answer everywhere.
dependenciesOf returns an empty list both for a target with no
predecessors and for a name that is not in the plan at all; use
includes to tell those apart. And a .forEach()
fan-out expands into its sub-targets while the run executes,
after the graph has been planned — so fan[us].prep has a summary
row, a run-record entry and an outcome, but
plan().includes("fan[us].prep") is false.
Crash-durable effects — .effect()
A target body that dies halfway leaves no trace of what it was doing. For work where that matters — posting a required status check, publishing a release, telling another system something finished — declare it as an effect. The intent to run it is written to the run record and confirmed before the body runs, so a process killed anywhere inside it leaves evidence that the effect was owed, and a resume drives it again.
class Ci extends Build {
checks = target().proceedAfterFailure().executes(() => {});
gate = target().dependsOn(this.checks).always()
.effect("post-gate", async (ctx) => {
const failed = [...ctx.outcomes()].some(([, o]) => o.status === "failed");
await postVerdict(failed ? "failure" : "success", ctx.redriven);
});
} - At-least-once, not exactly-once. A process that dies
after the side effect but before recording it will repeat the
effect. Write bodies that tolerate it — because repeating is harmless, or
because the far side converges (an upsert rather than an append).
ctx.redrivenistruewhen a previous attempt already committed its intent. - A completed effect is skipped, not repeated. Once
recorded
done, re-driving the target is free. - What re-drives it, precisely. A resume acts on a run
recorded
suspended, so an effect owed by a run that suspended at a wait is driven again by an ordinaryzuke resume/zuke resume --check. A process that was killed leaves its runrunning, and a run in that state is not resumable until something moves it back tosuspended— so an effect owed by a killed process waits for a reaping sweep or an operator. The intent itself is durable either way. - Pin what the effect acts on. Read it from
ctx.state/ctx.stateOf(...), which is replayed from the record and cannot be overridden from outside. A parameter is nearly as good — the record seeds a resume, so one nobody re-supplies keeps its original value — but a resume that passes it explicitly wins, which is how a secret gets re-supplied. For a value that must not drift, use state. - A store is required, and enabled automatically. With state explicitly disabled the run is refused rather than performing an effect nothing recorded as owed.
- Effects run after the body, in declaration order, and are repeatable. A target may declare effects and no body at all. An intent that cannot be recorded fails the target, with the body never having run: no durable intent, no side effect.
ctx.signals
ctx.signals is a read-only map of external signal payloads
the run has received — the mechanism a .waitsFor() target
uses to unblock, and how a later target reads what was delivered
({ data, receivedAt }). See
Suspend & resume for how a
signal is delivered with zuke resume --signal and how a
target waits on one.
awaitTesting = target().executes((ctx) => {
const approval = ctx.signals.get("testing-approved"); // SignalRecord | undefined
console.log(approval?.data, approval?.receivedAt);
}); Ambient cancellation
ctx.signal is an AbortSignal that aborts when
the run is cancelled. You rarely need to pass it around by hand: the
run's signal is installed as the $ shell's ambient
signal (via AsyncLocalStorage, scoped per run), so a
plain $\`…\` inside a target body is terminated on cancel
without threading ctx.signal through every call. Override it
on one command — or compose it with .killAfter() for a grace
period before SIGKILL:
ship = target().executes(async (ctx) => {
await $`terraform apply`; // ambient signal — SIGTERM'd on cancel
await $`long-migration`.signal(ctx.signal).killAfter("30s"); // explicit override, composes with killAfter
});
Zuke never forcibly interrupts arbitrary JavaScript, though: a body that
ignores signal and never touches the shell runs to
completion even after cancellation is requested. See
Cancellation & compensation
for the full picture — zuke cancel, Ctrl-C, and how
compensating targets unwind completed work.
Dry runs
ctx.dryRun reports whether the current run is a dry run. In
a dry run a target's body doesn't execute at all — planning stops short
of invoking it, whether triggered by --dry-run on the CLI or
the dryRun argument to an MCP
run: tool — so the flag exists mainly to round out the
context's shape rather than something a body branches on at runtime.