diff --git a/.gitignore b/.gitignore index f31e8780..83a72743 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .DS_Store .agent-relay/ .env +.agentworkforce/ diff --git a/docs/RFC-0001-everything-is-a-relayflow.md b/docs/RFC-0001-everything-is-a-relayflow.md index 963e1a94..e5867d77 100644 --- a/docs/RFC-0001-everything-is-a-relayflow.md +++ b/docs/RFC-0001-everything-is-a-relayflow.md @@ -24,6 +24,16 @@ deterministic step # a pure script — no LLM anywhere (legal; today's `llm` is a **kernel-level step type distinct from `agent`**: it has no workspace, its output is a value, and its verification is the rail that makes a prompt reliable. Most flows a customer writes on day one are deterministic + llm steps; agents are the rung you climb to when the step needs hands. +### The two covenants + +Every gate, surface, and SDK is bound by two covenants, born from real cofounder friction with the current engine: + +**Covenant 1 — easy to write, easy to read.** A relayflow's spec reads like the plan it came from. The measure is the **cofounder test**: a technical founder writes their first working relayflow in under ten minutes without reading engine docs, and can read a stranger's flow aloud and say what it does. Error messages name the author's mistake in the author's vocabulary, never engine internals. Sage is the zero-syntax on-ramp (conversation → spec). Authoring friction is a gate-blocking defect, not a docs problem. + +**Covenant 2 — no unexpected failures.** A relayflow may fail only in ways it declared. Two mechanisms enforce this: +- **Preflight.** At submit time the engine proves everything provable — spec validity, CLI existence *and auth health*, credential scopes, integration mounts, a worker existing to execute every trigger — and **refuses or warns before the run starts** on anything it cannot prove. Nothing may fail at minute 27 that was checkable at minute 0. (Evidence from the first dogfood run, 2026-08-27: an unknown `cli: grok` passed `--dry-run` and killed the run 27 minutes in; gemini's auth was dead and was discovered mid-run; a cron trigger reported `succeeded` into a void with no worker enrolled.) +- **Typed failure.** At runtime every failure is one of a closed set of declared kinds (`gate_failed`, `verification_failed`, `budget_exceeded`, `needs_human`, `environment_lost`, …), journaled with its `completionReason`. A raw stack trace, a silent wrong-workspace run, or a "succeeded" that did nothing is by definition a kernel bug. A flow with unprovable assumptions starts only after stating them to its author. + The engine underneath must be **competitive with Temporal and Inngest** as durable execution, and **agentic-leading** where those engines are structurally blind: | Capability | Temporal | Inngest | Relayflows target | @@ -81,7 +91,7 @@ Gates 5–8 are horizontal capabilities that start as soon as gate 1 holds and a **Forces into existence:** `@relayflows/kernel` (charter phase 4 + 5): append-only fsync'd journal that *fails the step* when the write fails (fail-closed, no `homeFallback` silently leaving the relayfile mount), idempotency keys, leases, durable timers, `completionReason`, **out-of-band step completion** — a step an external worker finishes asynchronously (Native's render workers), journaled with the same `completionReason` discipline as in-process steps — and **durable channels**: an inter-agent message is a journal append with consumer offsets, at-least-once and replayable, so coordination in flight survives `kill -9` like every other kind of state. -**Done when:** the canonical hello *ladder* — (a) a pure deterministic flow with zero agents (legalizing what today's validator rejects), (b) the same flow plus a bare `llm` step with a verification gate, (c) the same flow plus an `agent` step — each survives `kill -9` at every step boundary and between them, resumes completing only unfinished work, and its journal replays *results, not code*. Budget accounting is exact: the resumed run's token spend equals one execution of each step. +**Done when:** the canonical hello *ladder* — (a) a pure deterministic flow with zero agents (legalizing what today's validator rejects), (b) the same flow plus a bare `llm` step with a verification gate, (c) the same flow plus an `agent` step — each survives `kill -9` at every step boundary and between them, resumes completing only unfinished work, and its journal replays *results, not code*. Budget accounting is exact: the resumed run's token spend equals one execution of each step. **Preflight holds (covenant 2):** `flows check` refuses the ladder flows when a declared CLI is missing or unauthenticated or a trigger has no executor, warns on unprovable assumptions before starting, and the failure taxonomy is closed — every failed run's journal terminates in a declared failure kind, never a raw error. **Exists today:** `runner.ts` (11,560 lines, no checkpoint, no backoff) — the thing being replaced. The YAML/TS/Python authoring surface survives as compilers targeting the journal protocol. diff --git a/docs/bootstrap-report.md b/docs/bootstrap-report.md new file mode 100644 index 00000000..4684a4dd --- /dev/null +++ b/docs/bootstrap-report.md @@ -0,0 +1,204 @@ +# Bootstrap report — gate-1 skeleton (flows-bootstrap-gate1) + +Date: 2026-08-27. Produced by the `report` step of `workflows/bootstrap-gate1.yaml`. +Honest state only: what exists, what passed, what is missing. + +## What was built + +All of the following is **uncommitted** on `main` (untracked `kernel/`, `sdk/`, +`testdata/`; `workflows/bootstrap-gate1.yaml` modified mid-run to swap the +adversary agent's CLI from `grok` to `claude`). A human decides branch/commit/PR. + +### kernel/DESIGN.md (architect) +Gate-1 kernel design: 12 journal entry types with exact field lists +(`completionReason` on every completion; Appendix A pin fields — revision ids, +stream offsets, idempotency key), the SQLite journal schema (one file per run +cell, append-only, segment-per-epoch), the hello-ladder step state machine with +memoized resume, the three-crate cargo workspace layout, and journal protocol +v0 (12 verbs, JSON over unix socket). + +### kernel/ — cargo workspace (kernel-dev) +- **relayflowd-core** — pure, no I/O, no wall clock: fail-closed spec parsing + (`deny_unknown_fields` everywhere plus an explicit key-set check where + `#[serde(flatten)]` defeats serde), the step state machine with memoized + replay, verification gates as control flow, retry with deterministic + backoff+jitter, leases, recovery, simulated clock, journal entry types. +- **relayflowd-journal** — append-only SQLite journal (WAL, `synchronous=FULL`), + fail-closed writes (a failed commit is returned, not swallowed), effect + deduplication at the journal boundary, rebuildable run registry, atomic + segment-per-epoch rollover scaffolding. +- **relayflowd** (binary) — `run`, `resume`, and `serve` commands; executes + deterministic-step run specs end to end and resumes interrupted runs + re-executing only unfinished steps. `serve` speaks a subset of protocol v0 + (`hello`, `run.start`, `run.resume`, `run.get`, `journal.read`), versioned + handshake, fail-closed on unknown verification keys. +- Largest file 397 lines; `cargo clippy -D warnings` and `cargo fmt --check` + passed at build time (per kernel-dev's step report). + +### sdk/ — @relayflows/sdk, TypeScript (sdk-dev) +- `spec.ts` — spec types mirroring RFC §1's ladder (`deterministic | llm | + agent`), verification gates, recovery modes, agent surfaces, budgets; + zero-agent flows legal by construction. +- `canonical.ts` — canonical JSON + `specHash` (sha256, matches the kernel's + `spec_hash`). +- `validate.ts` — fail-closed validation (22 rejection cases pinned in tests). +- `compile.ts` — YAML → spec JSON compiler with defaults materialized; + `toKernelSpec` emits the single snake_case boundary dialect. +- `protocol.ts` / `journal-client.ts` — protocol v0 types for all 12 verbs and + a newline-delimited-JSON unix-socket client with request correlation, event + demux, and fail-closed error handling. + +### testdata/ — shared parity fixture +`hello-ladder.flow.yaml` → `hello-ladder.spec.canonical.json` + sha256, pinned +bit-for-bit on **both** sides (`sdk/tests/spec-parity.test.ts`, +`kernel/relayflowd-core/tests/spec_parity.rs`), so the SDK-compiled spec and +the kernel-parsed spec provably hash identically. + +## Test results (verbatim) + +Both suites re-run for this report on 2026-08-27. Environment caveat, honestly +noted: `~/.cargo/registry` is a broken symlink to an unmounted external volume +("Paris Drive"), which failed the workflow's own `kernel-tests` deterministic +step (`error: failed to create directory …/registry/cache/… File exists`). The +run below used a temporary `CARGO_HOME` as a workaround; nothing in `kernel/` +is at fault, but CI on this machine is broken until the symlink is fixed. + +### `cargo test --workspace` — 30 passed, 0 failed + +``` +running 4 tests +test server::tests::hello_enforces_protocol_version ... ok +test server::tests::run_start_fails_closed_on_an_unknown_verification_key ... ok +test exec_det::tests::captures_deterministic_output ... ok +test exec_det::tests::timeout_has_an_explicit_completion_reason ... ok +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + +running 3 tests +test an_attempt_left_running_is_recorded_dead_and_replaced_on_resume ... ok +test completed_steps_are_not_reexecuted_after_process_state_is_dropped ... ok +test sigkill_mid_run_preserves_completed_effects_and_replaces_the_dead_attempt ... ok +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.53s + +running 17 tests +test clock::tests::simulated_clock_is_explicitly_advanced ... ok +test journal::tests::memory_journal_assigns_sequences_and_rolls_epochs ... ok +test retry::tests::jitter_is_repeatable_and_bounded ... ok +test spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test spec::tests::a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate ... ok +test spec::tests::cycles_are_rejected ... ok +test spec::tests::spec_version_is_semver_and_gated ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test machine::tests::successful_memo_is_never_scheduled_again ... ok +test machine::tests::verification_failure_schedules_a_durable_retry ... ok +test spec::tests::zero_agent_flow_is_valid ... ok +test spec::tests::the_full_ladder_parses_in_the_one_dialect ... ok +test spec::tests::unknown_root_and_nested_fields_are_rejected ... ok +test state::tests::budget_decimal_strings_add_without_floats ... ok +test verify::tests::deterministic_output_requires_successful_exit_and_content ... ok +test state::tests::completed_output_is_memoized_and_unlocks_dependents ... ok +test verify::tests::json_schema_is_a_control_gate ... ok +test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + +running 1 test +test the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash ... ok +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + +running 5 tests +test registry::tests::registry_is_a_rebuildable_run_locator ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test tests::rollover_is_atomic_scaffolding_for_epoch_resume ... ok +test tests::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... ok +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +### `npm test` (sdk/: `tsc --noEmit && vitest run`) — 41 passed, 0 failed + +``` + ✓ tests/journal-client.test.ts (7 tests) 17ms + ✓ tests/spec-parity.test.ts (2 tests) 9ms + ✓ tests/validate.test.ts (22 tests) 12ms + ✓ tests/hello-deterministic.test.ts (5 tests) 12ms + ✓ tests/deterministic-llm.test.ts (5 tests) 12ms + + Test Files 5 passed (5) + Tests 41 passed (41) +``` + +## Review verdict + +**REVIEW_PASSED** — but only on the second pass. The first adversarial review +refuted the skeleton with demonstrated failures: fail-open verification (a +misspelled gate key silently dropped the gate), a false SDK↔kernel hash-parity +claim, a `task` vs `instruction` dialect split, crash tests that never sent +SIGKILL, a journal-client test overclaiming what it proved, and overloaded +epoch-resume fields. Two repair rounds fixed all six; the re-review verified +each fix empirically (source read, both suites run, three independent +end-to-end experiments against the real binary, including reading `spec_hash` +back out of the SQLite journal — byte-identical to the SDK's). + +Minor observations carried forward from the review (not violations): +1. `JournalClient.runStart` types its param as the authoring `FlowSpec`, but + the kernel parses the kernel dialect — fail-closed seam wart, one-line fix. +2. `next_actions` returns only `ArmTimer` for the first backing-off step in + spec order — wall-clock inefficiency for future parallel DAGs, irrelevant + to the sequential gate-1 ladder. +3. `sdk/dist/` build artifacts are checked in; fresh today, but they can drift. + +## What gate 1 still needs + +Gate 1's done-when: the full hello ladder (a/b/c) survives `kill -9` **at +every step boundary and between them**, resumes completing only unfinished +work, replays results not code, with exact budget accounting. + +1. **kill -9 harness against the real binary — partially exists, must be + completed.** `crash_resume.rs` already SIGKILLs the real `relayflowd` + process group mid-attempt and asserts exactly-once effects, an explained + dead attempt (`crashed | lease_expired`), and no re-execution. Still + missing: a systematic sweep of kill points (every boundary and mid-step, + not one chosen point), resume driven through the real binary's `resume` + CLI in the SIGKILL case (today that test resumes via in-process + `Engine::new().resume()`; only the `--stop-after` test resumes via the + binary), kill-under-`serve`, and the budget assertion (resumed run's token + spend equals one execution of each step). +2. **llm step.** Parses on both sides; the binary refuses to execute it + (`ensure_deterministic`, `kernel/relayflowd/src/engine.rs:231` — honest + fail-closed, not silent). Needs dispatch, verification-gate-driven semantic + retry, and the memoized value on resume. Depends on the missing protocol + verbs: `serve` implements 5 of 12 (`worker.attach`, `step.heartbeat`, + `step.complete` (out-of-band completion), `event.emit`, `stream.append`, + `stream.read`, `run.watch` are types-only). Durable channels + (journal-append messages with consumer offsets) are likewise not built. +3. **agent step + Appendix A pins.** Parse-only today. Needs: declared + mutable surfaces; `step.attempt.started` pinning revision ids, stream + offsets, and the idempotency key (entry fields exist — `StepOpenSummary` + carries `idempotency_key`; the pinning semantics do not); `reset` / + `inspect` / `manual` recovery; effect dedupe by + `(step id, idempotency key, surface path)` at the mount boundary (journal- + boundary dedupe exists as scaffolding); and Appendix A rule 7's + crash-injection extension — kill mid-edit, assert pinned-revision restart + and exactly one provider effect. + +## Next three work packages (priority order) + +1. **WP-1: Close ladder rung (a) — exhaustive crash harness + budget + exactness.** Kill-point sweep against the real binary (every boundary, + mid-step, and under `serve`), resume via the binary CLI in all cases, + budget assertion, and fix the broken `~/.cargo/registry` symlink or pin a + repo-local `CARGO_HOME` so the workflow's own `kernel-tests` gate runs + green. Fold in the review's three minor observations (runStart type, + `ArmTimer` scheduling, drift-prone `dist/`). Rung (a) is then done per the + RFC, not just demonstrated once. +2. **WP-2: llm step end to end — ladder rung (b).** Implement the remaining + protocol verbs (`worker.attach`, `step.heartbeat`, `step.complete`, + `run.watch`, `stream.*`, `event.emit`) in `serve`, an out-of-band worker + path with lease + heartbeat, verification gates driving semantic retry + (`maxIterations`, backoff), memoized llm output on resume, and extend the + crash harness to rung (b). +3. **WP-3: agent step + Appendix A — ladder rung (c).** Surface declaration, + pin-on-start (revision ids / worktree base commit, stream offsets, + idempotency key), `reset` recovery first (then `inspect`/`manual`), effect + dedupe at the mount boundary, completion pinning end state, and the rule-7 + crash-injection gate for agent steps. Gate 1 is then green in full. + +REPORT_DONE diff --git a/kernel/Cargo.lock b/kernel/Cargo.lock new file mode 100644 index 00000000..c7d7cda4 --- /dev/null +++ b/kernel/Cargo.lock @@ -0,0 +1,1411 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" +dependencies = [ + "ahash", + "base64", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "referencing" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relayflowd" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "libc", + "relayflowd-core", + "relayflowd-journal", + "serde", + "serde_json", + "sha2", + "tempfile", + "ulid", + "wait-timeout", +] + +[[package]] +name = "relayflowd-core" +version = "0.1.0" +dependencies = [ + "jsonschema", + "serde", + "serde_json", + "sha2", + "thiserror", + "ulid", +] + +[[package]] +name = "relayflowd-journal" +version = "0.1.0" +dependencies = [ + "relayflowd-core", + "rusqlite", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand", + "serde", + "web-time", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml new file mode 100644 index 00000000..43733699 --- /dev/null +++ b/kernel/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +members = ["relayflowd-core", "relayflowd-journal", "relayflowd"] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT" +version = "0.1.0" + +[workspace.dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +jsonschema = { version = "0.33", default-features = false } +libc = "0.2" +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +tempfile = "3.20" +thiserror = "2.0" +ulid = { version = "1.2", features = ["serde"] } +wait-timeout = "0.2" + diff --git a/kernel/DESIGN.md b/kernel/DESIGN.md new file mode 100644 index 00000000..5312eb4b --- /dev/null +++ b/kernel/DESIGN.md @@ -0,0 +1,369 @@ +# relayflowd — Gate 1 kernel design + +Scope: RFC-0001 gate 1 only — journal + memoization, resume without re-execution, +the hello ladder (deterministic / llm / agent), verification as control flow, +durable timers, leases, idempotency keys, durable streams, out-of-band step +completion. Nothing else. Vocabulary follows RFC §1 and Appendix A. + +Ground rules inherited (not restated per section): append-only, fail-closed — +a journal write that fails fails the step; `completionReason` on every +completion; the kernel holds no provider SDKs; core has no I/O and runs on a +simulated clock; the journal replays **results, not code**. + +--- + +## 1. Journal entry types + +Every entry shares one envelope; `payload` is entry-type-specific canonical +JSON (sorted keys, no floats for money — dollars are decimal strings, tokens +are integers). + +**Envelope (all entries):** + +| field | type | notes | +|---|---|---| +| `seq` | int | journal-assigned, strictly monotone per run file | +| `segment_id` | int | == epoch number | +| `entry_type` | string | one of the types below | +| `run_id` | string | ULID | +| `step_id` | string \| null | null for run-level entries | +| `attempt` | int \| null | 1-based | +| `at_ms` | int | clock reading (simulated in core tests) | +| `payload` | object | below | + +### 1.1 `run.spawned` +First entry of segment 1. Payload: +`spec` (full run-spec JSON, inlined — the journal is self-contained), +`spec_hash` (sha256), `parent_run_id` (null in gate 1), `journal_version` +(int, stamped per segment thereafter via `epoch.summary`), `created_by` +(client identity string). + +### 1.2 `step.attempt.started` +One per attempt. Payload: + +| field | notes | +|---|---| +| `step_type` | `deterministic` \| `llm` \| `agent` | +| `idempotency_key` | `sha256(run_id ‖ step_id)` — **stable across attempts** so effects dedupe per Appendix A rule 5 | +| `lease_id` | ULID | +| `lease_deadline_ms` | absolute; expiry ⇒ attempt is dead | +| `executor` | `kernel` (deterministic) \| worker id (llm/agent, dispatched) | +| `recovery_mode` | agent steps only: `reset` (default) \| `inspect` \| `manual` | +| `pins.workspace` | agent steps: `[{surface, revision_id}]` — relayfile revision id per declared mount surface, or `{worktree_base_commit}` | +| `pins.streams` | `[{stream, read_offset}]` — consumer offsets at attempt start | +| `max_iterations` | from spec, echoed for legibility | + +Deterministic/llm steps journal `pins.streams` only if they consume streams; +`pins.workspace` is empty (no workspace). + +### 1.3 `step.completed` +One per **attempt** (every completion, terminal or not, carries a reason). +Payload: + +| field | notes | +|---|---| +| `completionReason` | `success` \| `verification_failed` \| `retries_exhausted` \| `lease_expired` \| `crashed` \| `timeout` \| `worker_error` \| `budget_exceeded` \| `canceled` | +| `disposition` | `step_done` \| `retry` \| `park` — `step_done` ends the step; `retry` schedules the next attempt; `park` ⇒ `needs_human` | +| `output` | JSON value — the memoized result (null unless `step_done`+`success`) | +| `verification` | `{gate, verdict: pass\|fail, detail}` or null | +| `end_pins` | agent steps: `{workspace: [{surface, revision_id}], streams: [{stream, read_offset}]}` — Appendix A rule 6: the next step's starting state **is** this | +| `effects` | list of `{surface_path, idempotency_key}` dedupe keys recorded this attempt | +| `budget` | `{tokens_in, tokens_out, dollars}` — exact; zero for memoized replay by construction (no entry is written on replay) | +| `completed_by` | `kernel` \| worker id — out-of-band completion uses the same entry, same discipline | +| `next_attempt_at_ms` | when `disposition=retry`: computed backoff+jitter wake time | + +### 1.4 `wait.event` +Step parks on an external event. Payload: `wait_id` (ULID), `event_key` +(exact-match string in v0), `timeout_at_ms` (nullable). + +### 1.5 `wait.human` +Durable human await. Payload: `wait_id`, `prompt` (what is being asked), +`requested_of` (identity string), `options` (nullable list), +`timeout_at_ms` (nullable), `diff_ref` (nullable — Appendix A `manual` mode: +pinned revision vs. current state). + +### 1.6 `sleep.until` +Durable timer. Payload: `wait_id`, `wake_at_ms`, `reason` (free text: +`retry_backoff` \| `spec_sleep`). + +### 1.7 `wait.completed` +Closes any of 1.4–1.6 (every completion carries a reason). Payload: +`wait_id`, `completionReason` (`event_received` \| `human_responded` \| +`timer_fired` \| `timeout` \| `canceled`), `result` (event/human payload, +null for timers). + +### 1.8 `stream.appended` +Durable channel append — at-least-once, replayable. Payload: `stream` +(name), `offset` (0-based, dense per stream), `producer` (step_id or +identity), `message` (opaque JSON). Consumer offsets are not separate +entries: they are pinned in `step.attempt.started` / `step.completed` and +summarized per epoch. + +### 1.9 `effect.recorded` +Appendix A rule 3: the mount write is the effect record. Payload: +`surface_path`, `idempotency_key`, `revision_before`, `revision_after`, +`agent_identity`, `deduped` (bool — true when a second attempt's write was +suppressed by the dedupe table and no provider call occurred). + +### 1.10 `epoch.summary` +First entry of every segment after the first (decision #8). Resume reads +only the current segment, so this restates everything live. Payload: + +| field | notes | +|---|---| +| `epoch` | int, == segment_id | +| `prev_segment_id` | int | +| `journal_version` | writers write only the newest | +| `steps_done` | `{step_id: {completionReason, output}}` — memoization survives compaction | +| `steps_open` | `{step_id: {attempt, state, lease_deadline_ms}}` | +| `open_waits` | restated 1.4–1.6 payloads keyed by `wait_id` | +| `stream_state` | `{stream: {length, consumers: {consumer_id: offset}}}` | +| `pinned_revisions` | `{surface: revision_id}` current chain head | +| `budget_spent` | `{tokens_in, tokens_out, dollars}` run total | + +### 1.11 `segment.closed` +Last entry of a segment (so closing is an append, never an update). Payload: +`next_segment_id`. + +### 1.12 `run.completed` +Terminal entry. Payload: `completionReason` (`success` \| `step_failed` \| +`canceled` \| `budget_exceeded`), `failed_step_id` (nullable), +`budget_total`. + +--- + +## 2. SQLite schema + +One SQLite file per run cell: `/runs/.sqlite3`. A run's +entire durable state is this one file (decision #13: a sleeping run costs +storage only). A tiny registry `/relayflowd.sqlite3` maps +`run_id → file, status, next_wake_at_ms` so the binary can find due timers +without opening every run; it is an index, rebuildable from run files, never +authoritative. + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = FULL; -- fsync on every commit; a failed commit fails the step + +CREATE TABLE meta ( -- INSERT-only, written once at creation + key TEXT PRIMARY KEY, -- 'run_id', 'created_at_ms', 'journal_version' + value TEXT NOT NULL +) WITHOUT ROWID; + +CREATE TABLE segments ( -- INSERT-only; one row appended per epoch + segment_id INTEGER PRIMARY KEY, -- == epoch, 1-based + journal_version INTEGER NOT NULL, + opened_seq INTEGER NOT NULL -- seq of run.spawned / epoch.summary +); + +CREATE TABLE entries ( -- the journal; INSERT-only, no UPDATE/DELETE ever + seq INTEGER PRIMARY KEY, -- rowid alias; assigned monotonically + segment_id INTEGER NOT NULL REFERENCES segments(segment_id), + entry_type TEXT NOT NULL, + step_id TEXT, + attempt INTEGER, + at_ms INTEGER NOT NULL, + payload TEXT NOT NULL -- canonical JSON +); +CREATE INDEX ix_entries_segment ON entries(segment_id, seq); +CREATE INDEX ix_entries_step ON entries(step_id, seq) WHERE step_id IS NOT NULL; + +CREATE TABLE effects ( -- Appendix A rule 5: dedupe at the mount boundary + step_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + surface_path TEXT NOT NULL, + entry_seq INTEGER NOT NULL, -- the effect.recorded entry that won + PRIMARY KEY (step_id, idempotency_key, surface_path) +) WITHOUT ROWID; +-- INSERT OR IGNORE; a conflict means the effect already happened: suppress the +-- provider call and journal effect.recorded{deduped:true}. + +CREATE TABLE stream_index ( -- INSERT-only projection of stream.appended + stream TEXT NOT NULL, + offset INTEGER NOT NULL, + entry_seq INTEGER NOT NULL, + PRIMARY KEY (stream, offset) +) WITHOUT ROWID; +``` + +Append discipline: one transaction per logical append — `INSERT INTO +entries` plus any index-table inserts, then commit (fsync). If the commit +errors, `relayflowd-journal` returns `Err`, and core marks the step failed +with `completionReason` from the caller's context — never a warning, never a +fallback (AGENTS.md rule 4). + +Segment-per-epoch: rollover appends `segment.closed`, inserts the new +`segments` row, and appends `epoch.summary` — all in one transaction. Closed +segments are contiguous `seq` ranges; they are never rewritten. Archival +(export of a closed range to relayhistory) is out of gate 1; the range query +`WHERE segment_id = ?` is the archival contract and exists now. + +--- + +## 3. Step state machine (the hello ladder) + +``` + deps met lease granted + Pending ─────────────► Runnable ─────────────────► Running(attempt n) + ▲ │ + timer fired │ ├─ output produced ──► Verifying + Backoff ◄─────────────────┤ ├─ wait declared ───► Waiting + ▲ (sleep.until) │ └─ crash / lease expiry + │ │ │ + │ disposition=retry │ wait.completed ▼ + └── Verifying:fail ────┘ Waiting ────────────► Runnable dead attempt: + (n < max_iterations) step.completed{crashed|lease_expired} + then per recovery mode: + Verifying ── pass ──► Done(success) retry → Backoff/Runnable + Verifying ── fail, n = max_iterations ──► Done(retries_exhausted) park → NeedsHuman + NeedsHuman ── wait.completed{human_responded} ──► Runnable | Done(canceled) +``` + +Journal mapping: `Runnable→Running` appends `step.attempt.started`; every +exit from `Running`/`Verifying` appends `step.completed` with the reason and +disposition; `Backoff` is a `sleep.until` + `wait.completed{timer_fired}`; +`Waiting`/`NeedsHuman` are `wait.event`/`wait.human` + `wait.completed`. +Verification is control flow, not decoration: the verdict picks the edge. + +### Per rung + +**Deterministic step.** Executed by the `relayflowd` binary (spawn command, +capture stdout/exit code). Output = `{exit_code, stdout_tail}`. Verification +v0: `exit_code == 0` plus optional `output_contains`. Gate-1 deterministic +steps are pure (the hello ladder), so a dead attempt simply retries; no pins. + +**llm step.** No workspace, output is a value. The kernel never calls a +model: it dispatches the step to an attached SDK worker (§5), which makes +the call and returns `{output, usage}` via `step.complete`. The kernel then +runs the verification gate in-process — v0 gates are deterministic +(`output_contains`, `json_schema`) so verification is kernel-side and +replayable. Fail ⇒ `step.completed{verification_failed, retry}` with +backoff+jitter, bounded by `max_iterations` — semantic retry, the rail that +makes a prompt reliable. Each iteration's `usage` is charged to that +attempt's `budget` field. + +**Agent step.** Dispatched to a worker like llm, plus Appendix A in full: +`step.attempt.started` pins declared workspace revisions and stream offsets +under the attempt's idempotency key; every writeback is a journaled +`effect.recorded` deduped by `(step_id, idempotency_key, surface_path)`; +`step.completed` pins end state, which defines the next step's start. Dead +attempt ⇒ recovery mode: `reset` restores pinned revisions and retries; +`inspect` retries inside the dirty workspace with the failed attempt's tail +injected; `manual` parks as `wait.human` with `diff_ref`. + +### Memoized resume + +`resume(run_id)` re-executes nothing that finished. Algorithm: + +1. Open the run file; read the last `segments` row; scan entries of the + current segment only (segment 1 starts from `run.spawned`, later segments + from `epoch.summary`, which carries `steps_done` outputs forward). +2. Fold entries into a `RunState`: for each step, the latest + `step.completed` with `disposition=step_done` makes it `Done` — its + `output` is injected as fact, spending zero tokens and appending zero + entries. Replay is results, not code. +3. A `step.attempt.started` with no matching `step.completed` is a dead + attempt: append `step.completed{completionReason: crashed | + lease_expired, disposition per retry policy/recovery mode}` — the journal + explains both attempts (Appendix A rule 7c). +4. Open waits (`wait.*`/`sleep.until` without `wait.completed`) re-arm + against the real clock and event router; elapsed timers fire immediately. +5. Stream lengths and consumer offsets rebuild from `epoch.summary` + + subsequent `stream.appended` / pins — in-flight coordination survives + `kill -9` like all other state. +6. Scheduling continues. Invariant (gate 1 done-when): total run spend == + Σ(budget of exactly one `success` completion per step), checkable because + every token is journaled on exactly one attempt entry (decision #10). + +--- + +## 4. Crate layout — `kernel/` cargo workspace + +``` +kernel/ +├── Cargo.toml # [workspace] members = core, journal, binary +├── DESIGN.md # this file +├── relayflowd-core/ # PURE: no I/O, no wall clock, no SQLite, no sockets +│ └── src/ +│ ├── lib.rs +│ ├── spec.rs # RunSpec, StepSpec{Deterministic,Llm,Agent}, VerificationSpec +│ ├── entry.rs # §1 entry types (serde), completionReason enums +│ ├── state.rs # RunState fold: Vec → per-step states + memo table +│ ├── machine.rs # §3 transitions: (RunState, Input, now_ms) → Vec +│ ├── retry.rs # backoff + jitter; RNG seeded from idempotency_key (deterministic) +│ ├── verify.rs # v0 gates: exit_code, output_contains, json_schema +│ ├── clock.rs # trait Clock { fn now_ms(&self) -> i64 }; SimClock +│ └── journal.rs # trait Journal { append, scan_segment, current_segment, … } — no impl +├── relayflowd-journal/ # SQLite impl of the Journal trait +│ └── src/ +│ ├── lib.rs # SqliteJournal: open/create per-run file, §2 schema +│ ├── append.rs # single-transaction append, fsync, Err ⇒ fail the step +│ ├── segment.rs # rollover: segment.closed + segments row + epoch.summary +│ └── registry.rs # relayflowd.sqlite3 run index (rebuildable) +└── relayflowd/ # the binary + └── src/ + ├── main.rs # CLI: run | resume | serve + ├── engine.rs # drives machine.rs Actions against journal + executors + ├── exec_det.rs # deterministic steps: spawn, capture, timeout + ├── server.rs # unix socket, protocol v0 (§5), worker dispatch + └── clock.rs # WallClock impl of core's Clock trait +``` + +Core is sans-I/O: `machine.rs` returns `Action`s (`Append(Entry)`, +`Dispatch{step, worker_class}`, `ExecDeterministic{step}`, `ArmTimer{at}`, +`CompleteRun{reason}`) and the binary interprets them. Everything in §3 — +including crash-resume and the exact-budget invariant — is testable in +`relayflowd-core` alone with `SimClock` and an in-memory `Journal`. The +crash-injection tests against the real binary + SQLite file (kill between +and during steps, resume, exactly-once effects) live in `relayflowd/tests/` +and are the gate. + +Dependencies point one way: `relayflowd → {core, journal}`, +`journal → core`. Nothing in core names SQLite, tokio, or a socket. + +--- + +## 5. Journal protocol v0 (SDK boundary) + +Transport: newline-delimited JSON over a unix socket at +`/relayflowd.sock`. Requests `{id, verb, params}`; responses +`{id, ok: true, result}` or `{id, ok: false, error: {code, message}}`; +server-pushed events `{event, data}` (no `id`). Any verb whose journal +append fails returns `error{code: "journal_write_failed"}` and the affected +step fails — the protocol is fail-closed like everything behind it. + +Minimal verb set for gate 1: + +| verb | params → result | purpose | +|---|---|---| +| `hello` | `{protocol: 0, client}` → `{protocol: 0, server}` | handshake; version mismatch is a hard error | +| `run.start` | `{spec}` → `{run_id}` | validate spec (zero-agent flows are legal), create run file, append `run.spawned`, begin scheduling | +| `run.resume` | `{run_id}` → `{run_id, state}` | §3 memoized resume | +| `run.get` | `{run_id}` → `{status, steps, budget}` | snapshot for legibility | +| `run.watch` | `{run_id}` → stream of `{event: "entry", data: Entry}` | every appended entry, pushed | +| `worker.attach` | `{worker_id, step_types: ["llm","agent"]}` → `{}` | connection becomes a worker; receives `step.dispatch` events `{run_id, step_id, attempt, step_type, spec, idempotency_key, pins, lease_deadline_ms}` | +| `step.heartbeat` | `{run_id, step_id, attempt, lease_id}` → `{lease_deadline_ms}` | renew the lease; the one lease primitive | +| `step.complete` | `{run_id, step_id, attempt, idempotency_key, completionReason, output, usage, end_pins}` → `{}` | completes a dispatched step — **also the out-of-band path**: any worker holding the idempotency key may call it, journaled with the same discipline; kernel then runs verification and decides the edge | +| `event.emit` | `{run_id, event_key, payload}` → `{matched: n}` | satisfies `wait.event`; a human response arrives here too, closing `wait.human` with `completionReason: human_responded` | +| `stream.append` | `{run_id, stream, message}` → `{offset}` | durable channel write; journals `stream.appended` | +| `stream.read` | `{run_id, stream, from_offset, limit}` → `{messages, next_offset}` | at-least-once replayable read; committing the consumer offset happens via the reader's step pins, not a verb | +| `journal.read` | `{run_id, from_seq, limit}` → `{entries}` | raw journal access — replay, audit, the report step | + +Not in v0 (deliberately): triggers/subscriptions (gate 2), persona anything +(gate 2), memory verbs (gate 5), mounts as a protocol concern (gate 6 — +gate 1 agent-step pins take revision ids as opaque strings from the worker), +placement (gate 7), identity/credential resolution (gate 8 — `worker_id` +and `agent_identity` are plain strings for now). + +--- + +## Gate-1 acceptance mapping + +- Hello ladder (a): `run.start` with a spec of only deterministic steps — + legal, runs in the binary alone. +- Ladder (b): + one `llm` step with an `output_contains` gate — dispatch, + semantic retry, memoized value. +- Ladder (c): + one `agent` step — pins, `reset` recovery, effect dedupe. +- `kill -9` at every boundary and mid-step, then `run.resume`: completed + steps replay as results; budget equals one execution of each step; the + journal explains every attempt via `completionReason`. diff --git a/kernel/relayflowd-core/Cargo.toml b/kernel/relayflowd-core/Cargo.toml new file mode 100644 index 00000000..00a2fdd0 --- /dev/null +++ b/kernel/relayflowd-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "relayflowd-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +jsonschema.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +ulid.workspace = true + diff --git a/kernel/relayflowd-core/src/clock.rs b/kernel/relayflowd-core/src/clock.rs new file mode 100644 index 00000000..08dbc710 --- /dev/null +++ b/kernel/relayflowd-core/src/clock.rs @@ -0,0 +1,49 @@ +use std::cell::Cell; + +/// Time source supplied to kernel decisions. +pub trait Clock { + fn now_ms(&self) -> i64; +} + +/// Deterministic clock for state-machine tests and simulations. +#[derive(Debug)] +pub struct SimClock { + now_ms: Cell, +} + +impl SimClock { + pub fn new(now_ms: i64) -> Self { + Self { + now_ms: Cell::new(now_ms), + } + } + + pub fn set(&self, now_ms: i64) { + self.now_ms.set(now_ms); + } + + pub fn advance(&self, duration_ms: i64) { + assert!(duration_ms >= 0, "simulated time cannot move backwards"); + self.now_ms.set(self.now_ms.get() + duration_ms); + } +} + +impl Clock for SimClock { + fn now_ms(&self) -> i64 { + self.now_ms.get() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simulated_clock_is_explicitly_advanced() { + let clock = SimClock::new(10); + clock.advance(25); + assert_eq!(clock.now_ms(), 35); + clock.set(4); + assert_eq!(clock.now_ms(), 4); + } +} diff --git a/kernel/relayflowd-core/src/entry.rs b/kernel/relayflowd-core/src/entry.rs new file mode 100644 index 00000000..3170f75c --- /dev/null +++ b/kernel/relayflowd-core/src/entry.rs @@ -0,0 +1,363 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::spec::{RecoveryMode, StepType}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum EntryType { + #[serde(rename = "run.spawned")] + RunSpawned, + #[serde(rename = "step.attempt.started")] + StepAttemptStarted, + #[serde(rename = "step.completed")] + StepCompleted, + #[serde(rename = "wait.event")] + WaitEvent, + #[serde(rename = "wait.human")] + WaitHuman, + #[serde(rename = "sleep.until")] + SleepUntil, + #[serde(rename = "wait.completed")] + WaitCompleted, + #[serde(rename = "stream.appended")] + StreamAppended, + #[serde(rename = "effect.recorded")] + EffectRecorded, + #[serde(rename = "epoch.summary")] + EpochSummary, + #[serde(rename = "segment.closed")] + SegmentClosed, + #[serde(rename = "run.completed")] + RunCompleted, +} + +impl EntryType { + pub fn as_str(self) -> &'static str { + match self { + Self::RunSpawned => "run.spawned", + Self::StepAttemptStarted => "step.attempt.started", + Self::StepCompleted => "step.completed", + Self::WaitEvent => "wait.event", + Self::WaitHuman => "wait.human", + Self::SleepUntil => "sleep.until", + Self::WaitCompleted => "wait.completed", + Self::StreamAppended => "stream.appended", + Self::EffectRecorded => "effect.recorded", + Self::EpochSummary => "epoch.summary", + Self::SegmentClosed => "segment.closed", + Self::RunCompleted => "run.completed", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "run.spawned" => Self::RunSpawned, + "step.attempt.started" => Self::StepAttemptStarted, + "step.completed" => Self::StepCompleted, + "wait.event" => Self::WaitEvent, + "wait.human" => Self::WaitHuman, + "sleep.until" => Self::SleepUntil, + "wait.completed" => Self::WaitCompleted, + "stream.appended" => Self::StreamAppended, + "effect.recorded" => Self::EffectRecorded, + "epoch.summary" => Self::EpochSummary, + "segment.closed" => Self::SegmentClosed, + "run.completed" => Self::RunCompleted, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct JournalEntry { + /// Zero before persistence; the journal assigns a positive sequence. + pub seq: i64, + pub segment_id: i64, + pub entry_type: EntryType, + pub run_id: String, + pub step_id: Option, + pub attempt: Option, + pub at_ms: i64, + pub payload: Value, +} + +impl JournalEntry { + pub fn new( + entry_type: EntryType, + run_id: impl Into, + step_id: Option, + attempt: Option, + at_ms: i64, + payload: T, + ) -> Self { + Self { + seq: 0, + segment_id: 0, + entry_type, + run_id: run_id.into(), + step_id, + attempt, + at_ms, + payload: serde_json::to_value(payload).expect("journal payload must serialize"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunSpawnedPayload { + pub spec: Value, + pub spec_hash: String, + pub parent_run_id: Option, + pub journal_version: u32, + pub created_by: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AttemptStartedPayload { + pub step_type: StepType, + pub idempotency_key: String, + pub lease_id: String, + pub lease_deadline_ms: i64, + pub executor: String, + pub recovery_mode: Option, + pub pins: Pins, + pub max_iterations: u32, +} + +/// Runtime pins journaled per attempt (RFC Appendix A rules 2 and 6): the +/// revision id of each declared workspace surface and the offset of each +/// declared stream. Pins are journal facts, never spec fields — the spec only +/// *declares* surfaces (rule 1). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Pins { + #[serde(default)] + pub workspace: Vec, + #[serde(default)] + pub streams: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspacePin { + pub surface: String, + pub revision_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StreamPin { + pub stream: String, + pub read_offset: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompletionReason { + Success, + VerificationFailed, + RetriesExhausted, + LeaseExpired, + Crashed, + Timeout, + WorkerError, + BudgetExceeded, + Canceled, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Disposition { + StepDone, + Retry, + Park, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StepCompletedPayload { + #[serde(rename = "completionReason")] + pub completion_reason: CompletionReason, + pub disposition: Disposition, + pub output: Value, + pub verification: Option, + pub end_pins: Option, + #[serde(default)] + pub effects: Vec, + #[serde(default)] + pub budget: Budget, + pub completed_by: String, + pub next_attempt_at_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationRecord { + pub gate: String, + pub verdict: VerificationVerdict, + pub detail: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VerificationVerdict { + Pass, + Fail, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EffectRef { + pub surface_path: String, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Budget { + #[serde(default)] + pub tokens_in: u64, + #[serde(default)] + pub tokens_out: u64, + #[serde(default = "zero_dollars")] + pub dollars: String, +} + +impl Default for Budget { + fn default() -> Self { + Self { + tokens_in: 0, + tokens_out: 0, + dollars: zero_dollars(), + } + } +} + +fn zero_dollars() -> String { + "0".to_owned() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SleepUntilPayload { + pub wait_id: String, + pub wake_at_ms: i64, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WaitEventPayload { + pub wait_id: String, + pub event_key: String, + pub timeout_at_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WaitHumanPayload { + pub wait_id: String, + pub prompt: String, + pub requested_of: String, + pub options: Option>, + pub timeout_at_ms: Option, + pub diff_ref: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WaitCompletionReason { + EventReceived, + HumanResponded, + TimerFired, + Timeout, + Canceled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WaitCompletedPayload { + pub wait_id: String, + #[serde(rename = "completionReason")] + pub completion_reason: WaitCompletionReason, + pub result: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StreamAppendedPayload { + pub stream: String, + pub offset: u64, + pub producer: String, + pub message: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EffectRecordedPayload { + pub surface_path: String, + pub idempotency_key: String, + pub revision_before: String, + pub revision_after: String, + pub agent_identity: String, + pub deduped: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EpochSummaryPayload { + pub epoch: i64, + pub prev_segment_id: i64, + pub journal_version: u32, + #[serde(default)] + pub steps_done: BTreeMap, + #[serde(default)] + pub steps_open: BTreeMap, + #[serde(default)] + pub open_waits: BTreeMap, + #[serde(default)] + pub stream_state: BTreeMap, + #[serde(default)] + pub pinned_revisions: BTreeMap, + #[serde(default)] + pub budget_spent: Budget, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StepDoneSummary { + #[serde(rename = "completionReason")] + pub completion_reason: CompletionReason, + pub output: Value, +} + +/// An unfinished step carried across an epoch boundary. Each state uses its +/// own field: `running` carries the lease deadline and idempotency key, +/// `backoff` carries the wake time — no field is overloaded across states. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StepOpenSummary { + pub attempt: u32, + pub state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lease_deadline_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wake_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct StreamSummary { + pub length: u64, + #[serde(default)] + pub consumers: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SegmentClosedPayload { + pub next_segment_id: i64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunCompletionReason { + Success, + StepFailed, + Canceled, + BudgetExceeded, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunCompletedPayload { + #[serde(rename = "completionReason")] + pub completion_reason: RunCompletionReason, + pub failed_step_id: Option, + pub budget_total: Budget, +} diff --git a/kernel/relayflowd-core/src/journal.rs b/kernel/relayflowd-core/src/journal.rs new file mode 100644 index 00000000..7152e53d --- /dev/null +++ b/kernel/relayflowd-core/src/journal.rs @@ -0,0 +1,145 @@ +use std::collections::BTreeMap; + +use thiserror::Error; + +use crate::entry::{EntryType, EpochSummaryPayload, JournalEntry, SegmentClosedPayload}; + +pub trait Journal { + fn append(&mut self, entry: &JournalEntry) -> Result; + fn scan_segment(&self, segment_id: i64) -> Result, JournalError>; + fn current_segment(&self) -> Result; + fn rollover( + &mut self, + summary: EpochSummaryPayload, + at_ms: i64, + ) -> Result, JournalError>; +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[error("journal operation failed: {0}")] +pub struct JournalError(pub String); + +/// In-memory protocol implementation for pure state-machine tests. +#[derive(Debug, Clone)] +pub struct MemoryJournal { + run_id: String, + current_segment: i64, + next_seq: i64, + segments: BTreeMap>, +} + +impl MemoryJournal { + pub fn new(run_id: impl Into) -> Self { + Self { + run_id: run_id.into(), + current_segment: 1, + next_seq: 1, + segments: BTreeMap::from([(1, Vec::new())]), + } + } +} + +impl Journal for MemoryJournal { + fn append(&mut self, entry: &JournalEntry) -> Result { + if entry.run_id != self.run_id { + return Err(JournalError("run id mismatch".to_owned())); + } + if entry.segment_id != 0 && entry.segment_id != self.current_segment { + return Err(JournalError("entry targets a closed segment".to_owned())); + } + let mut persisted = entry.clone(); + persisted.seq = self.next_seq; + persisted.segment_id = self.current_segment; + self.next_seq += 1; + self.segments + .get_mut(&self.current_segment) + .expect("current in-memory segment") + .push(persisted.clone()); + Ok(persisted) + } + + fn scan_segment(&self, segment_id: i64) -> Result, JournalError> { + self.segments + .get(&segment_id) + .cloned() + .ok_or_else(|| JournalError(format!("unknown segment {segment_id}"))) + } + + fn current_segment(&self) -> Result { + Ok(self.current_segment) + } + + fn rollover( + &mut self, + mut summary: EpochSummaryPayload, + at_ms: i64, + ) -> Result, JournalError> { + let next_segment = self.current_segment + 1; + summary.epoch = next_segment; + summary.prev_segment_id = self.current_segment; + let closed = self.append(&JournalEntry::new( + EntryType::SegmentClosed, + self.run_id.clone(), + None, + None, + at_ms, + SegmentClosedPayload { + next_segment_id: next_segment, + }, + ))?; + self.current_segment = next_segment; + self.segments.insert(next_segment, Vec::new()); + let opened = self.append(&JournalEntry::new( + EntryType::EpochSummary, + self.run_id.clone(), + None, + None, + at_ms, + summary, + ))?; + Ok(vec![closed, opened]) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::JOURNAL_VERSION; + use crate::entry::Budget; + + #[test] + fn memory_journal_assigns_sequences_and_rolls_epochs() { + let mut journal = MemoryJournal::new("run"); + let first = journal + .append(&JournalEntry::new( + EntryType::RunSpawned, + "run", + None, + None, + 0, + serde_json::json!({}), + )) + .unwrap(); + assert_eq!((first.seq, first.segment_id), (1, 1)); + let entries = journal + .rollover( + EpochSummaryPayload { + epoch: 0, + prev_segment_id: 0, + journal_version: JOURNAL_VERSION, + steps_done: BTreeMap::new(), + steps_open: BTreeMap::new(), + open_waits: BTreeMap::new(), + stream_state: BTreeMap::new(), + pinned_revisions: BTreeMap::new(), + budget_spent: Budget::default(), + }, + 10, + ) + .unwrap(); + assert_eq!(entries[0].entry_type, EntryType::SegmentClosed); + assert_eq!((entries[1].seq, entries[1].segment_id), (3, 2)); + } +} diff --git a/kernel/relayflowd-core/src/lib.rs b/kernel/relayflowd-core/src/lib.rs new file mode 100644 index 00000000..9190eb96 --- /dev/null +++ b/kernel/relayflowd-core/src/lib.rs @@ -0,0 +1,24 @@ +//! Pure Relayflow execution semantics. +//! +//! This crate deliberately contains no filesystem, process, network, SQLite, +//! or wall-clock access. Callers persist [`JournalEntry`] actions before +//! interpreting any execution action. + +pub mod clock; +pub mod entry; +pub mod journal; +pub mod machine; +pub mod retry; +pub mod spec; +pub mod state; +pub mod verify; + +pub use clock::{Clock, SimClock}; +pub use entry::*; +pub use journal::{Journal, JournalError, MemoryJournal}; +pub use machine::{Action, AttemptResult, completion_actions, next_actions, recovery_actions}; +pub use spec::*; +pub use state::{RunState, StateError, StepRuntime, StepState}; + +pub const JOURNAL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 0; diff --git a/kernel/relayflowd-core/src/machine.rs b/kernel/relayflowd-core/src/machine.rs new file mode 100644 index 00000000..a52d2db3 --- /dev/null +++ b/kernel/relayflowd-core/src/machine.rs @@ -0,0 +1,393 @@ +use serde_json::Value; +use sha2::{Digest, Sha256}; +use ulid::Ulid; + +use crate::{ + entry::{ + AttemptStartedPayload, Budget, CompletionReason, Disposition, EffectRef, EntryType, + JournalEntry, Pins, RunCompletedPayload, RunCompletionReason, SleepUntilPayload, + StepCompletedPayload, WaitCompletedPayload, WaitCompletionReason, WaitHumanPayload, + }, + retry::backoff_delay_ms, + spec::{RecoveryMode, StepKind, StepSpec, StepType}, + state::{RunState, StepState}, + verify::verify, +}; + +const LEASE_DURATION_MS: i64 = 30_000; + +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + Append(JournalEntry), + ExecDeterministic { + step: StepSpec, + attempt: u32, + }, + Dispatch { + step: StepSpec, + attempt: u32, + worker_class: StepType, + }, + ArmTimer { + at_ms: i64, + }, + CompleteRun { + reason: RunCompletionReason, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AttemptResult { + pub output: Value, + pub budget: Budget, + pub completed_by: String, + pub end_pins: Option, + pub effects: Vec, + /// Execution failures bypass verification but still follow retry policy. + pub failure_reason: Option, +} + +impl AttemptResult { + pub fn successful(output: Value, completed_by: impl Into) -> Self { + Self { + output, + budget: Budget::default(), + completed_by: completed_by.into(), + end_pins: None, + effects: Vec::new(), + failure_reason: None, + } + } +} + +pub fn next_actions(state: &RunState, now_ms: i64) -> Vec { + if state.completion.is_some() { + return Vec::new(); + } + if let Some(failed_step_id) = state.failed_step() { + return complete_run_actions( + state, + RunCompletionReason::StepFailed, + Some(failed_step_id.to_owned()), + now_ms, + ); + } + if state.all_steps_succeeded() { + return complete_run_actions(state, RunCompletionReason::Success, None, now_ms); + } + + for spec in &state.spec.steps { + let runtime = &state.steps[&spec.id]; + match runtime.state { + StepState::Backoff { + attempt, + wake_at_ms, + } if wake_at_ms <= now_ms => { + return vec![Action::Append(JournalEntry::new( + EntryType::WaitCompleted, + state.run_id.clone(), + Some(spec.id.clone()), + Some(attempt), + now_ms, + WaitCompletedPayload { + wait_id: retry_wait_id(&state.run_id, &spec.id, attempt, wake_at_ms), + completion_reason: WaitCompletionReason::TimerFired, + result: Value::Null, + }, + ))]; + } + StepState::Backoff { wake_at_ms, .. } => { + return vec![Action::ArmTimer { at_ms: wake_at_ms }]; + } + StepState::Runnable => return start_actions(state, spec, runtime.attempts + 1, now_ms), + _ => {} + } + } + Vec::new() +} + +fn start_actions(state: &RunState, step: &StepSpec, attempt: u32, now_ms: i64) -> Vec { + let key = idempotency_key(&state.run_id, &step.id); + let recovery_mode = match &step.kind { + StepKind::Agent { recovery_mode, .. } => Some(*recovery_mode), + _ => None, + }; + // Pins are runtime facts (Appendix A rule 2): the revision/offset of each + // declared surface at attempt start. Gate-1 deterministic steps are pure + // (no pins), and no agent worker is attached yet to observe revisions. + let pins = Pins::default(); + let executor = if step.step_type() == StepType::Deterministic { + "kernel" + } else { + "unassigned" + }; + let started = JournalEntry::new( + EntryType::StepAttemptStarted, + state.run_id.clone(), + Some(step.id.clone()), + Some(attempt), + now_ms, + AttemptStartedPayload { + step_type: step.step_type(), + idempotency_key: key, + lease_id: deterministic_ulid(&state.run_id, &step.id, attempt, now_ms, "lease"), + lease_deadline_ms: now_ms.saturating_add(LEASE_DURATION_MS), + executor: executor.to_owned(), + recovery_mode, + pins, + max_iterations: step.max_iterations, + }, + ); + let execute = match step.step_type() { + StepType::Deterministic => Action::ExecDeterministic { + step: step.clone(), + attempt, + }, + worker_class => Action::Dispatch { + step: step.clone(), + attempt, + worker_class, + }, + }; + vec![Action::Append(started), execute] +} + +/// `semantic_executions` is the number of *completed* semantic executions +/// before this attempt (`StepRuntime::semantic_executions`). The attempt being +/// completed here ran to a result, so it is the `semantic_executions + 1`-th +/// semantic execution; `max_iterations` bounds that count, never the raw +/// attempt number — a crashed attempt must not consume iteration allowance. +pub fn completion_actions( + run_id: &str, + step: &StepSpec, + attempt: u32, + semantic_executions: u32, + result: AttemptResult, + now_ms: i64, +) -> Vec { + let verification = result + .failure_reason + .is_none() + .then(|| verify(step, &result.output)); + let verified = verification + .as_ref() + .is_some_and(|record| record.verdict == crate::entry::VerificationVerdict::Pass); + let may_retry = semantic_executions.saturating_add(1) < step.max_iterations; + let (reason, disposition, output, next_attempt_at_ms) = if verified { + ( + CompletionReason::Success, + Disposition::StepDone, + result.output, + None, + ) + } else if may_retry { + let key = idempotency_key(run_id, &step.id); + let delay = backoff_delay_ms(&step.retry, &key, attempt); + ( + result + .failure_reason + .unwrap_or(CompletionReason::VerificationFailed), + Disposition::Retry, + Value::Null, + Some(now_ms.saturating_add(delay as i64)), + ) + } else { + ( + result + .failure_reason + .unwrap_or(CompletionReason::RetriesExhausted), + Disposition::StepDone, + Value::Null, + None, + ) + }; + + let completed = JournalEntry::new( + EntryType::StepCompleted, + run_id, + Some(step.id.clone()), + Some(attempt), + now_ms, + StepCompletedPayload { + completion_reason: reason, + disposition, + output, + verification, + end_pins: result.end_pins, + effects: result.effects, + budget: result.budget, + completed_by: result.completed_by, + next_attempt_at_ms, + }, + ); + let mut actions = vec![Action::Append(completed)]; + if let Some(wake_at_ms) = next_attempt_at_ms { + actions.push(Action::Append(JournalEntry::new( + EntryType::SleepUntil, + run_id, + Some(step.id.clone()), + Some(attempt), + now_ms, + SleepUntilPayload { + wait_id: retry_wait_id(run_id, &step.id, attempt, wake_at_ms), + wake_at_ms, + reason: "retry_backoff".to_owned(), + }, + ))); + actions.push(Action::ArmTimer { at_ms: wake_at_ms }); + } + actions +} + +pub fn recovery_actions(state: &RunState, now_ms: i64) -> Vec { + let mut actions = Vec::new(); + for spec in &state.spec.steps { + let runtime = &state.steps[&spec.id]; + let StepState::Running { + attempt, + lease_deadline_ms, + .. + } = runtime.state + else { + continue; + }; + let reason = if now_ms >= lease_deadline_ms { + CompletionReason::LeaseExpired + } else { + CompletionReason::Crashed + }; + let manual = matches!( + spec.kind, + StepKind::Agent { + recovery_mode: RecoveryMode::Manual, + .. + } + ); + // A dead attempt produced no result, so it consumes no semantic + // iteration at all: a replacement is permitted as long as completed + // semantic executions have not exhausted `max_iterations`. + let may_retry = runtime.semantic_executions < spec.max_iterations; + let next_attempt_at_ms = (may_retry && !manual).then(|| { + now_ms.saturating_add(backoff_delay_ms( + &spec.retry, + &idempotency_key(&state.run_id, &spec.id), + attempt, + ) as i64) + }); + actions.push(Action::Append(JournalEntry::new( + EntryType::StepCompleted, + state.run_id.clone(), + Some(spec.id.clone()), + Some(attempt), + now_ms, + StepCompletedPayload { + completion_reason: reason, + disposition: if manual { + Disposition::Park + } else if may_retry { + Disposition::Retry + } else { + Disposition::StepDone + }, + output: Value::Null, + verification: None, + end_pins: None, + effects: vec![], + budget: Budget::default(), + completed_by: "kernel".to_owned(), + next_attempt_at_ms, + }, + ))); + if manual { + actions.push(Action::Append(JournalEntry::new( + EntryType::WaitHuman, + state.run_id.clone(), + Some(spec.id.clone()), + Some(attempt), + now_ms, + WaitHumanPayload { + wait_id: deterministic_ulid(&state.run_id, &spec.id, attempt, now_ms, "manual"), + prompt: "An agent attempt crashed with a dirty workspace".to_owned(), + requested_of: "run-owner".to_owned(), + options: Some(vec!["retry".to_owned(), "cancel".to_owned()]), + timeout_at_ms: None, + diff_ref: Some("pinned-revision..current".to_owned()), + }, + ))); + } else if let Some(wake_at_ms) = next_attempt_at_ms { + actions.push(Action::Append(JournalEntry::new( + EntryType::SleepUntil, + state.run_id.clone(), + Some(spec.id.clone()), + Some(attempt), + now_ms, + SleepUntilPayload { + wait_id: retry_wait_id(&state.run_id, &spec.id, attempt, wake_at_ms), + wake_at_ms, + reason: "retry_backoff".to_owned(), + }, + ))); + } + } + actions +} + +pub fn idempotency_key(run_id: &str, step_id: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(run_id.as_bytes()); + hasher.update(step_id.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn complete_run_actions( + state: &RunState, + reason: RunCompletionReason, + failed_step_id: Option, + now_ms: i64, +) -> Vec { + vec![ + Action::Append(JournalEntry::new( + EntryType::RunCompleted, + state.run_id.clone(), + None, + None, + now_ms, + RunCompletedPayload { + completion_reason: reason, + failed_step_id, + budget_total: state.budget.clone(), + }, + )), + Action::CompleteRun { reason }, + ] +} + +fn retry_wait_id(run_id: &str, step_id: &str, attempt: u32, at_ms: i64) -> String { + deterministic_ulid(run_id, step_id, attempt, at_ms, "retry") +} + +fn deterministic_ulid( + run_id: &str, + step_id: &str, + attempt: u32, + at_ms: i64, + purpose: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(run_id.as_bytes()); + hasher.update(step_id.as_bytes()); + hasher.update(attempt.to_be_bytes()); + hasher.update(purpose.as_bytes()); + let hash = hasher.finalize(); + let random = hash[..10] + .iter() + .fold(0_u128, |value, byte| (value << 8) | u128::from(*byte)); + Ulid::from_parts(at_ms.max(0) as u64, random).to_string() +} + +#[cfg(test)] +mod tests; diff --git a/kernel/relayflowd-core/src/machine/tests.rs b/kernel/relayflowd-core/src/machine/tests.rs new file mode 100644 index 00000000..23924d39 --- /dev/null +++ b/kernel/relayflowd-core/src/machine/tests.rs @@ -0,0 +1,141 @@ +use serde_json::json; + +use super::*; +use crate::{entry::AttemptStartedPayload, state::RunState}; + +fn retrying_spec() -> crate::RunSpec { + serde_json::from_value(json!({ + "steps": [{ + "id": "hello", + "type": "deterministic", + "command": "printf hello", + "max_iterations": 2, + "retry": {"initial_backoff_ms": 100, "max_backoff_ms": 100, "multiplier": 2, "jitter_percent": 0}, + "verification": {"output_contains": "hello"} + }] + })) + .unwrap() +} + +#[test] +fn machine_starts_runnable_step_with_stable_effect_key() { + let state = RunState::fold("run", retrying_spec(), &[]).unwrap(); + let actions = next_actions(&state, 10); + let Action::Append(started) = &actions[0] else { + panic!("first action must persist the lease") + }; + let payload: AttemptStartedPayload = serde_json::from_value(started.payload.clone()).unwrap(); + assert_eq!(payload.idempotency_key, idempotency_key("run", "hello")); + assert!(matches!( + actions[1], + Action::ExecDeterministic { attempt: 1, .. } + )); +} + +#[test] +fn verification_failure_schedules_a_durable_retry() { + let spec = retrying_spec(); + let actions = completion_actions( + "run", + &spec.steps[0], + 1, + 0, + AttemptResult::successful(json!({"exit_code": 0, "stdout_tail": "wrong"}), "kernel"), + 1_000, + ); + let Action::Append(completed) = &actions[0] else { + panic!() + }; + let payload: StepCompletedPayload = serde_json::from_value(completed.payload.clone()).unwrap(); + assert_eq!( + payload.completion_reason, + CompletionReason::VerificationFailed + ); + assert_eq!(payload.disposition, Disposition::Retry); + assert_eq!(payload.next_attempt_at_ms, Some(1_100)); + assert!(matches!( + actions[1], + Action::Append(JournalEntry { + entry_type: EntryType::SleepUntil, + .. + }) + )); +} + +#[test] +fn successful_memo_is_never_scheduled_again() { + let spec = retrying_spec(); + let Action::Append(completed) = completion_actions( + "run", + &spec.steps[0], + 1, + 0, + AttemptResult::successful(json!({"exit_code": 0, "stdout_tail": "hello"}), "kernel"), + 1_000, + ) + .remove(0) else { + panic!() + }; + let state = RunState::fold("run", spec, &[completed]).unwrap(); + let actions = next_actions(&state, 2_000); + assert!(matches!( + actions[0], + Action::Append(JournalEntry { + entry_type: EntryType::RunCompleted, + .. + }) + )); + assert!( + !actions + .iter() + .any(|action| matches!(action, Action::ExecDeterministic { .. })) + ); +} + +#[test] +fn crashed_attempt_does_not_consume_an_iteration() { + // max_iterations 2: crash attempt 1, verification-fail the replacement + // (attempt 2) — one semantic iteration must remain, so the step retries + // instead of exhausting after a single semantic result. + let spec = retrying_spec(); + let fresh = RunState::fold("run", spec.clone(), &[]).unwrap(); + let Action::Append(started) = next_actions(&fresh, 10).remove(0) else { + panic!("attempt 1 must journal its lease"); + }; + + // kill -9 between steps: attempt 1 is Running with no result. Recovery + // must record the dead attempt as a retry, not a consumed iteration. + let state = RunState::fold("run", spec.clone(), &[started.clone()]).unwrap(); + assert_eq!(state.steps["hello"].semantic_executions, 0); + let recovery = recovery_actions(&state, 1_000); + let Action::Append(crashed) = &recovery[0] else { + panic!("recovery must journal the dead attempt"); + }; + let crash_payload: StepCompletedPayload = + serde_json::from_value(crashed.payload.clone()).unwrap(); + assert_eq!(crash_payload.disposition, Disposition::Retry); + + // The replacement (attempt 2) completes but fails verification. Raw + // attempt number 2 == max_iterations, yet only one semantic execution has + // happened — the step must still have an iteration remaining. + let state = RunState::fold("run", spec.clone(), &[started, crashed.clone()]).unwrap(); + assert_eq!(state.steps["hello"].semantic_executions, 0); + let actions = completion_actions( + "run", + &spec.steps[0], + 2, + state.steps["hello"].semantic_executions, + AttemptResult::successful(json!({"exit_code": 0, "stdout_tail": "wrong"}), "kernel"), + 2_000, + ); + let Action::Append(completed) = &actions[0] else { + panic!("completion must journal"); + }; + let payload: StepCompletedPayload = serde_json::from_value(completed.payload.clone()).unwrap(); + assert_eq!( + payload.completion_reason, + CompletionReason::VerificationFailed + ); + assert_eq!(payload.disposition, Disposition::Retry); + assert!(payload.next_attempt_at_ms.is_some()); +} diff --git a/kernel/relayflowd-core/src/retry.rs b/kernel/relayflowd-core/src/retry.rs new file mode 100644 index 00000000..ed8550fa --- /dev/null +++ b/kernel/relayflowd-core/src/retry.rs @@ -0,0 +1,52 @@ +use sha2::{Digest, Sha256}; + +use crate::spec::RetryPolicy; + +/// Deterministic exponential backoff with symmetric bounded jitter. +/// +/// `completed_attempt` is one-based. The stable idempotency key makes the +/// same attempt choose the same wake-up delay after every resume. +pub fn backoff_delay_ms( + policy: &RetryPolicy, + idempotency_key: &str, + completed_attempt: u32, +) -> u64 { + let exponent = completed_attempt.saturating_sub(1); + let base = policy + .initial_backoff_ms + .saturating_mul((policy.multiplier as u64).saturating_pow(exponent)) + .min(policy.max_backoff_ms); + let jitter_range = base.saturating_mul(policy.jitter_percent as u64) / 100; + if jitter_range == 0 { + return base; + } + + let mut hasher = Sha256::new(); + hasher.update(idempotency_key.as_bytes()); + hasher.update(completed_attempt.to_be_bytes()); + let digest = hasher.finalize(); + let sample = u64::from_be_bytes(digest[..8].try_into().expect("eight hash bytes")); + let width = jitter_range.saturating_mul(2).saturating_add(1); + let offset = (sample % width) as i128 - jitter_range as i128; + (base as i128 + offset).max(0) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jitter_is_repeatable_and_bounded() { + let policy = RetryPolicy { + initial_backoff_ms: 1_000, + max_backoff_ms: 5_000, + multiplier: 2, + jitter_percent: 20, + }; + let first = backoff_delay_ms(&policy, "stable", 3); + assert_eq!(first, backoff_delay_ms(&policy, "stable", 3)); + assert!((3_200..=4_800).contains(&first)); + let capped = backoff_delay_ms(&policy, "stable", 8); + assert!((4_000..=6_000).contains(&capped)); + } +} diff --git a/kernel/relayflowd-core/src/spec.rs b/kernel/relayflowd-core/src/spec.rs new file mode 100644 index 00000000..4b857611 --- /dev/null +++ b/kernel/relayflowd-core/src/spec.rs @@ -0,0 +1,397 @@ +//! Run specs — the composable unit (RFC-0001 settled decision #5), one dialect. +//! +//! This is the single spec shape at the SDK↔kernel boundary: snake_case keys, +//! semver `version`, flat v0 verification (`output_contains` / `json_schema`; +//! `exit_code == 0` is implicit for deterministic steps — kernel DESIGN.md §4). +//! The SDK compiler emits exactly this shape; parity is pinned bit-for-bit by +//! `tests/spec_parity.rs` against `testdata/hello-ladder.spec.canonical.json`. +//! +//! Parsing is fail-closed (AGENTS.md rule 4): [`RunSpec::parse`] rejects any +//! unknown field, so a misspelled `verification` key can never silently drop a +//! gate. Nested structs carry `deny_unknown_fields`; step objects (which use +//! `#[serde(flatten)]`, where serde cannot enforce it) are checked explicitly. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +/// The spec schema version this kernel reads and writes (semver, RFC §7). +pub const SPEC_VERSION: &str = "0.1.0"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RunSpec { + #[serde(default = "default_spec_version")] + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub steps: Vec, + /// Budget envelope (RFC settled decision #10). Carried and journaled from + /// gate 1; enforced when llm/agent dispatch lands — the deterministic rung + /// spends zero tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget: Option, +} + +fn default_spec_version() -> String { + SPEC_VERSION.to_owned() +} + +impl RunSpec { + /// Fail-closed parse: reject unknown fields everywhere before + /// deserializing. `#[serde(flatten)]` on [`StepSpec`] prevents serde-level + /// `deny_unknown_fields` for step objects, so their key sets are checked + /// here; every non-flattened struct denies unknown fields via serde. + pub fn parse(value: &Value) -> Result { + reject_unknown_step_fields(value)?; + serde_json::from_value(value.clone()).map_err(|error| SpecError::Malformed(error.to_string())) + } + + pub fn validate(&self) -> Result<(), SpecError> { + if self.version != SPEC_VERSION { + return Err(SpecError::UnsupportedVersion(self.version.clone())); + } + + let mut ids = BTreeSet::new(); + for step in &self.steps { + if step.id.trim().is_empty() { + return Err(SpecError::EmptyStepId); + } + if !ids.insert(step.id.clone()) { + return Err(SpecError::DuplicateStep(step.id.clone())); + } + if step.max_iterations == 0 { + return Err(SpecError::ZeroIterations(step.id.clone())); + } + step.retry.validate(&step.id)?; + } + + for step in &self.steps { + for dependency in &step.depends_on { + if dependency == &step.id { + return Err(SpecError::DependencyCycle(step.id.clone())); + } + if !ids.contains(dependency) { + return Err(SpecError::UnknownDependency { + step: step.id.clone(), + dependency: dependency.clone(), + }); + } + } + } + + let dependencies = self + .steps + .iter() + .map(|step| (step.id.as_str(), step.depends_on.as_slice())) + .collect::>(); + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + for id in &ids { + visit(id, &dependencies, &mut visiting, &mut visited)?; + } + Ok(()) + } + + pub fn step(&self, id: &str) -> Option<&StepSpec> { + self.steps.iter().find(|step| step.id == id) + } +} + +const STEP_COMMON_FIELDS: &[&str] = &[ + "id", + "type", + "depends_on", + "max_iterations", + "retry", + "verification", +]; +const STEP_DETERMINISTIC_FIELDS: &[&str] = &["command", "timeout_ms"]; +const STEP_LLM_FIELDS: &[&str] = &["prompt", "model"]; +const STEP_AGENT_FIELDS: &[&str] = &["instruction", "recovery_mode", "surfaces", "permissions"]; + +fn reject_unknown_step_fields(value: &Value) -> Result<(), SpecError> { + let Some(steps) = value.get("steps").and_then(Value::as_array) else { + return Ok(()); // shape errors surface from serde with their own message + }; + for (index, step) in steps.iter().enumerate() { + let Some(object) = step.as_object() else { + continue; + }; + let kind_fields = match object.get("type").and_then(Value::as_str) { + Some("deterministic") => STEP_DETERMINISTIC_FIELDS, + Some("llm") => STEP_LLM_FIELDS, + Some("agent") => STEP_AGENT_FIELDS, + // Missing/unknown type is rejected by serde's tagged-enum error. + _ => continue, + }; + for key in object.keys() { + if !STEP_COMMON_FIELDS.contains(&key.as_str()) && !kind_fields.contains(&key.as_str()) { + return Err(SpecError::UnknownField { + at: format!("steps[{index}]"), + field: key.clone(), + }); + } + } + } + Ok(()) +} + +fn visit<'a>( + id: &'a str, + dependencies: &BTreeMap<&'a str, &'a [String]>, + visiting: &mut BTreeSet<&'a str>, + visited: &mut BTreeSet<&'a str>, +) -> Result<(), SpecError> { + if visited.contains(id) { + return Ok(()); + } + if !visiting.insert(id) { + return Err(SpecError::DependencyCycle(id.to_owned())); + } + for dependency in dependencies.get(id).copied().unwrap_or_default() { + visit(dependency, dependencies, visiting, visited)?; + } + visiting.remove(id); + visited.insert(id); + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StepSpec { + pub id: String, + #[serde(default)] + pub depends_on: Vec, + #[serde(default = "default_max_iterations")] + pub max_iterations: u32, + #[serde(default)] + pub retry: RetryPolicy, + #[serde(default)] + pub verification: VerificationSpec, + #[serde(flatten)] + pub kind: StepKind, +} + +fn default_max_iterations() -> u32 { + 1 +} + +impl StepSpec { + pub fn step_type(&self) -> StepType { + match self.kind { + StepKind::Deterministic { .. } => StepType::Deterministic, + StepKind::Llm { .. } => StepType::Llm, + StepKind::Agent { .. } => StepType::Agent, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StepKind { + Deterministic { + command: CommandSpec, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout_ms: Option, + }, + Llm { + prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + }, + Agent { + instruction: String, + #[serde(default)] + recovery_mode: RecoveryMode, + /// Declared mutable surfaces (RFC Appendix A rule 1) — names only. + /// Revision/offset *pins* are runtime facts journaled per attempt + /// (Appendix A rule 2), never spec fields. + #[serde(default, skip_serializing_if = "AgentSurfaces::is_empty")] + surfaces: AgentSurfaces, + #[serde(default, skip_serializing_if = "Option::is_none")] + permissions: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum CommandSpec { + Shell(String), + Argv(Vec), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StepType { + Deterministic, + Llm, + Agent, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RecoveryMode { + #[default] + Reset, + Inspect, + Manual, +} + +/// Declared state surfaces for an agent step (RFC Appendix A rule 1): +/// workspace mounts/worktrees, writable streams, and external writeback paths. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AgentSurfaces { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub streams: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub external: Vec, +} + +impl AgentSurfaces { + pub fn is_empty(&self) -> bool { + self.workspace.is_empty() && self.streams.is_empty() && self.external.is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceSurface { + pub surface: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct StreamSurface { + pub stream: String, +} + +/// Permission model for an agent step (gate 8). Carried as data in gate 1; +/// enforcement lands with agent dispatch. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PermissionsSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_globs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_allowlist: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_preset: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AccessPreset { + Readonly, + Readwrite, +} + +/// Budget envelope: tokens are integers; money is a decimal string, never a +/// float (kernel DESIGN.md §1). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BudgetSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens_in: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens_out: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_dollars: Option, +} + +/// v0 verification gates (kernel DESIGN.md §4): `exit_code == 0` is implicit +/// for deterministic steps; these two are optional and combinable. Unknown +/// keys are a parse error — verification is control flow, and a dropped gate +/// is a fail-open bug, not a default. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct VerificationSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_contains: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub json_schema: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RetryPolicy { + #[serde(default = "default_initial_backoff_ms")] + pub initial_backoff_ms: u64, + #[serde(default = "default_max_backoff_ms")] + pub max_backoff_ms: u64, + #[serde(default = "default_multiplier")] + pub multiplier: u32, + #[serde(default = "default_jitter_percent")] + pub jitter_percent: u8, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + initial_backoff_ms: default_initial_backoff_ms(), + max_backoff_ms: default_max_backoff_ms(), + multiplier: default_multiplier(), + jitter_percent: default_jitter_percent(), + } + } +} + +impl RetryPolicy { + fn validate(&self, step_id: &str) -> Result<(), SpecError> { + if self.multiplier == 0 || self.jitter_percent > 100 { + return Err(SpecError::InvalidRetry(step_id.to_owned())); + } + if self.max_backoff_ms < self.initial_backoff_ms { + return Err(SpecError::InvalidRetry(step_id.to_owned())); + } + Ok(()) + } +} + +fn default_initial_backoff_ms() -> u64 { + 100 +} + +fn default_max_backoff_ms() -> u64 { + 60_000 +} + +fn default_multiplier() -> u32 { + 2 +} + +fn default_jitter_percent() -> u8 { + 20 +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SpecError { + #[error("unsupported run spec version {0} (this kernel reads {SPEC_VERSION})")] + UnsupportedVersion(String), + #[error("unknown field \"{field}\" at {at} — refusing to guess (fail closed)")] + UnknownField { at: String, field: String }, + #[error("malformed run spec: {0}")] + Malformed(String), + #[error("step id cannot be empty")] + EmptyStepId, + #[error("duplicate step id: {0}")] + DuplicateStep(String), + #[error("step {0} must allow at least one iteration")] + ZeroIterations(String), + #[error("step {step} depends on unknown step {dependency}")] + UnknownDependency { step: String, dependency: String }, + #[error("dependency cycle includes step {0}")] + DependencyCycle(String), + #[error("invalid retry policy for step {0}")] + InvalidRetry(String), +} + +#[cfg(test)] +mod tests; diff --git a/kernel/relayflowd-core/src/spec/tests.rs b/kernel/relayflowd-core/src/spec/tests.rs new file mode 100644 index 00000000..07314a55 --- /dev/null +++ b/kernel/relayflowd-core/src/spec/tests.rs @@ -0,0 +1,116 @@ +use serde_json::json; + +use super::*; + +#[test] +fn zero_agent_flow_is_valid() { + let spec = RunSpec::parse(&json!({ + "steps": [{"id": "hello", "type": "deterministic", "command": "printf hello"}] + })) + .unwrap(); + assert_eq!(spec.steps[0].max_iterations, 1); + assert!(spec.validate().is_ok()); +} + +#[test] +fn cycles_are_rejected() { + let spec = RunSpec::parse(&json!({ + "steps": [ + {"id": "a", "type": "deterministic", "command": "true", "depends_on": ["b"]}, + {"id": "b", "type": "deterministic", "command": "true", "depends_on": ["a"]} + ] + })) + .unwrap(); + assert!(matches!( + spec.validate(), + Err(SpecError::DependencyCycle(_)) + )); +} + +#[test] +fn a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate() { + // The fail-open refutation case: "output_contain" (typo) must never + // silently deserialize to an empty VerificationSpec. + let result = RunSpec::parse(&json!({ + "steps": [{ + "id": "hello", + "type": "deterministic", + "command": "printf hello", + "verification": {"output_contain": "hello"} + }] + })); + assert!( + matches!(result, Err(SpecError::Malformed(ref message)) if message.contains("output_contain")), + "{result:?}" + ); +} + +#[test] +fn a_misspelled_step_level_key_is_a_parse_error() { + // `#[serde(flatten)]` would silently eat "verifcation"; RunSpec::parse + // must not. + let result = RunSpec::parse(&json!({ + "steps": [{ + "id": "hello", + "type": "deterministic", + "command": "printf hello", + "verifcation": {"output_contains": "hello"} + }] + })); + assert_eq!( + result, + Err(SpecError::UnknownField { + at: "steps[0]".to_owned(), + field: "verifcation".to_owned(), + }) + ); +} + +#[test] +fn unknown_root_and_nested_fields_are_rejected() { + assert!(RunSpec::parse(&json!({"steps": [], "extra": true})).is_err()); + assert!( + RunSpec::parse(&json!({ + "steps": [{ + "id": "a", "type": "agent", "instruction": "do", + "surfaces": {"workspaces": [{"surface": "repo/"}]} + }] + })) + .is_err() + ); +} + +#[test] +fn spec_version_is_semver_and_gated() { + let spec = RunSpec::parse(&json!({ + "version": "9.9.9", + "steps": [{"id": "a", "type": "deterministic", "command": "true"}] + })) + .unwrap(); + assert_eq!( + spec.validate(), + Err(SpecError::UnsupportedVersion("9.9.9".to_owned())) + ); +} + +#[test] +fn the_full_ladder_parses_in_the_one_dialect() { + let spec = RunSpec::parse(&json!({ + "version": "0.1.0", + "name": "ladder", + "steps": [ + {"id": "a", "type": "deterministic", "command": "true", "timeout_ms": 5000}, + {"id": "b", "type": "llm", "prompt": "plan", "model": "claude-sonnet-5", + "depends_on": ["a"], "verification": {"json_schema": {"type": "object"}}}, + {"id": "c", "type": "agent", "instruction": "edit", "depends_on": ["b"], + "recovery_mode": "inspect", + "surfaces": {"workspace": [{"surface": "repo/"}], "streams": [{"stream": "results"}], + "external": ["pr://github/example"]}, + "permissions": {"access_preset": "readwrite", "file_globs": ["src/**"]}} + ], + "budget": {"max_tokens_out": 2000, "max_dollars": "1.50"} + })) + .unwrap(); + assert!(spec.validate().is_ok()); + assert_eq!(spec.steps[2].step_type(), StepType::Agent); +} diff --git a/kernel/relayflowd-core/src/state.rs b/kernel/relayflowd-core/src/state.rs new file mode 100644 index 00000000..2d188d24 --- /dev/null +++ b/kernel/relayflowd-core/src/state.rs @@ -0,0 +1,400 @@ +use std::collections::BTreeMap; + +use serde_json::Value; +use thiserror::Error; + +use crate::{ + entry::{ + Budget, CompletionReason, Disposition, EntryType, EpochSummaryPayload, JournalEntry, + RunCompletedPayload, RunCompletionReason, SleepUntilPayload, StepCompletedPayload, + WaitCompletedPayload, WaitCompletionReason, + }, + spec::RunSpec, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum StepState { + Pending, + Runnable, + Running { + attempt: u32, + lease_deadline_ms: i64, + idempotency_key: String, + }, + Backoff { + attempt: u32, + wake_at_ms: i64, + }, + Waiting { + wait_id: String, + }, + NeedsHuman { + wait_id: String, + }, + Done { + completion_reason: CompletionReason, + output: Value, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StepRuntime { + pub state: StepState, + /// Highest attempt number observed (crashed and completed alike). + pub attempts: u32, + /// Completed *semantic* executions — attempts that finished and produced + /// a verifiable result. Crashed/lease-expired attempts died without a + /// result, so they do not count; `max_iterations` bounds this counter, + /// never the raw attempt number. + pub semantic_executions: u32, +} + +#[derive(Debug, Clone)] +pub struct RunState { + pub run_id: String, + pub spec: RunSpec, + pub steps: BTreeMap, + pub memo: BTreeMap, + pub budget: Budget, + pub completion: Option, +} + +impl RunState { + pub fn fold( + run_id: impl Into, + spec: RunSpec, + entries: &[JournalEntry], + ) -> Result { + spec.validate()?; + let run_id = run_id.into(); + let mut state = Self { + run_id, + steps: spec + .steps + .iter() + .map(|step| { + ( + step.id.clone(), + StepRuntime { + state: StepState::Pending, + attempts: 0, + semantic_executions: 0, + }, + ) + }) + .collect(), + spec, + memo: BTreeMap::new(), + budget: Budget::default(), + completion: None, + }; + + for entry in entries { + if entry.run_id != state.run_id { + return Err(StateError::WrongRun(entry.run_id.clone())); + } + match entry.entry_type { + EntryType::EpochSummary => state.apply_epoch(entry)?, + EntryType::StepAttemptStarted => { + let payload: crate::entry::AttemptStartedPayload = decode(entry)?; + let step = state.step_mut(entry)?; + let attempt = entry.attempt.ok_or(StateError::MissingAttempt(entry.seq))?; + step.attempts = step.attempts.max(attempt); + step.state = StepState::Running { + attempt, + lease_deadline_ms: payload.lease_deadline_ms, + idempotency_key: payload.idempotency_key, + }; + } + EntryType::StepCompleted => state.apply_step_completed(entry)?, + EntryType::SleepUntil => { + let payload: SleepUntilPayload = decode(entry)?; + let step = state.step_mut(entry)?; + step.state = StepState::Backoff { + attempt: entry.attempt.unwrap_or(step.attempts), + wake_at_ms: payload.wake_at_ms, + }; + } + EntryType::WaitEvent => { + let payload: crate::entry::WaitEventPayload = decode(entry)?; + state.step_mut(entry)?.state = StepState::Waiting { + wait_id: payload.wait_id, + }; + } + EntryType::WaitHuman => { + let payload: crate::entry::WaitHumanPayload = decode(entry)?; + state.step_mut(entry)?.state = StepState::NeedsHuman { + wait_id: payload.wait_id, + }; + } + EntryType::WaitCompleted => { + let payload: WaitCompletedPayload = decode(entry)?; + let step = state.step_mut(entry)?; + step.state = if payload.completion_reason == WaitCompletionReason::Canceled { + StepState::Done { + completion_reason: CompletionReason::Canceled, + output: Value::Null, + } + } else { + StepState::Runnable + }; + } + EntryType::RunCompleted => { + let payload: RunCompletedPayload = decode(entry)?; + state.completion = Some(payload.completion_reason); + } + EntryType::RunSpawned + | EntryType::StreamAppended + | EntryType::EffectRecorded + | EntryType::SegmentClosed => {} + } + } + state.refresh_runnable(); + Ok(state) + } + + pub fn successful_output(&self, step_id: &str) -> Option<&Value> { + self.memo.get(step_id) + } + + pub fn completed_steps(&self) -> usize { + self.steps + .values() + .filter(|step| matches!(step.state, StepState::Done { .. })) + .count() + } + + pub fn failed_step(&self) -> Option<&str> { + self.spec.steps.iter().find_map(|spec| { + let runtime = self.steps.get(&spec.id)?; + match runtime.state { + StepState::Done { + completion_reason: CompletionReason::Success, + .. + } => None, + StepState::Done { .. } => Some(spec.id.as_str()), + _ => None, + } + }) + } + + pub fn all_steps_succeeded(&self) -> bool { + self.steps.values().all(|step| { + matches!( + step.state, + StepState::Done { + completion_reason: CompletionReason::Success, + .. + } + ) + }) + } + + fn step_mut(&mut self, entry: &JournalEntry) -> Result<&mut StepRuntime, StateError> { + let id = entry + .step_id + .as_deref() + .ok_or(StateError::MissingStep(entry.seq))?; + self.steps + .get_mut(id) + .ok_or_else(|| StateError::UnknownStep(id.to_owned())) + } + + fn apply_step_completed(&mut self, entry: &JournalEntry) -> Result<(), StateError> { + let payload: StepCompletedPayload = decode(entry)?; + add_budget(&mut self.budget, &payload.budget)?; + let step_id = entry + .step_id + .clone() + .ok_or(StateError::MissingStep(entry.seq))?; + let step = self + .steps + .get_mut(&step_id) + .ok_or_else(|| StateError::UnknownStep(step_id.clone()))?; + let attempt = entry.attempt.ok_or(StateError::MissingAttempt(entry.seq))?; + step.attempts = step.attempts.max(attempt); + if !matches!( + payload.completion_reason, + CompletionReason::Crashed | CompletionReason::LeaseExpired + ) { + // The attempt ran to completion and produced a result the gate + // could judge; only these consume `max_iterations` allowance. + step.semantic_executions = step.semantic_executions.saturating_add(1); + } + step.state = match payload.disposition { + Disposition::StepDone => StepState::Done { + completion_reason: payload.completion_reason, + output: payload.output.clone(), + }, + Disposition::Retry => StepState::Backoff { + attempt, + wake_at_ms: payload.next_attempt_at_ms.unwrap_or(entry.at_ms), + }, + Disposition::Park => StepState::NeedsHuman { + wait_id: format!("park-{step_id}-{attempt}"), + }, + }; + if payload.disposition == Disposition::StepDone + && payload.completion_reason == CompletionReason::Success + { + self.memo.insert(step_id, payload.output); + } + Ok(()) + } + + fn apply_epoch(&mut self, entry: &JournalEntry) -> Result<(), StateError> { + let payload: EpochSummaryPayload = decode(entry)?; + self.memo.clear(); + self.budget = payload.budget_spent; + for runtime in self.steps.values_mut() { + *runtime = StepRuntime { + state: StepState::Pending, + attempts: 0, + semantic_executions: 0, + }; + } + for (id, done) in payload.steps_done { + let step = self + .steps + .get_mut(&id) + .ok_or_else(|| StateError::UnknownStep(id.clone()))?; + step.state = StepState::Done { + completion_reason: done.completion_reason, + output: done.output.clone(), + }; + if done.completion_reason == CompletionReason::Success { + self.memo.insert(id, done.output); + } + } + for (id, open) in payload.steps_open { + let step = self + .steps + .get_mut(&id) + .ok_or_else(|| StateError::UnknownStep(id.clone()))?; + step.attempts = open.attempt; + // The epoch summary predates the semantic counter; assume every + // prior attempt was semantic. Conservative: a squashed journal can + // grant fewer iterations than the live one, never more. + step.semantic_executions = open.attempt.saturating_sub(1); + step.state = match open.state.as_str() { + "running" => StepState::Running { + attempt: open.attempt, + lease_deadline_ms: open.lease_deadline_ms.unwrap_or(entry.at_ms), + // The key is deterministic in (run_id, step_id), so an + // epoch summary that predates the field still restores it. + idempotency_key: open + .idempotency_key + .clone() + .unwrap_or_else(|| crate::machine::idempotency_key(&self.run_id, &id)), + }, + "backoff" => StepState::Backoff { + attempt: open.attempt, + wake_at_ms: open.wake_at_ms.unwrap_or(entry.at_ms), + }, + "needs_human" => StepState::NeedsHuman { + wait_id: format!("epoch-{}-{id}", payload.epoch), + }, + _ => StepState::Pending, + }; + } + Ok(()) + } + + fn refresh_runnable(&mut self) { + for step in &self.spec.steps { + let is_pending = self + .steps + .get(&step.id) + .is_some_and(|runtime| runtime.state == StepState::Pending); + if !is_pending { + continue; + } + let dependencies_done = step.depends_on.iter().all(|dependency| { + self.steps.get(dependency).is_some_and(|runtime| { + matches!( + runtime.state, + StepState::Done { + completion_reason: CompletionReason::Success, + .. + } + ) + }) + }); + if dependencies_done { + self.steps.get_mut(&step.id).expect("known step").state = StepState::Runnable; + } + } + } +} + +fn decode(entry: &JournalEntry) -> Result { + serde_json::from_value(entry.payload.clone()).map_err(|source| StateError::Payload { + seq: entry.seq, + source, + }) +} + +fn add_budget(total: &mut Budget, value: &Budget) -> Result<(), StateError> { + total.tokens_in = total.tokens_in.saturating_add(value.tokens_in); + total.tokens_out = total.tokens_out.saturating_add(value.tokens_out); + total.dollars = add_decimal_strings(&total.dollars, &value.dollars)?; + Ok(()) +} + +fn add_decimal_strings(left: &str, right: &str) -> Result { + fn parts(value: &str) -> Result<(u128, usize), StateError> { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(StateError::InvalidDollars(value.to_owned())); + } + let digits = format!("{whole}{fraction}") + .parse::() + .map_err(|_| StateError::InvalidDollars(value.to_owned()))?; + Ok((digits, fraction.len())) + } + let (left_value, left_scale) = parts(left)?; + let (right_value, right_scale) = parts(right)?; + let scale = left_scale.max(right_scale); + let scaled_left = + left_value.saturating_mul(10_u128.saturating_pow((scale - left_scale) as u32)); + let scaled_right = + right_value.saturating_mul(10_u128.saturating_pow((scale - right_scale) as u32)); + let sum = scaled_left.saturating_add(scaled_right); + if scale == 0 { + return Ok(sum.to_string()); + } + let divisor = 10_u128.saturating_pow(scale as u32); + let fraction = format!("{:0scale$}", sum % divisor, scale = scale); + Ok(format!("{}.{fraction}", sum / divisor) + .trim_end_matches('0') + .trim_end_matches('.') + .to_owned()) +} + +#[derive(Debug, Error)] +pub enum StateError { + #[error(transparent)] + InvalidSpec(#[from] crate::spec::SpecError), + #[error("journal entry belongs to run {0}")] + WrongRun(String), + #[error("journal entry {0} has no step id")] + MissingStep(i64), + #[error("journal entry {0} has no attempt")] + MissingAttempt(i64), + #[error("journal references unknown step {0}")] + UnknownStep(String), + #[error("invalid payload at journal sequence {seq}: {source}")] + Payload { + seq: i64, + #[source] + source: serde_json::Error, + }, + #[error("invalid non-negative decimal dollar amount {0:?}")] + InvalidDollars(String), +} + +#[cfg(test)] +mod tests; diff --git a/kernel/relayflowd-core/src/state/tests.rs b/kernel/relayflowd-core/src/state/tests.rs new file mode 100644 index 00000000..f9ed2c3b --- /dev/null +++ b/kernel/relayflowd-core/src/state/tests.rs @@ -0,0 +1,71 @@ +use serde_json::json; + +use super::*; +use crate::entry::{Pins, StepCompletedPayload, VerificationRecord, VerificationVerdict}; + +fn spec() -> RunSpec { + serde_json::from_value(json!({ + "steps": [ + {"id": "one", "type": "deterministic", "command": "true"}, + {"id": "two", "type": "deterministic", "command": "true", "depends_on": ["one"]} + ] + })) + .unwrap() +} + +#[test] +fn completed_output_is_memoized_and_unlocks_dependents() { + let completion = JournalEntry::new( + EntryType::StepCompleted, + "run", + Some("one".to_owned()), + Some(1), + 5, + StepCompletedPayload { + completion_reason: CompletionReason::Success, + disposition: Disposition::StepDone, + output: json!({"exit_code": 0, "stdout_tail": "once"}), + verification: Some(VerificationRecord { + gate: "exit_code".to_owned(), + verdict: VerificationVerdict::Pass, + detail: "passed".to_owned(), + }), + end_pins: Some(Pins::default()), + effects: vec![], + budget: Budget::default(), + completed_by: "kernel".to_owned(), + next_attempt_at_ms: None, + }, + ); + let state = RunState::fold("run", spec(), &[completion]).unwrap(); + assert_eq!( + state.successful_output("one").unwrap()["stdout_tail"], + "once" + ); + assert_eq!(state.steps["two"].state, StepState::Runnable); +} + +#[test] +fn budget_decimal_strings_add_without_floats() { + let mut total = Budget::default(); + add_budget( + &mut total, + &Budget { + tokens_in: 2, + tokens_out: 3, + dollars: "0.015".to_owned(), + }, + ) + .unwrap(); + add_budget( + &mut total, + &Budget { + tokens_in: 1, + tokens_out: 1, + dollars: "1.2".to_owned(), + }, + ) + .unwrap(); + assert_eq!(total.dollars, "1.215"); + assert_eq!(total.tokens_in, 3); +} diff --git a/kernel/relayflowd-core/src/verify.rs b/kernel/relayflowd-core/src/verify.rs new file mode 100644 index 00000000..7bfca673 --- /dev/null +++ b/kernel/relayflowd-core/src/verify.rs @@ -0,0 +1,114 @@ +use serde_json::Value; + +use crate::{ + entry::{VerificationRecord, VerificationVerdict}, + spec::{StepKind, StepSpec}, +}; + +pub fn verify(step: &StepSpec, output: &Value) -> VerificationRecord { + let mut gates = Vec::new(); + let mut failures = Vec::new(); + + if matches!(step.kind, StepKind::Deterministic { .. }) { + gates.push("exit_code"); + match output.get("exit_code").and_then(Value::as_i64) { + Some(0) => {} + Some(code) => failures.push(format!("exit code was {code}")), + None => failures.push("output omitted integer exit_code".to_owned()), + } + } + + if let Some(needle) = &step.verification.output_contains { + gates.push("output_contains"); + let haystack = output + .as_str() + .or_else(|| output.get("stdout_tail").and_then(Value::as_str)) + .map(str::to_owned) + .unwrap_or_else(|| output.to_string()); + if !haystack.contains(needle) { + failures.push(format!("output did not contain {needle:?}")); + } + } + + if let Some(schema) = &step.verification.json_schema { + gates.push("json_schema"); + match jsonschema::validator_for(schema) { + Ok(validator) => { + if let Err(error) = validator.validate(output) { + failures.push(format!("JSON schema rejected output: {error}")); + } + } + Err(error) => failures.push(format!("invalid JSON schema: {error}")), + } + } + + if gates.is_empty() { + gates.push("completion"); + } + let passed = failures.is_empty(); + VerificationRecord { + gate: gates.join("+"), + verdict: if passed { + VerificationVerdict::Pass + } else { + VerificationVerdict::Fail + }, + detail: if passed { + "all gates passed".to_owned() + } else { + failures.join("; ") + }, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn deterministic_output_requires_successful_exit_and_content() { + let step: StepSpec = serde_json::from_value(json!({ + "id": "hello", + "type": "deterministic", + "command": "true", + "verification": {"output_contains": "hello"} + })) + .unwrap(); + assert_eq!( + verify( + &step, + &json!({"exit_code": 0, "stdout_tail": "hello world"}) + ) + .verdict, + VerificationVerdict::Pass + ); + assert_eq!( + verify( + &step, + &json!({"exit_code": 1, "stdout_tail": "hello world"}) + ) + .verdict, + VerificationVerdict::Fail + ); + } + + #[test] + fn json_schema_is_a_control_gate() { + let step: StepSpec = serde_json::from_value(json!({ + "id": "model", + "type": "llm", + "prompt": "answer", + "verification": { + "json_schema": {"type": "object", "required": ["answer"]} + } + })) + .unwrap(); + assert_eq!( + verify(&step, &json!({"answer": 42})).verdict, + VerificationVerdict::Pass + ); + assert_eq!(verify(&step, &json!({})).verdict, VerificationVerdict::Fail); + } +} diff --git a/kernel/relayflowd-core/tests/spec_parity.rs b/kernel/relayflowd-core/tests/spec_parity.rs new file mode 100644 index 00000000..47afe5da --- /dev/null +++ b/kernel/relayflowd-core/tests/spec_parity.rs @@ -0,0 +1,45 @@ +//! The kernel half of the cross-boundary spec-parity gate. +//! +//! `testdata/hello-ladder.spec.canonical.json` is emitted by the SDK compiler +//! (see `sdk/tests/spec-parity.test.ts`). This test proves the kernel parses +//! that exact artifact fail-closed, and that re-serializing it — precisely what +//! the engine hashes when it stamps `spec_hash` in `run.spawned` — reproduces +//! the same canonical bytes and the same sha256 the SDK computed. Together the +//! two tests make the "one spec dialect, one hash" claim a tested fact. + +use relayflowd_core::RunSpec; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const CANONICAL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../testdata/hello-ladder.spec.canonical.json" +)); +const SPEC_SHA256: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../testdata/hello-ladder.spec.sha256" +)); + +#[test] +fn the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash() { + let value: Value = serde_json::from_str(CANONICAL.trim()).unwrap(); + let spec = RunSpec::parse(&value).expect("kernel must parse the SDK's compiled spec"); + spec.validate().expect("the ladder fixture is a valid spec"); + + // Re-serialize exactly as the engine does before hashing: Value objects + // are BTreeMaps, so `to_string` emits sorted keys with no whitespace — + // the SDK's canonical form. + let reserialized = serde_json::to_value(&spec).unwrap(); + let canonical = serde_json::to_string(&reserialized).unwrap(); + assert_eq!( + canonical, + CANONICAL.trim(), + "kernel round-trip must reproduce the SDK's canonical JSON byte-for-byte" + ); + + let hash: String = Sha256::digest(canonical.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + assert_eq!(hash, SPEC_SHA256.trim(), "spec_hash parity with the SDK"); +} diff --git a/kernel/relayflowd-journal/Cargo.toml b/kernel/relayflowd-journal/Cargo.toml new file mode 100644 index 00000000..f736ff17 --- /dev/null +++ b/kernel/relayflowd-journal/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "relayflowd-journal" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +relayflowd-core = { path = "../relayflowd-core" } +rusqlite.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true + diff --git a/kernel/relayflowd-journal/src/append.rs b/kernel/relayflowd-journal/src/append.rs new file mode 100644 index 00000000..c0eaa910 --- /dev/null +++ b/kernel/relayflowd-journal/src/append.rs @@ -0,0 +1,162 @@ +use relayflowd_core::{EffectRecordedPayload, EntryType, JournalEntry, StreamAppendedPayload}; +use rusqlite::{OptionalExtension, Row, Transaction, TransactionBehavior, params}; +use serde_json::{Map, Value}; + +use crate::{JournalStoreError, SqliteJournal}; + +impl SqliteJournal { + pub(crate) fn append_entry( + &mut self, + entry: &JournalEntry, + ) -> Result { + if entry.run_id != self.run_id { + return Err(JournalStoreError::WrongRun { + expected: self.run_id.clone(), + actual: entry.run_id.clone(), + }); + } + let current_segment: i64 = + self.connection + .query_row("SELECT MAX(segment_id) FROM segments", [], |row| row.get(0))?; + if entry.segment_id != 0 && entry.segment_id != current_segment { + return Err(JournalStoreError::WrongSegment { + expected: current_segment, + actual: entry.segment_id, + }); + } + + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let persisted = insert_entry(&transaction, &self.run_id, current_segment, entry)?; + transaction.commit()?; + Ok(persisted) + } +} + +pub(crate) fn insert_entry( + transaction: &Transaction<'_>, + run_id: &str, + segment_id: i64, + entry: &JournalEntry, +) -> Result { + let mut persisted = entry.clone(); + persisted.segment_id = segment_id; + + let new_effect = if entry.entry_type == EntryType::EffectRecorded { + let mut effect: EffectRecordedPayload = serde_json::from_value(entry.payload.clone())?; + let step_id = entry + .step_id + .as_deref() + .ok_or(JournalStoreError::MissingStep("effect.recorded"))?; + let winner: Option = transaction + .query_row( + "SELECT entry_seq FROM effects + WHERE step_id = ?1 AND idempotency_key = ?2 AND surface_path = ?3", + params![step_id, effect.idempotency_key, effect.surface_path], + |row| row.get(0), + ) + .optional()?; + effect.deduped = winner.is_some(); + persisted.payload = serde_json::to_value(&effect)?; + (!effect.deduped).then_some((step_id.to_owned(), effect)) + } else { + None + }; + + let stream = if entry.entry_type == EntryType::StreamAppended { + let stream: StreamAppendedPayload = serde_json::from_value(entry.payload.clone())?; + let expected: u64 = transaction.query_row( + "SELECT COALESCE(MAX(offset) + 1, 0) FROM stream_index WHERE stream = ?1", + [&stream.stream], + |row| row.get(0), + )?; + if stream.offset != expected { + return Err(JournalStoreError::InvalidStreamOffset { + stream: stream.stream, + expected, + actual: stream.offset, + }); + } + Some(stream) + } else { + None + }; + + let payload = canonical_json(&persisted.payload)?; + transaction.execute( + "INSERT INTO entries(segment_id, entry_type, step_id, attempt, at_ms, payload) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + segment_id, + entry.entry_type.as_str(), + entry.step_id, + entry.attempt, + entry.at_ms, + payload + ], + )?; + let seq = transaction.last_insert_rowid(); + persisted.seq = seq; + + if let Some((step_id, effect)) = new_effect { + transaction.execute( + "INSERT INTO effects(step_id, idempotency_key, surface_path, entry_seq) + VALUES (?1, ?2, ?3, ?4)", + params![step_id, effect.idempotency_key, effect.surface_path, seq], + )?; + } + if let Some(stream) = stream { + transaction.execute( + "INSERT INTO stream_index(stream, offset, entry_seq) VALUES (?1, ?2, ?3)", + params![stream.stream, stream.offset, seq], + )?; + } + persisted.run_id = run_id.to_owned(); + Ok(persisted) +} + +pub(crate) fn entry_from_row(row: &Row<'_>, run_id: &str) -> Result { + let entry_type: String = row.get(2)?; + let payload: String = row.get(6)?; + let attempt: Option = row.get(4)?; + let entry_type = EntryType::parse(&entry_type).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + format!("unknown journal entry type {entry_type:?}").into(), + ) + })?; + let payload = serde_json::from_str(&payload).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(error)) + })?; + Ok(JournalEntry { + seq: row.get(0)?, + segment_id: row.get(1)?, + entry_type, + run_id: run_id.to_owned(), + step_id: row.get(3)?, + attempt: attempt.map(|value| value as u32), + at_ms: row.get(5)?, + payload, + }) +} + +fn canonical_json(value: &Value) -> Result { + fn sorted(value: &Value) -> Value { + match value { + Value::Object(object) => { + let mut keys = object.keys().collect::>(); + keys.sort_unstable(); + let mut result = Map::new(); + for key in keys { + result.insert(key.clone(), sorted(&object[key])); + } + Value::Object(result) + } + Value::Array(values) => Value::Array(values.iter().map(sorted).collect()), + other => other.clone(), + } + } + serde_json::to_string(&sorted(value)) +} diff --git a/kernel/relayflowd-journal/src/lib.rs b/kernel/relayflowd-journal/src/lib.rs new file mode 100644 index 00000000..2c512b3e --- /dev/null +++ b/kernel/relayflowd-journal/src/lib.rs @@ -0,0 +1,370 @@ +//! Append-only SQLite implementation of the Relayflow journal protocol. + +mod append; +mod registry; +mod segment; + +use std::path::{Path, PathBuf}; + +use relayflowd_core::{EpochSummaryPayload, Journal, JournalEntry, JournalError, RunSpec}; +use rusqlite::{Connection, OpenFlags, params}; +use thiserror::Error; + +pub use registry::{Registry, RegistryRecord}; + +const SCHEMA: &str = r#" +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) WITHOUT ROWID; + +CREATE TABLE segments ( + segment_id INTEGER PRIMARY KEY, + journal_version INTEGER NOT NULL, + opened_seq INTEGER NOT NULL +); + +CREATE TABLE entries ( + seq INTEGER PRIMARY KEY, + segment_id INTEGER NOT NULL REFERENCES segments(segment_id), + entry_type TEXT NOT NULL, + step_id TEXT, + attempt INTEGER, + at_ms INTEGER NOT NULL, + payload TEXT NOT NULL +); +CREATE INDEX ix_entries_segment ON entries(segment_id, seq); +CREATE INDEX ix_entries_step ON entries(step_id, seq) WHERE step_id IS NOT NULL; + +CREATE TABLE effects ( + step_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + surface_path TEXT NOT NULL, + entry_seq INTEGER NOT NULL, + PRIMARY KEY (step_id, idempotency_key, surface_path) +) WITHOUT ROWID; + +CREATE TABLE stream_index ( + stream TEXT NOT NULL, + offset INTEGER NOT NULL, + entry_seq INTEGER NOT NULL, + PRIMARY KEY (stream, offset) +) WITHOUT ROWID; +"#; + +pub struct SqliteJournal { + connection: Connection, + run_id: String, + path: PathBuf, +} + +impl SqliteJournal { + pub fn create( + path: impl AsRef, + run_id: impl Into, + created_at_ms: i64, + ) -> Result { + let path = path.as_ref().to_path_buf(); + if path.exists() { + return Err(JournalStoreError::AlreadyExists(path)); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let run_id = run_id.into(); + let connection = Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + )?; + configure(&connection)?; + connection.execute_batch(SCHEMA)?; + let mut journal = Self { + connection, + run_id, + path, + }; + let transaction = journal.connection.transaction()?; + transaction.execute( + "INSERT INTO meta(key, value) VALUES ('run_id', ?1), ('created_at_ms', ?2), ('journal_version', ?3)", + params![journal.run_id, created_at_ms.to_string(), relayflowd_core::JOURNAL_VERSION.to_string()], + )?; + transaction.execute( + "INSERT INTO segments(segment_id, journal_version, opened_seq) VALUES (1, ?1, 1)", + [i64::from(relayflowd_core::JOURNAL_VERSION)], + )?; + transaction.commit()?; + Ok(journal) + } + + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let connection = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + configure(&connection)?; + let run_id = + connection.query_row("SELECT value FROM meta WHERE key = 'run_id'", [], |row| { + row.get(0) + })?; + Ok(Self { + connection, + run_id, + path, + }) + } + + pub fn run_id(&self) -> &str { + &self.run_id + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn scan_all(&self) -> Result, JournalStoreError> { + self.scan_where("SELECT seq, segment_id, entry_type, step_id, attempt, at_ms, payload FROM entries ORDER BY seq", []) + } + + pub fn run_spec(&self) -> Result { + let payload: String = self.connection.query_row( + "SELECT payload FROM entries WHERE entry_type = 'run.spawned' ORDER BY seq LIMIT 1", + [], + |row| row.get(0), + )?; + let spawned: relayflowd_core::RunSpawnedPayload = serde_json::from_str(&payload)?; + Ok(serde_json::from_value(spawned.spec)?) + } + + pub fn scan_from( + &self, + from_seq: i64, + limit: usize, + ) -> Result, JournalStoreError> { + let mut statement = self.connection.prepare( + "SELECT seq, segment_id, entry_type, step_id, attempt, at_ms, payload + FROM entries WHERE seq >= ?1 ORDER BY seq LIMIT ?2", + )?; + let rows = statement.query_map(params![from_seq, limit as i64], |row| { + append::entry_from_row(row, &self.run_id) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn effect_count(&self) -> Result { + self.connection + .query_row("SELECT COUNT(*) FROM effects", [], |row| row.get(0)) + .map_err(Into::into) + } + + fn scan_where( + &self, + sql: &str, + params: [&dyn rusqlite::ToSql; N], + ) -> Result, JournalStoreError> { + let mut statement = self.connection.prepare(sql)?; + let rows = statement.query_map(rusqlite::params_from_iter(params), |row| { + append::entry_from_row(row, &self.run_id) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + #[cfg(test)] + fn make_read_only(&self) -> Result<(), JournalStoreError> { + self.connection.execute_batch("PRAGMA query_only = ON")?; + Ok(()) + } +} + +fn configure(connection: &Connection) -> Result<(), rusqlite::Error> { + connection.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA foreign_keys = ON;", + ) +} + +impl Journal for SqliteJournal { + fn append(&mut self, entry: &JournalEntry) -> Result { + self.append_entry(entry).map_err(to_core_error) + } + + fn scan_segment(&self, segment_id: i64) -> Result, JournalError> { + self.scan_where( + "SELECT seq, segment_id, entry_type, step_id, attempt, at_ms, payload + FROM entries WHERE segment_id = ?1 ORDER BY seq", + [&segment_id as &dyn rusqlite::ToSql], + ) + .map_err(to_core_error) + } + + fn current_segment(&self) -> Result { + self.connection + .query_row("SELECT MAX(segment_id) FROM segments", [], |row| row.get(0)) + .map_err(JournalStoreError::from) + .map_err(to_core_error) + } + + fn rollover( + &mut self, + summary: EpochSummaryPayload, + at_ms: i64, + ) -> Result, JournalError> { + self.rollover_segment(summary, at_ms).map_err(to_core_error) + } +} + +fn to_core_error(error: JournalStoreError) -> JournalError { + JournalError(error.to_string()) +} + +#[derive(Debug, Error)] +pub enum JournalStoreError { + #[error("journal file already exists: {0}")] + AlreadyExists(PathBuf), + #[error("journal I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("SQLite journal failed: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("journal JSON failed: {0}")] + Json(#[from] serde_json::Error), + #[error("unknown journal entry type {0:?}")] + UnknownEntryType(String), + #[error("journal entry belongs to run {actual}, expected {expected}")] + WrongRun { expected: String, actual: String }, + #[error("journal entry targets segment {actual}, current segment is {expected}")] + WrongSegment { expected: i64, actual: i64 }, + #[error("stream {stream} expected offset {expected}, received {actual}")] + InvalidStreamOffset { + stream: String, + expected: u64, + actual: u64, + }, + #[error("{0} entry requires a step id")] + MissingStep(&'static str), +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use relayflowd_core::{ + Budget, EffectRecordedPayload, EntryType, EpochSummaryPayload, RunSpawnedPayload, + }; + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + fn created() -> (tempfile::TempDir, SqliteJournal) { + let directory = tempdir().unwrap(); + let journal = + SqliteJournal::create(directory.path().join("run.sqlite3"), "run", 10).unwrap(); + (directory, journal) + } + + #[test] + fn append_is_durable_and_monotonic_after_reopen() { + let (directory, mut journal) = created(); + let persisted = journal + .append(&JournalEntry::new( + EntryType::RunSpawned, + "run", + None, + None, + 10, + RunSpawnedPayload { + spec: json!({"steps": []}), + spec_hash: "hash".to_owned(), + parent_run_id: None, + journal_version: relayflowd_core::JOURNAL_VERSION, + created_by: "test".to_owned(), + }, + )) + .unwrap(); + assert_eq!(persisted.seq, 1); + drop(journal); + + let reopened = SqliteJournal::open(directory.path().join("run.sqlite3")).unwrap(); + assert_eq!(reopened.scan_all().unwrap(), vec![persisted]); + let synchronous: i64 = reopened + .connection + .query_row("PRAGMA synchronous", [], |row| row.get(0)) + .unwrap(); + assert_eq!(synchronous, 2, "SQLite FULL synchronous mode"); + } + + #[test] + fn failed_commit_is_returned_not_swallowed() { + let (_directory, mut journal) = created(); + journal.make_read_only().unwrap(); + let error = journal + .append(&JournalEntry::new( + EntryType::RunSpawned, + "run", + None, + None, + 0, + json!({}), + )) + .unwrap_err(); + assert!(error.0.contains("readonly") || error.0.contains("read-only")); + } + + #[test] + fn effects_are_deduplicated_at_the_journal_boundary() { + let (_directory, mut journal) = created(); + let effect = JournalEntry::new( + EntryType::EffectRecorded, + "run", + Some("agent".to_owned()), + Some(1), + 10, + EffectRecordedPayload { + surface_path: "/github/pull/1".to_owned(), + idempotency_key: "stable".to_owned(), + revision_before: "a".to_owned(), + revision_after: "b".to_owned(), + agent_identity: "worker".to_owned(), + deduped: false, + }, + ); + let first = journal.append(&effect).unwrap(); + let second = journal.append(&effect).unwrap(); + assert!(!first.payload["deduped"].as_bool().unwrap()); + assert!(second.payload["deduped"].as_bool().unwrap()); + assert_eq!(journal.effect_count().unwrap(), 1); + } + + #[test] + fn rollover_is_atomic_scaffolding_for_epoch_resume() { + let (_directory, mut journal) = created(); + journal + .append(&JournalEntry::new( + EntryType::RunSpawned, + "run", + None, + None, + 0, + json!({}), + )) + .unwrap(); + let rolled = journal + .rollover( + EpochSummaryPayload { + epoch: 0, + prev_segment_id: 0, + journal_version: relayflowd_core::JOURNAL_VERSION, + steps_done: BTreeMap::new(), + steps_open: BTreeMap::new(), + open_waits: BTreeMap::new(), + stream_state: BTreeMap::new(), + pinned_revisions: BTreeMap::new(), + budget_spent: Budget::default(), + }, + 20, + ) + .unwrap(); + assert_eq!(rolled[0].entry_type, EntryType::SegmentClosed); + assert_eq!(rolled[1].entry_type, EntryType::EpochSummary); + assert_eq!(journal.current_segment().unwrap(), 2); + assert_eq!(journal.scan_segment(2).unwrap(), vec![rolled[1].clone()]); + } +} diff --git a/kernel/relayflowd-journal/src/registry.rs b/kernel/relayflowd-journal/src/registry.rs new file mode 100644 index 00000000..7b0099af --- /dev/null +++ b/kernel/relayflowd-journal/src/registry.rs @@ -0,0 +1,101 @@ +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::JournalStoreError; + +pub struct Registry { + connection: Connection, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryRecord { + pub run_id: String, + pub file: PathBuf, + pub status: String, + pub next_wake_at_ms: Option, +} + +impl Registry { + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let connection = Connection::open(path)?; + connection.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + file TEXT NOT NULL, + status TEXT NOT NULL, + next_wake_at_ms INTEGER + ) WITHOUT ROWID;", + )?; + Ok(Self { connection }) + } + + pub fn register(&self, run_id: &str, file: &Path) -> Result<(), JournalStoreError> { + self.connection.execute( + "INSERT INTO runs(run_id, file, status, next_wake_at_ms) + VALUES (?1, ?2, 'running', NULL)", + params![run_id, file.to_string_lossy()], + )?; + Ok(()) + } + + pub fn set_status( + &self, + run_id: &str, + status: &str, + next_wake_at_ms: Option, + ) -> Result<(), JournalStoreError> { + let changed = self.connection.execute( + "UPDATE runs SET status = ?2, next_wake_at_ms = ?3 WHERE run_id = ?1", + params![run_id, status, next_wake_at_ms], + )?; + if changed == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows.into()); + } + Ok(()) + } + + pub fn lookup(&self, run_id: &str) -> Result, JournalStoreError> { + self.connection + .query_row( + "SELECT run_id, file, status, next_wake_at_ms FROM runs WHERE run_id = ?1", + [run_id], + |row| { + let file: String = row.get(1)?; + Ok(RegistryRecord { + run_id: row.get(0)?, + file: PathBuf::from(file), + status: row.get(2)?, + next_wake_at_ms: row.get(3)?, + }) + }, + ) + .optional() + .map_err(Into::into) + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn registry_is_a_rebuildable_run_locator() { + let directory = tempdir().unwrap(); + let registry = Registry::open(directory.path().join("relayflowd.sqlite3")).unwrap(); + let run_file = directory.path().join("runs/run.sqlite3"); + registry.register("run", &run_file).unwrap(); + registry.set_status("run", "completed", None).unwrap(); + let record = registry.lookup("run").unwrap().unwrap(); + assert_eq!(record.file, run_file); + assert_eq!(record.status, "completed"); + } +} diff --git a/kernel/relayflowd-journal/src/segment.rs b/kernel/relayflowd-journal/src/segment.rs new file mode 100644 index 00000000..05091bd8 --- /dev/null +++ b/kernel/relayflowd-journal/src/segment.rs @@ -0,0 +1,58 @@ +use relayflowd_core::{EntryType, EpochSummaryPayload, JournalEntry, SegmentClosedPayload}; +use rusqlite::{TransactionBehavior, params}; + +use crate::{JournalStoreError, SqliteJournal, append::insert_entry}; + +impl SqliteJournal { + pub(crate) fn rollover_segment( + &mut self, + mut summary: EpochSummaryPayload, + at_ms: i64, + ) -> Result, JournalStoreError> { + let current_segment: i64 = + self.connection + .query_row("SELECT MAX(segment_id) FROM segments", [], |row| row.get(0))?; + let next_segment = current_segment + 1; + summary.epoch = next_segment; + summary.prev_segment_id = current_segment; + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate)?; + + let closed = insert_entry( + &transaction, + &self.run_id, + current_segment, + &JournalEntry::new( + EntryType::SegmentClosed, + self.run_id.clone(), + None, + None, + at_ms, + SegmentClosedPayload { + next_segment_id: next_segment, + }, + ), + )?; + let opened_seq = closed.seq + 1; + transaction.execute( + "INSERT INTO segments(segment_id, journal_version, opened_seq) VALUES (?1, ?2, ?3)", + params![next_segment, summary.journal_version, opened_seq], + )?; + let opened = insert_entry( + &transaction, + &self.run_id, + next_segment, + &JournalEntry::new( + EntryType::EpochSummary, + self.run_id.clone(), + None, + None, + at_ms, + summary, + ), + )?; + transaction.commit()?; + Ok(vec![closed, opened]) + } +} diff --git a/kernel/relayflowd/Cargo.toml b/kernel/relayflowd/Cargo.toml new file mode 100644 index 00000000..3b69b593 --- /dev/null +++ b/kernel/relayflowd/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "relayflowd" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +relayflowd-core = { path = "../relayflowd-core" } +relayflowd-journal = { path = "../relayflowd-journal" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +ulid.workspace = true +wait-timeout.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +[dev-dependencies] +tempfile.workspace = true + diff --git a/kernel/relayflowd/src/clock.rs b/kernel/relayflowd/src/clock.rs new file mode 100644 index 00000000..d09bf59a --- /dev/null +++ b/kernel/relayflowd/src/clock.rs @@ -0,0 +1,15 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use relayflowd_core::Clock; + +#[derive(Debug, Clone, Copy, Default)] +pub struct WallClock; + +impl Clock for WallClock { + fn now_ms(&self) -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after Unix epoch"); + duration.as_millis().min(i64::MAX as u128) as i64 + } +} diff --git a/kernel/relayflowd/src/engine.rs b/kernel/relayflowd/src/engine.rs new file mode 100644 index 00000000..1e9c89ce --- /dev/null +++ b/kernel/relayflowd/src/engine.rs @@ -0,0 +1,334 @@ +use std::{ + path::{Path, PathBuf}, + thread, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use relayflowd_core::{ + Action, Clock, EntryType, Journal, JournalEntry, RunCompletionReason, RunSpawnedPayload, + RunSpec, RunState, StepKind, completion_actions, next_actions, recovery_actions, +}; +use relayflowd_journal::{Registry, SqliteJournal}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use ulid::Ulid; + +use crate::{clock::WallClock, exec_det}; + +pub struct Engine { + data_dir: PathBuf, + clock: C, +} + +impl Engine { + pub fn new(data_dir: impl Into) -> Self { + Self { + data_dir: data_dir.into(), + clock: WallClock, + } + } +} + +impl Engine { + pub fn with_clock(data_dir: impl Into, clock: C) -> Self { + Self { + data_dir: data_dir.into(), + clock, + } + } + + pub fn start( + &self, + spec: RunSpec, + created_by: &str, + stop_after: Option, + ) -> Result { + spec.validate().context("invalid run spec")?; + ensure_deterministic(&spec)?; + let run_id = Ulid::new().to_string(); + let path = self.run_path(&run_id); + let now_ms = self.clock.now_ms(); + let mut journal = + SqliteJournal::create(&path, &run_id, now_ms).context("create run journal")?; + let spec_value = serde_json::to_value(&spec)?; + journal + .append(&JournalEntry::new( + EntryType::RunSpawned, + run_id.clone(), + None, + None, + now_ms, + RunSpawnedPayload { + spec: spec_value.clone(), + spec_hash: canonical_hash(&spec_value), + parent_run_id: None, + journal_version: relayflowd_core::JOURNAL_VERSION, + created_by: created_by.to_owned(), + }, + )) + .map_err(|error| anyhow!(error))?; + self.registry()? + .register(&run_id, &path) + .context("register run")?; + self.drive(journal, spec, stop_after) + } + + pub fn resume(&self, run_id: &str, stop_after: Option) -> Result { + let mut journal = self.open_run(run_id)?; + let spec = journal.run_spec().context("read run spec")?; + ensure_deterministic(&spec)?; + let state = self.load_state(&journal, spec.clone())?; + for action in recovery_actions(&state, self.clock.now_ms()) { + self.persist_only(&mut journal, action)?; + } + self.drive(journal, spec, stop_after) + } + + pub fn snapshot(&self, run_id: &str) -> Result { + let journal = self.open_run(run_id)?; + let spec = journal.run_spec().context("read run spec")?; + let state = self.load_state(&journal, spec)?; + Ok(snapshot_from_state(&state)) + } + + pub fn journal_entries( + &self, + run_id: &str, + from_seq: i64, + limit: usize, + ) -> Result> { + self.open_run(run_id)? + .scan_from(from_seq, limit) + .context("read journal entries") + } + + fn drive( + &self, + mut journal: SqliteJournal, + spec: RunSpec, + stop_after: Option, + ) -> Result { + let initial_completed = self.load_state(&journal, spec.clone())?.completed_steps(); + loop { + let state = self.load_state(&journal, spec.clone())?; + if let Some(reason) = state.completion { + return Ok(outcome_from_state(&state, reason)); + } + if stop_after.is_some_and(|limit| { + state.completed_steps().saturating_sub(initial_completed) >= limit + }) { + self.registry()? + .set_status(&state.run_id, "interrupted", None)?; + return Ok(RunOutcome { + run_id: state.run_id.clone(), + status: RunStatus::Interrupted, + completion_reason: None, + completed_steps: state.completed_steps(), + }); + } + + let actions = next_actions(&state, self.clock.now_ms()); + if actions.is_empty() { + self.registry()?.set_status(&state.run_id, "parked", None)?; + return Ok(RunOutcome { + run_id: state.run_id.clone(), + status: RunStatus::Parked, + completion_reason: None, + completed_steps: state.completed_steps(), + }); + } + for action in actions { + match action { + Action::Append(entry) => { + journal.append(&entry).map_err(|error| anyhow!(error))?; + } + Action::ExecDeterministic { step, attempt } => { + let result = exec_det::execute(&step); + // Completed semantic executions before this attempt; + // crashed attempts are excluded so they never consume + // `max_iterations` allowance. + let semantic_executions = state.steps[&step.id].semantic_executions; + for action in completion_actions( + journal.run_id(), + &step, + attempt, + semantic_executions, + result, + self.clock.now_ms(), + ) { + self.interpret_non_execution(&mut journal, action)?; + } + } + Action::Dispatch { worker_class, .. } => { + bail!("no {worker_class:?} worker is attached to the deterministic rung") + } + Action::ArmTimer { at_ms } => self.wait_for_timer(&journal, at_ms)?, + Action::CompleteRun { reason } => { + self.registry()? + .set_status(journal.run_id(), "completed", None)?; + let final_state = self.load_state(&journal, spec.clone())?; + return Ok(outcome_from_state(&final_state, reason)); + } + } + } + } + } + + fn interpret_non_execution(&self, journal: &mut SqliteJournal, action: Action) -> Result<()> { + match action { + Action::Append(entry) => { + journal.append(&entry).map_err(|error| anyhow!(error))?; + } + Action::ArmTimer { at_ms } => self.wait_for_timer(journal, at_ms)?, + _ => bail!("completion emitted an invalid execution action"), + } + Ok(()) + } + + fn persist_only(&self, journal: &mut SqliteJournal, action: Action) -> Result<()> { + match action { + Action::Append(entry) => { + journal.append(&entry).map_err(|error| anyhow!(error))?; + Ok(()) + } + _ => bail!("recovery emitted a non-journal action"), + } + } + + fn wait_for_timer(&self, journal: &SqliteJournal, at_ms: i64) -> Result<()> { + self.registry()? + .set_status(journal.run_id(), "sleeping", Some(at_ms))?; + let remaining = at_ms.saturating_sub(self.clock.now_ms()); + if remaining > 0 { + thread::sleep(Duration::from_millis(remaining as u64)); + } + self.registry()? + .set_status(journal.run_id(), "running", None)?; + Ok(()) + } + + fn load_state(&self, journal: &SqliteJournal, spec: RunSpec) -> Result { + let segment = journal.current_segment().map_err(|error| anyhow!(error))?; + let entries = journal + .scan_segment(segment) + .map_err(|error| anyhow!(error))?; + RunState::fold(journal.run_id(), spec, &entries).context("fold run journal") + } + + fn open_run(&self, run_id: &str) -> Result { + let registered = self.registry()?.lookup(run_id)?; + let path = registered + .map(|record| record.file) + .unwrap_or_else(|| self.run_path(run_id)); + SqliteJournal::open(&path).with_context(|| format!("open run journal {}", path.display())) + } + + fn registry(&self) -> Result { + Registry::open(self.data_dir.join("relayflowd.sqlite3")).context("open run registry") + } + + fn run_path(&self, run_id: &str) -> PathBuf { + self.data_dir.join("runs").join(format!("{run_id}.sqlite3")) + } +} + +fn ensure_deterministic(spec: &RunSpec) -> Result<()> { + if let Some(step) = spec + .steps + .iter() + .find(|step| !matches!(step.kind, StepKind::Deterministic { .. })) + { + bail!( + "step {} is {:?}; this binary rung executes deterministic steps only", + step.id, + step.step_type() + ); + } + Ok(()) +} + +/// sha256 of the spec's canonical JSON. `serde_json::Value` objects are +/// BTreeMaps, so `to_vec` emits sorted keys with no whitespace — the same +/// canonical form the SDK's `canonicalize()` produces. Parity is pinned by +/// `relayflowd-core/tests/spec_parity.rs` and the SDK's `spec-parity` test +/// over the shared `testdata/` fixture. +fn canonical_hash(value: &serde_json::Value) -> String { + let bytes = serde_json::to_vec(value).expect("run spec serializes"); + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn outcome_from_state(state: &RunState, reason: RunCompletionReason) -> RunOutcome { + RunOutcome { + run_id: state.run_id.clone(), + status: if reason == RunCompletionReason::Success { + RunStatus::Completed + } else { + RunStatus::Failed + }, + completion_reason: Some(reason), + completed_steps: state.completed_steps(), + } +} + +fn snapshot_from_state(state: &RunState) -> RunSnapshot { + RunSnapshot { + run_id: state.run_id.clone(), + status: match state.completion { + Some(RunCompletionReason::Success) => RunStatus::Completed, + Some(_) => RunStatus::Failed, + None if state.steps.values().any(|step| { + matches!(step.state, relayflowd_core::StepState::NeedsHuman { .. }) + }) => + { + RunStatus::Parked + } + None => RunStatus::Running, + }, + steps: state + .steps + .iter() + .map(|(id, runtime)| (id.clone(), format!("{:?}", runtime.state))) + .collect(), + budget: state.budget.clone(), + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + Running, + Completed, + Failed, + Interrupted, + Parked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunOutcome { + pub run_id: String, + pub status: RunStatus, + pub completion_reason: Option, + pub completed_steps: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunSnapshot { + pub run_id: String, + pub status: RunStatus, + pub steps: std::collections::BTreeMap, + pub budget: relayflowd_core::Budget, +} + +pub fn read_spec(path: &Path) -> Result { + let bytes = std::fs::read(path).with_context(|| format!("read run spec {}", path.display()))?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .with_context(|| format!("parse run spec {}", path.display()))?; + // Fail closed: unknown fields are an error, never a silently dropped gate. + let spec = RunSpec::parse(&value) + .with_context(|| format!("parse run spec {}", path.display()))?; + Ok(spec) +} diff --git a/kernel/relayflowd/src/exec_det.rs b/kernel/relayflowd/src/exec_det.rs new file mode 100644 index 00000000..df1b7e11 --- /dev/null +++ b/kernel/relayflowd/src/exec_det.rs @@ -0,0 +1,180 @@ +use std::{ + io::Read, + process::{Child, Command, Stdio}, + thread, + time::Duration, +}; + +use relayflowd_core::{AttemptResult, Budget, CommandSpec, CompletionReason, StepKind, StepSpec}; +use serde_json::json; +use wait_timeout::ChildExt; + +const OUTPUT_TAIL_BYTES: usize = 64 * 1024; + +pub fn execute(step: &StepSpec) -> AttemptResult { + let StepKind::Deterministic { + command, + timeout_ms, + } = &step.kind + else { + return worker_error("deterministic executor received a non-deterministic step"); + }; + let mut process = match command { + CommandSpec::Shell(script) => { + let mut command = Command::new("/bin/sh"); + command.args(["-c", script]); + command + } + CommandSpec::Argv(arguments) => { + let Some((program, arguments)) = arguments.split_first() else { + return worker_error("deterministic command argv cannot be empty"); + }; + let mut command = Command::new(program); + command.args(arguments); + command + } + }; + process.stdout(Stdio::piped()).stderr(Stdio::piped()); + // Run the command in its own process group so a timeout can kill every + // descendant. Killing only the shell leaves children that inherited the + // stdout/stderr pipes alive, and the reader-thread joins below would then + // block far past `timeout_ms`. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + process.process_group(0); + } + let mut child = match process.spawn() { + Ok(child) => child, + Err(error) => return worker_error(&format!("failed to spawn command: {error}")), + }; + + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let stdout_reader = thread::spawn(move || read_all(stdout)); + let stderr_reader = thread::spawn(move || read_all(stderr)); + let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000)); + let (status, timed_out) = match child.wait_timeout(timeout) { + Ok(Some(status)) => (Some(status), false), + Ok(None) => { + kill_process_group(&mut child); + (child.wait().ok(), true) + } + Err(error) => { + kill_process_group(&mut child); + let _ = child.wait(); + return worker_error(&format!("failed while waiting for command: {error}")); + } + }; + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + let output = json!({ + "exit_code": status.and_then(|status| status.code()).unwrap_or(-1), + "stdout_tail": tail(&stdout), + "stderr_tail": tail(&stderr), + }); + AttemptResult { + output, + budget: Budget::default(), + completed_by: "kernel".to_owned(), + end_pins: None, + effects: vec![], + failure_reason: timed_out.then_some(CompletionReason::Timeout), + } +} + +/// Kill the command's whole process group (the child was spawned as its own +/// group leader, so the group id is the child's pid), then the child itself as +/// a fallback. SIGKILL to `-pid` reaches every descendant still in the group, +/// closing the inherited stdout/stderr pipes so the reader joins return. +fn kill_process_group(child: &mut Child) { + #[cfg(unix)] + { + let pid = child.id() as i32; + // SAFETY: plain libc kill(2) on a negative pid — no memory at play. + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + let _ = child.kill(); +} + +fn read_all(mut reader: impl Read) -> Vec { + let mut bytes = Vec::new(); + let _ = reader.read_to_end(&mut bytes); + bytes +} + +fn tail(bytes: &[u8]) -> String { + let start = bytes.len().saturating_sub(OUTPUT_TAIL_BYTES); + String::from_utf8_lossy(&bytes[start..]).into_owned() +} + +fn worker_error(detail: &str) -> AttemptResult { + AttemptResult { + output: json!({"error": detail}), + budget: Budget::default(), + completed_by: "kernel".to_owned(), + end_pins: None, + effects: vec![], + failure_reason: Some(CompletionReason::WorkerError), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn captures_deterministic_output() { + let step: StepSpec = serde_json::from_value(json!({ + "id": "hello", "type": "deterministic", "command": ["/bin/sh", "-c", "printf hello"] + })) + .unwrap(); + let result = execute(&step); + assert_eq!(result.output["exit_code"], 0); + assert_eq!(result.output["stdout_tail"], "hello"); + assert_eq!(result.failure_reason, None); + } + + #[test] + fn timeout_has_an_explicit_completion_reason() { + let step: StepSpec = serde_json::from_value(json!({ + "id": "slow", "type": "deterministic", "command": "sleep 1", "timeout_ms": 5 + })) + .unwrap(); + let result = execute(&step); + assert_eq!(result.failure_reason, Some(CompletionReason::Timeout)); + } + + #[test] + fn timeout_kills_the_whole_process_group() { + // The backgrounded sleep inherits the stdout/stderr pipes. If a + // timeout killed only the shell, the reader joins would block until + // the sleep exits (~30s). Killing the process group must bound the + // whole call near timeout_ms. + let step: StepSpec = serde_json::from_value(json!({ + "id": "orphan", "type": "deterministic", + "command": "sleep 30 & echo started; wait", + "timeout_ms": 250 + })) + .unwrap(); + let started = std::time::Instant::now(); + let result = execute(&step); + let elapsed = started.elapsed(); + assert_eq!(result.failure_reason, Some(CompletionReason::Timeout)); + assert!( + elapsed < Duration::from_secs(3), + "timeout must not wait on orphaned descendants (took {elapsed:?})" + ); + assert!( + result.output["stdout_tail"] + .as_str() + .unwrap() + .contains("started"), + "output produced before the timeout is still captured" + ); + } +} diff --git a/kernel/relayflowd/src/lib.rs b/kernel/relayflowd/src/lib.rs new file mode 100644 index 00000000..be8f44be --- /dev/null +++ b/kernel/relayflowd/src/lib.rs @@ -0,0 +1,6 @@ +pub mod clock; +pub mod engine; +pub mod exec_det; +pub mod server; + +pub use engine::{Engine, RunOutcome, RunSnapshot, RunStatus}; diff --git a/kernel/relayflowd/src/main.rs b/kernel/relayflowd/src/main.rs new file mode 100644 index 00000000..dbe86231 --- /dev/null +++ b/kernel/relayflowd/src/main.rs @@ -0,0 +1,66 @@ +use std::path::PathBuf; + +use anyhow::{Result, bail}; +use clap::{Parser, Subcommand}; +use relayflowd::{Engine, RunStatus, engine::read_spec, server}; + +#[derive(Debug, Parser)] +#[command( + name = "relayflowd", + version, + about = "Relayflow durable execution kernel" +)] +struct Cli { + #[arg(long, global = true, default_value = ".relayflowd")] + data_dir: PathBuf, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Start and execute a run spec JSON file. + Run { + spec: PathBuf, + #[arg(long, default_value = "cli")] + created_by: String, + /// Test/debug boundary: return after this many newly completed steps. + #[arg(long, hide = true)] + stop_after: Option, + }, + /// Resume a run from its durable journal. + Resume { + run_id: String, + #[arg(long, hide = true)] + stop_after: Option, + }, + /// Serve journal protocol v0 over a Unix socket. + Serve, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let engine = Engine::new(&cli.data_dir); + match cli.command { + Command::Run { + spec, + created_by, + stop_after, + } => { + let outcome = engine.start(read_spec(&spec)?, &created_by, stop_after)?; + println!("{}", serde_json::to_string(&outcome)?); + if outcome.status == RunStatus::Failed { + bail!("run {} failed", outcome.run_id); + } + } + Command::Resume { run_id, stop_after } => { + let outcome = engine.resume(&run_id, stop_after)?; + println!("{}", serde_json::to_string(&outcome)?); + if outcome.status == RunStatus::Failed { + bail!("run {} failed", outcome.run_id); + } + } + Command::Serve => server::serve(&cli.data_dir)?, + } + Ok(()) +} diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs new file mode 100644 index 00000000..0bbd08a5 --- /dev/null +++ b/kernel/relayflowd/src/server.rs @@ -0,0 +1,205 @@ +use std::{ + io::{BufRead, BufReader, Write}, + path::Path, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::Engine; + +#[derive(Debug, Deserialize)] +struct Request { + id: Value, + verb: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Serialize)] +struct Response { + id: Value, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct ProtocolError { + code: String, + message: String, +} + +#[cfg(unix)] +pub fn serve(data_dir: &Path) -> Result<()> { + use std::os::unix::net::UnixListener; + + std::fs::create_dir_all(data_dir)?; + let socket_path = data_dir.join("relayflowd.sock"); + if socket_path.exists() { + std::fs::remove_file(&socket_path) + .with_context(|| format!("remove stale socket {}", socket_path.display()))?; + } + let listener = UnixListener::bind(&socket_path) + .with_context(|| format!("bind socket {}", socket_path.display()))?; + for connection in listener.incoming() { + let mut connection = connection?; + let reader = BufReader::new(connection.try_clone()?); + for line in reader.lines() { + let response = match line { + Ok(line) => handle_line(data_dir, &line), + Err(error) => error_response(Value::Null, "bad_request", error.to_string()), + }; + serde_json::to_writer(&mut connection, &response)?; + connection.write_all(b"\n")?; + connection.flush()?; + } + } + Ok(()) +} + +#[cfg(not(unix))] +pub fn serve(_data_dir: &Path) -> Result<()> { + anyhow::bail!("journal protocol v0 requires Unix domain sockets") +} + +fn handle_line(data_dir: &Path, line: &str) -> Response { + let request: Request = match serde_json::from_str(line) { + Ok(request) => request, + Err(error) => return error_response(Value::Null, "bad_request", error.to_string()), + }; + let id = request.id.clone(); + match handle_request(data_dir, request) { + Ok(result) => Response { + id, + ok: true, + result: Some(result), + error: None, + }, + Err((code, message)) => error_response(id, code, message), + } +} + +fn handle_request(data_dir: &Path, request: Request) -> Result { + let engine = Engine::new(data_dir); + match request.verb.as_str() { + "hello" => { + let protocol = request.params["protocol"].as_u64(); + if protocol != Some(u64::from(relayflowd_core::PROTOCOL_VERSION)) { + return Err(( + "protocol_mismatch", + "relayflowd supports protocol 0".to_owned(), + )); + } + Ok(json!({"protocol": relayflowd_core::PROTOCOL_VERSION, "server": "relayflowd"})) + } + "run.start" => { + // Fail closed at the protocol boundary: a spec with an unknown + // field (e.g. a misspelled verification key) is rejected, never + // accepted with the gate silently dropped. + let spec = relayflowd_core::RunSpec::parse(&request.params["spec"]) + .map_err(|error| ("invalid_spec", error.to_string()))?; + let outcome = engine + .start(spec, "protocol-v0", None) + .map_err(internal_error)?; + serde_json::to_value(outcome).map_err(|error| internal_error(error.into())) + } + "run.resume" => { + let run_id = required_string(&request.params, "run_id")?; + let outcome = engine.resume(run_id, None).map_err(internal_error)?; + serde_json::to_value(outcome).map_err(|error| internal_error(error.into())) + } + "run.get" => { + let run_id = required_string(&request.params, "run_id")?; + let snapshot = engine.snapshot(run_id).map_err(internal_error)?; + serde_json::to_value(snapshot).map_err(|error| internal_error(error.into())) + } + "journal.read" => { + let run_id = required_string(&request.params, "run_id")?; + let from_seq = request.params["from_seq"].as_i64().unwrap_or(1); + let limit = request.params["limit"].as_u64().unwrap_or(100) as usize; + let entries = engine + .journal_entries(run_id, from_seq, limit) + .map_err(internal_error)?; + Ok(json!({"entries": entries})) + } + _ => Err(( + "unsupported_verb", + format!( + "{} is not available in the deterministic gate-1 rung", + request.verb + ), + )), + } +} + +fn required_string<'a>(params: &'a Value, key: &str) -> Result<&'a str, (&'static str, String)> { + params[key] + .as_str() + .ok_or_else(|| ("bad_request", format!("missing string parameter {key}"))) +} + +fn internal_error(error: anyhow::Error) -> (&'static str, String) { + // Classify by the typed error in the cause chain, not by message + // substrings: any journal-layer failure is `journal_write_failed`. + let journal_failure = error.chain().any(|cause| { + cause.is::() + || cause.is::() + }); + let code = if journal_failure { + "journal_write_failed" + } else { + "internal" + }; + (code, format!("{error:#}")) +} + +fn error_response(id: Value, code: &str, message: String) -> Response { + Response { + id, + ok: false, + result: None, + error: Some(ProtocolError { + code: code.to_owned(), + message, + }), + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn hello_enforces_protocol_version() { + let directory = tempdir().unwrap(); + let response = handle_line( + directory.path(), + r#"{"id":1,"verb":"hello","params":{"protocol":0,"client":"test"}}"#, + ); + assert!(response.ok); + let mismatch = handle_line( + directory.path(), + r#"{"id":2,"verb":"hello","params":{"protocol":1,"client":"test"}}"#, + ); + assert!(!mismatch.ok); + } + + #[test] + fn run_start_fails_closed_on_an_unknown_verification_key() { + // A misspelled gate key must be rejected at the boundary, never + // accepted with the gate silently dropped (AGENTS.md rule 4). + let directory = tempdir().unwrap(); + let response = handle_line( + directory.path(), + r#"{"id":3,"verb":"run.start","params":{"spec":{"steps":[{"id":"x","type":"deterministic","command":"true","verification":{"output_contain":"x"}}]}}}"#, + ); + assert!(!response.ok); + assert_eq!(response.error.unwrap().code, "invalid_spec"); + } +} diff --git a/kernel/relayflowd/tests/crash_resume.rs b/kernel/relayflowd/tests/crash_resume.rs new file mode 100644 index 00000000..40decd10 --- /dev/null +++ b/kernel/relayflowd/tests/crash_resume.rs @@ -0,0 +1,310 @@ +//! Crash-injection gate (AGENTS.md rule 5): kill between and during steps, +//! resume, assert exactly-once effects. +//! +//! Three tiers, honestly labeled: +//! 1. `sigkill_...` — a REAL crash: the relayflowd process is SIGKILLed +//! mid-run (after step 1's effect, during step 2's attempt), with no +//! chance to flush or clean up. This is the gate. +//! 2. `completed_steps_...` — a controlled interruption at a step boundary +//! (`--stop-after`), pinning the deterministic between-steps window that +//! a racy kill cannot land on reliably. +//! 3. `an_attempt_left_running_...` — a journal-level simulation of a dead +//! attempt, pinning the recovery state machine without any process. + +use std::{ + fs, + os::unix::process::CommandExt, + path::Path, + process::{Child, Command}, + time::{Duration, Instant}, +}; + +use relayflowd::{Engine, RunOutcome}; +use relayflowd_core::{ + Action, CompletionReason, EntryType, Journal, JournalEntry, RunSpawnedPayload, RunSpec, + RunState, StepCompletedPayload, next_actions, +}; +use relayflowd_journal::{Registry, SqliteJournal}; +use serde_json::json; +use tempfile::tempdir; + +fn wait_until(what: &str, mut condition: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(15); + while !condition() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn only_run_id(data_dir: &Path) -> String { + let runs = data_dir.join("runs"); + let mut ids: Vec = fs::read_dir(&runs) + .unwrap() + .filter_map(|entry| { + let name = entry.unwrap().file_name().into_string().unwrap(); + name.strip_suffix(".sqlite3").map(str::to_owned) + }) + .collect(); + assert_eq!(ids.len(), 1, "expected exactly one run journal"); + ids.pop().unwrap() +} + +/// The real crash gate. `kill -9` lands while step `second`'s attempt is +/// running and after step `first`'s effect is durable, so one kill exercises +/// both invariants: the completed step replays as a memoized fact (its effect +/// happens exactly once) and the dead attempt is explained and replaced. +#[test] +fn sigkill_mid_run_preserves_completed_effects_and_replaces_the_dead_attempt() { + let directory = tempdir().unwrap(); + let data_dir = directory.path().join("data"); + let marker = directory.path().join("effects.txt"); + let attempts = directory.path().join("second-attempts.txt"); + let gate = directory.path().join("go"); + let spec_path = directory.path().join("run.json"); + let spec = json!({ + "name": "sigkill-crash-resume", + "steps": [ + { + "id": "first", + "type": "deterministic", + "command": ["/bin/sh", "-c", + format!("printf 'first\\n' >> '{}'", marker.to_string_lossy())] + }, + { + "id": "second", + "type": "deterministic", + "depends_on": ["first"], + "command": ["/bin/sh", "-c", format!( + "printf 'started\\n' >> '{attempts}'; while [ ! -f '{gate}' ]; do sleep 0.05; done; printf 'second\\n' >> '{marker}'", + attempts = attempts.to_string_lossy(), + gate = gate.to_string_lossy(), + marker = marker.to_string_lossy(), + )] + } + ] + }); + fs::write(&spec_path, serde_json::to_vec(&spec).unwrap()).unwrap(); + + // Run relayflowd in its own process group so the kill takes down the + // kernel *and* the step's shell — the machine-crash shape, with no + // orphaned child left to finish the effect on the dead run's behalf. + let mut child: Child = Command::new(env!("CARGO_BIN_EXE_relayflowd")) + .args(["--data-dir", data_dir.to_str().unwrap(), "run"]) + .arg(&spec_path) + .process_group(0) + .spawn() + .unwrap(); + + // Step 1's effect is durable and step 2's attempt has started (the journal + // entry is appended before execution) — now the process dies for real. + wait_until("second step's attempt to start", || attempts.exists()); + let group_killed = Command::new("/bin/kill") + .args(["-9", &format!("-{}", child.id())]) // SIGKILL the whole group + .status() + .unwrap(); + assert!(group_killed.success()); + // exec_det spawns the step's shell as its own process-group leader (so a + // timeout can kill the whole tree), which also detaches it from the group + // killed above. A machine crash takes the step down too: find the shell + // by its unique command line and SIGKILL its group as well, so no orphan + // finishes the effect on the dead run's behalf. + let step_shells = Command::new("pgrep") + .args(["-f", gate.to_str().unwrap()]) + .output() + .unwrap(); + for pid in String::from_utf8_lossy(&step_shells.stdout).split_whitespace() { + let _ = Command::new("/bin/kill") + .args(["-9", &format!("-{pid}")]) + .status(); + } + child.wait().unwrap(); + assert_eq!( + fs::read_to_string(&marker).unwrap(), + "first\n", + "step 2 must not have completed before the kill" + ); + + // Unblock step 2 and resume from the durable journal in a fresh engine. + fs::write(&gate, b"").unwrap(); + let run_id = only_run_id(&data_dir); + let outcome = Engine::new(&data_dir).resume(&run_id, None).unwrap(); + assert_eq!(outcome.status, relayflowd::RunStatus::Completed); + + // Exactly-once effects for the completed step: `first` ran once. + // At-least-once execution for the dead attempt: `second` started twice. + assert_eq!(fs::read_to_string(&marker).unwrap(), "first\nsecond\n"); + assert_eq!(fs::read_to_string(&attempts).unwrap(), "started\nstarted\n"); + + let journal_path = data_dir.join("runs").join(format!("{run_id}.sqlite3")); + let journal = SqliteJournal::open(journal_path).unwrap(); + let entries = journal + .scan_segment(journal.current_segment().unwrap()) + .unwrap(); + let first_starts = entries + .iter() + .filter(|entry| { + entry.entry_type == EntryType::StepAttemptStarted + && entry.step_id.as_deref() == Some("first") + }) + .count(); + assert_eq!(first_starts, 1, "memoized completed step was started again"); + let dead = entries + .iter() + .find(|entry| { + entry.entry_type == EntryType::StepCompleted + && entry.step_id.as_deref() == Some("second") + && entry.attempt == Some(1) + }) + .expect("the killed attempt must be explained in the journal"); + let payload: StepCompletedPayload = serde_json::from_value(dead.payload.clone()).unwrap(); + assert!(matches!( + payload.completion_reason, + CompletionReason::Crashed | CompletionReason::LeaseExpired + )); + assert!(entries.iter().any(|entry| { + entry.entry_type == EntryType::StepAttemptStarted + && entry.step_id.as_deref() == Some("second") + && entry.attempt == Some(2) + })); +} + +/// Controlled interruption exactly at the step boundary (`--stop-after` exits +/// the process after step 1 completes, before step 2's attempt starts). Not a +/// crash — the SIGKILL test above is — but it pins the between-steps window +/// deterministically: a fresh process must inject `first` as a memoized fact. +#[test] +fn completed_steps_are_not_reexecuted_after_process_state_is_dropped() { + let directory = tempdir().unwrap(); + let data_dir = directory.path().join("data"); + let marker = directory.path().join("executions.txt"); + let spec_path = directory.path().join("run.json"); + let marker_text = marker.to_string_lossy(); + let spec = json!({ + "name": "crash-resume", + "steps": [ + { + "id": "first", + "type": "deterministic", + "command": ["/bin/sh", "-c", format!("printf 'first\\n' >> '{}'", marker_text)] + }, + { + "id": "second", + "type": "deterministic", + "depends_on": ["first"], + "command": ["/bin/sh", "-c", format!("printf 'second\\n' >> '{}'", marker_text)] + } + ] + }); + fs::write(&spec_path, serde_json::to_vec(&spec).unwrap()).unwrap(); + + let first_process = Command::new(env!("CARGO_BIN_EXE_relayflowd")) + .args(["--data-dir", data_dir.to_str().unwrap(), "run"]) + .arg(&spec_path) + .args(["--stop-after", "1"]) + .output() + .unwrap(); + assert!(first_process.status.success(), "{:?}", first_process); + let interrupted: RunOutcome = serde_json::from_slice(&first_process.stdout).unwrap(); + assert_eq!(interrupted.status, relayflowd::RunStatus::Interrupted); + assert_eq!(fs::read_to_string(&marker).unwrap(), "first\n"); + + // The first relayflowd process is gone. A fresh process reconstructs state + // from SQLite and must inject `first` as a memoized fact. + let second_process = Command::new(env!("CARGO_BIN_EXE_relayflowd")) + .args([ + "--data-dir", + data_dir.to_str().unwrap(), + "resume", + &interrupted.run_id, + ]) + .output() + .unwrap(); + assert!(second_process.status.success(), "{:?}", second_process); + let resumed: RunOutcome = serde_json::from_slice(&second_process.stdout).unwrap(); + assert_eq!(resumed.status, relayflowd::RunStatus::Completed); + assert_eq!(fs::read_to_string(&marker).unwrap(), "first\nsecond\n"); + + let journal_path = data_dir + .join("runs") + .join(format!("{}.sqlite3", interrupted.run_id)); + let journal = SqliteJournal::open(journal_path).unwrap(); + let entries = journal + .scan_segment(journal.current_segment().unwrap()) + .unwrap(); + let first_starts = entries + .iter() + .filter(|entry| { + entry.entry_type == EntryType::StepAttemptStarted + && entry.step_id.as_deref() == Some("first") + }) + .count(); + assert_eq!(first_starts, 1, "memoized completed step was started again"); +} + +/// Journal-level simulation (no process is killed here — the SIGKILL test +/// covers that): a hand-authored journal says attempt 1 is running while no +/// process holds it. Pins the recovery edge of the state machine in isolation. +#[test] +fn an_attempt_left_running_is_recorded_dead_and_replaced_on_resume() { + let directory = tempdir().unwrap(); + let data_dir = directory.path().join("data"); + let marker = directory.path().join("mid-attempt.txt"); + let run_id = "01KERNELCRASHRESUMETEST0000"; + let spec: RunSpec = RunSpec::parse(&json!({ + "steps": [{ + "id": "unfinished", + "type": "deterministic", + "command": ["/bin/sh", "-c", format!("printf 'finished\\n' >> '{}'", marker.to_string_lossy())] + }] + })) + .unwrap(); + let run_path = data_dir.join("runs").join(format!("{run_id}.sqlite3")); + let mut journal = SqliteJournal::create(&run_path, run_id, 1).unwrap(); + journal + .append(&JournalEntry::new( + EntryType::RunSpawned, + run_id, + None, + None, + 1, + RunSpawnedPayload { + spec: serde_json::to_value(&spec).unwrap(), + spec_hash: "test-hash".to_owned(), + parent_run_id: None, + journal_version: relayflowd_core::JOURNAL_VERSION, + created_by: "crash-test".to_owned(), + }, + )) + .unwrap(); + let state = RunState::fold(run_id, spec, &journal.scan_segment(1).unwrap()).unwrap(); + let Action::Append(started) = &next_actions(&state, 2)[0] else { + panic!("step start must be journaled before execution") + }; + journal.append(started).unwrap(); + Registry::open(data_dir.join("relayflowd.sqlite3")) + .unwrap() + .register(run_id, &run_path) + .unwrap(); + + // Drop all in-memory state while the durable journal says attempt 1 is + // running. A new engine process equivalent must explain and replace it. + drop(journal); + let outcome = Engine::new(&data_dir).resume(run_id, None).unwrap(); + assert_eq!(outcome.status, relayflowd::RunStatus::Completed); + assert_eq!(fs::read_to_string(&marker).unwrap(), "finished\n"); + + let journal = SqliteJournal::open(run_path).unwrap(); + let entries = journal.scan_segment(1).unwrap(); + let dead_attempt = entries + .iter() + .find(|entry| entry.entry_type == EntryType::StepCompleted && entry.attempt == Some(1)); + let payload: StepCompletedPayload = + serde_json::from_value(dead_attempt.unwrap().payload.clone()).unwrap(); + assert!(matches!( + payload.completion_reason, + CompletionReason::Crashed | CompletionReason::LeaseExpired + )); + assert!(entries.iter().any(|entry| { + entry.entry_type == EntryType::StepAttemptStarted && entry.attempt == Some(2) + })); +} diff --git a/sdk/package-lock.json b/sdk/package-lock.json new file mode 100644 index 00000000..7342d96c --- /dev/null +++ b/sdk/package-lock.json @@ -0,0 +1,1491 @@ +{ + "name": "@relayflows/sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@relayflows/sdk", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "yaml": "^2.5.1" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 00000000..be985ae1 --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,34 @@ +{ + "name": "@relayflows/sdk", + "version": "0.1.0", + "description": "TypeScript-first authoring SDK for Relayflows. Compiles specs; speaks the journal protocol.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "tsc --noEmit && vitest run", + "test:watch": "vitest" + }, + "license": "UNLICENSED", + "private": true, + "dependencies": { + "yaml": "^2.5.1" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/sdk/src/canonical.ts b/sdk/src/canonical.ts new file mode 100644 index 00000000..8a7fbe37 --- /dev/null +++ b/sdk/src/canonical.ts @@ -0,0 +1,42 @@ +// Canonical JSON for specs — sorted keys, no whitespace, deterministic. +// +// The kernel hashes `serde_json::to_vec(to_value(spec))`; serde_json objects +// are BTreeMaps, so that is exactly this canonical form. Given the same spec +// value (a kernel-dialect spec from `toKernelSpec`), sha256 here equals the +// kernel's `spec_hash`. This is pinned by `tests/spec-parity.test.ts` and the +// kernel's `tests/spec_parity.rs` over the shared `testdata/` fixture — not +// assumed. + +import { createHash } from 'node:crypto'; + +/** + * Serialize a value as canonical JSON: object keys sorted recursively, + * arrays in order, no whitespace. Numbers are kept as-is (tokens are + * integers; money is a decimal string, never a float — so no float drift). + */ +export function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return '[' + (value as unknown[]).map(canonicalize).join(',') + ']'; + } + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return ( + '{' + + keys + .filter((k) => obj[k] !== undefined) + .map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k])) + .join(',') + + '}' + ); +} + +/** + * sha256 of the canonical JSON of a spec. Pass a kernel-dialect spec + * (`toKernelSpec`) to get the identity the kernel stamps as `spec_hash`. + */ +export function specHash(spec: unknown): string { + return createHash('sha256').update(canonicalize(spec)).digest('hex'); +} diff --git a/sdk/src/compile.ts b/sdk/src/compile.ts new file mode 100644 index 00000000..0563eb7a --- /dev/null +++ b/sdk/src/compile.ts @@ -0,0 +1,236 @@ +// The YAML -> spec compiler. Authoring surface in, the kernel's spec dialect +// out (RFC settled decision #5: the composable unit is the spec, not any +// language). Compilation is two mappings: +// +// compileYaml / compileSpec — parse + validate (fail-closed) + authoring +// defaults, staying in the authoring shape. +// toKernelSpec — authoring shape -> the ONE boundary dialect +// the kernel parses, journals, and hashes +// (snake_case, flat v0 verification, defaults +// materialized). +// +// `compileYamlToCanonicalJson` / `specHash` operate on the kernel dialect, so +// sha256(canonical JSON) equals the kernel's `spec_hash`. That claim is +// proven, not asserted: `tests/spec-parity.test.ts` and the kernel's +// `tests/spec_parity.rs` pin both sides to the same `testdata/` fixture. + +import { parse as parseYaml } from 'yaml'; +import type { + AgentStepSpec, + DeterministicStepSpec, + FlowSpec, + KernelAgentStep, + KernelRunSpec, + KernelStepCommon, + KernelStepSpec, + KernelVerificationSpec, + LlmStepSpec, + StepSpec, + StepType, +} from './spec.js'; +import { SPEC_SCHEMA_VERSION } from './spec.js'; +import { canonicalize, specHash } from './canonical.js'; +import { validateSpec, type ValidationResult } from './validate.js'; + +export class CompileError extends Error { + readonly errors: string[]; + constructor(errors: string[]) { + super('spec compile failed:\n - ' + errors.join('\n - ')); + this.name = 'CompileError'; + this.errors = errors; + } +} + +/** + * Compile a YAML string into a validated authoring `FlowSpec`. + * Throws `CompileError` on a YAML parse error or any validation failure. + */ +export function compileYaml(yaml: string): FlowSpec { + const parsed = parseYaml(yaml); + if (parsed === null || typeof parsed !== 'object') { + throw new CompileError(['YAML: expected a mapping at the top level']); + } + return compileSpec(parsed); +} + +/** Kernel-dialect canonical JSON of `compileYaml` (sorted keys, no whitespace). */ +export function compileYamlToCanonicalJson(yaml: string): string { + return canonicalize(toKernelSpec(compileYaml(yaml))); +} + +/** + * Validate a parsed spec object and apply authoring defaults, returning a + * normalized `FlowSpec`. Throws `CompileError` on validation failure. + */ +export function compileSpec(spec: unknown): FlowSpec { + const validation: ValidationResult = validateSpec(spec); + if (!validation.ok) throw new CompileError(validation.errors); + + const input = spec as FlowSpec; + const steps = input.steps.map(compileStep); + const flow: FlowSpec = { + version: input.version, + name: input.name, + ...(input.description !== undefined ? { description: input.description } : {}), + steps, + ...(input.budget !== undefined ? { budget: input.budget } : {}), + }; + return flow; +} + +function compileStep(step: StepSpec): StepSpec { + const maxIterations = step.maxIterations ?? 1; + const base = { + id: step.id, + type: step.type, + ...(step.dependsOn !== undefined ? { dependsOn: step.dependsOn } : {}), + ...(step.verification !== undefined ? { verification: step.verification } : {}), + maxIterations, + ...(step.timeoutMs !== undefined ? { timeoutMs: step.timeoutMs } : {}), + }; + + switch (step.type as StepType) { + case 'deterministic': { + const s = step as DeterministicStepSpec; + // A deterministic step with no verification gets the implicit exit_code gate. + const verification = s.verification ?? { type: 'exit_code' as const }; + return { ...base, type: 'deterministic', command: s.command, verification }; + } + case 'llm': { + const s = step as LlmStepSpec; + return { + ...base, + type: 'llm', + prompt: s.prompt, + ...(s.model !== undefined ? { model: s.model } : {}), + }; + } + case 'agent': { + const s = step as AgentStepSpec; + const recoveryMode = s.recoveryMode ?? 'reset'; + return { + ...base, + type: 'agent', + instruction: s.instruction, + recoveryMode, + ...(s.surfaces !== undefined ? { surfaces: s.surfaces } : {}), + ...(s.permissions !== undefined ? { permissions: s.permissions } : {}), + }; + } + default: + // validateSpec already gated this; unreachable. + throw new CompileError([`step "${step.id}": unknown type "${String((step as { type: unknown }).type)}"`]); + } +} + +// Kernel defaults, materialized at compile time so the emitted spec is +// byte-identical to the kernel's own serialization of it (spec.rs defaults). +const KERNEL_RETRY_DEFAULTS = { + initial_backoff_ms: 100, + max_backoff_ms: 60_000, + multiplier: 2, + jitter_percent: 20, +} as const; + +/** + * Map an authoring `FlowSpec` to the kernel spec dialect — the single shape at + * the SDK↔kernel boundary (`kernel/relayflowd-core/src/spec.rs`). Authoring + * sugar that the dialect cannot carry is a `CompileError`, never a silent drop. + */ +export function toKernelSpec(flow: FlowSpec): KernelRunSpec { + return { + version: flow.version, + name: flow.name, + ...(flow.description !== undefined ? { description: flow.description } : {}), + steps: flow.steps.map(toKernelStep), + ...(flow.budget !== undefined + ? { + budget: { + ...(flow.budget.maxTokensIn !== undefined ? { max_tokens_in: flow.budget.maxTokensIn } : {}), + ...(flow.budget.maxTokensOut !== undefined ? { max_tokens_out: flow.budget.maxTokensOut } : {}), + ...(flow.budget.maxDollars !== undefined ? { max_dollars: flow.budget.maxDollars } : {}), + }, + } + : {}), + }; +} + +function toKernelStep(step: StepSpec): KernelStepSpec { + const common: KernelStepCommon = { + id: step.id, + depends_on: step.dependsOn ?? [], + max_iterations: step.maxIterations ?? 1, + retry: { ...KERNEL_RETRY_DEFAULTS }, + verification: toKernelVerification(step), + }; + switch (step.type) { + case 'deterministic': + return { + ...common, + type: 'deterministic', + command: step.command, + ...(step.timeoutMs !== undefined ? { timeout_ms: step.timeoutMs } : {}), + }; + case 'llm': { + requireNoTimeout(step); + return { + ...common, + type: 'llm', + prompt: step.prompt, + ...(step.model !== undefined ? { model: step.model } : {}), + }; + } + case 'agent': { + requireNoTimeout(step); + const out: KernelAgentStep = { + ...common, + type: 'agent', + instruction: step.instruction, + recovery_mode: step.recoveryMode ?? 'reset', + }; + const surfaces = { + ...(step.surfaces?.workspace?.length ? { workspace: step.surfaces.workspace.map((w) => ({ surface: w.surface })) } : {}), + ...(step.surfaces?.streams?.length ? { streams: step.surfaces.streams.map((s) => ({ stream: s.stream })) } : {}), + ...(step.surfaces?.external?.length ? { external: step.surfaces.external } : {}), + }; + if (Object.keys(surfaces).length > 0) out.surfaces = surfaces; + if (step.permissions !== undefined) { + out.permissions = { + ...(step.permissions.fileGlobs !== undefined ? { file_globs: step.permissions.fileGlobs } : {}), + ...(step.permissions.networkAllowlist !== undefined ? { network_allowlist: step.permissions.networkAllowlist } : {}), + ...(step.permissions.accessPreset !== undefined ? { access_preset: step.permissions.accessPreset } : {}), + }; + } + return out; + } + } +} + +function requireNoTimeout(step: StepSpec): void { + if (step.timeoutMs !== undefined) { + throw new CompileError([ + `step "${step.id}": only deterministic steps carry a timeout in spec v${SPEC_SCHEMA_VERSION}`, + ]); + } +} + +function toKernelVerification(step: StepSpec): KernelVerificationSpec { + const gate = step.verification; + // No gate / explicit exit_code both compile to {}: exit_code == 0 is the + // kernel's implicit gate for deterministic steps (kernel DESIGN.md §4). + if (gate === undefined || gate.type === 'exit_code') return {}; + if (gate.type === 'output_contains') return { output_contains: gate.value }; + return { json_schema: gate.schema }; +} + +/** + * Compile + hash in one call. `hash` is sha256 of the kernel-dialect canonical + * JSON — the spec identity the kernel stamps as `spec_hash` in `run.spawned`. + */ +export function compileAndHash(yaml: string): { spec: FlowSpec; kernelSpec: KernelRunSpec; hash: string } { + const spec = compileYaml(yaml); + const kernelSpec = toKernelSpec(spec); + return { spec, kernelSpec, hash: specHash(kernelSpec) }; +} + +export { SPEC_SCHEMA_VERSION, canonicalize, specHash }; diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 00000000..e1bc1933 --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,83 @@ +// @relayflows/sdk — TypeScript-first authoring SDK for Relayflows. +// Compiles specs (RFC-0001 §1 ladder) and speaks the journal protocol v0 +// (kernel DESIGN.md §5). + +export type { + AgentStepSpec, + AgentSurfaces, + BaseStepSpec, + BudgetSpec, + DeterministicStepSpec, + ExitCodeGate, + FlowSpec, + JsonSchemaGate, + KernelAgentStep, + KernelAgentSurfaces, + KernelBudgetSpec, + KernelDeterministicStep, + KernelLlmStep, + KernelPermissionsSpec, + KernelRetryPolicy, + KernelRunSpec, + KernelStepCommon, + KernelStepSpec, + KernelVerificationSpec, + LlmStepSpec, + OutputContainsGate, + PermissionsSpec, + RecoveryMode, + StreamSurface, + StepSpec, + StepType, + VerificationGateType, + VerificationSpec, + WorkspaceSurface, +} from './spec.js'; +export { SPEC_SCHEMA_VERSION } from './spec.js'; + +export { canonicalize, specHash } from './canonical.js'; +export { + compileAndHash, + compileSpec, + compileYaml, + compileYamlToCanonicalJson, + toKernelSpec, + CompileError, +} from './compile.js'; +export { validateSpec, type ValidationResult } from './validate.js'; + +export type { + CompletionReason, + EventEmitParams, + EventEmitResult, + HelloParams, + HelloResult, + JournalReadParams, + JournalReadResult, + ProtocolError, + Request, + Response, + RunGetParams, + RunGetResult, + RunResumeParams, + RunResumeResult, + RunState, + RunStatus, + RunWatchParams, + ServerEvent, + StepCompleteParams, + StepDispatchEvent, + StepHeartbeatParams, + StepHeartbeatResult, + StepRunState, + StreamAppendParams, + StreamAppendResult, + StreamReadParams, + StreamReadResult, + Verb, + VerbContract, + WorkerAttachParams, +} from './protocol.js'; +export { JOURNAL_WRITE_FAILED, PROTOCOL_VERSION } from './protocol.js'; + +export { JournalClient, type JournalClientOptions } from './journal-client.js'; diff --git a/sdk/src/journal-client.ts b/sdk/src/journal-client.ts new file mode 100644 index 00000000..07f807e5 --- /dev/null +++ b/sdk/src/journal-client.ts @@ -0,0 +1,256 @@ +// Journal-protocol v0 client (kernel DESIGN.md §5). +// +// A real client implementation of the wire protocol — newline-delimited JSON +// over a unix socket. It correlates requests by `id`, demultiplexes +// server-pushed events, and is fail-closed: a connection drop or write error +// rejects every pending request (a journal write that fails fails the step; +// AGENTS.md rule 4). The kernel binary (`kernel/relayflowd serve`) speaks +// this transport; its gate-1 rung serves `hello`, `run.start`, `run.resume`, +// `run.get`, and `journal.read`, and rejects the remaining typed verbs +// explicitly. The client's own framing and failure behavior is covered by a +// loopback double in tests. + +import { EventEmitter } from 'node:events'; +import { randomUUID } from 'node:crypto'; +import { createConnection, type Socket } from 'node:net'; +import type { VerbContract } from './protocol.js'; +import { + PROTOCOL_VERSION, + type CompletionReason, + type Request, + type Response, + type ServerEvent, +} from './protocol.js'; +import { toKernelSpec } from './compile.js'; +import type { FlowSpec, StepType } from './spec.js'; + +export interface JournalClientOptions { + /** Override the per-request timeout (ms). Default 30000. */ + requestTimeoutMs?: number; +} + +interface Pending { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +export class JournalClient extends EventEmitter { + private socket: Socket | null = null; + private buffer = ''; + private readonly pending = new Map(); + private readonly requestTimeoutMs: number; + + constructor( + private readonly socketPath: string, + options: JournalClientOptions = {}, + ) { + super(); + this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000; + } + + /** Open the unix socket connection. Rejects on connect failure (fail-closed). */ + connect(): Promise { + return new Promise((resolve, reject) => { + if (this.socket) return resolve(); + const socket = createConnection({ path: this.socketPath }); + const onError = (err: Error): void => { + socket.removeAllListeners(); + this.failAll(err); + reject(new Error(`journal client: connect failed: ${err.message}`)); + }; + socket.once('error', onError); + socket.once('connect', () => { + socket.removeListener('error', onError); + socket.on('error', (err) => this.failAll(err)); + socket.on('data', (chunk) => this.onData(chunk)); + socket.on('close', () => this.failAll(new Error('journal client: connection closed'))); + this.socket = socket; + resolve(); + }); + }); + } + + /** Close the connection and reject any pending requests. */ + close(): void { + this.failAll(new Error('journal client: closed by caller')); + this.socket?.destroy(); + this.socket = null; + this.buffer = ''; + } + + private onData(chunk: Buffer): void { + this.buffer += chunk.toString('utf8'); + let nl: number; + while ((nl = this.buffer.indexOf('\n')) !== -1) { + const line = this.buffer.slice(0, nl); + this.buffer = this.buffer.slice(nl + 1); + if (line.length > 0) this.onLine(line); + } + } + + private onLine(line: string): void { + let msg: Response | ServerEvent; + try { + msg = JSON.parse(line) as Response | ServerEvent; + } catch { + // A malformed frame is a protocol violation; fail closed. + this.failAll(new Error('journal client: malformed frame from server')); + return; + } + + if (typeof (msg as Response).id === 'string' && 'ok' in (msg as Response)) { + const res = msg as Response; + const pending = this.pending.get(res.id); + if (!pending) return; // reply for an already-timed-out request + this.pending.delete(res.id); + clearTimeout(pending.timer); + if (res.ok) pending.resolve(res.result); + else pending.reject(new Error(`${res.error.code}: ${res.error.message}`)); + } else { + const ev = msg as ServerEvent; + this.emit(ev.event, ev.data); + this.emit('event', ev); + } + } + + private failAll(err: Error): void { + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + } + + private request( + verb: V, + params: VerbContract[V]['params'], + ): Promise { + return new Promise((resolve, reject) => { + if (!this.socket || this.socket.destroyed) { + reject(new Error(`journal client: not connected (${verb})`)); + return; + } + const id = randomUUID(); + const frame: Request = { id, verb: verb as string, params }; + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`journal client: ${verb} timed out after ${this.requestTimeoutMs}ms`)); + }, this.requestTimeoutMs); + this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer }); + this.socket.write(JSON.stringify(frame) + '\n', (err) => { + if (err) { + const p = this.pending.get(id); + if (p) { + clearTimeout(p.timer); + this.pending.delete(id); + p.reject(new Error(`journal client: ${verb} write failed: ${err.message}`)); + } + } + }); + }); + } + + // --- Typed verb methods (gate 1 minimal set, kernel DESIGN.md §5) -------- + + /** Handshake; version mismatch is a hard error. */ + hello(client: string): Promise { + return this.request('hello', { protocol: PROTOCOL_VERSION, client }); + } + + /** + * Validate spec (zero-agent flows legal), create run file, append + * `run.spawned`. Takes the authoring `FlowSpec` and converts it to the + * kernel dialect at the boundary (`toKernelSpec`): the kernel's + * `RunSpec::parse` is fail-closed and rejects authoring keys like + * `maxIterations`/`dependsOn`, so sending the authoring shape verbatim + * could never start a run. + */ + runStart(spec: FlowSpec): Promise { + return this.request('run.start', { spec: toKernelSpec(spec) }); + } + + /** §3 memoized resume. */ + runResume(runId: string): Promise { + return this.request('run.resume', { run_id: runId }); + } + + /** Snapshot for legibility. */ + runGet(runId: string): Promise { + return this.request('run.get', { run_id: runId }); + } + + /** + * Open a push stream of every appended entry. Resolves once subscribed; + * entries arrive as `'entry'` events: `client.on('entry', (entry) => …)`. + */ + runWatch(runId: string): Promise { + return this.request('run.watch', { run_id: runId }); + } + + /** Connection becomes a worker; receives `step.dispatch` events. */ + workerAttach(workerId: string, stepTypes: StepType[]): Promise { + return this.request('worker.attach', { worker_id: workerId, step_types: stepTypes }); + } + + /** Renew the lease — the one lease primitive. */ + stepHeartbeat( + runId: string, + stepId: string, + attempt: number, + leaseId: string, + ): Promise { + return this.request('step.heartbeat', { + run_id: runId, + step_id: stepId, + attempt, + lease_id: leaseId, + }); + } + + /** Complete a dispatched step — also the out-of-band path. */ + stepComplete( + runId: string, + stepId: string, + attempt: number, + idempotencyKey: string, + completionReason: CompletionReason, + extra: { + output?: unknown; + usage?: { tokens_in: number; tokens_out: number; dollars: string }; + end_pins?: { + workspace?: { surface: string; revision_id: string }[]; + streams?: { stream: string; read_offset: number }[]; + }; + } = {}, + ): Promise { + return this.request('step.complete', { + run_id: runId, + step_id: stepId, + attempt, + idempotency_key: idempotencyKey, + completionReason, + ...extra, + }); + } + + /** Satisfy `wait.event`; a human response arrives here too. */ + eventEmit(runId: string, eventKey: string, payload: unknown): Promise { + return this.request('event.emit', { run_id: runId, event_key: eventKey, payload }); + } + + /** Durable channel write; journals `stream.appended`. */ + streamAppend(runId: string, stream: string, message: unknown): Promise { + return this.request('stream.append', { run_id: runId, stream, message }); + } + + /** At-least-once replayable read. Committing the consumer offset happens via pins. */ + streamRead(runId: string, stream: string, fromOffset: number, limit?: number): Promise { + return this.request('stream.read', { run_id: runId, stream, from_offset: fromOffset, limit }); + } + + /** Raw journal access — replay, audit, the report step. */ + journalRead(runId: string, fromSeq: number, limit?: number): Promise { + return this.request('journal.read', { run_id: runId, from_seq: fromSeq, limit }); + } +} diff --git a/sdk/src/protocol.ts b/sdk/src/protocol.ts new file mode 100644 index 00000000..8f692da0 --- /dev/null +++ b/sdk/src/protocol.ts @@ -0,0 +1,226 @@ +// Journal protocol v0 — the SDK boundary (kernel DESIGN.md §5). +// +// Transport: newline-delimited JSON over a unix socket at +// `/relayflowd.sock`. Requests `{id, verb, params}`; responses +// `{id, ok: true, result}` or `{id, ok: false, error: {code, message}}`; +// server-pushed events `{event, data}` (no `id`). Any verb whose journal +// append fails returns `error{code: "journal_write_failed"}` and the affected +// step fails — the protocol is fail-closed like everything behind it. +// +// This module is the typed wire surface; `journal-client.ts` implements it. + +import type { KernelRunSpec, StepType } from './spec.js'; + +/** Stamped per segment; readers read every past version, writers write newest. */ +export const PROTOCOL_VERSION = 0 as const; + +/** A journal append that fails fails the step (AGENTS.md rule 4). */ +export const JOURNAL_WRITE_FAILED = 'journal_write_failed' as const; + +export type ProtocolError = { code: string; message: string }; + +/** Client -> server. */ +export interface Request

{ + id: string; + verb: string; + params: P; +} + +/** Server -> client response, correlated by `id`. */ +export type Response = + | { id: string; ok: true; result: R } + | { id: string; ok: false; error: ProtocolError }; + +/** Server -> client push (no `id`). */ +export interface ServerEvent { + event: string; + data: unknown; +} + +// --- Verb set (gate 1 minimal) --------------------------------------------- +// Verb names mirror kernel DESIGN.md §5 verbatim. + +export type Verb = + | 'hello' + | 'run.start' + | 'run.resume' + | 'run.get' + | 'run.watch' + | 'worker.attach' + | 'step.heartbeat' + | 'step.complete' + | 'event.emit' + | 'stream.append' + | 'stream.read' + | 'journal.read'; + +// --- Typed params / results ------------------------------------------------- + +export interface HelloParams { + protocol: 0; + client: string; +} +export interface HelloResult { + protocol: 0; + server: string; +} + +export interface RunStartParams { + /** + * The kernel spec dialect — the ONE boundary shape `RunSpec::parse` + * accepts (snake_case, flat v0 verification, defaults materialized). + * The authoring `FlowSpec` never crosses the wire; `JournalClient.runStart` + * converts via `toKernelSpec`. + */ + spec: KernelRunSpec; +} +export interface RunStartResult { + run_id: string; +} + +export interface RunResumeParams { + run_id: string; +} +export interface RunResumeResult { + run_id: string; + state: RunState; +} + +export type RunStatus = 'running' | 'waiting' | 'parked' | 'done' | 'failed'; + +export interface StepRunState { + step_id: string; + status: RunStatus; + attempt?: number; + completionReason?: string; + output?: unknown; +} + +export interface RunState { + status: RunStatus; + steps: StepRunState[]; + budget: { tokens_in: number; tokens_out: number; dollars: string }; +} + +export interface RunGetParams { + run_id: string; +} +export interface RunGetResult { + status: RunStatus; + steps: StepRunState[]; + budget: { tokens_in: number; tokens_out: number; dollars: string }; +} + +export interface RunWatchParams { + run_id: string; +} +/** `run.watch` opens a push stream of `{event: "entry", data: Entry}`. */ + +export interface WorkerAttachParams { + worker_id: string; + step_types: StepType[]; +} +/** Server then pushes `step.dispatch` events to the attached worker. */ +export interface StepDispatchEvent { + run_id: string; + step_id: string; + attempt: number; + step_type: StepType; + spec: unknown; + idempotency_key: string; + pins: { + workspace?: { surface: string; revision_id: string }[]; + streams?: { stream: string; read_offset: number }[]; + }; + lease_deadline_ms: number; +} + +export interface StepHeartbeatParams { + run_id: string; + step_id: string; + attempt: number; + lease_id: string; +} +export interface StepHeartbeatResult { + lease_deadline_ms: number; +} + +/** completionReason mirrors kernel DESIGN.md §1.3. */ +export type CompletionReason = + | 'success' + | 'verification_failed' + | 'retries_exhausted' + | 'lease_expired' + | 'crashed' + | 'timeout' + | 'worker_error' + | 'budget_exceeded' + | 'canceled'; + +export interface StepCompleteParams { + run_id: string; + step_id: string; + attempt: number; + idempotency_key: string; + completionReason: CompletionReason; + output?: unknown; + usage?: { tokens_in: number; tokens_out: number; dollars: string }; + end_pins?: { + workspace?: { surface: string; revision_id: string }[]; + streams?: { stream: string; read_offset: number }[]; + }; +} + +export interface EventEmitParams { + run_id: string; + event_key: string; + payload: unknown; +} +export interface EventEmitResult { + matched: number; +} + +export interface StreamAppendParams { + run_id: string; + stream: string; + message: unknown; +} +export interface StreamAppendResult { + offset: number; +} + +export interface StreamReadParams { + run_id: string; + stream: string; + from_offset: number; + limit?: number; +} +export interface StreamReadResult { + messages: unknown[]; + next_offset: number; +} + +export interface JournalReadParams { + run_id: string; + from_seq: number; + limit?: number; +} +export interface JournalReadResult { + entries: unknown[]; +} + +/** Typed map of verb -> { params, result }. Used by the client for type-safety. */ +export interface VerbContract { + hello: { params: HelloParams; result: HelloResult }; + 'run.start': { params: RunStartParams; result: RunStartResult }; + 'run.resume': { params: RunResumeParams; result: RunResumeResult }; + 'run.get': { params: RunGetParams; result: RunGetResult }; + 'run.watch': { params: RunWatchParams; result: void }; + 'worker.attach': { params: WorkerAttachParams; result: void }; + 'step.heartbeat': { params: StepHeartbeatParams; result: StepHeartbeatResult }; + 'step.complete': { params: StepCompleteParams; result: void }; + 'event.emit': { params: EventEmitParams; result: EventEmitResult }; + 'stream.append': { params: StreamAppendParams; result: StreamAppendResult }; + 'stream.read': { params: StreamReadParams; result: StreamReadResult }; + 'journal.read': { params: JournalReadParams; result: JournalReadResult }; +} diff --git a/sdk/src/spec.ts b/sdk/src/spec.ts new file mode 100644 index 00000000..35f886a3 --- /dev/null +++ b/sdk/src/spec.ts @@ -0,0 +1,244 @@ +// Spec types for Relayflows — data, not code (RFC-0001 settled decision #5). +// +// Mirrors RFC-0001 §1's ladder. Every rung is a legal relayflow: +// deterministic step # a pure script — no LLM anywhere (legal) +// + llm step # a bare model call — prompt in, verified output out +// + agent step # a harnessed agent in a workspace — artifact + diff + trajectory +// +// Step types are exactly `deterministic | llm | agent` (AGENTS.md rule 7). +// Zero-agent flows are legal: a spec with only `deterministic` (and/or `llm`) +// steps is valid. Nothing here requires an `agent` step. + +/** The three rungs of the ladder (RFC §1; AGENTS.md rule 7). */ +export type StepType = 'deterministic' | 'llm' | 'agent'; + +/** + * Verification is control flow, not decoration (kernel DESIGN.md §3). + * v0 gates are deterministic so verification is kernel-side and replayable. + */ +export type VerificationGateType = 'exit_code' | 'output_contains' | 'json_schema'; + +/** + * `exit_code == 0` — the implicit gate for deterministic steps. v0 judges + * exactly zero (kernel DESIGN.md §4); it is not configurable, so this gate + * carries no parameters. Writing it explicitly is allowed and compiles to the + * same kernel spec as omitting it. + */ +export interface ExitCodeGate { + type: 'exit_code'; +} + +/** Step output (stdout_tail / llm value, stringified) contains `value`. */ +export interface OutputContainsGate { + type: 'output_contains'; + value: string; +} + +/** Step output validates against a JSON Schema. Used for `llm` structured output. */ +export interface JsonSchemaGate { + type: 'json_schema'; + schema: Record; +} + +export type VerificationSpec = ExitCodeGate | OutputContainsGate | JsonSchemaGate; + +/** + * Agent-step recovery modes (RFC Appendix A rule 4). Default is `reset`. + * `deterministic` / `llm` steps have no workspace, so these do not apply. + */ +export type RecoveryMode = 'reset' | 'inspect' | 'manual'; + +/** + * Declared mutable surfaces for an agent step (RFC Appendix A rule 1). + * Anything undeclared is outside the contract and outside the step's + * permissions (gate 8 makes this enforceable, not advisory). + */ +export interface WorkspaceSurface { + /** Relayfile mount path, or a named worktree. */ + surface: string; +} + +export interface StreamSurface { + /** Durable channel the agent may write (kernel DESIGN.md §1.8). */ + stream: string; +} + +export interface AgentSurfaces { + workspace?: WorkspaceSurface[]; + streams?: StreamSurface[]; + /** Integration writeback paths — mount writes per gate 6. */ + external?: string[]; +} + +/** Permission model for an agent step (gate 8). `readonly` provably cannot write. */ +export interface PermissionsSpec { + fileGlobs?: string[]; + networkAllowlist?: string[]; + accessPreset?: 'readonly' | 'readwrite'; +} + +/** + * Budget envelope. Per decision #10 every token has exactly one owner: an + * injected context pack spends the consuming step's budget. Money is a decimal + * string at the boundary (kernel DESIGN.md §1: no floats for money); tokens + * are integers. + */ +export interface BudgetSpec { + maxTokensIn?: number; + maxTokensOut?: number; + /** Decimal string, e.g. "1.50". */ + maxDollars?: string; +} + +/** Fields shared by every step on the ladder. */ +export interface BaseStepSpec { + /** Stable step identity; journaled as `step_id` and hashed into the idempotency key. */ + id: string; + type: StepType; + /** Step dependencies — a step runs only after these complete. */ + dependsOn?: string[]; + /** Verification gate. Omit on a deterministic step to get the implicit `exit_code` gate. */ + verification?: VerificationSpec; + /** Semantic retry bound (kernel DESIGN.md §1.2 `max_iterations`). Default 1. */ + maxIterations?: number; + timeoutMs?: number; +} + +/** + * Rung 1 — a pure script. Executed by the `relayflowd` binary: spawn command, + * capture stdout/exit code. Output = `{exit_code, stdout_tail}`. Gate-1 + * deterministic steps are pure (no pins). + */ +export interface DeterministicStepSpec extends BaseStepSpec { + type: 'deterministic'; + command: string; +} + +/** + * Rung 2 — a bare model call. No workspace, output is a value. The kernel + * never calls a model: it dispatches to an attached SDK worker (§5) which + * returns `{output, usage}`; the kernel then runs the verification gate. + */ +export interface LlmStepSpec extends BaseStepSpec { + type: 'llm'; + prompt: string; + model?: string; +} + +/** + * Rung 3 — a harnessed agent in a workspace. Dispatched like `llm`, plus + * Appendix A in full: pins declared workspace revisions and stream offsets; + * every writeback is a journaled `effect.recorded` deduped by + * `(step_id, idempotency_key, surface_path)`. + */ +export interface AgentStepSpec extends BaseStepSpec { + type: 'agent'; + instruction: string; + surfaces?: AgentSurfaces; + recoveryMode?: RecoveryMode; + permissions?: PermissionsSpec; +} + +export type StepSpec = DeterministicStepSpec | LlmStepSpec | AgentStepSpec; + +/** + * A Relayflow spec in the authoring shape — the composable unit (RFC settled + * decision #5). Schema-validated, diffable, signable (gate 8), and emittable + * by a step (gate 9 self-authoring). What the kernel inlines in `run.spawned` + * is this spec mapped to the kernel dialect (`toKernelSpec`). + */ +export interface FlowSpec { + /** Spec schema semver (RFC §7). Compilers always emit latest. */ + version: string; + name: string; + description?: string; + steps: StepSpec[]; + budget?: BudgetSpec; +} + +/** Current spec schema version emitted by this SDK. */ +export const SPEC_SCHEMA_VERSION = '0.1.0'; + +// --- The kernel dialect ------------------------------------------------------ +// +// The authoring types above are TypeScript-idiomatic (camelCase, tagged +// verification sugar). The *boundary artifact* is singular: the kernel's spec +// dialect — snake_case keys, semver `version`, flat v0 verification, defaults +// materialized. `toKernelSpec` (compile.ts) maps authoring → kernel; parity +// with `kernel/relayflowd-core/src/spec.rs` is pinned bit-for-bit by +// `tests/spec-parity.test.ts` and the kernel's `tests/spec_parity.rs` over the +// shared `testdata/` fixture. + +export interface KernelRetryPolicy { + initial_backoff_ms: number; + max_backoff_ms: number; + multiplier: number; + jitter_percent: number; +} + +/** + * Flat v0 gates (kernel DESIGN.md §4): `exit_code == 0` is implicit for + * deterministic steps; these two are optional and combinable. An empty object + * means "implicit gates only". + */ +export interface KernelVerificationSpec { + output_contains?: string; + json_schema?: Record; +} + +export interface KernelStepCommon { + id: string; + depends_on: string[]; + max_iterations: number; + retry: KernelRetryPolicy; + verification: KernelVerificationSpec; +} + +export interface KernelDeterministicStep extends KernelStepCommon { + type: 'deterministic'; + command: string; + timeout_ms?: number; +} + +export interface KernelLlmStep extends KernelStepCommon { + type: 'llm'; + prompt: string; + model?: string; +} + +export interface KernelAgentSurfaces { + workspace?: { surface: string }[]; + streams?: { stream: string }[]; + external?: string[]; +} + +export interface KernelPermissionsSpec { + file_globs?: string[]; + network_allowlist?: string[]; + access_preset?: 'readonly' | 'readwrite'; +} + +export interface KernelAgentStep extends KernelStepCommon { + type: 'agent'; + instruction: string; + recovery_mode: RecoveryMode; + surfaces?: KernelAgentSurfaces; + permissions?: KernelPermissionsSpec; +} + +export type KernelStepSpec = KernelDeterministicStep | KernelLlmStep | KernelAgentStep; + +export interface KernelBudgetSpec { + max_tokens_in?: number; + max_tokens_out?: number; + max_dollars?: string; +} + +/** The compiled spec as the kernel parses, journals, and hashes it. */ +export interface KernelRunSpec { + version: string; + name?: string; + description?: string; + steps: KernelStepSpec[]; + budget?: KernelBudgetSpec; +} diff --git a/sdk/src/validate.ts b/sdk/src/validate.ts new file mode 100644 index 00000000..626ceecd --- /dev/null +++ b/sdk/src/validate.ts @@ -0,0 +1,411 @@ +// Spec validation — fail-closed (AGENTS.md rule 4). The compiler runs every +// spec through this before emitting JSON; a malformed spec is rejected with a +// concrete error, never silently coerced. Zero-agent flows are legal: there is +// no requirement that any step be `llm` or `agent`. + +import type { + AgentStepSpec, + BudgetSpec, + DeterministicStepSpec, + FlowSpec, + LlmStepSpec, + PermissionsSpec, + RecoveryMode, + StepSpec, + StepType, + VerificationSpec, +} from './spec.js'; + +export interface ValidationResult { + ok: boolean; + errors: string[]; +} + +const STEP_TYPES: ReadonlySet = new Set([ + 'deterministic', + 'llm', + 'agent', +]); + +const RECOVERY_MODES: ReadonlySet = new Set([ + 'reset', + 'inspect', + 'manual', +]); + +const DECIMAL_RE = /^\d+(\.\d+)?$/; +const SEMVER_RE = /^\d+\.\d+\.\d+$/; + +// Allowed keys per authoring object level. Validation is fail-closed on +// unknown keys (AGENTS.md rule 4; RFC covenant 2): a typo'd key like +// `depends_on` must be an error naming the nearest valid key, never a +// silently discarded field — silently dropping `dependsOn` loses ordering. +const ROOT_KEYS = ['version', 'name', 'description', 'steps', 'budget'] as const; +const BUDGET_KEYS = ['maxTokensIn', 'maxTokensOut', 'maxDollars'] as const; +const STEP_COMMON_KEYS = ['id', 'type', 'dependsOn', 'verification', 'maxIterations', 'timeoutMs'] as const; +const STEP_TYPE_KEYS: Record = { + deterministic: ['command'], + llm: ['prompt', 'model'], + agent: ['instruction', 'surfaces', 'recoveryMode', 'permissions'], +}; +const VERIFICATION_KEYS: Record = { + exit_code: ['type', 'expect'], + output_contains: ['type', 'value'], + json_schema: ['type', 'schema'], +}; +const SURFACES_KEYS = ['workspace', 'streams', 'external'] as const; +const WORKSPACE_SURFACE_KEYS = ['surface'] as const; +const STREAM_SURFACE_KEYS = ['stream'] as const; +const PERMISSIONS_KEYS = ['fileGlobs', 'networkAllowlist', 'accessPreset'] as const; + +class Validator { + private errors: string[] = []; + private ids = new Set(); + + fail(msg: string): void { + this.errors.push(msg); + } + + /** + * Reject unknown keys at an authoring object level, suggesting the nearest + * valid key. Errors speak the author's vocabulary (RFC covenant 1): + * `unknown key "depends_on" — did you mean "dependsOn"?`. + */ + private checkKeys(obj: Record, allowed: readonly string[], at: string): void { + for (const key of Object.keys(obj)) { + if (allowed.includes(key)) continue; + const suggestion = nearestKey(key, allowed); + this.fail( + suggestion !== null + ? `${at}: unknown key "${key}" — did you mean "${suggestion}"?` + : `${at}: unknown key "${key}" (expected one of ${allowed.join(' | ')})`, + ); + } + } + + result(): ValidationResult { + return { ok: this.errors.length === 0, errors: this.errors }; + } + + run(spec: unknown): ValidationResult { + if (!isObject(spec)) { + this.fail('spec: expected an object'); + return this.result(); + } + const s = spec as Record; + this.checkKeys(s, ROOT_KEYS, 'spec'); + + if (!isNonEmptyString(s['version'])) { + this.fail('spec.version: expected a non-empty semver string (e.g. "0.1.0")'); + } else if (!SEMVER_RE.test(s['version'] as string)) { + this.fail(`spec.version: "${s['version']}" is not semver (MAJOR.MINOR.PATCH)`); + } + + if (!isNonEmptyString(s['name'])) { + this.fail('spec.name: expected a non-empty string'); + } + + if (s['description'] !== undefined && typeof s['description'] !== 'string') { + this.fail('spec.description: expected a string'); + } + + if (s['budget'] !== undefined) this.validateBudget(s['budget']); + + if (!Array.isArray(s['steps']) || s['steps'].length === 0) { + this.fail('spec.steps: expected a non-empty array'); + return this.result(); + } + + const steps = s['steps'] as unknown[]; + for (let i = 0; i < steps.length; i++) { + this.validateStep(steps[i], i); + } + + // Dependents must reference real step ids and form a DAG (no cycles). + this.validateDeps(steps as StepSpec[]); + return this.result(); + } + + private validateBudget(b: unknown): void { + if (!isObject(b)) { + this.fail('spec.budget: expected an object'); + return; + } + this.checkKeys(b, BUDGET_KEYS, 'spec.budget'); + const budget = b as BudgetSpec; + if ( + budget.maxTokensIn !== undefined && + !isNonNegInt(budget.maxTokensIn) + ) { + this.fail('spec.budget.maxTokensIn: expected a non-negative integer'); + } + if ( + budget.maxTokensOut !== undefined && + !isNonNegInt(budget.maxTokensOut) + ) { + this.fail('spec.budget.maxTokensOut: expected a non-negative integer'); + } + if (budget.maxDollars !== undefined) { + if (typeof budget.maxDollars !== 'string' || !DECIMAL_RE.test(budget.maxDollars)) { + this.fail('spec.budget.maxDollars: expected a decimal string, e.g. "1.50"'); + } + } + } + + private validateStep(step: unknown, index: number): void { + const at = `spec.steps[${index}]`; + if (!isObject(step)) { + this.fail(`${at}: expected an object`); + return; + } + const st = step as Record; + + if (!isNonEmptyString(st['id'])) { + this.fail(`${at}.id: expected a non-empty string`); + } else if (this.ids.has(st['id'] as string)) { + this.fail(`${at}.id: duplicate step id "${st['id']}"`); + } else { + this.ids.add(st['id'] as string); + } + + if (!isNonEmptyString(st['type']) || !STEP_TYPES.has(st['type'] as StepType)) { + this.fail(`${at}.type: expected one of deterministic | llm | agent`); + return; + } + const type = st['type'] as StepType; + this.checkKeys(st, [...STEP_COMMON_KEYS, ...STEP_TYPE_KEYS[type]], at); + + if (st['dependsOn'] !== undefined) { + if (!Array.isArray(st['dependsOn']) || !(st['dependsOn'] as unknown[]).every(isNonEmptyString)) { + this.fail(`${at}.dependsOn: expected an array of step ids`); + } + } + + if (st['verification'] !== undefined) { + this.validateVerification(st['verification'], `${at}.verification`); + } + + if (st['maxIterations'] !== undefined && !isPosInt(st['maxIterations'])) { + this.fail(`${at}.maxIterations: expected a positive integer`); + } + + if (st['timeoutMs'] !== undefined && !isPosInt(st['timeoutMs'])) { + this.fail(`${at}.timeoutMs: expected a positive integer`); + } + if (st['timeoutMs'] !== undefined && type !== 'deterministic') { + // The v0.1.0 spec dialect carries timeout_ms on deterministic steps + // only; llm/agent timeouts land with worker dispatch. Fail closed + // rather than silently drop the field. + this.fail(`${at}.timeoutMs: only deterministic steps carry a timeout in spec v0.1.0`); + } + + if (type === 'deterministic') { + this.validateDeterministic(st as unknown as DeterministicStepSpec, at); + } else if (type === 'llm') { + this.validateLlm(st as unknown as LlmStepSpec, at); + } else { + this.validateAgent(st as unknown as AgentStepSpec, at); + } + } + + private validateVerification(v: unknown, at: string): void { + if (!isObject(v)) { + this.fail(`${at}: expected an object`); + return; + } + const gate = v as unknown as VerificationSpec & { expect?: unknown }; + const gateKeys = typeof gate.type === 'string' ? VERIFICATION_KEYS[gate.type] : undefined; + if (gateKeys !== undefined) { + this.checkKeys(v, gateKeys, at); + } + if (gate.type === 'exit_code') { + // v0 judges exit_code == 0 exactly (kernel DESIGN.md §4). Fail closed + // rather than compile a spec whose gate the kernel cannot enforce. + if (gate.expect !== undefined && gate.expect !== 0) { + this.fail(`${at}.expect: v0 exit_code gate judges exit_code == 0; a custom expect is not supported`); + } + } else if (gate.type === 'output_contains') { + if (typeof gate.value !== 'string' || gate.value.length === 0) { + this.fail(`${at}.value: expected a non-empty string`); + } + } else if (gate.type === 'json_schema') { + if (!isObject(gate.schema)) { + this.fail(`${at}.schema: expected a JSON Schema object`); + } + } else { + this.fail(`${at}.type: expected exit_code | output_contains | json_schema`); + } + } + + private validateDeterministic(st: DeterministicStepSpec, at: string): void { + if (!isNonEmptyString(st.command)) { + this.fail(`${at}.command: expected a non-empty string`); + } + } + + private validateLlm(st: LlmStepSpec, at: string): void { + if (!isNonEmptyString(st.prompt)) { + this.fail(`${at}.prompt: expected a non-empty string`); + } + if (st.model !== undefined && typeof st.model !== 'string') { + this.fail(`${at}.model: expected a string`); + } + } + + private validateAgent(st: AgentStepSpec, at: string): void { + if (!isNonEmptyString(st.instruction)) { + this.fail(`${at}.instruction: expected a non-empty string`); + } + if (st.recoveryMode !== undefined && !RECOVERY_MODES.has(st.recoveryMode)) { + this.fail(`${at}.recoveryMode: expected reset | inspect | manual`); + } + if (st.surfaces !== undefined) this.validateSurfaces(st.surfaces, `${at}.surfaces`); + if (st.permissions !== undefined) this.validatePermissions(st.permissions, `${at}.permissions`); + } + + private validateSurfaces(surfaces: AgentStepSpec['surfaces'], at: string): void { + if (!isObject(surfaces)) { + this.fail(`${at}: expected an object`); + return; + } + const s = surfaces as Record; + this.checkKeys(s, SURFACES_KEYS, at); + if (s['workspace'] !== undefined) { + if (!Array.isArray(s['workspace']) || !(s['workspace'] as unknown[]).every((w) => isObject(w) && isNonEmptyString((w as Record)['surface']))) { + this.fail(`${at}.workspace: expected an array of {surface: string}`); + } else { + for (const [i, w] of (s['workspace'] as Record[]).entries()) { + this.checkKeys(w, WORKSPACE_SURFACE_KEYS, `${at}.workspace[${i}]`); + } + } + } + if (s['streams'] !== undefined) { + if (!Array.isArray(s['streams']) || !(s['streams'] as unknown[]).every((w) => isObject(w) && isNonEmptyString((w as Record)['stream']))) { + this.fail(`${at}.streams: expected an array of {stream: string}`); + } else { + for (const [i, w] of (s['streams'] as Record[]).entries()) { + this.checkKeys(w, STREAM_SURFACE_KEYS, `${at}.streams[${i}]`); + } + } + } + if (s['external'] !== undefined) { + if (!Array.isArray(s['external']) || !(s['external'] as unknown[]).every(isNonEmptyString)) { + this.fail(`${at}.external: expected an array of path strings`); + } + } + } + + private validatePermissions(p: PermissionsSpec, at: string): void { + if (!isObject(p)) { + this.fail(`${at}: expected an object`); + return; + } + this.checkKeys(p as Record, PERMISSIONS_KEYS, at); + if (p.accessPreset !== undefined && p.accessPreset !== 'readonly' && p.accessPreset !== 'readwrite') { + this.fail(`${at}.accessPreset: expected readonly | readwrite`); + } + if (p.fileGlobs !== undefined && !(Array.isArray(p.fileGlobs) && p.fileGlobs.every(isNonEmptyString))) { + this.fail(`${at}.fileGlobs: expected an array of strings`); + } + if (p.networkAllowlist !== undefined && !(Array.isArray(p.networkAllowlist) && p.networkAllowlist.every(isNonEmptyString))) { + this.fail(`${at}.networkAllowlist: expected an array of strings`); + } + } + + private validateDeps(steps: StepSpec[]): void { + const known = this.ids; + const adj = new Map(); + for (const step of steps) { + const deps = step.dependsOn ?? []; + for (const d of deps) { + if (!known.has(d)) { + this.fail(`spec.steps: step "${step.id}" dependsOn unknown step "${d}"`); + } + } + adj.set(step.id, deps); + } + // Cycle detection (DFS, WHITE/GRAY/BLACK). + const WHITE = 0, GRAY = 1, BLACK = 2; + const color = new Map(); + for (const id of adj.keys()) color.set(id, WHITE); + const stack: string[] = []; + const dfs = (id: string): void => { + color.set(id, GRAY); + stack.push(id); + const deps = adj.get(id) ?? []; + for (const d of deps) { + const c = color.get(d); + if (c === GRAY) { + this.fail(`spec.steps: dependency cycle detected at "${d}" (path: ${[...stack].join(' -> ')} -> ${d})`); + } else if (c === WHITE) { + dfs(d); + } + } + stack.pop(); + color.set(id, BLACK); + }; + for (const id of adj.keys()) { + if (color.get(id) === WHITE) dfs(id); + } + } +} + +/** Validate a parsed spec object. Returns `{ok, errors}`; never throws. */ +export function validateSpec(spec: unknown): ValidationResult { + return new Validator().run(spec); +} + +// --- unknown-key suggestions ------------------------------------------------ + +/** + * The nearest valid key for a typo, or null when nothing is close. A key that + * differs only in casing/separators (`depends_on` -> `dependsOn`) always + * matches; otherwise small edit distances catch plain misspellings. + */ +function nearestKey(key: string, allowed: readonly string[]): string | null { + const normalize = (value: string): string => value.toLowerCase().replace(/[_-]/g, ''); + let best: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of allowed) { + if (normalize(candidate) === normalize(key)) return candidate; + const distance = levenshtein(key.toLowerCase(), candidate.toLowerCase()); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + return best !== null && bestDistance <= 3 && bestDistance < best.length ? best : null; +} + +function levenshtein(a: string, b: string): number { + let previous: number[] = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const current: number[] = [i]; + for (let j = 1; j <= b.length; j++) { + const deletion = (previous[j] ?? 0) + 1; + const insertion = (current[j - 1] ?? 0) + 1; + const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1); + current[j] = Math.min(deletion, insertion, substitution); + } + previous = current; + } + return previous[b.length] ?? 0; +} + +// --- predicates ------------------------------------------------------------- + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function isNonEmptyString(v: unknown): v is string { + return typeof v === 'string' && v.length > 0; +} + +function isNonNegInt(v: unknown): v is number { + return typeof v === 'number' && Number.isInteger(v) && v >= 0; +} + +function isPosInt(v: unknown): v is number { + return typeof v === 'number' && Number.isInteger(v) && v > 0; +} diff --git a/sdk/tests/deterministic-llm.test.ts b/sdk/tests/deterministic-llm.test.ts new file mode 100644 index 00000000..98445bdf --- /dev/null +++ b/sdk/tests/deterministic-llm.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { compileYaml } from '../src/compile.js'; +import type { AgentStepSpec, DeterministicStepSpec, LlmStepSpec } from '../src/spec.js'; + +// Ladder rung (b): the same flow plus a bare `llm` step with a verification +// gate (RFC §3 Gate 1 done-when). `llm` is a kernel-level step type distinct +// from `agent`: no workspace, output is a value, verification is the rail. + +const DET_PLUS_LLM_YAML = ` +version: '0.1.0' +name: deterministic-plus-llm +description: Ladder rung (b) — a deterministic step feeds a verified llm step. +steps: + - id: prepare + type: deterministic + command: "echo 'What is 2+2?'" + verification: + type: output_contains + value: "2+2" + - id: answer + type: llm + dependsOn: [prepare] + prompt: "Reply with JSON {answer: number} for 2+2." + model: gpt-4o-mini + maxIterations: 3 + verification: + type: json_schema + schema: + type: object + required: [answer] + properties: + answer: + type: number +`; + +describe('compile: deterministic + llm flow (ladder rung b)', () => { + it('compiles a deterministic step and an llm step to spec JSON', () => { + const spec = compileYaml(DET_PLUS_LLM_YAML); + expect(spec.steps).toHaveLength(2); + + const prepare = spec.steps[0] as DeterministicStepSpec; + expect(prepare.type).toBe('deterministic'); + expect(prepare.command).toBe("echo 'What is 2+2?'"); + + const answer = spec.steps[1] as LlmStepSpec; + expect(answer.type).toBe('llm'); + expect(answer.dependsOn).toEqual(['prepare']); + expect(answer.prompt).toContain('JSON'); + expect(answer.model).toBe('gpt-4o-mini'); + // Semantic retry bound is carried through (kernel DESIGN.md §1.2 max_iterations). + expect(answer.maxIterations).toBe(3); + }); + + it('keeps the llm step workspace-free and value-output (distinct from agent)', () => { + const spec = compileYaml(DET_PLUS_LLM_YAML); + const answer = spec.steps[1] as LlmStepSpec; + expect(answer.type).toBe('llm'); + expect('surfaces' in answer).toBe(false); + expect('recoveryMode' in answer).toBe(false); + expect('instruction' in answer).toBe(false); + }); + + it('carries the json_schema verification gate for structured llm output', () => { + const spec = compileYaml(DET_PLUS_LLM_YAML); + const answer = spec.steps[1] as LlmStepSpec; + expect(answer.verification?.type).toBe('json_schema'); + const schema = answer.verification as { type: string; schema: { type: string; required: string[] } }; + expect(schema.schema.type).toBe('object'); + expect(schema.schema.required).toEqual(['answer']); + }); +}); + +// Ladder rung (c) is covered structurally here: an `agent` step compiles with +// Appendix A surfaces + recovery mode. The kernel binary + crash harness is +// gate 1's remaining work; the spec surface for it lands now. + +const WITH_AGENT_YAML = ` +version: '0.1.0' +name: deterministic-llm-agent +steps: + - id: prep + type: deterministic + command: "echo prep" + - id: think + type: llm + dependsOn: [prep] + prompt: "plan" + - id: act + type: agent + dependsOn: [think] + instruction: "Edit the repo per the plan." + recoveryMode: inspect + maxIterations: 2 + surfaces: + workspace: + - surface: repo/ + streams: + - stream: results + external: + - pr://github/example + permissions: + accessPreset: readwrite + fileGlobs: ["src/**"] +`; + +describe('compile: agent step (ladder rung c, Appendix A surface)', () => { + it('compiles surfaces, recovery mode, and permissions', () => { + const spec = compileYaml(WITH_AGENT_YAML); + const act = spec.steps[2] as AgentStepSpec; + expect(act.type).toBe('agent'); + expect(act.instruction).toBe('Edit the repo per the plan.'); + expect(act.recoveryMode).toBe('inspect'); + expect(act.maxIterations).toBe(2); + expect(act.surfaces?.workspace).toEqual([{ surface: 'repo/' }]); + expect(act.surfaces?.streams).toEqual([{ stream: 'results' }]); + expect(act.surfaces?.external).toEqual(['pr://github/example']); + expect(act.permissions?.accessPreset).toBe('readwrite'); + expect(act.permissions?.fileGlobs).toEqual(['src/**']); + }); + + it('defaults agent recoveryMode to reset when omitted (Appendix A rule 4)', () => { + const spec = compileYaml(` +version: '0.1.0' +name: agent-default-recovery +steps: + - id: act + type: agent + instruction: "do something" +`); + const act = spec.steps[0] as AgentStepSpec; + expect(act.recoveryMode).toBe('reset'); + }); +}); diff --git a/sdk/tests/hello-deterministic.test.ts b/sdk/tests/hello-deterministic.test.ts new file mode 100644 index 00000000..ab9e07c2 --- /dev/null +++ b/sdk/tests/hello-deterministic.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { compileAndHash, compileYaml, compileYamlToCanonicalJson, toKernelSpec } from '../src/compile.js'; +import { canonicalize, specHash } from '../src/canonical.js'; +import { SPEC_SCHEMA_VERSION } from '../src/spec.js'; +import type { KernelRunSpec } from '../src/spec.js'; + +// Ladder rung (a) from RFC §3 Gate 1 done-when: a pure deterministic flow with +// zero agents (legalizing what the old validator rejected). No LLM, no agent. + +const HELLO_YAML = ` +version: '0.1.0' +name: hello-deterministic +description: The canonical hello ladder rung (a) — zero agents, legal. +steps: + - id: greet + type: deterministic + command: "echo hello" + verification: + type: output_contains + value: "hello" + - id: shout + type: deterministic + dependsOn: [greet] + command: "echo HELLO" + verification: + type: output_contains + value: "HELLO" +`; + +describe('compile: pure-deterministic hello flow (ladder rung a, zero agents)', () => { + it('compiles YAML to a valid FlowSpec with zero agent/llm steps', () => { + const spec = compileYaml(HELLO_YAML); + expect(spec.version).toBe(SPEC_SCHEMA_VERSION); + expect(spec.name).toBe('hello-deterministic'); + expect(spec.steps).toHaveLength(2); + + expect(spec.steps[0]?.type).toBe('deterministic'); + expect(spec.steps[0]?.id).toBe('greet'); + expect(spec.steps[0]?.maxIterations).toBe(1); + + const shout = spec.steps[1]; + expect(shout?.type).toBe('deterministic'); + expect(shout?.dependsOn).toEqual(['greet']); + + // Zero-agent invariant (RFC §1): no step is llm or agent. + for (const step of spec.steps) { + expect(step.type).toBe('deterministic'); + } + }); + + it('applies the implicit exit_code gate to a deterministic step with no verification', () => { + const spec = compileYaml(` +version: '0.1.0' +name: implicit-gate +steps: + - id: raw + type: deterministic + command: "true" +`); + expect(spec.steps[0]?.verification).toEqual({ type: 'exit_code' }); + }); + + it('preserves an explicit verification gate on a deterministic step', () => { + const spec = compileYaml(HELLO_YAML); + expect(spec.steps[0]?.verification).toEqual({ + type: 'output_contains', + value: 'hello', + }); + }); + + it('emits kernel-dialect canonical JSON with a stable hash', () => { + const json = compileYamlToCanonicalJson(HELLO_YAML); + // Canonical: sorted keys, no whitespace, kernel dialect (snake_case, + // flat verification, defaults materialized). + expect(json).not.toContain('\n'); + expect(json).toContain('"depends_on":["greet"]'); + expect(json).toContain('"max_iterations":1'); + expect(json).toContain('"verification":{"output_contains":"hello"}'); + expect(json.indexOf('"id":"greet"')).toBeLessThan(json.indexOf('"type":"deterministic"')); + + const kernel = toKernelSpec(compileYaml(HELLO_YAML)); + expect(specHash(kernel)).toMatch(/^[0-9a-f]{64}$/); + // Determinism: same input -> same hash. + expect(specHash(toKernelSpec(compileYaml(HELLO_YAML)))).toBe(specHash(kernel)); + // canonicalize(JSON.parse(json)) is idempotent. + expect(canonicalize(JSON.parse(json) as KernelRunSpec)).toBe(json); + // Cross-boundary parity with the kernel's parser + spec_hash is pinned by + // tests/spec-parity.test.ts and kernel/relayflowd-core/tests/spec_parity.rs. + }); + + it('compileAndHash returns spec + kernel spec + matching hash', () => { + const { spec, kernelSpec, hash } = compileAndHash(HELLO_YAML); + expect(spec.name).toBe('hello-deterministic'); + expect(kernelSpec.steps[0]?.depends_on).toEqual([]); + expect(hash).toBe(specHash(kernelSpec)); + }); +}); diff --git a/sdk/tests/journal-client.test.ts b/sdk/tests/journal-client.test.ts new file mode 100644 index 00000000..c16687c5 --- /dev/null +++ b/sdk/tests/journal-client.test.ts @@ -0,0 +1,280 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { createServer, type Server, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { rmSync } from 'node:fs'; +import { JournalClient } from '../src/journal-client.js'; +import { compileYaml } from '../src/compile.js'; +import { PROTOCOL_VERSION } from '../src/protocol.js'; +import type { FlowSpec } from '../src/spec.js'; + +// Protocol-v0 client tests over a real unix socket. The transport is real +// (newline-delimited JSON frames over `node:net`), but the server side is a +// minimal loopback test double — NOT the kernel. What these tests prove is +// the client: framing, request/response correlation, event demultiplexing, +// and fail-closed behavior on errors and connection drops. Kernel semantics +// are proven in `kernel/relayflowd/` (unit + crash-injection tests). + +function sockPath(): string { + return join(tmpdir(), `rf-${randomUUID().slice(0, 8)}.sock`); +} + +const HELLO_SPEC: FlowSpec = { + version: '0.1.0', + name: 'client-roundtrip', + steps: [{ id: 'greet', type: 'deterministic', command: 'echo hi' }], +}; + +interface FrameCtx { + id: string; + socket: Socket; + send: (obj: unknown) => void; +} + +function startLoopback(path: string, handlers: { + hello?: (ctx: FrameCtx) => void; + 'run.start'?: (ctx: FrameCtx, params: Record) => void; + 'journal.read'?: (ctx: FrameCtx, params: Record) => void; + 'stream.append'?: (ctx: FrameCtx, params: Record) => void; +}): Server { + const server = createServer((socket) => { + let buffer = ''; + const send = (obj: unknown): void => { + socket.write(JSON.stringify(obj) + '\n'); + }; + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + let nl: number; + while ((nl = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + if (line.length === 0) continue; + const req = JSON.parse(line) as { id: string; verb: string; params: Record }; + const ctx: FrameCtx = { id: req.id, socket, send }; + switch (req.verb) { + case 'hello': + handlers.hello?.(ctx); + break; + case 'run.start': + handlers['run.start']?.(ctx, req.params); + break; + case 'journal.read': + handlers['journal.read']?.(ctx, req.params); + break; + case 'stream.append': + handlers['stream.append']?.(ctx, req.params); + break; + default: + send({ id: req.id, ok: false, error: { code: 'unknown_verb', message: req.verb } }); + } + } + }); + }); + server.listen(path); + return server; +} + +let lastStartedSpec: Record | null = null; + +// A faithful mini-mirror of `RunSpec::parse` (kernel/relayflowd-core/src/spec.rs): +// snake_case keys only, per-type step key sets, flat v0 verification. Returns +// an error message, or null when the spec is in the kernel dialect. +function kernelDialectError(spec: unknown): string | null { + if (typeof spec !== 'object' || spec === null) return 'spec: expected an object'; + const rootAllowed = new Set(['version', 'name', 'description', 'steps', 'budget']); + for (const key of Object.keys(spec)) { + if (!rootAllowed.has(key)) return `unknown field "${key}" at spec`; + } + const steps = (spec as { steps?: unknown }).steps; + if (!Array.isArray(steps)) return 'steps: expected an array'; + const common = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification']; + const byType: Record = { + deterministic: ['command', 'timeout_ms'], + llm: ['prompt', 'model'], + agent: ['instruction', 'recovery_mode', 'surfaces', 'permissions'], + }; + for (const [index, step] of steps.entries()) { + const st = step as Record; + const kindFields = byType[st.type as string]; + if (kindFields === undefined) return `steps[${index}]: unknown type`; + const allowed = new Set([...common, ...kindFields]); + for (const key of Object.keys(st)) { + if (!allowed.has(key)) return `unknown field "${key}" at steps[${index}]`; + } + if (st.verification !== undefined) { + const gateAllowed = new Set(['output_contains', 'json_schema']); + for (const key of Object.keys(st.verification as Record)) { + if (!gateAllowed.has(key)) return `unknown field "${key}" at steps[${index}].verification`; + } + } + } + return null; +} + +describe('JournalClient: protocol v0 over unix socket', () => { + let path: string; + let server: Server; + + beforeAll(() => { + path = sockPath(); + server = startLoopback(path, { + hello: (ctx) => { + sendOk(ctx); + }, + 'run.start': (ctx, params) => { + // Mirror the kernel's fail-closed `RunSpec::parse`: only the kernel + // dialect is accepted — an authoring-shape spec (camelCase keys, + // tagged verification) is rejected, exactly like the real server. + const dialectError = kernelDialectError(params.spec); + if (dialectError !== null) { + ctx.send({ id: ctx.id, ok: false, error: { code: 'invalid_spec', message: dialectError } }); + return; + } + lastStartedSpec = params.spec as Record; + const spec = params.spec as { name?: string }; + sendResult(ctx, { run_id: 'run-01' }); + // Server-pushed entry event for run.watch subscribers would follow. + ctx.send({ event: 'run.spawned', data: { run_id: 'run-01', name: spec.name } }); + }, + 'journal.read': (ctx) => { + sendResult(ctx, { entries: [{ seq: 1, entry_type: 'run.spawned', step_id: null, attempt: null, at_ms: 0, payload: {} }] }); + }, + 'stream.append': (ctx, params) => { + // The client sends {run_id, stream, message}; echo an offset only for + // the params it actually transmitted, so the test pins the wire shape. + const ok = params.run_id === 'run-01' && params.stream === 'results' + && (params.message as { hello?: string })?.hello === 'world'; + sendResult(ctx, { offset: ok ? 7 : -1 }); + }, + }); + }); + + afterAll(async () => { + await new Promise((r) => server.close(() => r())); + rmSync(path, { force: true }); + }); + + let client: JournalClient; + + afterEach(() => client?.close()); + + it('handshakes with the matching protocol version', async () => { + client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + const res = await client.hello('sdk-test'); + expect(res.protocol).toBe(PROTOCOL_VERSION); + expect(res.server).toBeDefined(); + }); + + it('starts a run and receives the run_id (zero-agent spec is legal)', async () => { + client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('sdk-test'); + const res = await client.runStart(HELLO_SPEC); + expect(res.run_id).toBe('run-01'); + }); + + it('round-trips a spec straight from compileYaml through run.start in the kernel dialect', async () => { + // Authoring sugar the kernel's RunSpec::parse rejects verbatim: + // maxIterations, dependsOn, tagged verification. runStart must convert + // via toKernelSpec, or the fail-closed loopback double rejects the frame. + const flow = compileYaml(` +version: '0.1.0' +name: ladder-roundtrip +steps: + - id: fetch + type: deterministic + command: echo hi + maxIterations: 3 + - id: check + type: deterministic + command: grep hi out.txt + dependsOn: [fetch] + verification: + type: output_contains + value: hi +`); + client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('sdk-test'); + lastStartedSpec = null; + const res = await client.runStart(flow); + expect(res.run_id).toBe('run-01'); + // What crossed the wire is the kernel dialect, not the authoring shape. + const wired = lastStartedSpec as Record; + expect(wired).not.toBeNull(); + const check = (wired.steps as Record[])[1]; + expect(check.depends_on).toEqual(['fetch']); + expect(check.max_iterations).toBe(1); + expect(check.verification).toEqual({ output_contains: 'hi' }); + expect(check.dependsOn).toBeUndefined(); + expect((wired.steps as Record[])[0].max_iterations).toBe(3); + }); + + it('demultiplexes server-pushed events', async () => { + client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('sdk-test'); + const seen = new Promise((resolve) => { + client.once('run.spawned', (data) => { + expect((data as { run_id: string }).run_id).toBe('run-01'); + resolve(); + }); + }); + await client.runStart(HELLO_SPEC); + await seen; + }); + + it('reads the journal and streams', async () => { + client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('sdk-test'); + const jr = await client.journalRead('run-01', 1); + expect(jr.entries).toHaveLength(1); + expect((jr.entries[0] as { entry_type: string }).entry_type).toBe('run.spawned'); + const sa = await client.streamAppend('run-01', 'results', { hello: 'world' }); + expect(sa.offset).toBe(7); // loopback echoes 7 only if the wire params matched + }); + + it('fails closed when the server returns an error', async () => { + // Use a verb the loopback does not implement -> unknown_verb. + client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('sdk-test'); + await expect(client.runResume('run-01')).rejects.toThrow(/unknown_verb/); + }); + + it('fails closed on connection drop (pending requests reject)', async () => { + const dropPath = sockPath(); + const dropServer = startLoopback(dropPath, { + hello: (ctx) => { + // Never respond; then destroy the socket to simulate kill -9. + ctx.socket.destroy(); + }, + }); + try { + const c = new JournalClient(dropPath, { requestTimeoutMs: 5000 }); + await c.connect(); + await expect(c.hello('sdk-test')).rejects.toThrow(/connection closed|connect failed/); + } finally { + await new Promise((r) => dropServer.close(() => r())); + rmSync(dropPath, { force: true }); + } + }); + + it('rejects requests when not connected', async () => { + client = new JournalClient(path); + await expect(client.hello('x')).rejects.toThrow(/not connected/); + }); +}); + +// --- helpers --- + +function sendOk(ctx: FrameCtx): void { + ctx.send({ id: ctx.id, ok: true, result: { protocol: PROTOCOL_VERSION, server: 'relayflowd-test' } }); +} + +function sendResult(ctx: FrameCtx, result: unknown): void { + ctx.send({ id: ctx.id, ok: true, result }); +} diff --git a/sdk/tests/spec-parity.test.ts b/sdk/tests/spec-parity.test.ts new file mode 100644 index 00000000..4d642f01 --- /dev/null +++ b/sdk/tests/spec-parity.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { compileYamlToCanonicalJson, compileAndHash } from '../src/compile.js'; + +// The SDK half of the cross-boundary spec-parity gate. The shared fixture in +// testdata/ pins one spec dialect at the SDK<->kernel seam: this test proves +// the compiler emits exactly the fixture's canonical JSON and hash, and +// kernel/relayflowd-core/tests/spec_parity.rs proves the kernel parses that +// same artifact fail-closed and stamps the identical spec_hash. Together they +// make "sha256(canonical JSON) == kernel spec_hash" a tested fact, not a +// comment. + +const TESTDATA = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'testdata'); + +function fixture(name: string): string { + return readFileSync(join(TESTDATA, name), 'utf8'); +} + +describe('spec parity: one dialect at the SDK<->kernel boundary', () => { + it('compiles the ladder fixture to the pinned canonical JSON', () => { + const yaml = fixture('hello-ladder.flow.yaml'); + const canonical = compileYamlToCanonicalJson(yaml); + expect(canonical).toBe(fixture('hello-ladder.spec.canonical.json').trim()); + }); + + it('hashes the ladder fixture to the pinned spec_hash', () => { + const yaml = fixture('hello-ladder.flow.yaml'); + const { hash } = compileAndHash(yaml); + expect(hash).toBe(fixture('hello-ladder.spec.sha256').trim()); + }); +}); diff --git a/sdk/tests/validate.test.ts b/sdk/tests/validate.test.ts new file mode 100644 index 00000000..5a5c39f5 --- /dev/null +++ b/sdk/tests/validate.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest'; +import { validateSpec } from '../src/validate.js'; +import { compileYaml, CompileError } from '../src/compile.js'; +import type { FlowSpec } from '../src/spec.js'; + +// Fail-closed (AGENTS.md rule 4): a malformed spec is rejected with a concrete +// error, never silently coerced. Every case below must produce a non-ok result. + +describe('validate: rejects malformed specs', () => { + it('rejects a non-object spec', () => { + expect(validateSpec(null).ok).toBe(false); + expect(validateSpec('hello').ok).toBe(false); + expect(validateSpec([]).ok).toBe(false); + }); + + it('rejects missing or malformed version/name', () => { + const r = validateSpec({ steps: [{ id: 'a', type: 'deterministic', command: 'x' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('version'); + expect(r.errors.join(' ')).toContain('name'); + }); + + it('rejects a non-semver version', () => { + const r = validateSpec({ version: '1.0', name: 'x', steps: [{ id: 'a', type: 'deterministic', command: 'x' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('semver'); + }); + + it('rejects an empty steps array', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('non-empty array'); + }); + + it('rejects an unknown step type', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'integration', command: 'x' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('deterministic | llm | agent'); + }); + + it('rejects duplicate step ids', () => { + const r = validateSpec({ + version: '0.1.0', + name: 'x', + steps: [ + { id: 'a', type: 'deterministic', command: 'x' }, + { id: 'a', type: 'deterministic', command: 'y' }, + ], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('duplicate'); + }); + + it('rejects a deterministic step missing its command', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'deterministic' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('command'); + }); + + it('rejects an llm step missing its prompt', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'llm' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('prompt'); + }); + + it('rejects an agent step missing its instruction', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'agent' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('instruction'); + }); + + it('rejects an invalid recovery mode', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'agent', instruction: 't', recoveryMode: 'rollback' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('recoveryMode'); + }); + + it('rejects a custom exit_code expectation (v0 judges exit_code == 0)', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'deterministic', command: 'x', verification: { type: 'exit_code', expect: 3 } }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('exit_code == 0'); + }); + + it('rejects a timeout on a non-deterministic step (no dialect surface in v0.1.0)', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'llm', prompt: 'p', timeoutMs: 1000 }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('deterministic steps carry a timeout'); + }); + + it('rejects an output_contains gate with no value', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'deterministic', command: 'x', verification: { type: 'output_contains' } }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('value'); + }); + + it('rejects an unknown verification gate type', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'deterministic', command: 'x', verification: { type: 'regex' } }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('exit_code | output_contains | json_schema'); + }); + + it('rejects a dependsOn referencing an unknown step', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'deterministic', command: 'x', dependsOn: ['ghost'] }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('unknown step "ghost"'); + }); + + it('rejects a dependency cycle', () => { + const r = validateSpec({ + version: '0.1.0', + name: 'x', + steps: [ + { id: 'a', type: 'deterministic', command: 'x', dependsOn: ['b'] }, + { id: 'b', type: 'deterministic', command: 'y', dependsOn: ['a'] }, + ], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('cycle'); + }); + + it('rejects a float money budget (must be a decimal string)', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', budget: { maxDollars: 1.5 }, steps: [{ id: 'a', type: 'deterministic', command: 'x' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('maxDollars'); + }); + + it('rejects a non-integer maxIterations', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [{ id: 'a', type: 'deterministic', command: 'x', maxIterations: 2.5 }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('maxIterations'); + }); +}); + +describe('validate: fail-closed on unknown keys (RFC covenant 2)', () => { + const step = { id: 'a', type: 'deterministic', command: 'x' }; + + it('rejects the depends_on typo with the authoring-vocabulary suggestion', () => { + // The regression this pins: `depends_on` used to pass validation and be + // silently discarded, so step ordering was lost. + const r = validateSpec({ + version: '0.1.0', + name: 'x', + steps: [step, { id: 'b', type: 'deterministic', command: 'y', depends_on: ['a'] }], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('unknown key "depends_on" — did you mean "dependsOn"?'); + }); + + it('rejects unknown keys at the root level', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [step], budgets: {} }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('spec: unknown key "budgets" — did you mean "budget"?'); + }); + + it('rejects unknown keys at the budget level', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', budget: { max_dollars: '1.50' }, steps: [step] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('spec.budget: unknown key "max_dollars" — did you mean "maxDollars"?'); + }); + + it('rejects unknown keys at the step level, including per-type fields', () => { + const r = validateSpec({ + version: '0.1.0', + name: 'x', + steps: [ + { id: 'a', type: 'deterministic', command: 'x', max_iterations: 2 }, + { id: 'b', type: 'llm', prompt: 'p', instruction: 'not an llm field' }, + ], + }); + expect(r.ok).toBe(false); + const joined = r.errors.join(' '); + expect(joined).toContain('spec.steps[0]: unknown key "max_iterations" — did you mean "maxIterations"?'); + expect(joined).toContain('spec.steps[1]: unknown key "instruction"'); + }); + + it('rejects unknown keys at the verification level', () => { + const r = validateSpec({ + version: '0.1.0', + name: 'x', + steps: [{ ...step, verification: { type: 'output_contains', value: 'hi', values: 'oops' } }], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('unknown key "values" — did you mean "value"?'); + }); + + it('rejects unknown keys at the surfaces level (object and items)', () => { + const r = validateSpec({ + version: '0.1.0', + name: 'x', + steps: [{ + id: 'a', + type: 'agent', + instruction: 't', + surfaces: { workspaces: [{ surface: 'w' }], workspace: [{ surface: 'w', writable: true }] }, + }], + }); + expect(r.ok).toBe(false); + const joined = r.errors.join(' '); + expect(joined).toContain('unknown key "workspaces" — did you mean "workspace"?'); + expect(joined).toContain('surfaces.workspace[0]: unknown key "writable"'); + }); + + it('rejects unknown keys at the permissions level', () => { + const r = validateSpec({ + version: '0.1.0', + name: 'x', + steps: [{ id: 'a', type: 'agent', instruction: 't', permissions: { file_globs: ['*'] } }], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('unknown key "file_globs" — did you mean "fileGlobs"?'); + }); + + it('names allowed keys when no valid key is close', () => { + const r = validateSpec({ version: '0.1.0', name: 'x', steps: [step], zzzzzzzz: 1 }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('spec: unknown key "zzzzzzzz" (expected one of'); + }); +}); + +describe('compile: surfaces validation failures as CompileError', () => { + it('throws CompileError with the offending messages for a malformed YAML spec', () => { + expect(() => + compileYaml(` +version: '0.1.0' +name: bad +steps: + - id: a + type: deterministic +`), + ).toThrow(CompileError); + + let caught: CompileError | null = null; + try { + compileYaml(` +version: '0.1.0' +name: bad +steps: + - id: a + type: deterministic +`); + } catch (e) { + caught = e as CompileError; + } + expect(caught).not.toBeNull(); + expect(caught!.errors.some((m) => m.includes('command'))).toBe(true); + }); + + it('throws CompileError for YAML that is not a mapping', () => { + expect(() => compileYaml('- just\n- a\n- list')).toThrow(CompileError); + }); +}); + +describe('validate: accepts the legal zero-agent flow', () => { + it('accepts a pure-deterministic spec — zero agents/llm is legal (RFC §1)', () => { + const spec: FlowSpec = { + version: '0.1.0', + name: 'zero-agent', + steps: [ + { id: 'a', type: 'deterministic', command: 'echo hi' }, + { id: 'b', type: 'deterministic', command: 'echo bye', dependsOn: ['a'] }, + ], + }; + expect(validateSpec(spec).ok).toBe(true); + }); + + it('accepts a deterministic + llm spec', () => { + const spec: FlowSpec = { + version: '0.1.0', + name: 'det-llm', + steps: [ + { id: 'a', type: 'deterministic', command: 'echo hi' }, + { id: 'b', type: 'llm', prompt: 'say hi', dependsOn: ['a'], verification: { type: 'output_contains', value: 'hi' } }, + ], + }; + expect(validateSpec(spec).ok).toBe(true); + }); +}); diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 00000000..e8b004db --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/sdk/vitest.config.ts b/sdk/vitest.config.ts new file mode 100644 index 00000000..efa710b6 --- /dev/null +++ b/sdk/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + globals: false, + }, +}); diff --git a/testdata/hello-ladder.flow.yaml b/testdata/hello-ladder.flow.yaml new file mode 100644 index 00000000..837d0874 --- /dev/null +++ b/testdata/hello-ladder.flow.yaml @@ -0,0 +1,45 @@ +# The canonical hello ladder (RFC-0001 §3 gate 1): deterministic + llm + agent. +# Source of the shared SDK<->kernel spec-parity fixture. Compiled by +# sdk/tests/spec-parity.test.ts into hello-ladder.spec.canonical.json, which +# kernel/relayflowd-core/tests/spec_parity.rs must parse fail-closed and hash +# to the identical spec_hash (hello-ladder.spec.sha256). +version: '0.1.0' +name: hello-ladder +description: One flow, all three rungs — the single spec dialect end to end. +steps: + - id: greet + type: deterministic + command: "echo hello" + timeoutMs: 5000 + verification: + type: output_contains + value: "hello" + - id: plan + type: llm + dependsOn: [greet] + prompt: "Reply with JSON {answer: number} for 2+2." + model: claude-sonnet-5 + maxIterations: 3 + verification: + type: json_schema + schema: + type: object + required: [answer] + - id: act + type: agent + dependsOn: [plan] + instruction: "Apply the plan to the repo." + recoveryMode: inspect + surfaces: + workspace: + - surface: repo/ + streams: + - stream: results + external: + - pr://github/example + permissions: + accessPreset: readwrite + fileGlobs: ["src/**"] +budget: + maxTokensOut: 2000 + maxDollars: "1.50" diff --git a/testdata/hello-ladder.spec.canonical.json b/testdata/hello-ladder.spec.canonical.json new file mode 100644 index 00000000..f0f4defd --- /dev/null +++ b/testdata/hello-ladder.spec.canonical.json @@ -0,0 +1 @@ +{"budget":{"max_dollars":"1.50","max_tokens_out":2000},"description":"One flow, all three rungs — the single spec dialect end to end.","name":"hello-ladder","steps":[{"command":"echo hello","depends_on":[],"id":"greet","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"timeout_ms":5000,"type":"deterministic","verification":{"output_contains":"hello"}},{"depends_on":["greet"],"id":"plan","max_iterations":3,"model":"claude-sonnet-5","prompt":"Reply with JSON {answer: number} for 2+2.","retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"llm","verification":{"json_schema":{"required":["answer"],"type":"object"}}},{"depends_on":["plan"],"id":"act","instruction":"Apply the plan to the repo.","max_iterations":1,"permissions":{"access_preset":"readwrite","file_globs":["src/**"]},"recovery_mode":"inspect","retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"surfaces":{"external":["pr://github/example"],"streams":[{"stream":"results"}],"workspace":[{"surface":"repo/"}]},"type":"agent","verification":{}}],"version":"0.1.0"} diff --git a/testdata/hello-ladder.spec.sha256 b/testdata/hello-ladder.spec.sha256 new file mode 100644 index 00000000..07dd3316 --- /dev/null +++ b/testdata/hello-ladder.spec.sha256 @@ -0,0 +1 @@ +ecccd7b2af27c265d473b1bd567e8e244fbb5051f1e1d7e667af7522de095a29 diff --git a/workflows/bootstrap-gate1.yaml b/workflows/bootstrap-gate1.yaml index 6c42a400..9f8e679e 100644 --- a/workflows/bootstrap-gate1.yaml +++ b/workflows/bootstrap-gate1.yaml @@ -24,8 +24,15 @@ agents: cli: opencode preset: worker role: TypeScript engineer. Implements the authoring SDK and spec compiler. + # Adversary provenance (run of 2026-08-27): the original adversary was + # `cli: grok`, which failed — grok is not a registered CLI. The gemini + # fallback failed too (broken auth). claude performed the review, and the + # verdict was independently spot-checked outside the runner. Changing the + # reviewer mid-run was an operator action, logged in + # docs/bootstrap-report.md. The gate-integrity rail (agents may not edit + # gates that judge them) was not violated: no judged agent edited this gate. - name: adversary - cli: grok + cli: claude preset: reviewer role: Adversarial reviewer. Tries to refute that the work meets RFC-0001 and AGENTS.md.