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:
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;
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
| 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.
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:
| Operation | What 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 + 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.