Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

Saved Cases

A case is a recorded run frozen at a fork point: inputs, history up to the fork, every recorded call result. It is a content-hashed bundle, so saving the same run at the same fork twice gives one copy.

Terminal window
export PYRULA_FIXTURE_STORE=/srv/pyrula/cases
pyrula case save my_workflow run_abc123 --fork-after-seq 42
{"fixture_hash": "9f2c...", "fixture_store": "file:///srv/pyrula/cases", "manifest_summary": {"format": 1, "kind": "workflow", "target_name": "my_workflow", "fork_after_seq": 42, "capture": {"complete": true, "missing": []}, "result_count": 6, "history_count": 19}}

--fork-after-seq is a run_seq, not an event count. Omit it to keep only the inputs.

Resolved from --fixture-store, then PYRULA_FIXTURE_STORE, then PYRULA_POSTGRES_DSN. With none set, every subcommand refuses with no_fixture_store.

ValueStore
memory:Per instance, tests only.
file:///abs/path or a bare pathA local directory, created if missing.
postgres://...Tables in the archive schema. Run pyrula db migrate first.
s3://bucket/prefix?region=...&endpoint_url=...Needs the "pyrula[workflows,s3]" extra. Credentials come from the AWS SDK chain, never the URL.

Cloud-connected workers read the same PYRULA_FIXTURE_STORE / PYRULA_POSTGRES_DSN. Ordinary workers support save_case, run_case and delete_case_bundle; Kafka workers and API-only processes don’t.

A run_case message names a source:

  • bundle: a saved case from the fixture store. Needs a store.
  • journal: a run on the worker’s own journal, archived runs included. No store needed, but then save_case and delete_case_bundle are unavailable.

If the run aged out of Valkey (completed_ttl, a day by default) with no archive, the new run ends whatif_source_missing; a changed rebuild ends whatif_source_changed. Budgets, labels and codes: What-if runs. A run_case ack means only that the run was durably submitted; the outcome arrives in the run’s events.

pyrula case save TARGET RUN_ID reads the run from Valkey (--namespace) or the Postgres archive and writes the bundle to the store. It refuses a capture replay could not use (capture_incomplete). --kind is detected from the run’s model and tool steps; --kind workflow on a run with model or tool calls refuses with kind_mismatch.

Terminal window
pyrula case export 9f2c... --out run.json --json # readable, diffable
pyrula case import run.json # same hash back
pyrula case list
pyrula case show 9f2c... [--values]
pyrula case delete 9f2c...

export writes the compressed blob by default; --json gives a diffable file. import accepts either.

Bundles hold raw inputs and outputs, unredacted. Treat a fixture store like a copy of the data the run touched; export and show --values warn on stderr.

Format 1 is JSON with three keys:

  • manifest: format, kind, target_name, code_version, registration, recorded_config, pyrula_version, source, fork_after_seq, capture and inputs.
  • history: events with run_seq <= fork_after_seq, terminal kinds dropped.
  • results: every recorded call result, including after the fork, as {key, kind, fingerprint, result} or {..., error}.

The hash is SHA-256 of the uncompressed bytes (sorted keys, compact separators, UTF-8, results sorted by (kind, key)). An unknown manifest kind or unreadable format refuses with format_unsupported. Bundles over 512 MiB are refused at save.

For agents, registration.tools lists every tool offered with its writes flag, and recorded_config.model is the turn’s first model call.

Terminal window
pyrula case save my_workflow run_abc123 --fork-after-seq 42 --fixture-store tests/cases
pyrula case export 9f2c... --out tests/cases/9f2c....json --json
Terminal window
pyrula case gc --keep 9f2c... --keep-file keep-hashes.txt [--yes]

Without --keep every bundle is a candidate. Without --yes, gc only lists.

pyrula case run starts a local run from a bundle, named by its hash in the fixture store or by a .case file such as the ones pyrula dev keeps in .pyrula/last/. It first replays the recorded prefix in verification mode: every prefix call must be consumed with its recorded identity and fingerprint, or it refuses with prefix_diverged.

FlagEffect
--from-startReruns the whole recorded run instead of only what follows the fork.
--model, --prompt-fileRun an agent’s model calls live on another model or system prompt (Record once, iterate free).
--input KEY=JSONChanges an input; only for inputs that don’t change the prefix.

Each call then resolves down a ladder:

  1. Reuse the recorded result (reused).
  2. Use a supplied answer (simulated).
  3. Run live if permitted (live for a new input, changed when it replaces a recorded result).
  4. Stop with a reason code.
Terminal window
pyrula case run CASE_HASH --target my_app.workflows:checkout \
--fixture-store tests/cases --input sku='"SKU-42"'

Agent cases also need --llm-ref MODULE:ATTR. --deps-ref MODULE:ATTR supplies dependencies, --app-dir DIR extends the import path, --run-id ID sets the run id.

New model and read calls run live by default, within --live-budget N (max_live_calls). Writes stop with write_not_allowed unless allowed with --allow-write TOKEN, repeated per token. Steps declare effect="read" | "write" | "pure"; undeclared is "write". A tool is a read only with @tool(read_only=True) or as a sub-agent. Pure calls do not count against the budget.

Tokens: tool:<name> for a tool the model calls, step:<name> for one the agent body calls, and step:<id> using ids from registration.steps (step:child:0, step:sigsend:0, step:gather:<id>). A signal send is a write.

--replay-only removes the live rung: every non-pure call must be answered by the recording, so no model client is needed and --supply does not apply. An unanswerable call stops with replay_diverged, with reason one of input_changed, unexpected_call or recorded_error. A recorded post-fork call the run never makes is listed in unconsumed_keys and the command exits 3.

A pass means the changed code made the same calls with the same inputs, not that a live model would answer the same. Replay checks in CI runs a Cloud suite this way.

--supply FILE is a JSON object with calls (keyed "<kind>:<key>") and tools (keyed by tool name), at most 200 entries each and 64 KiB total. A wrong-shaped answer stops with missing_context. Model answers are a string or {"role": "assistant", "content": [...]}; interrupt answers are {"action": "...", "value": ...}; signal answers require payload.

--max-cost-usd X limits spend using the default price table. --prices FILE adds or overrides models (at most 100, cache rates default to the input rate; not stored in the bundle). An entry may add "long_context": {"above_input_tokens": N, "input_per_mtok": ..., "output_per_mtok": ...} (cache rates optional, defaulting to that tier’s input rate): a call whose prompt (input + cache creation + cache read tokens) is above N is priced entirely at the tier’s rates:

{"model-id": {"input_per_mtok": 2.5, "output_per_mtok": 10, "cache_write_per_mtok": 2.5, "cache_read_per_mtok": 0.5}}

Under a cost limit, a live model call with no price or unobservable spend stops with live_budget_exceeded.

A case run never waits. A post-fork durable sleep advances the run’s clock (and ctx.now()) to its wake time. Interrupt, signal and external stream reads with no recorded or supplied answer stop with missing_context.

A live model call gets new tool-use ids, so recorded tool results keyed by the old ids won’t match. Supply them under tools, keyed by tool name.

--values includes call values. The command prints one {"type":"call", ...} line per new post-fork call, then a {"type":"outcome", ...} line with status, provenance counts, changed_calls, live-call count, cost, code versions and fixture hash. Exit 0 is complete, 2 a refusal before start (JSON code and message on stdout), 3 a started run that failed, was cancelled or suspended, 1 invalid input or operational error (redacted error: on stderr).

A missing_context, live_budget_exceeded, write_not_allowed or replay_diverged stop puts stop_key (such as tool:toolu_01, the key supplied_results.calls uses) and stop_token (the --allow-write token for a refused write, else null) on the run:error event.

Refusals exit 2 with {"code", "message"} on stdout; other failures exit 1.

CodeMeaning
capture_incompleteThe message lists what is missing, including a call in flight at the fork. Pick another --fork-after-seq or save after the run finishes.
no_fixture_store, bundle_missing, bundle_corruptNo store configured; hash not in the store; bytes fail decompression, hashing or validation (re-export).
format_unsupported, kind_mismatchBundle format not readable by this version; --kind workflow on a run with model or tool calls.
invalid_fixture_hash, invalid_keep_hashNot a 64-character lowercase hex hash.
prefix_divergedThe target no longer consumes the recorded prefix with matching inputs.
replay_input_changedResuming finds changed input in the case run’s own journal.
missing_contextA call has no live rung, or a supplied answer has the wrong shape.
live_budget_exceededCall or cost ceiling reached, or spend unobservable under a cost limit.
write_not_allowedA write has no allow token.
replay_divergedA --replay-only call the recording cannot answer.
binding_mismatch, invalid_overridesExpected fixture store identity differs from the opened store; runtime settings missing or invalid.
whatif_source_missing, whatif_source_changedSee What-if runs.
runtime_error, cancelledThe runtime failed, or the run was cancelled.
no_eligible_runtime, runtime_lost, runs_quota_exceededCloud only.

View this page as Markdown