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 outcomeundefined rather 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.redriven is true when 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 ordinary zuke resume / zuke resume --check. A process that was killed leaves its run running, and a run in that state is not resumable until something moves it back to suspended — 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.