Durable run state

By default a Zuke run is in-memory — once the process exits, nothing about it remains. Durable run state adds a persistent, versioned JSON record — status, planned graph, resolved non-secret params, per-target progress — so a run's outcome survives the process and a target can leave metadata for a later run to read. It's opt-in and zero-overhead when unused.

ctx.state — writing metadata

Inside a target body, ctx.state is a TargetStateHandle scoped to the executing target:

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`;
    await ctx.state.set({ slot: "sit-7" });
  });
}
export interface TargetStateHandle {
  set(patch: Record<string, JsonValue>): Promise<void>;     // merge a JSON patch, awaits write
  trySet(patch: Record<string, JsonValue>): Promise<boolean>; // …and report whether it landed
  get(): Record<string, JsonValue>;                          // read current metadata
}
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

set(patch) merges a JSON patch into the target's metadata and awaits the write; get() reads it back. With no state store configured, ctx.state is an in-memory no-op — consistent within the run, nothing persisted. See run context for the full TargetContext — run id, cancellation signal, signals, dry-run — that ctx.state lives on.

Checking that a write landed

set resolves when the write has been attempted, whether or not it landed. When a body needs to know, use trySet, which resolves true when the patch reached the store and false when the write was dropped — conflicted away for good, or refused by a store that errored:

deploy = target().executes(async (ctx) => {
  const slot = await lease();
  if (!await ctx.state.trySet({ slot })) {
    // Nothing has been deployed yet, so failing here is cheap. Going ahead
    // would leave a slot held that no compensation can find again.
    throw new Error(`could not record the leased slot ${slot}`);
  }
  await deployTo(slot);
});

Treat false as not recorded: a dropped write is sometimes re-persisted by a later one, but nothing guarantees it. A dropped write also warns, and one that is definitely unrecoverable marks the record degraded so a later resume refuses it rather than repeating a step against state it cannot trust.

Two contexts have nothing durable behind them and so always answer true: a build with no state store, and a compensation body, whose ctx.state is seeded from the original target's metadata and kept in memory (the run is ending, so cleanup state is not persisted).

Durable run state

Turning on a store gives every run a RunRecord: one JSON document tracking the run's status, its planned target graph, resolved non-secret parameters, per-target status and metadata, received signals, and an append-only event log. The record is written at run creation, at each target's start and finish, and at run end — so a killed process leaves its executing target's status as running with a startedAt already recorded, rather than silently vanishing.

Store selection precedence

resolveStateStore picks the store in this order — the first one that applies wins:

  1. execute(build, root, { stateStore }) — that store (pass false to disable).
  2. Build.stateStore() override.
  3. ZUKE_STATE_URL (with optional ZUKE_STATE_TOKEN) → an HttpStateStore (production).
  4. ZUKE_STATE_DIR → a FileSystemStateStore at that directory.
  5. --state CLI flag, with nothing above set → a FileSystemStateStore at .zuke/runs.

None applying means no store and no record — a run stays in-memory.

import { Build, HttpStateStore, parameter, target } from "jsr:@zuke/core";
class CD extends Build {
  stateUrl = parameter("state service URL");
  stateToken = parameter("state service token").secret();
  override stateStore() {
    return new HttpStateStore({ url: this.stateUrl.value, token: this.stateToken.value });
  }
  deploy = target().executes(async (ctx) => { await ctx.state.set({ target: "sit-7" }); });
}

StateStore interface & RunRecord

Every backend implements the same StateStore contract:

export type PutResult = { ok: true; version: string } | { ok: false; conflict: true };
export interface StateStore {
  getRun(id: string): Promise<{ record: RunRecord; version: string } | null>;
  putRun(record: RunRecord, expectedVersion: string | null): Promise<PutResult>;
  listRuns(query: RunQuery): Promise<RunSummary[]>;   // newest first (createdAt, then id)
  acquireLock(key: string, holder: LockHolder, ttlMs: number): Promise<LockResult>;
  renewLock(key: string, token: string, ttlMs: number): Promise<boolean>;
  releaseLock(key: string, token: string): Promise<void>;
}
// build.ts:  stateStore(): StateStore | undefined       // override to inject a store
// FileSystemStateStore: constructor(dir: string, host?: StateHost)
export interface HttpStateStoreOptions { url: string; token?: string; fetch?: typeof fetch }
// HttpStateStore: constructor(options: HttpStateStoreOptions)  — token → Authorization: Bearer

...persisting a RunRecord built from these types:

export type RunStatus = "running" | "suspended" | "cancelling" | "succeeded" | "failed" | "cancelled";
export type TargetRunStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "skipped";
export interface TargetRunState {
  status: TargetRunStatus; meta: Record<string, JsonValue>;
  startedAt?: string; endedAt?: string; error?: string; waitingFor?: WaitState;
  effects?: Record<string, EffectState>;    // per-effect intent + settlement, for crash re-drive
}
export interface RunRecord {
  id: string; build: string; rootTarget: string; status: RunStatus; actor: string;
  createdAt: string; updatedAt: string;
  graph: RunGraphNode[];                    // { name, dependsOn[] }, declaration order
  params: Record<string, string>;           // resolved, NON-secret params only
  targets: Record<string, TargetRunState>;
  signals: Record<string, SignalRecord>;    // { data, receivedAt }
  events: RunEvent[];                       // append-only audit trail (MCP calls, reap events)
  degraded?: boolean;                       // a state write was permanently lost — see below
  deadlineAt?: string;                      // from Build.deadline(), enforced by the reaping sweep
  intendedTerminal?: RunStatus;             // set when a run enters "cancelling"; absent means "cancelled"
}
export interface RunQuery { status?: RunStatus; target?: string; since?: string; limit?: number; }
export interface RunSummary { id; build; rootTarget; status; actor; createdAt; updatedAt; }

Two vocabularies describe the same run: the record's TargetRunStatus versus the console's in-memory passed/cached — both map to succeeded in the record, and waiting exists only there (it has no console equivalent).

Writes are CAS-protected: putRun only lands when expectedVersion matches the store's current version (null means "must not exist yet"). Two writers racing the same run — one wins, the loser gets { ok: false, conflict: true } and re-reads before retrying. State writes are also best-effort: a briefly unavailable store is reported but never crashes the build.

FileSystem vs HTTP backends

BackendConstructorVersion authority
FileSystemStateStore constructor(dir: string, host?: StateHost) Single-host: write-temp-then-rename plus an O_EXCL lock; version is a content hash.
HttpStateStore constructor(options: HttpStateStoreOptions) Uses ETags; the server is the version authority, sidestepping client clock skew.

HttpStateStoreOptions is { url: string; token?: string; fetch?: typeof fetch } — a token is sent as Authorization: Bearer.

A conformance kit verifies a hosted HTTP backend against the same contract before you trust it in production.

Degraded records

Most dropped writes lose nothing. The writer applies its mutation at the top of its retry loop, so a compare-and-swap that conflicts is simply re-applied to the freshly-read record on the next attempt; and when it gives up because the run vanished from the store, or because the store threw, the mutation is still held in memory for any later write to re-persist. Those paths warn and carry on.

One path genuinely loses a write. If a foreign writer — an MCP audit append, a concurrent zuke cancel — wins the compare-and-swap race often enough to exhaust the writer's retry budget, the last attempt's mutation is discarded along with the base it was applied to. The writer then sets degraded: true on the record, and the next write that does land persists the flag (the failing write, by definition, could not carry it). zuke runs show prints it. So degraded means exactly one thing: a mutation was permanently lost.

The concrete consequence is a target that succeeded but is still recorded running or pending. That matters in two directions:

OperationWhat a degraded record changes
zuke resume A resume trusts the record as written and re-runs every target it does not show as succeeded — so that target would run a second time, which for a deploy or a release means doing it twice. Resume therefore refuses a degraded record and names the risk; --resume-degraded accepts it and continues, because the operator — not Zuke — knows whether the target is safe to repeat.
zuke resume --check Counts a degraded run as failed on every sweep until an operator resolves it, and prints the refusal so the cause is visible: a sweep cannot make that call, and a non-zero result is the only channel a cron watches. Pass --resume-degraded to the sweep to let it through.
zuke cancel Normally compensates the targets recorded succeeded; on a degraded record that test would skip a deploy that really happened. So cancel widens the walk to every target whose success it cannot rule out — anything not recorded failed or skipped — and its output says the record was incomplete, not that the target had succeeded.

Under-cleanup is the more dangerous direction: a compensation that runs for work which never happened is a no-op for an idempotent rollback (a delete of what was never created), while one that is skipped leaves the side effect in place. The run stays suspended either way, so it remains resumable.

If no later write ever lands — a store that stays down for the rest of the run — the flag never reaches the store. That run also never records its transition to suspended, so there is nothing for a resume to continue: it reports the run as missing or not suspended rather than resuming a record it cannot trust.

zuke runs CLI

zuke runs list                                                   # all runs, newest first
zuke runs list --status failed --target deploy --since 2026-07-01
zuke runs list --limit 20                                        # newest 20 only
zuke runs list --counts                                          # aggregate counts (total + per status); honours filters
zuke runs list --counts --json                                   # { total, byStatus }
zuke runs show 6f1c…                                             # header, params, per-target, signals, events
zuke runs show 6f1c… --json                                      # raw record

zuke runs list takes --status, --target, and --since filters, AND-combined, newest-first, plus --limit <n> (mirroring listRuns({ limit })) to cap the result to the newest N so a large store stays listable. --counts swaps the rows for aggregate counts (total plus per-status), honouring the same filters; with --json it returns { total, byStatus }. zuke runs show <id> prints the header, params, per-target status, signals, and events — add --json for the raw RunRecord.

Forcing a target — overrides

An operator sometimes has to take a step off a live run: one that cannot succeed, or one a person completed by hand.

zuke force <run-id> <target> --outcome skipped|succeeded [--reason ""]

That records an entry under overrides, keyed by target name, carrying the outcome, who forced it, when, and why. The executor reads it when it reaches the target and settles it without running the body — ahead of the target's onlyWhen conditions and its cache, because forcing is a decision that outranks what the build would work out for itself. Dependents proceed either way.

The two outcomes differ in what a later cancellation does. A forced succeeded asserts the target's effects exist, so it is compensated like any other succeeded target; a forced skipped never happened, so it is not — exactly like a target a condition skipped.

It is refused, naming the rule, when the target has already settled (the record is the account of what happened, and rewriting a settled outcome would make it untrue), when the run is terminal, when the target is not in the run's graph, or when the build declared it off-limits:

class CD extends Build {
  override unforceable() {
    return [this.applyProduction]; // references, so a rename cannot empty this
  }
}

An override lands for any target the run has not started yet, which in practice means the next resume — that is the process which loads the record after the force was written. zuke runs show prints every override, and over MCP the same operation is the force_target tool, subject to the same authorization as cancelling or signalling the run.

Retention

Records accumulate, so old ones can be pruned — say, delete terminal runs older than 90 days, but always keep the newest 50:

zuke runs prune --keep 90d --keep-last 50

Preview what would go, without deleting:

zuke runs prune --keep 30d --dry-run

A run is removed only when it is terminal (succeeded, failed, cancelled) and matches neither rule — it is both older than --keep and beyond the newest --keep-last. A non-terminal run (suspended, running, cancelling) is never pruned: a run suspended for days awaiting a human is the point of the system. At least one of --keep / --keep-last is required, so an accidental bare prune never wipes the store.

Who owns retention depends on the backend. The filesystem store is dev-grade and single-host, so it owns its pruning through this CLI. For the HTTP backend, retention is the server's job (a TTL or scheduled sweep) — GET /runs takes a limit so large stores stay listable, and DELETE /runs/:id (which prune drives) is an optional endpoint a hosted store implements only if it wants the CLI to prune it too.

HTTP REST contract

The HTTP backend speaks a small REST contract — bearer auth (Authorization: Bearer, 401 on reject), version carried via ETag/If-Match/If-None-Match:

Verb + pathBehavior
GET /runs/:id 200 body is the record, ETag required; 404 means not found (not an error).
PUT /runs/:id Create with If-None-Match: * (else 412); update with If-Match: <etag> (else 412). Returns the new ETag; a 412 is a CAS conflict — re-read and retry.
DELETE /runs/:id Backs zuke runs prune; optional — a server that owns its own retention may leave it unimplemented. 2xx means deleted (or already absent); 404 is treated as success (delete is idempotent).
GET /runs?status=&target=&since=&limit= Array of RunSummary; query params are AND-combined; server returns newest-first. limit caps the result to the newest N, applied after ordering, so a large store stays listable.

Every client request carries x-zuke-state-protocol: 1 (this page describes wire protocol 1). A service may echo its own version in that response header — when it does and the value differs, the client fails loudly rather than risk a silent mis-parse; a service that omits the header is treated as compatible. A breaking change to the contract bumps the number.

The same base URL, auth, and ETag scheme also hosts the build registry under /builds.

Hosting your own backend? The canonical wire contract lives in docs/state-api.md (and the @zuke/core JSR docs) — implement against that plus the conformance kit, don't re-derive it from this prose.

API stability

The durable-state surface — the StateStore interface, resumeRun, acquireLock/renewLock/releaseLock, and the RunRecord/RunEvent shapes — is stable with a deprecation cycle: a breaking change ships with the old form kept working for one minor version, emitting a warning, before removal. The RunRecord JSON is versioned by tolerant parsing (an older record still loads, its missing fields defaulted), and that tolerance is a stated guarantee, not an accident — the schema-evolution tests enforce it.

The HTTP wire contract carries the protocol version header (x-zuke-state-protocol); a breaking change to the contract bumps the number, and a client fails loudly against a server that declares a different one rather than mis-parsing silently. A conformance kit verifies a backend against the same contract — CAS stale-write rejection, one-writer-wins, listing filter/sort, TTL lock takeover, append-only events, and the /builds register/deregister CAS:

deno run -A jsr:@zuke/core/conformance --url http://localhost:8080 [--token …]

It exits 0 when every scenario passes and 1 (naming the failures) when one does not — build it into your backend's CI so a contract change surfaces the moment it lands.

Secrets never touch state

A parameter().secret() value never lands in RunRecord.params — only resolved non-secret parameters are recorded. Every value passed to ctx.state.set(...) also passes through the run's redactor before it's persisted, the same protection described in secrets.