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:
execute(build, root, { stateStore })— that store (passfalseto disable).Build.stateStore()override.ZUKE_STATE_URL(with optionalZUKE_STATE_TOKEN) → anHttpStateStore(production).ZUKE_STATE_DIR→ aFileSystemStateStoreat that directory.--stateCLI flag, with nothing above set → aFileSystemStateStoreat.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
| Backend | Constructor | Version 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 + path | Behavior |
|---|---|
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.