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
  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.

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;
}
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 MCP audit trail
}
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.

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.

Retention

Records accumulate, so old ones can be pruned:

# 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.