diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md new file mode 100644 index 000000000..11c1a8a8d --- /dev/null +++ b/docs/evidence/event-await-implementation/README.md @@ -0,0 +1,141 @@ +# Event-await local implementation evidence + +Implementation commits: `ec4345a8c474bbe16b9727e5a0d2dd874e399569` and +`6e8c3cb697e911ddcba89999bd58bf3b0f603228`. + +## Scope and acceptance map + +`kernel/relayflowd/tests/event_activities.rs` is the deterministic SQLite +journal harness. It covers cases 1–8 and 10–15 from `docs/EVENT-AWAIT.md`; +case 9 is the SDK preflight test named below. The live SDK test uses the real +daemon socket and includes an actual `SIGKILL` / restart boundary after +`stream.appended`. + +| Acceptance case | Test | +| --- | --- | +| 1, 3–5, 7, 8, 10–12, 15 | `remaining_event_await_acceptance_cases_use_the_real_journal` | +| 2, 6 | `accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next` (plus the SDK SIGKILL test) | +| 8 | `cancel_closes_an_open_activity_before_the_terminal_run_record` | +| 9 | `packages/sdk/tests/activity-preflight.test.ts` | +| 13 | `exact_deadline_tie_wins_and_reports_unread_range` | +| 14 | `overflow_closes_before_the_1001st_unread_frame_and_recovery_never_reopens_it` | + +## Kernel acceptance command + +Command (exit 0): + +```sh +PATH=/Users/khaliqgant/.relayflows-toolchain/rustup/toolchains/local/bin:$PATH CARGO_HOME=/Users/khaliqgant/.relayflows-toolchain/cargo RUSTUP_HOME=/Users/khaliqgant/.relayflows-toolchain/rustup RUSTUP_TOOLCHAIN=local CARGO_TARGET_DIR=/Users/khaliqgant/.relayflows-toolchain/target/1398563233 /Users/khaliqgant/.relayflows-toolchain/rustup/toolchains/local/bin/cargo test --manifest-path kernel/Cargo.toml -p relayflowd --test event_activities +``` + +Captured output: + +```text +Compiling relayflowd-core, relayflowd-journal, and relayflowd +Finished `test` profile [unoptimized + debuginfo] target(s) in 3.82s +Running tests/event_activities.rs (/Users/khaliqgant/.relayflows-toolchain/target/1398563233/debug/deps/event_activities-54b0211a77a95d2a) + +running 6 tests +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test cancel_closes_an_open_activity_before_the_terminal_run_record ... ok +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test idle_wait_is_durable_and_fires_without_an_event ... ok +test accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next ... ok +test remaining_event_await_acceptance_cases_use_the_real_journal ... ok +test overflow_closes_before_the_1001st_unread_frame_and_recovery_never_reopens_it ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.91s + +EXIT=0 +``` + +## SDK command + +Command (exit 0): + +```sh +cd packages/sdk && /Users/khaliqgant/.bun/bin/bun run typecheck && /Users/khaliqgant/.bun/bin/bun run build && /Users/khaliqgant/.bun/bin/bun run typecheck:tests && RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/1398563233/debug/relayflowd /Users/khaliqgant/.bun/bin/bun x vitest run tests/authored-activity.test.ts tests/activity-preflight.test.ts tests/live-event-activities.test.ts +``` + +Captured output: + +```text +$ tsc --noEmit && tsc -p tsconfig.type-tests.json +$ tsc && node scripts/make-cli-executable.mjs +$ tsc -p tsconfig.tests.json + + RUN v2.1.9 /Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917/packages/sdk + + ✓ tests/activity-preflight.test.ts (1 test) 6ms + ✓ tests/authored-activity.test.ts (8 tests) 19ms + ✓ tests/live-event-activities.test.ts (2 tests) 629ms + ✓ runs surface f.on through the local daemon event path and journals its buffered wake 570ms + + Test Files 3 passed (3) + Tests 11 passed (11) + Start at 08:04:53 + Duration 2.40s (transform 839ms, setup 0ms, collect 4.48s, tests 655ms, environment 0ms, prepare 208ms) + +EXIT=0 +``` + +## Surface command + +Command (exit 0): + +```sh +cd packages/surface && PATH=/Users/khaliqgant/.bun/bin:$PATH /Users/khaliqgant/.bun/bin/bun run test +``` + +Captured output: + +```text +$ bun run build && tsc -p tsconfig.test.json && vitest run +$ tsc + + RUN v2.1.9 /Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917/packages/surface + + ✓ tests/activity.test.ts (1 test) 1ms + ✓ tests/triggers.test.ts (4 tests) 4ms + ✓ tests/slack-block-kit.test.ts (5 tests) 3ms + ✓ tests/provider-triggers.test.ts (3 tests) 4ms + ✓ tests/flow.test.ts (20 tests) 9ms + ✓ tests/helpers.snapshot.test.ts (1 test) 475ms + ✓ regenerates helpers byte-identically from the pinned adapter 475ms + + Test Files 6 passed (6) + Tests 34 passed (34) + Start at 08:05:02 + Duration 810ms (transform 189ms, setup 0ms, collect 644ms, tests 495ms, environment 1ms, prepare 583ms) + +EXIT=0 +``` + +## Cloud handoff + +No Cloud credentials, remote configuration, or deployment was touched. The +repository-owned local adapter deliberately uses `event.emit` with a provider +delivery id and actor, and the kernel records only the tenant-neutral facts. +Production router work still outside this repository is: + +1. Before acknowledging `subscription.open`, durably create the fenced Cloud + binding for `(run_id, subscription_id, generation, ingress_offset)` with + the installation, canonical resource scope, authorization snapshot, event + types, pattern, and run identity. Persist that binding receipt and ingress + offset in `subscription.opened`. +2. On recovery, remove a prepared binding lacking `subscription.opened`; for + an opened binding replay ingress strictly after its saved offset before + making it visible. If the binding is `closing: overflow`, submit the same + idempotent overflow-close command and never reopen or replay it. +3. Authenticate every provider frame against the bound installation and + canonical scope, apply the actor/self filter, and pass its provider delivery + id to the per-subscription journal sequencer. A user pattern must not widen + installation or resource authorization. +4. On a would-exceed frame, first durably fence the Cloud binding and refuse + later appends; then submit the overflow close to the same sequencer as + appends and timer claims. Remove the binding only after the close commits. + +There is no local blocker. The only intentionally unimplemented portion is +that Cloud-owned provider binding/ingress handoff above; its absence is why the +local acceptance case proves the journal side of the post-open handoff rather +than claiming a real provider-router crash test. diff --git a/docs/evidence/event-await-implementation/audit-pass-1.md b/docs/evidence/event-await-implementation/audit-pass-1.md new file mode 100644 index 000000000..3043d99ad --- /dev/null +++ b/docs/evidence/event-await-implementation/audit-pass-1.md @@ -0,0 +1,122 @@ +# Event-await implementation audit — pass 1 + +Audited at `ef692235c3064346b5326ca7374392d484bf1b9c` plus the working-tree +fixes recorded below. Scope was every event-await commit after `c8c68315`: +`73f32ad1`, `ee3f3452`, `5742029e`, `d06eabdf`, `c5f54733`, `ec4345a8`, and +`6e8c3cb6`, together with their evidence-only commits. + +Fix commit: `3236011f20f918aa347a576426b6530a3e52e107` +(`fix(event-await): preserve durable activity wake recovery`). + +## Fixed findings + +1. **F1 — immediate event completion was not recoverable.** + `next_subscription()` could append `wait.completed` and an acknowledgement + without first appending its `wait.event`. A crash between those records + made the completion orphaned during fold and could redeliver its frames. + It now records `wait.event` before a ready-batch completion. + +2. **F2 — a fenced overflow of a parked `next()` decoded as an internal + protocol error.** The overflow close writes a durable completion result + `{ wake: "overflow" }`; the recovery decoder previously required event + offsets instead. It now returns the fenced `Wake.overflow` and leaves the + closed wake stable. The regression exercises the real SQLite journal across + the fence/restart/close boundary. + +3. **F3 — activity wait ids collided at a simulated-clock instant.** They + were derived from `now_ms`, so two wakes in one millisecond reused an id. + Wait ids are now a durable per-subscription sequence reconstructed from the + journal. + +4. **F4 — activity cleanup skipped authored validation errors.** A missing + `done()` or a post-body operation-validation failure left an opened cursor + live. Those paths now close it with `canceled`. The SDK test no longer uses + a zero-millisecond timing assumption; it waits for the actual open request. + +## Acceptance status and blocker + +The local daemon/kernel regressions cover the journal-side cases. The +provider-router portion of acceptance case 11 remains **unimplemented in this +worktree**: `open_subscription()` records `ingress_offset: 0` and the neutral +`{"transport":"local-daemon"}` receipt, while no durable prepared Cloud +binding, generation, ingress log/replay, or recovery cleanup exists here. +The current `event.emit` route is run-local, so it cannot prove a frame that +arrives between external binding preparation and body visibility. This is a +contractual blocker for a full acceptance-11 / production-router claim, not a +kernel substitute. No Cloud credentials, remote configuration, or deployment +was touched. + +## Verification + +Focused kernel regression, exit 0: + +```text +$ cd kernel && zsh -c 'PATH=/Users/khaliqgant/.cargo/bin:$PATH RUSTUP_TOOLCHAIN=local sh ../ops/cargo.sh test -p relayflowd --test event_activities --quiet; audit_rc=$?; printf "EVENT_AWAIT_KERNEL_EXIT=%s\n" "$audit_rc"; exit "$audit_rc"' + +running 8 tests +........ +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.84s + +EVENT_AWAIT_KERNEL_EXIT=0 +``` + +Full kernel workspace, exit 0: + +```text +$ cd kernel && PATH=/Users/khaliqgant/.cargo/bin:$PATH RUSTUP_TOOLCHAIN=local sh ../ops/cargo.sh test --workspace + +test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +Focused SDK regression plus test typecheck, exit 0: + +```text +$ cd packages/sdk && npm exec vitest -- run tests/authored-activity.test.ts && npm run typecheck:tests + +✓ tests/authored-activity.test.ts (9 tests) 69ms +Test Files 1 passed (1) +Tests 9 passed (9) + +> @relayflows/sdk@2.0.14 typecheck:tests +> tsc -p tsconfig.tests.json +``` + +Full surface package, exit 0: + +```text +$ cd packages/surface && PATH=/Users/khaliqgant/.bun/bin:$PATH /Users/khaliqgant/.bun/bin/bun run test + +Test Files 6 passed (6) +Tests 34 passed (34) +``` + +The full SDK package command was run with the required shim paths: + +```text +$ cd packages/sdk && export PATH=/Users/khaliqgant/.cargo/bin:/Users/khaliqgant/.bun/bin:$PATH; export RUSTUP_TOOLCHAIN=local; npm test +EVENT_AWAIT_SDK_FULL_EXIT=1 +``` + +Its failure is outside this slice's source changes and is recorded rather than +masked: 1,687 tests passed and 18 were skipped; two environment preconditions +failed. `tests/authored-node-runtime.test.ts` pins Bun `1.4.0`, but the +available executable reports `1.4.2`; `tests/mcp.test.ts` falls back to the +absent `kernel/target/release/relayflowd` rather than the wrapper's debug +binary. Literal checks: + +```text +$ PATH=/Users/khaliqgant/.bun/bin:$PATH bun --version +1.4.2 +$ test -x kernel/target/release/relayflowd; printf 'RELEASE_RELAYFLOWD_EXISTS=%s\n' "$?" +RELEASE_RELAYFLOWD_EXISTS=1 +``` + +The changed files pass `git diff --check` (exit 0). `cargo fmt --check` could +not run because this installed toolchain has no `fmt` component: + +```text +$ cd kernel && sh ../ops/cargo.sh fmt --all -- --check +error: no such command: `fmt` +``` diff --git a/docs/evidence/event-await-implementation/audit-pass-2.md b/docs/evidence/event-await-implementation/audit-pass-2.md new file mode 100644 index 000000000..018ec888d --- /dev/null +++ b/docs/evidence/event-await-implementation/audit-pass-2.md @@ -0,0 +1,162 @@ +# Event-await implementation audit — pass 2 + +Audited the local EVENT-AWAIT sequence from `73f32ad1` through +`c9cb5de2`, including each implementation, test, and evidence commit: +`73f32ad1`, `ee3f3452`, `5742029e`, `d06eabdf`, `c5f54733`, `ec4345a8`, +`6e8c3cb6`, `3236011f`, and `c9cb5de2`. + +Implementation fix commit: `651d07a3a2d818782f2f453173d3f001626be511` +(`fix(event-await): retain normal wakes until acknowledged`). + +The source contract was `docs/EVENT-AWAIT.md`, acceptance cases 1–15, read +with RFC-0001 and the repository AGENTS rules before this audit. + +## Fixed findings + +1. **F5 — a normal wake could be lost in the daemon-to-body hand-off.** + `next_subscription()` committed `subscription.acknowledged` before its + socket response. A daemon death after that append and before the response + permanently advanced the unread cursor although the body had not observed + the wake. Normal wakes now remain durable until the following `next()` + supplies the previous wait receipt. The daemon returns that opaque receipt + only on its additive protocol result; `Activity.next()` keeps it internal. + Recovery also receives the durable receipt rather than guessing a sequence. + +2. **F6 — overflow settled an active wait with the wrong journal reason.** + EVENT-AWAIT §5.3 requires the overflow close to settle it as + `event_received` with the overflow result. The implementation wrote + `timeout`. Overflow now writes `event_received`; deadline remains `timeout`. + +3. **F7 — the live regression could silently exercise a stale daemon.** + `live-event-activities.test.ts` contained a hard-coded cargo target path. + It now derives this worktree's target using the same `cksum` convention as + `ops/cargo.sh`. The tested daemon was + `/Users/khaliqgant/.relayflows-toolchain/target/1445268772/debug/relayflowd`. + +New regressions pin the delayed acknowledgement/restart boundary, recovered +receipt delivery, overflow completion reason, SDK receipt propagation, and the +current-worktree live daemon path. + +## Remaining contractual blocker + +Acceptance case 11 is still not implementable in this worktree. The local +daemon records `ingress_offset: 0` and a `local-daemon` receipt; it does not +contain the Cloud durable prepared binding, binding generation, ingress log, +post-offset replay, or prepared-binding recovery cleanup that EVENT-AWAIT §5–6 +requires. This audit did not represent local ingress as a Cloud-router proof. +No credentials, remote configuration, deployment, push, or merge was used. + +## Verification + +Focused kernel regression (exit 0): + +```text +$ cd kernel && PATH=/Users/khaliqgant/.cargo/bin:$PATH RUSTUP_TOOLCHAIN=local sh ../ops/cargo.sh test -p relayflowd --test event_activities --quiet; audit_rc=$?; printf 'EVENT_AWAIT_AUDIT_KERNEL_EXIT=%s\n' "$audit_rc"; exit "$audit_rc" + +running 9 tests +......... +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.91s + +EVENT_AWAIT_AUDIT_KERNEL_EXIT=0 +``` + +Focused SDK activity, live-current-daemon, and test typecheck (exit 0): + +```text +$ cd packages/sdk && npm exec vitest -- run tests/authored-activity.test.ts tests/live-event-activities.test.ts && npm run typecheck:tests; audit_rc=$?; printf 'EVENT_AWAIT_AUDIT_SDK_NARROW_EXIT=%s\n' "$audit_rc"; exit "$audit_rc" + +✓ tests/authored-activity.test.ts (10 tests) 71ms +✓ tests/live-event-activities.test.ts (2 tests) 180ms +Test Files 2 passed (2) +Tests 12 passed (12) + +> @relayflows/sdk@2.0.14 typecheck:tests +> tsc -p tsconfig.tests.json + +EVENT_AWAIT_AUDIT_SDK_NARROW_EXIT=0 +``` + +The live test was then run without an override, proving its repaired target +selection (exit 0): + +```text +$ cd packages/sdk && npm exec vitest -- run tests/live-event-activities.test.ts && npm run typecheck:tests; audit_rc=$?; printf 'EVENT_AWAIT_AUDIT_SDK_LIVE_CURRENT_DAEMON_EXIT=%s\n' "$audit_rc"; exit "$audit_rc" + +✓ tests/live-event-activities.test.ts (2 tests) 164ms +Test Files 1 passed (1) +Tests 2 passed (2) + +> @relayflows/sdk@2.0.14 typecheck:tests +> tsc -p tsconfig.tests.json + +EVENT_AWAIT_AUDIT_SDK_LIVE_CURRENT_DAEMON_EXIT=0 +``` + +Full kernel workspace (exit 0; literal terminal summary): + +```text +$ cd kernel && PATH=/Users/khaliqgant/.cargo/bin:$PATH RUSTUP_TOOLCHAIN=local sh ../ops/cargo.sh test --workspace --quiet; audit_rc=$?; printf 'EVENT_AWAIT_AUDIT_KERNEL_WORKSPACE_EXIT=%s\n' "$audit_rc"; exit "$audit_rc" + +running 49 tests +................................................. +test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.58s + +running 40 tests +........................................ +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 37.16s + +running 9 tests +......... +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.23s + +running 65 tests +................................................................. +test result: ok. 65 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.62s + +EVENT_AWAIT_AUDIT_KERNEL_WORKSPACE_EXIT=0 +``` + +Full surface package (exit 0): + +```text +$ cd packages/surface && PATH=/Users/khaliqgant/.bun/bin:$PATH /Users/khaliqgant/.bun/bin/bun run test; audit_rc=$?; printf 'EVENT_AWAIT_AUDIT_SURFACE_FULL_EXIT=%s\n' "$audit_rc"; exit "$audit_rc" + +Test Files 6 passed (6) +Tests 34 passed (34) + +EVENT_AWAIT_AUDIT_SURFACE_FULL_EXIT=0 +``` + +Full SDK package suite was run against the current daemon and exited 1 for +two environment-only preconditions, not an event-await failure. Literal final +output was: + +```text +$ cd packages/sdk && export PATH=/Users/khaliqgant/.cargo/bin:/Users/khaliqgant/.bun/bin:$PATH; export RUSTUP_TOOLCHAIN=local; npm test; audit_rc=$?; printf 'EVENT_AWAIT_AUDIT_SDK_FULL_EXIT=%s\n' "$audit_rc"; exit "$audit_rc" + +FAIL tests/authored-node-runtime.test.ts > Bun 1.4.0 standalone → native Node authored lifecycle +AssertionError: expected '1.4.2' to be '1.4.0' + +FAIL tests/mcp.test.ts > authored MCP effects against the real kernel +Error: journal client: connect failed: connect ENOENT .../relayflowd-4bccca3feb68.sock + +Error: spawn .../kernel/target/release/relayflowd ENOENT + +Test Files 2 failed | 106 passed | 2 skipped (110) +Tests 1688 passed | 18 skipped (1706) +Errors 1 error + +EVENT_AWAIT_AUDIT_SDK_FULL_EXIT=1 +``` + +The command's interactive progress output was terminal-truncated by the test +runner transport; the exit code and final failure output above are the exact +captured terminal result. The focused and live event-await regressions above +passed in that same worktree. + +Diff check (exit 0): + +```text +$ git diff --check; audit_rc=$?; printf 'EVENT_AWAIT_AUDIT_DIFF_CHECK_EXIT=%s\n' "$audit_rc"; exit "$audit_rc" +EVENT_AWAIT_AUDIT_DIFF_CHECK_EXIT=0 +``` diff --git a/docs/evidence/event-await-implementation/kernel-daemon.md b/docs/evidence/event-await-implementation/kernel-daemon.md new file mode 100644 index 000000000..66f81de52 --- /dev/null +++ b/docs/evidence/event-await-implementation/kernel-daemon.md @@ -0,0 +1,94 @@ +# Event Await — Kernel and local daemon slice + +Implementation commits: + +- `5742029e02ac66d306cc19d1267fe92f2e6ba6c4` + (`feat(kernel): add durable event activities`) +- `d06eabdf422430d977604374e1530e7c6f6ec18e` + (`fix(daemon): restrict activity close reasons`) +- `c5f547332b975a6e68a9789b995aa8c576b42035` + (`fix(daemon): reserve subscription streams`) + +## Scope and transport boundary + +The Rust cell now journals body-level `subscription.opened`, durable +`wait.event` cursor waits, `stream.appended` delivery-id frames, +`subscription.overflow.fenced`, and `subscription.closed`. It exposes the +Surface slice's `subscription.open`, `subscription.next`, and +`subscription.close` protocol verbs. A `next` wait records absolute idle and +deadline instants, and recovery reclaims passed activity timers before normal +step recovery. + +Cloud owns provider tenancy, immutable installation/resource authorization, +ingress replay, self-actor filtering, and the external binding fence. Its +transport boundary is: + +1. Cloud durably records `(run_id, subscription_id, generation, + ingress_offset)` and only then invokes local `subscription.open`. +2. For each authorized provider frame, Cloud calls the cell-local append path + with the provider delivery id and encoded `EventFrameV1`; the kernel stores + it as `stream.appended` and refuses duplicates or frames after closure. +3. On a would-exceed frame Cloud fences its binding, then submits the local + overflow close. The local daemon mirrors that fence durably and recovery + completes the close without reopening the cursor. + +No tenant id, provider SDK, installation lookup, or authorization policy was +added to the kernel. The public generic `stream.append` endpoint refuses the +reserved `subscription/` namespace, so it cannot bypass the bounded, +delivery-id-aware router append path. + +## Focused coverage + +`kernel/relayflowd/tests/event_activities.rs` uses the real SQLite journal: + +- append while work is elsewhere, delivery-id dedupe, and restart after + `stream.appended` before `next()`; +- durable idle wake; +- exact deadline/append tie, including pending unread range; +- 1,000 unread-frame boundary, fence-before-close recovery, and refusal after + fencing; +- terminal cancellation closes an activity before `run.completed`. + +## Commands and captured output + +Command (exit 0): + +```text +cd /Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917/kernel +/Users/khaliqgant/.cargo/bin/cargo check --workspace + + Checking relayflowd v0.1.0 (.../kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.82s +``` + +Command (exit 0): + +```text +cd /Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917/kernel +/Users/khaliqgant/.cargo/bin/cargo test -p relayflowd --test event_activities --no-fail-fast + +running 5 tests +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test cancel_closes_an_open_activity_before_the_terminal_run_record ... ok +test accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next ... ok +test idle_wait_is_durable_and_fires_without_an_event ... ok +test overflow_closes_before_the_1001st_unread_frame_and_recovery_never_reopens_it ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.81s +``` + +Command (exit 1; repository-wide formatting baseline, not modified): + +```text +cd /Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917/kernel +/Users/khaliqgant/.cargo/bin/cargo fmt --check + +Diff in .../kernel/relayflowd/src/engine/remote.rs:588: +Diff in .../kernel/relayflowd/tests/event_wake.rs:191: +Diff in .../kernel/relayflowd/tests/hn_monitor_integration.rs:36: +Diff in .../kernel/relayflowd-core/src/machine/tests.rs:63: +... +``` + +The formatter reports pre-existing changes outside this slice; it was not run +in write mode, preserving unrelated work. diff --git a/docs/evidence/event-await-implementation/surface-sdk.md b/docs/evidence/event-await-implementation/surface-sdk.md new file mode 100644 index 000000000..07e333062 --- /dev/null +++ b/docs/evidence/event-await-implementation/surface-sdk.md @@ -0,0 +1,87 @@ +# Event Await — Surface and SDK slice + +Implementation commits: + +- `73f32ad13abc0d99c79f14f8b99fbce3de03d82f` (`feat(surface): add bounded event activities`) +- `ee3f3452ba377aaaafcacfd4923d477342a2d68d` (`fix(sdk): open activities before body work`) + +Scope: the authored TypeScript surface and direct-run adapter only. This adds +`Ctx.on(source, options): Activity`, required `idle` and `deadline` typing and +runtime validation, journal protocol lowering (`subscription.open`, +`subscription.next`, `subscription.close`), strict `Wake` decoding, and +automatic close on terminal body lifecycle. It does not claim kernel timer, +router binding, ingress replay, dedupe, overflow, or recovery behavior. + +`packages/schema/flows.schema.json` was inspected and intentionally unchanged: +it is generated from declarative `FlowSpec`; body-level `Ctx` operations are +TypeScript authored code and have no declarative schema representation. + +## Commands and captured output + +Command (initial invocation, exit 254): + +```text +cd /Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917 +npm run typecheck --workspace=@relayflows/surface && npm run typecheck --workspace=@relayflows/sdk + +npm error code ENOENT +npm error syscall open +npm error path /Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917/package.json +npm error errno -2 +npm error Could not read package.json: Error: ENOENT: no such file or directory, open '/Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917/package.json' +``` + +Blocker resolved locally: this repository has no root `package.json`; the SDK +also initially had no local dependencies. `cd packages/sdk && npm ci +--ignore-scripts` restored only lockfile-pinned local dependencies. It reported +six dependency audit findings (4 moderate, 1 high, 1 critical); no `npm audit +fix`, credential, configuration, publish, deploy, or remote action was run. + +Command (exit 0): + +```text +cd packages/surface && npm run typecheck && npm run build && npx vitest run tests/activity.test.ts + +> @relayflows/surface@2.0.14 typecheck +> tsc --noEmit + +> @relayflows/surface@2.0.14 build +> tsc + + RUN v2.1.9 .../packages/surface + + ✓ tests/activity.test.ts (1 test) 1ms + + Test Files 1 passed (1) + Tests 1 passed (1) +``` + +Command (exit 0): + +```text +cd packages/sdk && npm run typecheck && npx vitest run tests/authored-flow.test.ts tests/authored-activity.test.ts tests/activity-preflight.test.ts && git diff --check + +> @relayflows/sdk@2.0.14 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + RUN v2.1.9 .../packages/sdk + + ✓ tests/activity-preflight.test.ts (1 test) 7ms + ✓ tests/authored-activity.test.ts (8 tests) 18ms + ✓ tests/authored-flow.test.ts (25 tests) 689ms + + Test Files 3 passed (3) + Tests 34 passed (34) +``` + +The final `git diff --check` produced no output and exited 0. + +## Focused coverage + +- `packages/surface/tests/activity.test.ts`: public type contract, all `Wake` + variants, and compile-time rejection of either missing required bound. +- `packages/sdk/tests/activity-preflight.test.ts`: `flows check`-side static + refusal with `unbounded_subscription` for a literal `f.on` missing a bound. +- `packages/sdk/tests/authored-activity.test.ts`: protocol lowering, events / idle + / deadline / overflow result decoding, malformed result refusal, automatic + run-completion closure, and no reopening after explicit `close()`. diff --git a/kernel/relayflowd-core/src/entry.rs b/kernel/relayflowd-core/src/entry.rs index 3dbb952cf..90c8f9b02 100644 --- a/kernel/relayflowd-core/src/entry.rs +++ b/kernel/relayflowd-core/src/entry.rs @@ -25,6 +25,21 @@ pub enum EntryType { /// "Native silent-death" answer at the journal level. #[serde(rename = "subscription.stale")] SubscriptionStale, + /// A body-local event cursor. This is distinct from trigger-plane + /// `subscription.registered`: it never creates a run. + #[serde(rename = "subscription.opened")] + SubscriptionOpened, + #[serde(rename = "subscription.closed")] + SubscriptionClosed, + /// Local durable mirror of the router's `closing: overflow` fence. Cloud + /// owns its provider binding; recovery uses this journal fact to finish + /// the already-submitted close without ever reopening the cursor. + #[serde(rename = "subscription.overflow.fenced")] + SubscriptionOverflowFenced, + /// The body has observed a normal activity wake. This advances only that + /// cursor's unread boundary; it is not a provider acknowledgement. + #[serde(rename = "subscription.acknowledged")] + SubscriptionAcknowledged, #[serde(rename = "step.routed")] StepRouted, #[serde(rename = "step.attempt.started")] @@ -70,6 +85,10 @@ impl EntryType { Self::SubscriptionRegistered => "subscription.registered", Self::SubscriptionMatched => "subscription.matched", Self::SubscriptionStale => "subscription.stale", + Self::SubscriptionOpened => "subscription.opened", + Self::SubscriptionClosed => "subscription.closed", + Self::SubscriptionOverflowFenced => "subscription.overflow.fenced", + Self::SubscriptionAcknowledged => "subscription.acknowledged", Self::StepRouted => "step.routed", Self::StepAttemptStarted => "step.attempt.started", Self::StepCompleted => "step.completed", @@ -98,6 +117,10 @@ impl EntryType { "subscription.registered" => Self::SubscriptionRegistered, "subscription.matched" => Self::SubscriptionMatched, "subscription.stale" => Self::SubscriptionStale, + "subscription.opened" => Self::SubscriptionOpened, + "subscription.closed" => Self::SubscriptionClosed, + "subscription.overflow.fenced" => Self::SubscriptionOverflowFenced, + "subscription.acknowledged" => Self::SubscriptionAcknowledged, "step.routed" => Self::StepRouted, "step.attempt.started" => Self::StepAttemptStarted, "step.completed" => Self::StepCompleted, @@ -436,6 +459,18 @@ pub struct WaitEventPayload { pub wait_id: String, pub event_key: String, pub timeout_at_ms: Option, + /// Body activities use a stream cursor; old exact-match event waits leave + /// these additive fields absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub from_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settle_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline_at_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -472,6 +507,65 @@ pub struct StreamAppendedPayload { pub offset: u64, pub producer: String, pub message: Value, + /// Provider delivery id for body-subscription frames. It is optional so + /// existing generic streams retain their protocol shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_delivery_id: Option, +} + +/// The kernel records the immutable, tenant-neutral part of an activity +/// opening. Cloud owns the provider installation/resource binding and fences +/// it before asking the cell to append this fact; `router_binding` is an opaque +/// receipt, never policy interpreted by the kernel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SubscriptionOpenedPayload { + pub subscription_id: String, + pub event_types: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pattern: Option, + pub stream: String, + pub settle_ms: i64, + pub idle_ms: i64, + pub deadline_at_ms: i64, + pub include_self: bool, + #[serde(default)] + pub ingress_offset: u64, + #[serde(default)] + pub router_binding: Value, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionCompletionReason { + Closed, + RunCompleted, + Canceled, + Deadline, + Overflow, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SubscriptionClosedPayload { + pub subscription_id: String, + #[serde(rename = "completionReason")] + pub completion_reason: SubscriptionCompletionReason, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubscriptionOverflowFencedPayload { + pub subscription_id: String, + pub retained: u64, + pub bytes: u64, + pub from: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubscriptionAcknowledgedPayload { + pub subscription_id: String, + pub wait_id: String, + /// Present only for an event wake; idle has no stream range to advance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_offset: Option, } /// Appendix A rule 5. The record *elects* one attempt to perform the diff --git a/kernel/relayflowd-core/src/state.rs b/kernel/relayflowd-core/src/state.rs index 0575b8cb4..988559f2e 100644 --- a/kernel/relayflowd-core/src/state.rs +++ b/kernel/relayflowd-core/src/state.rs @@ -176,9 +176,14 @@ impl RunState { } EntryType::WaitEvent => { let payload: crate::entry::WaitEventPayload = decode(entry)?; - state.step_mut(entry)?.state = StepState::Waiting { - wait_id: payload.wait_id, - }; + // Body-level activities are run-local cursors rather than + // steps. Their `wait.event` fact intentionally has no + // step id, so it must not be folded as a step transition. + if entry.step_id.is_some() { + state.step_mut(entry)?.state = StepState::Waiting { + wait_id: payload.wait_id, + }; + } } EntryType::WaitHuman => { let payload: crate::entry::WaitHumanPayload = decode(entry)?; @@ -188,15 +193,17 @@ impl RunState { } 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 - }; + if entry.step_id.is_some() { + 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)?; @@ -214,6 +221,10 @@ impl RunState { // state machine — it never affects run/step state, so state // folding ignores it here. | EntryType::SubscriptionStale + | EntryType::SubscriptionOpened + | EntryType::SubscriptionClosed + | EntryType::SubscriptionOverflowFenced + | EntryType::SubscriptionAcknowledged | EntryType::ChannelAppended | EntryType::ChannelDelivered | EntryType::ChannelAcknowledged diff --git a/kernel/relayflowd/src/engine.rs b/kernel/relayflowd/src/engine.rs index 34e202e3d..2dea7a05b 100644 --- a/kernel/relayflowd/src/engine.rs +++ b/kernel/relayflowd/src/engine.rs @@ -88,6 +88,8 @@ mod memory; mod model; mod placement; mod remote; +mod subscriptions; +pub use subscriptions::{PendingRange, SubscriptionWake}; mod wake; pub use channels::ChannelCommandError; pub use model::{RunOutcome, RunSnapshot, RunStatus, StepSnapshot, StepStatus}; @@ -342,7 +344,7 @@ impl Engine { options: DriveOptions, lease_is_active: &dyn Fn(&str, u32) -> bool, ) -> Result { - let mut journal = self.open_run(run_id)?; + let journal = self.open_run(run_id)?; if !options.allow_human_influenced { for entry in journal.scan_all()? { if entry.entry_type == EntryType::StepCompleted @@ -366,6 +368,12 @@ impl Engine { .context("repair missing run registry entry")?; } let spec = journal.run_spec().context("read run spec")?; + // Activity timers are independent of step leases. Claim their durable + // instants before ordinary recovery so a timer that passed while this + // cell was down wakes immediately without re-running prior work. + drop(journal); + self.claim_subscription_timeouts(run_id)?; + let mut journal = self.open_run(run_id)?; let state = self.load_state(&journal, spec.clone())?; for action in recovery_actions_filtered(&state, self.clock.now_ms(), lease_is_active) { self.persist_only(&mut journal, action)?; diff --git a/kernel/relayflowd/src/engine/drive.rs b/kernel/relayflowd/src/engine/drive.rs index 2005b9e05..1d7b7b7ee 100644 --- a/kernel/relayflowd/src/engine/drive.rs +++ b/kernel/relayflowd/src/engine/drive.rs @@ -53,6 +53,19 @@ impl Engine { for action in actions { match action { Action::Append(mut entry) => { + if entry.entry_type == relayflowd_core::EntryType::RunCompleted { + let reason: relayflowd_core::RunCompletedPayload = serde_json::from_value(entry.payload.clone()) + .context("decode terminal completion while closing activities")?; + self.close_subscriptions_for_terminal( + &mut journal, + if reason.completion_reason == RunCompletionReason::Canceled { + relayflowd_core::SubscriptionCompletionReason::Canceled + } else { + relayflowd_core::SubscriptionCompletionReason::RunCompleted + }, + self.clock.now_ms(), + )?; + } if entry.entry_type == relayflowd_core::EntryType::StepAttemptStarted { // A deterministic peer may have completed since this batch // was elected. Re-fold before admitting the next start. diff --git a/kernel/relayflowd/src/engine/remote.rs b/kernel/relayflowd/src/engine/remote.rs index 19035996a..b202e79f1 100644 --- a/kernel/relayflowd/src/engine/remote.rs +++ b/kernel/relayflowd/src/engine/remote.rs @@ -265,6 +265,7 @@ impl Engine { offset, producer: producer.to_owned(), message, + provider_delivery_id: None, }, ), )?; @@ -294,7 +295,14 @@ impl Engine { Ok((messages, next_offset)) } - pub fn emit_event(&self, run_id: &str, event_key: &str, payload: Value) -> Result { + pub fn emit_event( + &self, + run_id: &str, + event_key: &str, + payload: Value, + delivery_id: Option<&str>, + actor: Option<&str>, + ) -> Result { let mut journal = self.open_run(run_id)?; let spec = journal.run_spec().context("read run spec")?; let entries = journal.scan_all().context("read event waits")?; @@ -327,10 +335,17 @@ impl Engine { ), )?; } + let activity_matches = self.append_local_subscription_event( + run_id, + event_key, + payload.clone(), + delivery_id, + actor, + )?; if !open.is_empty() { let _ = self.drive(journal, spec, DriveOptions::default())?; } - Ok(open.len()) + Ok(open.len() + activity_matches) } } diff --git a/kernel/relayflowd/src/engine/subscriptions.rs b/kernel/relayflowd/src/engine/subscriptions.rs new file mode 100644 index 000000000..29f04d2f0 --- /dev/null +++ b/kernel/relayflowd/src/engine/subscriptions.rs @@ -0,0 +1,512 @@ +//! Body-local event activities: durable cursors over journal streams. +//! +//! Provider bindings are deliberately not implemented here. Cloud fences its +//! tenant/provider binding, then calls `subscription.open`; this module owns +//! only the cell-local journal ordering, cursor, and timers. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail}; +use relayflowd_core::{ + Clock, EntryType, JournalEntry, RunSpawnedPayload, StreamAppendedPayload, SubscriptionAcknowledgedPayload, SubscriptionClosedPayload, + SubscriptionCompletionReason, SubscriptionOpenedPayload, WaitCompletedPayload, + SubscriptionOverflowFencedPayload, WaitCompletionReason, WaitEventPayload, +}; +use relayflowd_journal::SqliteJournal; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::Engine; + +const MAX_UNREAD_FRAMES: usize = 1_000; +const MAX_UNREAD_BYTES: usize = 1_024 * 1_024; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SubscriptionWake { + Events { events: Vec, offset: u64 }, + Idle, + Deadline { pending: Option }, + Overflow { retained: u64, bytes: u64, from: u64 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingRange { + pub from: u64, + pub to: u64, +} + +#[derive(Debug, Clone)] +struct SubscriptionState { + opened: SubscriptionOpenedPayload, + closed: Option, + acknowledged_offset: u64, + last_wake_at_ms: i64, + active_wait: Option, + ready: Option, + overflow_fence: Option, + next_wait_sequence: u64, +} + +impl SubscriptionState { + fn stream(&self) -> &str { &self.opened.stream } +} + +impl Engine { + /// Repository-owned local router adapter. Cloud performs the corresponding + /// binding and authorization work outside this tenant-unaware kernel. + #[doc(hidden)] + pub fn append_local_subscription_event(&self, run_id: &str, event_type: &str, payload: Value, delivery_id: Option<&str>, actor: Option<&str>) -> Result { + let journal = self.open_run(run_id)?; + let entries = journal.scan_all()?; + let run_identity = entries.iter().find(|entry| entry.entry_type == EntryType::RunSpawned) + .map(|entry| serde_json::from_value::(entry.payload.clone())).transpose()? + .map(|spawned| spawned.created_by); + let matched = subscriptions(&journal)?.into_iter().filter_map(|(id, state)| { + (state.closed.is_none() && state.overflow_fence.is_none() + && state.opened.event_types.iter().any(|kind| kind == event_type) + && state.opened.pattern.as_ref().is_none_or(|pattern| relayflowd_core::event::matches(pattern, &payload)) + && (state.opened.include_self || actor != run_identity.as_deref())).then_some(id) + }).collect::>(); + drop(journal); + if matched.is_empty() { return Ok(0); } + let delivery_id = delivery_id.filter(|id| !id.is_empty()).context("body subscription frame requires a provider delivery id")?; + let frame = json!({"type": event_type, "payload": payload}); + let mut appended = 0; + for id in matched { if self.append_subscription_frame(run_id, &id, delivery_id, frame.clone())? { appended += 1; } } + Ok(appended) + } + + pub(super) fn close_subscriptions_for_terminal(&self, journal: &mut SqliteJournal, reason: SubscriptionCompletionReason, now: i64) -> Result<()> { + let ids = subscriptions(journal)?.into_iter().filter_map(|(id, state)| state.closed.is_none().then_some(id)).collect::>(); + for id in ids { self.close_subscription_in_journal(journal, &id, reason, now)?; } + Ok(()) + } + + pub fn open_subscription( + &self, + run_id: &str, + subscription_id: &str, + event_types: Vec, + pattern: Option, + settle_ms: i64, + idle_ms: i64, + deadline_ms: i64, + include_self: bool, + ) -> Result<(String, i64)> { + if subscription_id.is_empty() || event_types.is_empty() || event_types.iter().any(String::is_empty) + || settle_ms < 0 || idle_ms <= 0 || deadline_ms <= 0 { + bail!("invalid durable subscription bounds or identity") + } + if let Some(pattern) = &pattern { relayflowd_core::event::validate_pattern(pattern)?; } + let mut journal = self.open_run(run_id)?; + let mut current = subscriptions(&journal)?; + let existing = current.remove(subscription_id); + if let Some(existing) = existing { + if existing.closed.is_none() { + return Ok((existing.opened.stream, existing.opened.deadline_at_ms)); + } + bail!("subscription {subscription_id} is closed") + } + let now = self.clock.now_ms(); + let deadline_at_ms = now.checked_add(deadline_ms).context("subscription deadline overflow")?; + let stream = format!("subscription/{subscription_id}"); + self.append(&mut journal, &JournalEntry::new( + EntryType::SubscriptionOpened, run_id, None, None, now, + SubscriptionOpenedPayload { + subscription_id: subscription_id.to_owned(), event_types, pattern, stream: stream.clone(), + settle_ms, idle_ms, deadline_at_ms, include_self, ingress_offset: 0, + // The local daemon is not a provider router. Cloud replaces + // this neutral receipt after it durably fenced its binding. + router_binding: json!({"transport": "local-daemon"}), + }, + ))?; + Ok((stream, deadline_at_ms)) + } + + pub fn close_subscription( + &self, run_id: &str, subscription_id: &str, reason: SubscriptionCompletionReason, + ) -> Result { + let mut journal = self.open_run(run_id)?; + let states = subscriptions(&journal)?; + let Some(state) = states.get(subscription_id) else { bail!("unknown subscription {subscription_id}") }; + if state.closed.is_some() || state.overflow_fence.is_some() { return Ok(false); } + self.close_subscription_in_journal(&mut journal, subscription_id, reason, self.clock.now_ms())?; + Ok(true) + } + + /// Appends one router-delivered frame. `delivery_id` is the provider's + /// idempotency key; a duplicate is a successful no-op. The caller has + /// already performed provider binding and self-actor authorization. + pub fn append_subscription_frame( + &self, run_id: &str, subscription_id: &str, delivery_id: &str, frame: Value, + ) -> Result { + if delivery_id.is_empty() { bail!("subscription frame requires a provider delivery id") } + let mut journal = self.open_run(run_id)?; + let states = subscriptions(&journal)?; + let Some(state) = states.get(subscription_id) else { bail!("unknown subscription {subscription_id}") }; + if state.closed.is_some() || state.overflow_fence.is_some() { return Ok(false); } + let entries = journal.scan_all()?; + let unread = unread_frames(&entries, state)?; + if unread.iter().any(|(_, append)| append.provider_delivery_id.as_deref() == Some(delivery_id)) + || entries.iter().filter(|entry| entry.entry_type == EntryType::StreamAppended).any(|entry| { + serde_json::from_value::(entry.payload.clone()).ok() + .is_some_and(|append| append.stream == state.stream() && append.provider_delivery_id.as_deref() == Some(delivery_id)) + }) { + return Ok(false); + } + let encoded = serde_json::to_vec(&frame).context("encode subscription frame")?; + let unread_bytes = unread.iter().try_fold(0usize, |sum, (_, append)| { + serde_json::to_vec(&append.message).map(|bytes| sum.saturating_add(bytes.len())) + })?; + if unread.len() >= MAX_UNREAD_FRAMES || unread_bytes.saturating_add(encoded.len()) > MAX_UNREAD_BYTES { + // In Cloud the router's durable binding fence precedes this + // command. Locally this append and close share the per-run journal + // sequencer, so there is no post-fence crash window to replay. + self.fence_subscription_overflow_in_journal(&mut journal, subscription_id, unread.len() as u64, unread_bytes as u64, state.acknowledged_offset, self.clock.now_ms())?; + self.complete_fenced_overflows_in_journal(&mut journal, self.clock.now_ms())?; + return Ok(false); + } + let offset = next_stream_offset(&entries, state.stream()); + self.append(&mut journal, &JournalEntry::new( + EntryType::StreamAppended, run_id, None, None, self.clock.now_ms(), + StreamAppendedPayload { stream: state.stream().to_owned(), offset, producer: "event-router".to_owned(), message: frame, provider_delivery_id: Some(delivery_id.to_owned()) }, + ))?; + Ok(true) + } + + /// Complete any persisted activity wait whose recorded timer is due. This + /// is called by resume and by the daemon's parked `next` loop; it is also a + /// deterministic test seam for a simulated clock. + pub fn claim_subscription_timeouts(&self, run_id: &str) -> Result { + let mut journal = self.open_run(run_id)?; + let now = self.clock.now_ms(); + let fenced = self.complete_fenced_overflows_in_journal(&mut journal, now)?; + let states = subscriptions(&journal)?; + let entries = journal.scan_all()?; + let mut claimed = fenced; + for (id, state) in states { + if state.closed.is_some() { continue; } + // Deadline is deliberately first: at an exact tie it beats an + // append already visible in this journal snapshot. + if now >= state.opened.deadline_at_ms { + self.close_subscription_in_journal(&mut journal, &id, SubscriptionCompletionReason::Deadline, now)?; + claimed += 1; + continue; + } + let Some(ref wait) = state.active_wait else { continue; }; + let unread = unread_frames(&entries, &state)?; + if !unread.is_empty() { + let newest_at = unread.last().map(|(entry, _)| entry.at_ms).unwrap_or(now); + if now >= newest_at.saturating_add(state.opened.settle_ms) || now >= wait.idle_at_ms.unwrap_or(i64::MAX) { + self.complete_events(&mut journal, &state, &wait, &unread, now)?; + claimed += 1; + } + } else if now >= wait.idle_at_ms.unwrap_or(i64::MAX) { + self.complete_wait(&mut journal, &wait, WaitCompletionReason::Timeout, json!({"subscription_id": id, "timeout": "idle"}), now)?; + claimed += 1; + } + } + claimed += self.claim_non_activity_wait_timeouts_in_journal(&mut journal, now)?; + Ok(claimed) + } + + /// Block only at the daemon edge. Every wait boundary and wake result is + /// journaled first, so a restarted caller observes the same state. + pub fn next_subscription(&self, run_id: &str, subscription_id: &str) -> Result { + self.next_subscription_after_ack(run_id, subscription_id, None) + } + + /// Return the next durable wake, optionally acknowledging the prior wake + /// first. A normal wake deliberately stays unacknowledged until the body + /// asks for another one: committing an acknowledgement before the socket + /// reply can lose a wake if the daemon dies in that hand-off window. + pub fn next_subscription_after_ack( + &self, + run_id: &str, + subscription_id: &str, + acknowledge_wait_id: Option<&str>, + ) -> Result { + self.next_subscription_after_ack_with_receipt(run_id, subscription_id, acknowledge_wait_id) + .map(|(wake, _)| wake) + } + + /// Same as [`Self::next_subscription_after_ack`], retaining the opaque + /// receipt id needed by a protocol client to acknowledge a recovered wake + /// whose durable sequence predates that client process. + pub fn next_subscription_after_ack_with_receipt( + &self, + run_id: &str, + subscription_id: &str, + acknowledge_wait_id: Option<&str>, + ) -> Result<(SubscriptionWake, Option)> { + let mut acknowledge_wait_id = acknowledge_wait_id; + loop { + self.claim_subscription_timeouts(run_id)?; + let mut journal = self.open_run(run_id)?; + let states = subscriptions(&journal)?; + let state = states.get(subscription_id).context("unknown subscription")?; + let now = self.clock.now_ms(); + if let Some(wait_id) = acknowledge_wait_id.take() { + self.acknowledge_normal_wake(&mut journal, state, wait_id, now)?; + continue; + } + if let Some(completed) = &state.ready { + let wake = wake_from_completed(&journal, state, completed)?; + let receipt = matches!(wake, SubscriptionWake::Events { .. } | SubscriptionWake::Idle) + .then(|| completed.wait_id.clone()); + return Ok((wake, receipt)); + } + if let Some(reason) = state.closed { return self.closed_wake(&journal, state, reason).map(|wake| (wake, None)); } + let entries = journal.scan_all()?; + let unread = unread_frames(&entries, state)?; + if !unread.is_empty() { + let newest_at = unread.last().unwrap().0.at_ms; + if now >= newest_at.saturating_add(state.opened.settle_ms) + || now >= state.last_wake_at_ms.saturating_add(state.opened.idle_ms) { + let wait = state.active_wait.clone().unwrap_or_else(|| activity_wait(state, now)); + // A completion without its preceding wait cannot be + // recovered: after a crash the fold has no activity to + // associate it with and would offer the same frames + // again. Persist the wait first even when a ready batch + // lets this call complete without parking. + if state.active_wait.is_none() { + self.append(&mut journal, &JournalEntry::new( + EntryType::WaitEvent, run_id, None, None, now, wait.clone(), + ))?; + } + self.complete_events(&mut journal, state, &wait, &unread, now)?; + return events_wake(&unread).map(|wake| (wake, Some(wait.wait_id))); + } + } + if let Some(wait) = &state.active_wait { + // A completed wait is reconstructed by the timer claimant on + // the next loop iteration. Leave it durable while parked. + let sleep_ms = wait.deadline_at_ms.unwrap_or(state.opened.deadline_at_ms).saturating_sub(now).clamp(1, 10); + drop(journal); + std::thread::sleep(std::time::Duration::from_millis(sleep_ms as u64)); + continue; + } + let wait = activity_wait(state, now); + self.append(&mut journal, &JournalEntry::new(EntryType::WaitEvent, run_id, None, None, now, wait))?; + } + } + + fn close_subscription_in_journal(&self, journal: &mut SqliteJournal, subscription_id: &str, reason: SubscriptionCompletionReason, now: i64) -> Result<()> { + let states = subscriptions(journal)?; + if let Some(state) = states.get(subscription_id) + && let Some(wait) = &state.active_wait + { + let result = match reason { + SubscriptionCompletionReason::Overflow => json!({"subscription_id": subscription_id, "wake": "overflow"}), + SubscriptionCompletionReason::Deadline => json!({"subscription_id": subscription_id, "timeout": "deadline", "pending": pending(&unread_frames(&journal.scan_all()?, state)?) }), + _ => json!({"subscription_id": subscription_id, "closed": true}), + }; + let completion_reason = if reason == SubscriptionCompletionReason::Overflow { + WaitCompletionReason::EventReceived + } else { + WaitCompletionReason::Timeout + }; + self.complete_wait(journal, wait, completion_reason, result, now)?; + } + self.append(journal, &JournalEntry::new(EntryType::SubscriptionClosed, journal.run_id(), None, None, now, + SubscriptionClosedPayload { subscription_id: subscription_id.to_owned(), completion_reason: reason }))?; + Ok(()) + } + + /// The Cloud router calls this only after it has durably fenced its own + /// binding. It is intentionally separate from close so a process death at + /// that boundary is visible to recovery rather than reopening the stream. + #[doc(hidden)] + pub fn fence_subscription_overflow(&self, run_id: &str, subscription_id: &str) -> Result<()> { + let mut journal = self.open_run(run_id)?; + let states = subscriptions(&journal)?; + let state = states.get(subscription_id).context("unknown subscription")?; + let unread = unread_frames(&journal.scan_all()?, state)?; + self.fence_subscription_overflow_in_journal(&mut journal, subscription_id, unread.len() as u64, unread_bytes(&unread) as u64, state.acknowledged_offset, self.clock.now_ms()) + } + + fn fence_subscription_overflow_in_journal(&self, journal: &mut SqliteJournal, subscription_id: &str, retained: u64, bytes: u64, from: u64, now: i64) -> Result<()> { + let states = subscriptions(journal)?; + let state = states.get(subscription_id).context("unknown subscription")?; + if state.closed.is_some() || state.overflow_fence.is_some() { return Ok(()); } + self.append(journal, &JournalEntry::new(EntryType::SubscriptionOverflowFenced, journal.run_id(), None, None, now, + SubscriptionOverflowFencedPayload { subscription_id: subscription_id.to_owned(), retained, bytes, from }))?; + Ok(()) + } + + fn complete_fenced_overflows_in_journal(&self, journal: &mut SqliteJournal, now: i64) -> Result { + let states = subscriptions(journal)?; + let fenced = states.into_iter().filter_map(|(id, state)| (state.closed.is_none() && state.overflow_fence.is_some()).then_some(id)).collect::>(); + for id in &fenced { self.close_subscription_in_journal(journal, id, SubscriptionCompletionReason::Overflow, now)?; } + Ok(fenced.len()) + } + + fn claim_non_activity_wait_timeouts_in_journal(&self, journal: &mut SqliteJournal, now: i64) -> Result { + let mut open = BTreeMap::, Option, i64)>::new(); + for entry in journal.scan_all()? { + match entry.entry_type { + EntryType::WaitHuman => { + let wait: relayflowd_core::WaitHumanPayload = serde_json::from_value(entry.payload)?; + if let Some(timeout) = wait.timeout_at_ms { open.insert(wait.wait_id, (entry.step_id, entry.attempt, timeout)); } + } + EntryType::WaitEvent => { + let wait: WaitEventPayload = serde_json::from_value(entry.payload)?; + if wait.stream.is_none() && let Some(timeout) = wait.timeout_at_ms { open.insert(wait.wait_id, (entry.step_id, entry.attempt, timeout)); } + } + EntryType::WaitCompleted => { open.remove(&serde_json::from_value::(entry.payload)?.wait_id); } + _ => {} + } + } + let due = open.into_iter().filter(|(_, (_, _, timeout))| *timeout <= now).collect::>(); + for (wait_id, (step_id, attempt, _)) in &due { + self.append(journal, &JournalEntry::new(EntryType::WaitCompleted, journal.run_id(), step_id.clone(), *attempt, now, + WaitCompletedPayload { wait_id: wait_id.clone(), completion_reason: WaitCompletionReason::Timeout, result: json!({"timeout": "timeout"}) }))?; + } + Ok(due.len()) + } + + fn complete_events(&self, journal: &mut SqliteJournal, state: &SubscriptionState, wait: &WaitEventPayload, unread: &[(JournalEntry, StreamAppendedPayload)], now: i64) -> Result<()> { + let from = state.acknowledged_offset; + let next = unread.last().expect("nonempty").1.offset.saturating_add(1); + self.complete_wait(journal, wait, WaitCompletionReason::EventReceived, + json!({"subscription_id": state.opened.subscription_id, "from_offset": from, "next_offset": next}), now) + } + + fn complete_wait(&self, journal: &mut SqliteJournal, wait: &WaitEventPayload, reason: WaitCompletionReason, result: Value, now: i64) -> Result<()> { + self.append(journal, &JournalEntry::new(EntryType::WaitCompleted, journal.run_id(), None, None, now, + WaitCompletedPayload { wait_id: wait.wait_id.clone(), completion_reason: reason, result }))?; + Ok(()) + } + + fn acknowledge_normal_wake(&self, journal: &mut SqliteJournal, state: &SubscriptionState, wait_id: &str, now: i64) -> Result<()> { + if state.ready.as_ref().is_some_and(|ready| ready.wait_id == wait_id) { + let completed = state.ready.as_ref().expect("checked ready wake"); + if !matches!(wake_from_completed(journal, state, completed)?, SubscriptionWake::Events { .. } | SubscriptionWake::Idle) { + bail!("subscription {} cannot acknowledge terminal wake {}", state.opened.subscription_id, wait_id); + } + self.append(journal, &JournalEntry::new(EntryType::SubscriptionAcknowledged, journal.run_id(), None, None, now, + SubscriptionAcknowledgedPayload { subscription_id: state.opened.subscription_id.clone(), wait_id: wait_id.to_owned(), next_offset: completed.result.get("next_offset").and_then(Value::as_u64) }))?; + return Ok(()); + } + let already_acknowledged = journal.scan_all()?.into_iter().any(|entry| { + entry.entry_type == EntryType::SubscriptionAcknowledged + && serde_json::from_value::(entry.payload) + .is_ok_and(|ack| ack.subscription_id == state.opened.subscription_id && ack.wait_id == wait_id) + }); + if !already_acknowledged { + bail!("subscription {} has no normal wake {} to acknowledge", state.opened.subscription_id, wait_id); + } + Ok(()) + } + + fn closed_wake(&self, journal: &SqliteJournal, state: &SubscriptionState, reason: SubscriptionCompletionReason) -> Result { + let unread = unread_frames(&journal.scan_all()?, state)?; + Ok(match reason { + SubscriptionCompletionReason::Overflow => { + let fence = state.overflow_fence.as_ref(); + SubscriptionWake::Overflow { retained: fence.map_or(unread.len() as u64, |fence| fence.retained), bytes: fence.map_or(unread_bytes(&unread) as u64, |fence| fence.bytes), from: fence.map_or(state.acknowledged_offset, |fence| fence.from) } + } + SubscriptionCompletionReason::Deadline => SubscriptionWake::Deadline { pending: pending(&unread) }, + SubscriptionCompletionReason::Closed | SubscriptionCompletionReason::RunCompleted | SubscriptionCompletionReason::Canceled => bail!("subscription {} is closed", state.opened.subscription_id), + }) + } +} + +fn subscriptions(journal: &SqliteJournal) -> Result> { + let mut states = BTreeMap::new(); + for entry in journal.scan_all()? { + match entry.entry_type { + EntryType::SubscriptionOpened => { + let opened: SubscriptionOpenedPayload = serde_json::from_value(entry.payload)?; + states.insert(opened.subscription_id.clone(), SubscriptionState { opened, closed: None, acknowledged_offset: 0, last_wake_at_ms: entry.at_ms, active_wait: None, ready: None, overflow_fence: None, next_wait_sequence: 0 }); + } + EntryType::SubscriptionClosed => { + let closed: SubscriptionClosedPayload = serde_json::from_value(entry.payload)?; + if let Some(state) = states.get_mut(&closed.subscription_id) { state.closed = Some(closed.completion_reason); } + } + EntryType::SubscriptionOverflowFenced => { + let fence: SubscriptionOverflowFencedPayload = serde_json::from_value(entry.payload)?; + if let Some(state) = states.get_mut(&fence.subscription_id) { state.overflow_fence = Some(fence); } + } + EntryType::SubscriptionAcknowledged => { + let acknowledged: SubscriptionAcknowledgedPayload = serde_json::from_value(entry.payload)?; + if let Some(state) = states.get_mut(&acknowledged.subscription_id) { + state.ready = state.ready.take().filter(|ready| ready.wait_id != acknowledged.wait_id); + if let Some(next) = acknowledged.next_offset { state.acknowledged_offset = state.acknowledged_offset.max(next); } + } + } + EntryType::WaitEvent => { + let wait: WaitEventPayload = serde_json::from_value(entry.payload)?; + if let Some(stream) = &wait.stream { + if let Some(state) = states.values_mut().find(|state| state.stream() == stream) { + state.active_wait = Some(wait); + state.next_wait_sequence = state.next_wait_sequence.saturating_add(1); + } + } + } + EntryType::WaitCompleted => { + let completed: WaitCompletedPayload = serde_json::from_value(entry.payload)?; + for state in states.values_mut() { + if state.active_wait.as_ref().is_some_and(|wait| wait.wait_id == completed.wait_id) { + state.active_wait = None; + state.last_wake_at_ms = entry.at_ms; + state.ready = Some(completed.clone()); + } + } + } + _ => {} + } + } + Ok(states) +} + +fn activity_wait(state: &SubscriptionState, _now: i64) -> WaitEventPayload { + let idle_at_ms = state.last_wake_at_ms.saturating_add(state.opened.idle_ms); + WaitEventPayload { + wait_id: format!("{}/next/{}", state.opened.subscription_id, state.next_wait_sequence), event_key: state.opened.subscription_id.clone(), timeout_at_ms: Some(state.opened.deadline_at_ms), + stream: Some(state.stream().to_owned()), from_offset: Some(state.acknowledged_offset), settle_ms: Some(state.opened.settle_ms), idle_at_ms: Some(idle_at_ms), deadline_at_ms: Some(state.opened.deadline_at_ms), + } +} + +fn unread_frames(entries: &[JournalEntry], state: &SubscriptionState) -> Result> { + let mut unread = Vec::new(); + for entry in entries.iter().filter(|entry| entry.entry_type == EntryType::StreamAppended) { + let append: StreamAppendedPayload = serde_json::from_value(entry.payload.clone()) + .context("decode stream.appended while reading subscription")?; + if append.stream == state.stream() && append.offset >= state.acknowledged_offset { + unread.push((entry.clone(), append)); + } + } + Ok(unread) +} + +fn next_stream_offset(entries: &[JournalEntry], stream: &str) -> u64 { + entries.iter().filter(|entry| entry.entry_type == EntryType::StreamAppended).filter_map(|entry| serde_json::from_value::(entry.payload.clone()).ok()).filter(|append| append.stream == stream).map(|append| append.offset.saturating_add(1)).max().unwrap_or(0) +} + +fn events_wake(unread: &[(JournalEntry, StreamAppendedPayload)]) -> Result { + Ok(SubscriptionWake::Events { events: unread.iter().map(|(_, append)| append.message.clone()).collect(), offset: unread.last().context("nonempty")?.1.offset.saturating_add(1) }) +} +fn wake_from_completed(journal: &SqliteJournal, state: &SubscriptionState, completed: &WaitCompletedPayload) -> Result { + if completed.result.get("wake").and_then(Value::as_str) == Some("overflow") { + let unread = unread_frames(&journal.scan_all()?, state)?; + let fence = state.overflow_fence.as_ref(); + return Ok(SubscriptionWake::Overflow { + retained: fence.map_or(unread.len() as u64, |fence| fence.retained), + bytes: fence.map_or(unread_bytes(&unread) as u64, |fence| fence.bytes), + from: fence.map_or(state.acknowledged_offset, |fence| fence.from), + }); + } + match completed.result.get("timeout").and_then(Value::as_str) { + Some("idle") => return Ok(SubscriptionWake::Idle), + Some("deadline") => return Ok(SubscriptionWake::Deadline { pending: completed.result.get("pending").cloned().and_then(|value| serde_json::from_value(value).ok()) }), + _ => {} + } + let from = completed.result.get("from_offset").and_then(Value::as_u64).context("activity event completion lacks from_offset")?; + let next = completed.result.get("next_offset").and_then(Value::as_u64).context("activity event completion lacks next_offset")?; + let events = journal.scan_all()?.into_iter().filter(|entry| entry.entry_type == EntryType::StreamAppended).filter_map(|entry| serde_json::from_value::(entry.payload).ok()).filter(|append| append.stream == state.stream() && append.offset >= from && append.offset < next).map(|append| append.message).collect(); + Ok(SubscriptionWake::Events { events, offset: next }) +} +fn unread_bytes(unread: &[(JournalEntry, StreamAppendedPayload)]) -> usize { unread.iter().filter_map(|(_, append)| serde_json::to_vec(&append.message).ok()).map(|bytes| bytes.len()).sum() } +fn pending(unread: &[(JournalEntry, StreamAppendedPayload)]) -> Option { Some(PendingRange { from: unread.first()?.1.offset, to: unread.last()?.1.offset.saturating_add(1) }) } diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index 7655d742c..fecaf2df5 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -465,7 +465,13 @@ fn handle_request( let _guard = lock.lock().expect("run lock"); ensure_mutable(&engine, ¶ms.run_id)?; let matched = engine - .emit_event(¶ms.run_id, ¶ms.event_key, params.payload) + .emit_event( + ¶ms.run_id, + ¶ms.event_key, + params.payload, + params.delivery_id.as_deref(), + params.actor.as_deref(), + ) .map_err(internal_error)?; Ok(json!({"matched": matched})) } @@ -479,11 +485,64 @@ fn handle_request( .map_err(internal_error)?, ) } + "subscription.open" => { + let params: SubscriptionOpenParams = decode_params(request.params)?; + let lock = hub.run_lock(¶ms.run_id); + let _guard = lock.lock().expect("run lock"); + ensure_mutable(&engine, ¶ms.run_id)?; + let (stream, deadline_at_ms) = engine + .open_subscription( + ¶ms.run_id, ¶ms.subscription_id, params.event_types, params.pattern, + params.settle_ms, params.idle_ms, params.deadline_ms, params.include_self, + ) + .map_err(internal_error)?; + Ok(json!({"subscription_id": params.subscription_id, "stream": stream, "deadline_at_ms": deadline_at_ms})) + } + "subscription.next" => { + let params: SubscriptionNextParams = decode_params(request.params)?; + // Do not hold the per-run mutex while parked: a router append on a + // second connection must be able to commit and wake this request. + let (wake, acknowledge_wait_id) = engine.next_subscription_after_ack_with_receipt( + ¶ms.run_id, + ¶ms.subscription_id, + params.acknowledge_wait_id.as_deref(), + ).map_err(internal_error)?; + let mut result = to_value(wake)?; + if let Some(acknowledge_wait_id) = acknowledge_wait_id { + result.as_object_mut().expect("subscription wake serializes as object") + .insert("acknowledge_wait_id".to_owned(), Value::String(acknowledge_wait_id)); + } + Ok(result) + } + "subscription.close" => { + let params: SubscriptionCloseParams = decode_params(request.params)?; + if matches!( + params.completion_reason, + relayflowd_core::SubscriptionCompletionReason::Deadline + | relayflowd_core::SubscriptionCompletionReason::Overflow + ) { + return Err(( + "bad_request", + "subscription.close accepts only closed, run_completed, or canceled" + .to_owned(), + )); + } + let lock = hub.run_lock(¶ms.run_id); + let _guard = lock.lock().expect("run lock"); + let changed = engine.close_subscription(¶ms.run_id, ¶ms.subscription_id, params.completion_reason).map_err(internal_error)?; + Ok(json!({"closed": if changed { params.subscription_id } else { String::new() }})) + } "channel.append" | "channel.receive" | "channel.ack" => { channels::handle(&engine, hub, connection_id, &request.verb, request.params) } "stream.append" => { let params: StreamAppendParams = decode_params(request.params)?; + if params.stream.starts_with("subscription/") { + return Err(( + "bad_request", + "subscription streams are reserved for the fenced event router".to_owned(), + )); + } let lock = hub.run_lock(¶ms.run_id); let _guard = lock.lock().expect("run lock"); ensure_mutable(&engine, ¶ms.run_id)?; diff --git a/kernel/relayflowd/src/server/wire.rs b/kernel/relayflowd/src/server/wire.rs index 9768b6b4f..5742002e9 100644 --- a/kernel/relayflowd/src/server/wire.rs +++ b/kernel/relayflowd/src/server/wire.rs @@ -1,4 +1,6 @@ -use relayflowd_core::{Budget, CompletionReason, EffectRef, Pins, StepType}; +use relayflowd_core::{ + Budget, CompletionReason, EffectRef, Pins, StepType, SubscriptionCompletionReason, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -134,6 +136,10 @@ pub(super) struct EventEmitParams { pub run_id: String, pub event_key: String, pub payload: Value, + #[serde(default)] + pub delivery_id: Option, + #[serde(default)] + pub actor: Option, } #[derive(Deserialize)] @@ -160,6 +166,37 @@ pub(super) struct StreamReadParams { pub limit: Option, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SubscriptionOpenParams { + pub run_id: String, + pub subscription_id: String, + pub event_types: Vec, + #[serde(default)] + pub pattern: Option, + pub settle_ms: i64, + pub idle_ms: i64, + pub deadline_ms: i64, + pub include_self: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SubscriptionNextParams { + pub run_id: String, + pub subscription_id: String, + #[serde(default)] + pub acknowledge_wait_id: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SubscriptionCloseParams { + pub run_id: String, + pub subscription_id: String, + pub completion_reason: SubscriptionCompletionReason, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct JournalReadParams { diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs new file mode 100644 index 000000000..2bdcb4b85 --- /dev/null +++ b/kernel/relayflowd/tests/event_activities.rs @@ -0,0 +1,264 @@ +//! Crash and state-machine coverage for docs/EVENT-AWAIT.md acceptance cases. +//! These use the real SQLite journal and a simulated clock where ordering +//! matters; no mock bypasses recovery. + +use relayflowd::Engine; +use relayflowd::engine::{PendingRange, SubscriptionWake}; +use relayflowd_core::{Clock, EntryType, Journal, JournalEntry, RunSpec, SimClock, StreamAppendedPayload, SubscriptionAcknowledgedPayload, WaitHumanPayload}; +use relayflowd_journal::SqliteJournal; +use serde_json::json; +use std::sync::{Arc, atomic::{AtomicI64, Ordering}}; + +#[derive(Clone)] +struct TestClock(Arc); +impl TestClock { + fn new(now: i64) -> Self { Self(Arc::new(AtomicI64::new(now))) } + fn set(&self, now: i64) { self.0.store(now, Ordering::SeqCst); } +} +impl Clock for TestClock { fn now_ms(&self) -> i64 { self.0.load(Ordering::SeqCst) } } + +fn parked_run(engine: &Engine) -> String { + let spec = RunSpec::parse(&serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), "/../../testdata/hello-agent.spec.canonical.json" + ))).unwrap()).unwrap(); + engine.start(spec, "event-activity-test", Some(0)).unwrap().run_id +} + +fn open(engine: &Engine, run_id: &str, deadline_ms: i64) { + engine.open_subscription( + run_id, "pr-42", vec!["github.pull_request".to_owned()], None, + 0, 10, deadline_ms, false, + ).unwrap(); +} + +#[test] +fn accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next() { + let directory = tempfile::tempdir().unwrap(); + let clock = SimClock::new(100); + let engine = Engine::with_clock(directory.path(), clock); + let run_id = parked_run(&engine); + open(&engine, &run_id, 1_000); + + assert!(engine.append_subscription_frame(&run_id, "pr-42", "delivery-1", json!({"type":"github.pull_request", "n": 1})).unwrap()); + assert!(!engine.append_subscription_frame(&run_id, "pr-42", "delivery-1", json!({"type":"github.pull_request", "n": 1})).unwrap()); + + // Fresh Engine + same journal is the crash boundary after stream.appended + // and before an activity wait can complete. + let resumed = Engine::with_clock(directory.path(), SimClock::new(100)); + assert_eq!(resumed.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Events { + events: vec![json!({"type":"github.pull_request", "n": 1})], offset: 1, + }); + let entries = resumed.journal_entries(&run_id, 1, 100).unwrap(); + assert_eq!(entries.iter().filter(|entry| entry.entry_type == EntryType::StreamAppended).count(), 1); +} + +#[test] +fn idle_wait_is_durable_and_fires_without_an_event() { + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::new(directory.path()); + let run_id = parked_run(&engine); + engine.open_subscription(&run_id, "quiet", vec!["github.pull_request".to_owned()], None, 0, 1, 100, false).unwrap(); + assert_eq!(engine.next_subscription(&run_id, "quiet").unwrap(), SubscriptionWake::Idle); + assert!(engine.journal_entries(&run_id, 1, 100).unwrap().iter().any(|entry| entry.entry_type == EntryType::WaitCompleted)); +} + +#[test] +fn exact_deadline_tie_wins_and_reports_unread_range() { + let directory = tempfile::tempdir().unwrap(); + let clock = TestClock::new(0); + let engine = Engine::with_clock(directory.path(), clock.clone()); + let run_id = parked_run(&engine); + open(&engine, &run_id, 10); + clock.set(10); + assert!(engine.append_subscription_frame(&run_id, "pr-42", "at-deadline", json!({"type":"github.pull_request"})).unwrap()); + assert_eq!(engine.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Deadline { + pending: Some(PendingRange { from: 0, to: 1 }), + }); +} + +#[test] +fn overflow_closes_before_the_1001st_unread_frame_and_recovery_never_reopens_it() { + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), SimClock::new(0)); + let run_id = parked_run(&engine); + open(&engine, &run_id, 10_000); + for number in 0..1_000 { + assert!(engine.append_subscription_frame(&run_id, "pr-42", &format!("delivery-{number}"), json!({"type":"github.pull_request", "n": number})).unwrap()); + } + // Simulate SIGKILL after the router's durable fence and before its close + // command reaches this local journal sequencer. + engine.fence_subscription_overflow(&run_id, "pr-42").unwrap(); + assert!(!engine.append_subscription_frame(&run_id, "pr-42", "would-exceed", json!({"type":"github.pull_request", "n": 1_000})).unwrap()); + assert_eq!(engine.journal_entries(&run_id, 1, 2_000).unwrap().iter().filter(|entry| entry.entry_type == EntryType::SubscriptionOverflowFenced).count(), 1); + + let resumed = Engine::with_clock(directory.path(), SimClock::new(0)); + assert_eq!(resumed.claim_subscription_timeouts(&run_id).unwrap(), 1); + assert!(matches!(resumed.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Overflow { retained: 1_000, bytes, from: 0 } if bytes > 0)); + let entries = resumed.journal_entries(&run_id, 1, 2_000).unwrap(); + assert_eq!(entries.iter().filter(|entry| entry.entry_type == EntryType::StreamAppended).count(), 1_000); + assert_eq!(entries.iter().filter(|entry| entry.entry_type == EntryType::SubscriptionClosed).count(), 1); +} + +#[test] +fn overflow_of_a_parked_next_returns_overflow_after_recovery() { + let directory = tempfile::tempdir().unwrap(); + let clock = TestClock::new(0); + let engine = Engine::with_clock(directory.path(), clock.clone()); + let run_id = parked_run(&engine); + open(&engine, &run_id, 10_000); + + // This is the durable boundary of a parked next(). The fence/close then + // races it just as a router overflow does while the body is asleep. + let mut journal = SqliteJournal::open(directory.path().join("runs").join(format!("{run_id}.sqlite3"))).unwrap(); + journal.append(&JournalEntry::new(EntryType::WaitEvent, &run_id, None, None, 0, relayflowd_core::WaitEventPayload { + wait_id: "pr-42/next/0".into(), event_key: "pr-42".into(), timeout_at_ms: Some(10_000), + stream: Some("subscription/pr-42".into()), from_offset: Some(0), settle_ms: Some(0), idle_at_ms: Some(10), deadline_at_ms: Some(10_000), + })).unwrap(); + drop(journal); + engine.fence_subscription_overflow(&run_id, "pr-42").unwrap(); + + let resumed = Engine::with_clock(directory.path(), clock); + assert_eq!(resumed.claim_subscription_timeouts(&run_id).unwrap(), 1); + assert_eq!(resumed.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Overflow { + retained: 0, bytes: 0, from: 0, + }); + let completion = resumed.journal_entries(&run_id, 1, 100).unwrap().into_iter() + .find(|entry| entry.entry_type == EntryType::WaitCompleted).unwrap(); + assert_eq!(serde_json::from_value::(completion.payload).unwrap().completion_reason, + relayflowd_core::WaitCompletionReason::EventReceived); +} + +#[test] +fn immediate_event_wakes_have_durable_distinct_wait_boundaries() { + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), SimClock::new(0)); + let run_id = parked_run(&engine); + open(&engine, &run_id, 10_000); + + for (delivery, number) in [("one", 1), ("two", 2)] { + assert!(engine.append_subscription_frame(&run_id, "pr-42", delivery, json!({"type":"github.pull_request", "n": number})).unwrap()); + let prior = (number > 1).then(|| "pr-42/next/0"); + assert!(matches!(engine.next_subscription_after_ack(&run_id, "pr-42", prior).unwrap(), SubscriptionWake::Events { .. })); + } + + let waits = engine.journal_entries(&run_id, 1, 100).unwrap().into_iter() + .filter(|entry| entry.entry_type == EntryType::WaitEvent) + .map(|entry| serde_json::from_value::(entry.payload).unwrap().wait_id) + .collect::>(); + assert_eq!(waits, vec!["pr-42/next/0", "pr-42/next/1"]); +} + +#[test] +fn cancel_closes_an_open_activity_before_the_terminal_run_record() { + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), SimClock::new(0)); + let run_id = parked_run(&engine); + open(&engine, &run_id, 1_000); + engine.cancel(&run_id, "event-activity-test").unwrap(); + let entries = engine.journal_entries(&run_id, 1, 100).unwrap(); + let close = entries.iter().position(|entry| entry.entry_type == EntryType::SubscriptionClosed).unwrap(); + let terminal = entries.iter().position(|entry| entry.entry_type == EntryType::RunCompleted).unwrap(); + assert!(close < terminal, "activity close must be durable before terminal run completion"); +} + +#[test] +fn remaining_event_await_acceptance_cases_use_the_real_journal() { + // Cases 1, 7, 8 and 11: the local router path buffers post-open frames, + // filters self, and refuses a closed cursor. + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), SimClock::new(0)); + let run_id = parked_run(&engine); + open(&engine, &run_id, 100); + assert_eq!(engine.append_local_subscription_event(&run_id, "github.pull_request", json!({"n": 1}), Some("busy"), Some("reviewer")).unwrap(), 1); + assert!(matches!(engine.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Events { offset: 1, .. })); + assert!(engine.close_subscription(&run_id, "pr-42", relayflowd_core::SubscriptionCompletionReason::Closed).unwrap()); + assert_eq!(engine.append_local_subscription_event(&run_id, "github.pull_request", json!({}), Some("closed"), Some("reviewer")).unwrap(), 0); + + let self_run = parked_run(&engine); + engine.open_subscription(&self_run, "self", vec!["github.pull_request".into()], None, 0, 10, 100, false).unwrap(); + assert_eq!(engine.append_local_subscription_event(&self_run, "github.pull_request", json!({}), Some("self"), Some("event-activity-test")).unwrap(), 0); + + // Cases 4 and 5: a settle burst yields one ordered wake; after that wake + // idle is measured from the wake while deadline stays fixed. + let directory = tempfile::tempdir().unwrap(); + let clock = TestClock::new(0); + let engine = Engine::with_clock(directory.path(), clock.clone()); + let run_id = parked_run(&engine); + engine.open_subscription(&run_id, "timed", vec!["github.pull_request".into()], None, 5, 10, 20, false).unwrap(); + for (at, n) in [(1, 1), (2, 2), (3, 3)] { clock.set(at); assert!(engine.append_subscription_frame(&run_id, "timed", &format!("d{n}"), json!({"type":"github.pull_request", "n":n})).unwrap()); } + clock.set(8); + assert!(matches!(engine.next_subscription(&run_id, "timed").unwrap(), SubscriptionWake::Events { offset: 3, .. })); + clock.set(18); + assert_eq!(engine.next_subscription_after_ack(&run_id, "timed", Some("timed/next/0")).unwrap(), SubscriptionWake::Idle); + clock.set(20); + assert!(matches!(engine.next_subscription_after_ack(&run_id, "timed", Some("timed/next/1")).unwrap(), SubscriptionWake::Deadline { .. })); + + // Case 3: a parked idle wait is recovered from the SQLite journal after + // its instant passes while the owning engine is absent. + let directory = tempfile::tempdir().unwrap(); + let clock = TestClock::new(0); + let engine = Engine::with_clock(directory.path(), clock.clone()); + let run_id = parked_run(&engine); + engine.open_subscription(&run_id, "idle-restart", vec!["github.pull_request".into()], None, 0, 10, 100, false).unwrap(); + let mut journal = SqliteJournal::open(directory.path().join("runs").join(format!("{run_id}.sqlite3"))).unwrap(); + journal.append(&JournalEntry::new(EntryType::WaitEvent, &run_id, None, None, 0, relayflowd_core::WaitEventPayload { wait_id: "idle-restart/next/0".into(), event_key: "idle-restart".into(), timeout_at_ms: Some(100), stream: Some("subscription/idle-restart".into()), from_offset: Some(0), settle_ms: Some(0), idle_at_ms: Some(10), deadline_at_ms: Some(100) })).unwrap(); + drop(journal); clock.set(10); + assert_eq!(Engine::with_clock(directory.path(), clock).next_subscription(&run_id, "idle-restart").unwrap(), SubscriptionWake::Idle); + + // Case 10: the human timer is a journaled fact, claimed after restart. + let directory = tempfile::tempdir().unwrap(); + let clock = TestClock::new(0); + let engine = Engine::with_clock(directory.path(), clock.clone()); + let run_id = parked_run(&engine); + let mut journal = SqliteJournal::open(directory.path().join("runs").join(format!("{run_id}.sqlite3"))).unwrap(); + journal.append(&JournalEntry::new(EntryType::WaitHuman, &run_id, None, None, 0, WaitHumanPayload { wait_id: "human".into(), prompt: "approve".into(), requested_of: "owner".into(), options: None, timeout_at_ms: Some(10), diff_ref: None })).unwrap(); + drop(journal); clock.set(10); + assert_eq!(engine.claim_subscription_timeouts(&run_id).unwrap(), 1); + + // Cases 12 and 15: byte overflow is explicit; a normal wake is retained + // once before a later fenced overflow closes the cursor. + let run_id = parked_run(&engine); + open(&engine, &run_id, 10_000); + assert!(engine.append_subscription_frame(&run_id, "pr-42", "normal", json!({"type":"github.pull_request"})).unwrap()); + assert!(matches!(engine.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Events { .. })); + engine.fence_subscription_overflow(&run_id, "pr-42").unwrap(); + assert_eq!(engine.claim_subscription_timeouts(&run_id).unwrap(), 1); + assert!(matches!(engine.next_subscription_after_ack(&run_id, "pr-42", Some("pr-42/next/0")).unwrap(), SubscriptionWake::Overflow { .. })); + let bytes_run = parked_run(&engine); + engine.open_subscription(&bytes_run, "bytes", vec!["github.pull_request".into()], None, 0, 10, 10_000, false).unwrap(); + assert!(!engine.append_subscription_frame(&bytes_run, "bytes", "too-big", json!("x".repeat(1_024 * 1_024))).unwrap()); + assert!(matches!(engine.next_subscription(&bytes_run, "bytes").unwrap(), SubscriptionWake::Overflow { retained: 0, bytes: 0, from: 0 })); + let keeps_up = parked_run(&engine); + engine.open_subscription(&keeps_up, "keeps-up", vec!["github.pull_request".into()], None, 0, 10, 10_000, false).unwrap(); + let mut journal = SqliteJournal::open(directory.path().join("runs").join(format!("{keeps_up}.sqlite3"))).unwrap(); + for offset in 0..1_001_u64 { + journal.append(&JournalEntry::new(EntryType::StreamAppended, &keeps_up, None, None, 0, StreamAppendedPayload { stream: "subscription/keeps-up".into(), offset, producer: "event-router".into(), message: json!({"type":"github.pull_request"}), provider_delivery_id: Some(format!("kept-{offset}")) })).unwrap(); + journal.append(&JournalEntry::new(EntryType::SubscriptionAcknowledged, &keeps_up, None, None, 0, SubscriptionAcknowledgedPayload { subscription_id: "keeps-up".into(), wait_id: format!("wake-{offset}"), next_offset: Some(offset + 1) })).unwrap(); + } + drop(journal); + assert!(engine.append_subscription_frame(&keeps_up, "keeps-up", "kept-final", json!({"type":"github.pull_request"})).unwrap()); +} + +#[test] +fn normal_wake_is_not_acknowledged_until_the_following_next() { + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), SimClock::new(0)); + let run_id = parked_run(&engine); + open(&engine, &run_id, 10_000); + assert!(engine.append_subscription_frame(&run_id, "pr-42", "first", json!({"type":"github.pull_request", "n": 1})).unwrap()); + + assert!(matches!(engine.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Events { offset: 1, .. })); + let entries = engine.journal_entries(&run_id, 1, 100).unwrap(); + assert!(!entries.iter().any(|entry| entry.entry_type == EntryType::SubscriptionAcknowledged)); + + // A restart before the body asks for another wake retains the committed + // normal completion instead of advancing its cursor behind the caller. + let resumed = Engine::with_clock(directory.path(), SimClock::new(0)); + assert!(matches!(resumed.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Events { offset: 1, .. })); + assert!(resumed.append_subscription_frame(&run_id, "pr-42", "second", json!({"type":"github.pull_request", "n": 2})).unwrap()); + assert_eq!(resumed.next_subscription_after_ack(&run_id, "pr-42", Some("pr-42/next/0")).unwrap(), SubscriptionWake::Events { + events: vec![json!({"type":"github.pull_request", "n": 2})], offset: 2, + }); + let entries = resumed.journal_entries(&run_id, 1, 100).unwrap(); + assert_eq!(entries.iter().filter(|entry| entry.entry_type == EntryType::SubscriptionAcknowledged).count(), 1); +} diff --git a/ops/event-await-overnight/README.md b/ops/event-await-overnight/README.md new file mode 100644 index 000000000..ca6c0d093 --- /dev/null +++ b/ops/event-await-overnight/README.md @@ -0,0 +1,17 @@ +# Event-await overnight flows + +`implement-event-await.flow.ts` is a local, journaled implementation loop for +the merged `docs/EVENT-AWAIT.md` contract. It uses direct local Codex workers +in one isolated worktree. It may create local commits and evidence only; it +does not push, open a pull request, merge, deploy, publish, or touch Cloud +credentials. + +Run it from this directory after installing dependencies: + +```sh +flows check implement-event-await.flow.ts +flows run implement-event-await.flow.ts --local-agent --input '{ + "repoRoot": "/Volumes/Paris Drive/AgentWorkforce/.worktrees/flows-v2-lead-0913/event-await-flows-overnight-0917", + "auditPasses": 2 +}' +``` diff --git a/ops/event-await-overnight/implement-event-await.flow.ts b/ops/event-await-overnight/implement-event-await.flow.ts new file mode 100644 index 000000000..e647e0171 --- /dev/null +++ b/ops/event-await-overnight/implement-event-await.flow.ts @@ -0,0 +1,105 @@ +import { flow } from "@relayflows/surface"; + +type Input = { + /** Fresh, isolated Flows worktree. */ + repoRoot: string; + /** Number of independent review-and-fix passes after the implementation slices. */ + auditPasses: number; +}; + +const safety = ` +Work only in the supplied fresh worktree. Do not touch a shared checkout or a +different repository. Do not run git push, gh, wrangler, npm publish, a deploy, +or alter credentials, environment secrets, or remote configuration. Never +merge. You may make focused local commits after each green implementation +slice. Preserve existing unrelated work. Report exact commands, exit codes, +commit IDs, and any blocker in docs/evidence/event-await-implementation/. +`; + +const contract = ` +Implement docs/EVENT-AWAIT.md as the source of truth. The required public +contract is body-level f.on() returning an Activity with next()/close(), with +required idle and deadline, optional settle and includeSelf. f.on() becomes +visible only after a durable fenced binding and ingress offset. Delivery is +ordered, deduplicated, and buffered while the body works. Cap unread data at +1,000 frames or 1 MiB; the would-exceed frame is refused and next() observes +overflow. Router appends, idle/deadline claims, and overflow closure serialize +per subscription. Idle returns buffered events; deadline wins an exact tie and +reports unread pending data. Recovery preserves a committed normal completion +once, otherwise completes a fenced overflow closure without reopening it. +Read the full document, especially acceptance cases 1–15. Do not weaken its +contract or replace crash tests with mocks that bypass journal recovery. +`; + +export default flow("event-await-flows-overnight", { + budget: { dollars: 80, wallclock: "10h" }, +}, async (f, input) => { + await f.agent("event-await-surface-and-preflight", { + cli: "codex", + task: `${safety}\n${contract}\n +Own the Surface and SDK authoring slice. Inspect the existing authored-flow +lowering path, Ctx types, validation, generated schemas, and direct-run +executor before changing code. Implement the smallest additive Activity API, +lowering, validation, and result decoding needed by the contract. Add focused +type and runtime tests for missing idle/deadline, Wake variants, and lifecycle +closure. Do not claim kernel/router behavior you have not implemented. Run the +most focused relevant test and typecheck commands, record their outputs, then +commit only your local slice if it is green.`, + }); + + await f.agent("event-await-kernel-and-timers", { + cli: "codex", + task: `${safety}\n${contract}\n +Own the Rust kernel and local daemon slice. First inspect the previous Surface +slice and current journal/state/recovery/timer machinery. Implement durable +subscription open/close, stream-backed waits, offset acknowledgement, +per-subscription ordering, timeout arming, and restart recovery. Keep the +closed step vocabulary intact as the spec requires. Add crash-injection and +state-machine tests that exercise accepted append, idle/deadline ties, and the +overflow fence/close boundary. Run focused cargo tests and commit a green +local slice. If an interface belongs in Cloud rather than the kernel, document +the exact transport boundary rather than inventing tenant policy here.`, + }); + + await f.agent("event-await-local-router-and-acceptance", { + cli: "codex", + task: `${safety}\n${contract}\n +Integrate the completed local Surface and kernel slices through the local +event path. Implement only repository-owned adapters needed to exercise the +contract without Cloud credentials. Add the deterministic acceptance harness +for all fifteen cases, including process kill/restart, redelivery dedupe, +unread accounting, normal-completion-versus-overflow ordering, and refusal of +events after close. Test actual journal replay rather than only pure helpers. +Run the relevant SDK and kernel suites and commit the green local slice. Write +a precise Cloud handoff describing any production router operations still +outside this repository.`, + }); + + for (let pass = 1; pass <= input.auditPasses; pass += 1) { + await f.agent(`event-await-flows-audit-${pass}`, { + cli: "codex", + task: `${safety}\n${contract}\n +Perform independent implementation audit pass ${pass}. Review every local +commit and test added for EVENT-AWAIT against acceptance cases 1–15 and the +existing kernel invariants. Fix concrete defects you find, especially replay, +idempotency, timer ordering, byte accounting, cleanup, and public API +compatibility. Run the narrowest meaningful regression suites plus the full +affected package suites. Commit only real fixes; otherwise write a no-finding +report with the commands and exit codes.`, + }); + } + + await f.agent("event-await-flows-evidence", { + cli: "codex", + task: `${safety}\n${contract}\n +Act as release evidence owner. Do not change implementation semantics. Inspect +the final local branch, run the comprehensive relevant test matrix, and write +docs/evidence/event-await-implementation/final-local-report.md. It must map +each acceptance case to its test and literal result, list all local commits, +and distinguish proven local behavior from the Cloud router handoff. Commit +that evidence only when its commands all pass; otherwise record the exact +failure and leave it visible for the next human.`, + }); + + f.done("needs_human"); +}); diff --git a/ops/event-await-overnight/package-lock.json b/ops/event-await-overnight/package-lock.json new file mode 100644 index 000000000..7a01837dc --- /dev/null +++ b/ops/event-await-overnight/package-lock.json @@ -0,0 +1,1892 @@ +{ + "name": "event-await-overnight", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "event-await-overnight", + "dependencies": { + "@relayflows/surface": "2.0.14" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "peer": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@relayfile/adapter-core": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@relayfile/adapter-core/-/adapter-core-0.5.24.tgz", + "integrity": "sha512-bOQRuBoAw2RlYs30RtKsOvXlXzcRx4owhHdj384hPrznBIY3U4ZYcb4pVfzreW9TStHEiX7EguUyqhD9g8DJmA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@scalar/postman-to-openapi": "^0.6.0", + "cheerio": "^1.2.0", + "minimatch": "^10.0.3", + "yaml": "^2.8.1" + }, + "bin": { + "adapter-core": "dist/src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + } + }, + "node_modules/@relayfile/adapter-linear": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@relayfile/adapter-linear/-/adapter-linear-0.4.12.tgz", + "integrity": "sha512-obICrTmIkVKXX0vAjGiezOK/cu3iUH6i67fuKLe7Fqcp/Upwrsr70Ap68YxyMvpNOvE4TdbbM1vU+2uGXwM5sg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@relayfile/adapter-core": "^0.5.18" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + } + }, + "node_modules/@relayfile/adapter-reddit": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@relayfile/adapter-reddit/-/adapter-reddit-0.2.9.tgz", + "integrity": "sha512-/ZWkr4SguRRCk4fY6DEtP5NVUHSMWP66GtMou4NJOcf5PaxYSa0ceSVxAN/IcTBG6JqdtbqB4IoWn8V9d/JC3g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@relayfile/adapter-core": "^0.5.15" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + } + }, + "node_modules/@relayfile/core": { + "version": "0.10.63", + "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.10.63.tgz", + "integrity": "sha512-gMK63uZGsuWVnjmDHpUb/sb6P0HukMU2e4tJHohUfEVLfl9qe12KJ2ySsN91LUEAs+MsiULSkvVJVlrWUiWVJw==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@relayfile/mount-darwin-arm64": { + "version": "0.10.63", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.63.tgz", + "integrity": "sha512-zA4fRSBe4YRWeYYqpZKvX6HsY/04G0tOwl58lTcvy2r9jEwNDJNCpqwtPS2yCoqQTyWay5sXjHx+qDhS5YiPjQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@relayfile/mount-darwin-x64": { + "version": "0.10.63", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.63.tgz", + "integrity": "sha512-4lBUHuizQr1Mxbt/uvegojoPZsU00Od5w6VjsGgFKaffuCAkaYT0E1WKuQb7Qvt95ZuiCk97fkumsP9U+RWbdA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@relayfile/mount-linux-arm64": { + "version": "0.10.63", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.63.tgz", + "integrity": "sha512-Nsgw1Hp1ITPtw975TBQ8tm1OtAMjb9gCl8BkbChuMQ4oPWCONXDbDuJON1A9K/U81Msr8RNURfMAyQdFd3gmXA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@relayfile/mount-linux-x64": { + "version": "0.10.63", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.63.tgz", + "integrity": "sha512-dsA1amLyR8Kpwowil5UGFmT9cM2C7Gbdv/BklsbMY+Ev5zCzngV6Cqo9N3SqwHc96g0mEdh5DkHo4gBhcnWucA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@relayfile/relay-helpers": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@relayfile/relay-helpers/-/relay-helpers-0.4.11.tgz", + "integrity": "sha512-J9S2L+dVRQcxv32BxMq+xEqpBJoG1mNzo1E/CCXY4LYUSl0euZD0ZayaFMcpBhGqx+2IDNiSlGjFun/YQk9OqA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@relayfile/adapter-core": "^0.5.15", + "@relayfile/adapter-linear": "^0.4.11", + "@relayfile/adapter-reddit": "^0.2.9" + } + }, + "node_modules/@relayfile/sdk": { + "version": "0.10.63", + "resolved": "https://registry.npmjs.org/@relayfile/sdk/-/sdk-0.10.63.tgz", + "integrity": "sha512-XVagKtSIumjG497WWmKHIPVBn6BYZf8nOCNO/hwebjzyiI2zig88JYQgQWnrA0FzAZI+JZF1dvy8gI8lb/OsTQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@relayfile/core": "0.10.63", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@relayfile/mount-darwin-arm64": "0.10.63", + "@relayfile/mount-darwin-x64": "0.10.63", + "@relayfile/mount-linux-arm64": "0.10.63", + "@relayfile/mount-linux-x64": "0.10.63" + } + }, + "node_modules/@relayflows/surface": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@relayflows/surface/-/surface-2.0.14.tgz", + "integrity": "sha512-p3F5KN0LWjMJkd3xFDePAR0zSoR5u3R4A8MKrJSGZa6kZ5EjXj+9IoPpHzGE2WLeWsGhnE8AIU/iGzqe/ACFKQ==", + "license": "Apache-2.0", + "dependencies": { + "ai-hist": "0.4.1" + }, + "engines": { + "node": ">=20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@relayfile/relay-helpers": "0.4.11" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.5.1.tgz", + "integrity": "sha512-9VvPfv8b+YZVIFwR3SWeq4Y8ij/kU3/kf2M6NKcbf2iVyh63d8s0ssap5m/nOhiz/Puidv/29MAJlJCA0LRssA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.7.0.tgz", + "integrity": "sha512-kN0PwlJW0de4bwQ4ib+mBHzKJUvBCyR/gwU4zLEq6SCbj+GfgYUh+2a0/yl1WYVUiSkkwFsHjfmQ8KjhR3HK0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/postman-to-openapi": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@scalar/postman-to-openapi/-/postman-to-openapi-0.6.3.tgz", + "integrity": "sha512-Y/tMuRZG34wEfpTxDfXFp5o2X3ibb5ojGWupGJ9ZxkThCx7rOGydnszJPzEbgDK3eF6nJ6UuE7bCTpIEutYnPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@scalar/helpers": "0.5.1", + "@scalar/openapi-types": "0.7.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ai-hist": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ai-hist/-/ai-hist-0.4.1.tgz", + "integrity": "sha512-qn/jXFtWoY4timtzRj1DO5RgqPJA8UoDAm9Qe3bJ9wM3ph+McK+kY/6H2YDUs67MHzWjGlSc1KAe6cdmFbDfeQ==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "sql.js": "^1.13.0", + "zod": "^4.4.3" + }, + "bin": { + "ai-hist-mcp": "dist/mcp-server.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC", + "peer": true + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "peer": true, + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "peer": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "peer": true, + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "peer": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "peer": true, + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql.js": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "peer": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/ops/event-await-overnight/package.json b/ops/event-await-overnight/package.json new file mode 100644 index 000000000..b634200b3 --- /dev/null +++ b/ops/event-await-overnight/package.json @@ -0,0 +1,8 @@ +{ + "name": "event-await-overnight", + "private": true, + "type": "module", + "dependencies": { + "@relayflows/surface": "2.0.14" + } +} diff --git a/packages/sdk/src/authored-activity.ts b/packages/sdk/src/authored-activity.ts new file mode 100644 index 000000000..ed56e04ff --- /dev/null +++ b/packages/sdk/src/authored-activity.ts @@ -0,0 +1,210 @@ +import type { + Activity, + ActivityDuration, + ActivityOptions, + EventFrame, + TriggerSource, + Wake, +} from '@relayflows/surface'; +import type { SubscriptionNextResult } from './protocol.js'; +import { AuthoredFlowExecutionError } from './authored-flow-error.js'; +import { JournalClient } from './journal-client.js'; + +type CloseReason = 'closed' | 'run_completed' | 'canceled'; + +interface OpenActivity { + readonly activity: Activity; + close(reason: CloseReason): Promise; +} + +/** Owns body-local cursors that must close before the authored root completes. */ +export class AuthoredActivities { + private readonly activities: OpenActivity[] = []; + + constructor( + private readonly journal: JournalClient, + private readonly runId: string | undefined, + ) {} + + open(source: TriggerSource, options: ActivityOptions): Activity { + if (this.runId === undefined) { + throw new AuthoredFlowExecutionError('journal_protocol_violation', 'f.on() requires a durable authored root run'); + } + const id = `activity-${this.activities.length + 1}`; + const activity = new JournalActivity(this.journal, this.runId, id, source, normalizeOptions(options)); + this.activities.push(activity); + return activity.activity; + } + + async closeAll(reason: Exclude): Promise { + for (const activity of this.activities) await activity.close(reason); + } +} + +class JournalActivity implements OpenActivity { + readonly activity: Activity; + private readonly openPromise: Promise; + private closed = false; + private nextSequence = 0; + private acknowledgeWaitId: string | undefined; + + constructor( + private readonly journal: JournalClient, + private readonly runId: string, + private readonly subscriptionId: string, + private readonly source: TriggerSource, + private readonly options: NormalizedActivityOptions, + ) { + this.activity = Object.freeze({ + next: () => this.next(), + close: () => this.close('closed'), + }); + // Opening starts at f.on(), rather than at the first next(), so frames + // arriving while the body reads state or runs another step are inside the + // router's binding window. next()/close() await this same handshake. + this.openPromise = this.openNow(); + void this.openPromise.catch(() => undefined); + } + + async close(reason: CloseReason): Promise { + if (this.closed) return; + await this.ensureOpen(); + await this.journal.subscriptionClose({ + run_id: this.runId, + subscription_id: this.subscriptionId, + completion_reason: reason, + }); + this.closed = true; + } + + private async next(): Promise { + if (this.closed) throw new AuthoredFlowExecutionError('activity_closed', 'activity is already closed'); + await this.ensureOpen(); + const result = await this.journal.subscriptionNext({ + run_id: this.runId, + subscription_id: this.subscriptionId, + ...(this.acknowledgeWaitId === undefined ? {} : { acknowledge_wait_id: this.acknowledgeWaitId }), + }); + const wake = decodeWake(result); + if (wake.kind === 'deadline' || wake.kind === 'overflow') { + this.closed = true; + } else { + this.acknowledgeWaitId = receiptId(result) ?? `${this.subscriptionId}/next/${this.nextSequence++}`; + this.nextSequence += 1; + } + return wake; + } + + private async ensureOpen(): Promise { + await this.openPromise; + } + + private async openNow(): Promise { + await this.journal.subscriptionOpen({ + run_id: this.runId, + subscription_id: this.subscriptionId, + event_types: [this.source.name], + ...(this.source.filter === undefined ? {} : { pattern: this.source.filter }), + settle_ms: this.options.settleMs, + idle_ms: this.options.idleMs, + deadline_ms: this.options.deadlineMs, + include_self: this.options.includeSelf, + }); + } +} + +function receiptId(result: SubscriptionNextResult): string | undefined { + const receipt = (result as { acknowledge_wait_id?: unknown }).acknowledge_wait_id; + return typeof receipt === 'string' && receipt.length > 0 ? receipt : undefined; +} + +interface NormalizedActivityOptions { + readonly settleMs: number; + readonly idleMs: number; + readonly deadlineMs: number; + readonly includeSelf: boolean; +} + +function normalizeOptions(options: ActivityOptions): NormalizedActivityOptions { + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + throw unboundedSubscription('f.on() requires idle and deadline bounds'); + } + const value = options as unknown as Record; + const allowed = new Set(['settle', 'idle', 'deadline', 'includeSelf']); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new AuthoredFlowExecutionError('unbounded_subscription', `f.on() has unknown option ${JSON.stringify(key)}`); + } + if (!Object.hasOwn(value, 'idle') || !Object.hasOwn(value, 'deadline')) { + throw unboundedSubscription('f.on() requires both idle and deadline bounds'); + } + if (value['includeSelf'] !== undefined && typeof value['includeSelf'] !== 'boolean') { + throw new AuthoredFlowExecutionError('unbounded_subscription', 'f.on() includeSelf must be a boolean'); + } + return Object.freeze({ + settleMs: parseDuration(value['settle'] as ActivityDuration | undefined, 'settle', true), + idleMs: parseDuration(value['idle'] as ActivityDuration, 'idle', false), + deadlineMs: parseDuration(value['deadline'] as ActivityDuration, 'deadline', false), + includeSelf: value['includeSelf'] === true, + }); +} + +function parseDuration(value: ActivityDuration | undefined, field: string, zeroAllowed: boolean): number { + if (value === undefined && zeroAllowed) return 0; + let milliseconds: number; + if (typeof value === 'number') milliseconds = value; + else if (typeof value === 'string') { + const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value); + const units: Record = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + const unit = match?.[2]; + milliseconds = match === null || unit === undefined ? NaN : Math.round(Number(match[1]) * units[unit]!); + } else milliseconds = NaN; + if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || (!zeroAllowed && milliseconds === 0)) { + throw new AuthoredFlowExecutionError( + 'unbounded_subscription', + `f.on() ${field} must be ${zeroAllowed ? 'a non-negative' : 'a positive'} whole number of milliseconds or a duration such as "72h"`, + ); + } + return milliseconds; +} + +function unboundedSubscription(message: string): AuthoredFlowExecutionError { + return new AuthoredFlowExecutionError('unbounded_subscription', message); +} + +/** Decode the wire result at the journal boundary so malformed wakes fail closed. */ +export function decodeWake(value: SubscriptionNextResult): Wake { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidWake(); + if (value.kind === 'idle') return Object.freeze({ kind: 'idle' }); + if (value.kind === 'events') { + if (!Array.isArray(value.events) || !isOffset(value.offset) || !value.events.every(isEventFrame)) return invalidWake(); + return Object.freeze({ kind: 'events', events: Object.freeze([...value.events]) as readonly EventFrame[], offset: value.offset }); + } + if (value.kind === 'deadline') { + if (value.pending !== null && !isPending(value.pending)) return invalidWake(); + return Object.freeze({ kind: 'deadline', pending: value.pending === null ? null : Object.freeze({ ...value.pending }) }); + } + if (value.kind === 'overflow') { + if (!isOffset(value.retained) || !isOffset(value.bytes) || !isOffset(value.from)) return invalidWake(); + return Object.freeze({ kind: 'overflow', retained: value.retained, bytes: value.bytes, from: value.from }); + } + return invalidWake(); +} + +function isEventFrame(value: unknown): value is EventFrame { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && typeof (value as { type?: unknown }).type === 'string'; +} + +function isPending(value: unknown): value is { from: number; to: number } { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && isOffset((value as { from?: unknown }).from) + && isOffset((value as { to?: unknown }).to); +} + +function isOffset(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function invalidWake(): never { + throw new AuthoredFlowExecutionError('journal_protocol_violation', 'subscription.next returned an invalid wake result'); +} diff --git a/packages/sdk/src/authored-flow-error.ts b/packages/sdk/src/authored-flow-error.ts index e372f24a5..325bc562b 100644 --- a/packages/sdk/src/authored-flow-error.ts +++ b/packages/sdk/src/authored-flow-error.ts @@ -27,6 +27,8 @@ export type AuthoredFlowExecutionErrorCode = | 'unsettled_derived_work' | 'unsupported_promise_lifecycle' | 'unsupported_workspace_permission' + | 'unbounded_subscription' + | 'activity_closed' | 'unawaited_step' | 'unsupported_verb'; diff --git a/packages/sdk/src/authored-flow-executor.ts b/packages/sdk/src/authored-flow-executor.ts index 568ec917c..2fce681cc 100644 --- a/packages/sdk/src/authored-flow-executor.ts +++ b/packages/sdk/src/authored-flow-executor.ts @@ -40,6 +40,7 @@ import { verifyAuthoredOperations, } from './authored-flow-operation.js'; import { AuthoredFlowLifecycle } from './authored-flow-lifecycle.js'; +import { AuthoredActivities } from './authored-activity.js'; import { JournalClient } from './journal-client.js'; import type { CompletionReason as ProtocolCompletionReason, @@ -188,6 +189,7 @@ export async function executeAuthoredFlow( const journalSteps: AuthoredFlowJournalStep[] = []; const authoredSteps: AuthoredFlowOperation[] = []; const lifecycle = new AuthoredFlowLifecycle(); + const activities = new AuthoredActivities(journal, options.rootRunId); let nextStep = 1; let requestedCompletion: FlowCompletionReason | undefined; @@ -308,6 +310,10 @@ export async function executeAuthoredFlow( ); return trackStep(authoredSteps, agentOp); }, + on(source, activityOptions) { + assertOperationAllowed('on', definition.name, requestedCompletion); + return activities.open(source, activityOptions); + }, human() { assertOperationAllowed('human', definition.name, requestedCompletion); throw unsupportedVerb('human'); @@ -368,6 +374,7 @@ export async function executeAuthoredFlow( if (bodyFailed) { try { await stopAuthoredOperations(authoredSteps, bodyFailure); + await activities.closeAll('canceled'); } finally { lifecycle.close(); } @@ -394,6 +401,7 @@ export async function executeAuthoredFlow( ); try { await stopAuthoredOperations(authoredSteps, missingCompletion); + await activities.closeAll('canceled'); } finally { lifecycle.close(); } @@ -401,6 +409,16 @@ export async function executeAuthoredFlow( } try { await verifyAuthoredOperations(definition.name, authoredSteps, lifecycle); + } catch (error) { + try { + await activities.closeAll('canceled'); + } finally { + lifecycle.close(); + } + throw error; + } + try { + await activities.closeAll('run_completed'); } finally { lifecycle.close(); } diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index e4dc4fe87..aa4b239e6 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -2,6 +2,7 @@ import { addPlugin } from './cli/add.js'; import { watchCheck } from './cli-watch.js'; import { checkHelperBody } from './cli/check-helper-body.js'; +import { checkAuthoredActivities } from './cli/check-activities.js'; import { renderProgress, type ProgressEvent } from './progress.js'; import { realpathSync } from 'node:fs'; @@ -242,6 +243,8 @@ export async function runCli( async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckReport }> { const helper = await checkHelperBody(path); if (!helper.report.ok) return helper; + const activities = await checkAuthoredActivities(path); + if (!activities.report.ok) return activities; const mcp = await checkTypeScriptFlow(path); const triggers = isAuthoredFlowPath(path) ? await checkAuthoredTriggers(path) @@ -251,8 +254,8 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR return { report: { ...mcp.report, - diagnostics: [...helper.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics], - ok: helper.report.ok && mcp.report.ok && triggerOk, + diagnostics: [...helper.report.diagnostics, ...activities.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics], + ok: helper.report.ok && activities.report.ok && mcp.report.ok && triggerOk, }, }; } diff --git a/packages/sdk/src/cli/check-activities.ts b/packages/sdk/src/cli/check-activities.ts new file mode 100644 index 000000000..c4b0d8c21 --- /dev/null +++ b/packages/sdk/src/cli/check-activities.ts @@ -0,0 +1,50 @@ +import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; +import { inputFailureReport, type CheckExecution } from './check.js'; + +/** + * Fail closed on body-level `f.on` calls that visibly omit an end bound. + * Dynamic options cannot be proved at check time and are refused here too; + * runtime validation covers JS callers and values assembled at runtime. + */ +export async function checkAuthoredActivities(path: string): Promise { + try { + const source = await readFile(path, 'utf8'); + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); + let diagnostic: string | undefined; + const visit = (node: ts.Node): void => { + if (diagnostic !== undefined) return; + if (ts.isCallExpression(node) && isContextOn(node.expression)) { + const options = node.arguments[1]; + if (options === undefined || !ts.isObjectLiteralExpression(options)) { + diagnostic = 'body-level f.on() requires literal idle and deadline bounds (unbounded_subscription)'; + return; + } + const keys = new Set(options.properties.flatMap((property) => { + if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return []; + return ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? [property.name.text] : []; + })); + if (!keys.has('idle') || !keys.has('deadline')) { + diagnostic = 'body-level f.on() requires both idle and deadline bounds (unbounded_subscription)'; + return; + } + } + ts.forEachChild(node, visit); + }; + visit(file); + if (diagnostic !== undefined) { + return { report: inputFailureReport({ kind: 'invalid_spec', message: diagnostic }, path) }; + } + return { report: { ok: true, path, gates: [], resolutions: [], diagnostics: [] } }; + } catch (error) { + return { report: inputFailureReport({ kind: 'invalid_spec', + message: error instanceof Error ? error.message : 'Cannot inspect body-level activities' }, path) }; + } +} + +function isContextOn(expression: ts.LeftHandSideExpression): boolean { + return ts.isPropertyAccessExpression(expression) + && ts.isIdentifier(expression.expression) + && expression.expression.text === 'f' + && expression.name.text === 'on'; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b3ead2c60..461eaa483 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -118,6 +118,12 @@ export type { EffectRef, EventEmitParams, EventEmitResult, + SubscriptionCloseParams, + SubscriptionCloseResult, + SubscriptionNextParams, + SubscriptionNextResult, + SubscriptionOpenParams, + SubscriptionOpenResult, HelloParams, HelloResult, JournalReadParams, diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 0c8e32868..79990914d 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -13,7 +13,7 @@ import { EventEmitter } from 'node:events'; export { walkJournal, JournalReadError, type JournalEvent, type JournalReadFailure } from './journal-reader.js'; import { randomUUID } from 'node:crypto'; import { createConnection, type Socket } from 'node:net'; -import type { VerbContract, EventSubmitParams } from './protocol.js'; +import type { VerbContract, EventEmitParams, EventSubmitParams } from './protocol.js'; import { PROTOCOL_VERSION, type CompletionReason, @@ -386,8 +386,13 @@ export class JournalClient extends EventEmitter { } /** 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 }); + eventEmit( + runId: string, + eventKey: string, + payload: unknown, + options: Pick = {}, + ): Promise { + return this.request('event.emit', { run_id: runId, event_key: eventKey, payload, ...options }); } /** @@ -401,6 +406,21 @@ export class JournalClient extends EventEmitter { return this.request('event.submit', { spec, event }); } + /** Open a fenced body subscription. A successful reply makes the Activity visible to its body. */ + subscriptionOpen(params: VerbContract['subscription.open']['params']): Promise { + return this.request('subscription.open', params, null); + } + + /** Park for the next journaled subscription wake. */ + subscriptionNext(params: VerbContract['subscription.next']['params']): Promise { + return this.request('subscription.next', params, null); + } + + /** Close a body subscription; the server refuses later external appends. */ + subscriptionClose(params: VerbContract['subscription.close']['params']): Promise { + return this.request('subscription.close', params, null); + } + /** Durable channel write; journals `stream.appended`. */ streamAppend(runId: string, stream: string, message: unknown): Promise { return this.request('stream.append', { run_id: runId, stream, message }); diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 89105709c..80fb9d49c 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -54,6 +54,9 @@ export type Verb = | 'step.complete' | 'event.emit' | 'event.submit' + | 'subscription.open' + | 'subscription.next' + | 'subscription.close' | 'stream.append' | 'stream.read' | 'journal.read'; @@ -333,6 +336,10 @@ export interface EventEmitParams { run_id: string; event_key: string; payload: unknown; + /** Provider delivery id for body activities. Required by Cloud; optional for legacy exact waits. */ + delivery_id?: string; + /** Provider actor identity, used by the local router adapter for self filtering. */ + actor?: string; } export interface EventEmitResult { matched: number; @@ -359,6 +366,44 @@ export interface EventSubmitResult { run?: unknown; } +/** Body-level activity open; the server acknowledges only after its fenced binding is durable. */ +export interface SubscriptionOpenParams { + run_id: string; + subscription_id: string; + event_types: string[]; + pattern?: Record; + settle_ms: number; + idle_ms: number; + deadline_ms: number; + include_self: boolean; +} +export interface SubscriptionOpenResult { + subscription_id: string; + stream: string; + deadline_at_ms: number; +} + +export interface SubscriptionNextParams { + run_id: string; + subscription_id: string; + /** Internal durable receipt for the prior normal wake, supplied with the next pull. */ + acknowledge_wait_id?: string; +} +export type SubscriptionNextResult = + | { kind: 'events'; events: unknown[]; offset: number; acknowledge_wait_id?: string } + | { kind: 'idle'; acknowledge_wait_id?: string } + | { kind: 'deadline'; pending: { from: number; to: number } | null } + | { kind: 'overflow'; retained: number; bytes: number; from: number }; + +export interface SubscriptionCloseParams { + run_id: string; + subscription_id: string; + completion_reason: 'closed' | 'run_completed' | 'canceled'; +} +export interface SubscriptionCloseResult { + closed: string; +} + export interface StreamAppendParams { run_id: string; stream: string; @@ -403,6 +448,9 @@ export interface VerbContract { 'step.complete': { params: StepCompleteParams; result: StepCompleteResult }; 'event.emit': { params: EventEmitParams; result: EventEmitResult }; 'event.submit': { params: EventSubmitParams; result: EventSubmitResult }; + 'subscription.open': { params: SubscriptionOpenParams; result: SubscriptionOpenResult }; + 'subscription.next': { params: SubscriptionNextParams; result: SubscriptionNextResult }; + 'subscription.close': { params: SubscriptionCloseParams; result: SubscriptionCloseResult }; 'stream.append': { params: StreamAppendParams; result: StreamAppendResult }; 'stream.read': { params: StreamReadParams; result: StreamReadResult }; 'journal.read': { params: JournalReadParams; result: JournalReadResult }; diff --git a/packages/sdk/tests/activity-preflight.test.ts b/packages/sdk/tests/activity-preflight.test.ts new file mode 100644 index 000000000..8dd6cd4db --- /dev/null +++ b/packages/sdk/tests/activity-preflight.test.ts @@ -0,0 +1,18 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { checkAuthoredActivities } from '../src/cli/check-activities.js'; + +const directories: string[] = []; +afterEach(() => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); + +it('refuses a body-level f.on without both required bounds', async () => { + const directory = mkdtempSync(join(tmpdir(), 'flows-activity-check-')); + directories.push(directory); + const path = join(directory, 'missing-bound.flow.ts'); + writeFileSync(path, 'export default async function body(f: unknown) { f.on(source, { idle: "1h" }); }'); + await expect(checkAuthoredActivities(path)).resolves.toMatchObject({ + report: { ok: false, diagnostics: [{ message: expect.stringContaining('unbounded_subscription') }] }, + }); +}); diff --git a/packages/sdk/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts new file mode 100644 index 000000000..6fd82d681 --- /dev/null +++ b/packages/sdk/tests/authored-activity.test.ts @@ -0,0 +1,161 @@ +import { rmSync } from 'node:fs'; +import type { Server } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { flow, webhook } from '@relayflows/surface'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { AuthoredFlowExecutionError } from '../src/authored-flow-error.js'; +import { decodeWake } from '../src/authored-activity.js'; +import { JournalClient } from '../src/journal-client.js'; +import { sendOk, sendResult, sockPath, startLoopback } from './journal-client-loopback.js'; + +describe('authored event activities', () => { + let path: string; + let server: Server; + const calls: Array<{ verb: string; params: Record }> = []; + + beforeAll(() => { + path = sockPath(); + server = startLoopback(path, { + hello: (ctx) => sendOk(ctx), + 'subscription.open': (ctx, params) => { + calls.push({ verb: 'subscription.open', params }); + sendResult(ctx, { subscription_id: params.subscription_id, stream: 'subscription/activity-1', deadline_at_ms: 99 }); + }, + 'subscription.next': (ctx, params) => { + calls.push({ verb: 'subscription.next', params }); + sendResult(ctx, { kind: 'events', events: [{ type: 'pull_request', payload: { number: 42 } }], offset: 1 }); + }, + 'subscription.close': (ctx, params) => { + calls.push({ verb: 'subscription.close', params }); + sendResult(ctx, { closed: params.subscription_id }); + }, + 'run.start': (ctx) => sendResult(ctx, { + run_id: 'completion-run', status: 'completed', completion_reason: 'success', completed_steps: 1, + }), + 'journal.read': (ctx) => sendResult(ctx, { entries: [{ + entry_type: 'step.completed', step_id: 'complete-1', + payload: { completionReason: 'success', disposition: 'step_done', output: { exit_code: 0, stdout_tail: '', stderr_tail: '' } }, + }] }), + }); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(path, { force: true }); + }); + + it('lowers a bounded activity, decodes events, and closes it with run completion', async () => { + calls.length = 0; + const journal = new JournalClient(path, { requestTimeoutMs: 2_000 }); + await journal.connect(); + await journal.hello('authored-activity-test'); + try { + const result = await executeAuthoredFlow(flow('activity', async (f) => { + const activity = f.on(webhook('pull_request'), { settle: '2m', idle: '72h', deadline: '14d' }); + await expect.poll(() => calls.map(call => call.verb), { timeout: 2_000 }) + .toEqual(['subscription.open']); + const wake = await activity.next(); + expect(wake).toEqual({ kind: 'events', events: [{ type: 'pull_request', payload: { number: 42 } }], offset: 1 }); + f.done('success'); + }), journal, undefined, { rootRunId: 'root-activity' }); + expect(result.completionReason).toBe('success'); + expect(calls).toEqual([ + { verb: 'subscription.open', params: { + run_id: 'root-activity', subscription_id: 'activity-1', event_types: ['pull_request'], + settle_ms: 120_000, idle_ms: 259_200_000, deadline_ms: 1_209_600_000, include_self: false, + } }, + { verb: 'subscription.next', params: { run_id: 'root-activity', subscription_id: 'activity-1' } }, + { verb: 'subscription.close', params: { run_id: 'root-activity', subscription_id: 'activity-1', completion_reason: 'run_completed' } }, + ]); + } finally { journal.close(); } + }); + + it('refuses missing required bounds before journal contact', async () => { + const journal = new JournalClient('/journal-must-not-be-contacted'); + await expect(executeAuthoredFlow(flow('unbounded', async (f) => { + f.on(webhook('pull_request'), { idle: '1h' } as never); + f.done('success'); + }), journal, undefined, { rootRunId: 'root-unbounded' })).rejects.toMatchObject({ code: 'unbounded_subscription' }); + }); + + it('does not reopen a cursor after explicit close', async () => { + calls.length = 0; + const journal = new JournalClient(path, { requestTimeoutMs: 2_000 }); + await journal.connect(); + await journal.hello('authored-activity-close-test'); + try { + await executeAuthoredFlow(flow('closed-activity', async (f) => { + const activity = f.on(webhook('pull_request'), { idle: '1h', deadline: '1d' }); + await activity.close(); + await expect(activity.next()).rejects.toMatchObject({ code: 'activity_closed' }); + f.done('success'); + }), journal, undefined, { rootRunId: 'root-closed-activity' }); + expect(calls.filter(call => call.verb === 'subscription.close')).toEqual([ + { verb: 'subscription.close', params: { + run_id: 'root-closed-activity', subscription_id: 'activity-1', completion_reason: 'closed', + } }, + ]); + } finally { journal.close(); } + }); + + it('acknowledges a normal wake only with the following pull', async () => { + calls.length = 0; + const journal = new JournalClient(path, { requestTimeoutMs: 2_000 }); + await journal.connect(); + await journal.hello('authored-activity-ack-test'); + try { + await executeAuthoredFlow(flow('ack-activity', async (f) => { + const activity = f.on(webhook('pull_request'), { idle: '1h', deadline: '1d' }); + await activity.next(); + await activity.next(); + f.done('success'); + }), journal, undefined, { rootRunId: 'root-ack' }); + expect(calls).toEqual([ + { verb: 'subscription.open', params: { + run_id: 'root-ack', subscription_id: 'activity-1', event_types: ['pull_request'], + settle_ms: 0, idle_ms: 3_600_000, deadline_ms: 86_400_000, include_self: false, + } }, + { verb: 'subscription.next', params: { run_id: 'root-ack', subscription_id: 'activity-1' } }, + { verb: 'subscription.next', params: { + run_id: 'root-ack', subscription_id: 'activity-1', acknowledge_wait_id: 'activity-1/next/0', + } }, + { verb: 'subscription.close', params: { + run_id: 'root-ack', subscription_id: 'activity-1', completion_reason: 'run_completed', + } }, + ]); + } finally { journal.close(); } + }); + + it('cancels an opened cursor when the body fails completion validation', async () => { + calls.length = 0; + const journal = new JournalClient(path, { requestTimeoutMs: 2_000 }); + await journal.connect(); + await journal.hello('authored-activity-missing-completion-test'); + try { + await expect(executeAuthoredFlow(flow('missing-activity-completion', async (f) => { + f.on(webhook('pull_request'), { idle: '1h', deadline: '1d' }); + await new Promise(resolve => setTimeout(resolve, 0)); + }), journal, undefined, { rootRunId: 'root-missing-activity-completion' })) + .rejects.toMatchObject({ code: 'missing_completion' }); + expect(calls.filter(call => call.verb === 'subscription.close')).toEqual([ + { verb: 'subscription.close', params: { + run_id: 'root-missing-activity-completion', subscription_id: 'activity-1', completion_reason: 'canceled', + } }, + ]); + } finally { journal.close(); } + }); + + it.each([ + [{ kind: 'idle' }, { kind: 'idle' }], + [{ kind: 'deadline', pending: null }, { kind: 'deadline', pending: null }], + [{ kind: 'deadline', pending: { from: 3, to: 8 } }, { kind: 'deadline', pending: { from: 3, to: 8 } }], + [{ kind: 'overflow', retained: 4, bytes: 9, from: 5 }, { kind: 'overflow', retained: 4, bytes: 9, from: 5 }], + ])('decodes Wake %j', (wire, expected) => { + expect(decodeWake(wire as never)).toEqual(expected); + }); + + it('fails closed on malformed Wake results', () => { + expect(() => decodeWake({ kind: 'events', events: [{}], offset: 0 } as never)) + .toThrow(AuthoredFlowExecutionError); + }); +}); diff --git a/packages/sdk/tests/journal-client-loopback.ts b/packages/sdk/tests/journal-client-loopback.ts index 84df23336..fcaa9e33b 100644 --- a/packages/sdk/tests/journal-client-loopback.ts +++ b/packages/sdk/tests/journal-client-loopback.ts @@ -36,6 +36,9 @@ export interface LoopbackHandlers { 'effect.confirm'?: (ctx: FrameCtx, params: Record) => void; 'step.complete'?: (ctx: FrameCtx, params: Record) => void; 'event.emit'?: (ctx: FrameCtx, params: Record) => void; + 'subscription.open'?: (ctx: FrameCtx, params: Record) => void; + 'subscription.next'?: (ctx: FrameCtx, params: Record) => void; + 'subscription.close'?: (ctx: FrameCtx, params: Record) => void; 'journal.read'?: (ctx: FrameCtx, params: Record) => void; 'stream.append'?: (ctx: FrameCtx, params: Record) => void; 'stream.read'?: (ctx: FrameCtx, params: Record) => void; diff --git a/packages/sdk/tests/live-event-activities.test.ts b/packages/sdk/tests/live-event-activities.test.ts new file mode 100644 index 000000000..99b3c0a60 --- /dev/null +++ b/packages/sdk/tests/live-event-activities.test.ts @@ -0,0 +1,147 @@ +import { existsSync, lstatSync, mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { afterEach, expect, it } from 'vitest'; +import { flow, webhook } from '@relayflows/surface'; +import { compileYaml, toKernelSpec } from '../src/compile.js'; +import { socketPathFor } from '../src/daemon-connection.js'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { JournalClient } from '../src/journal-client.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const worktreeKey = execFileSync('sh', ['-c', 'printf %s "$1" | cksum | cut -d" " -f1', 'sh', ROOT], { encoding: 'utf8' }).trim(); +const RELAYFLOWD = resolve(process.env['RELAYFLOWD_BIN'] ?? join( + process.env['CARGO_TARGET_DIR'] ?? join(homedir(), '.relayflows-toolchain', 'target', worktreeKey), + 'debug', 'relayflowd', +)); +const daemons: ChildProcess[] = []; +const directories: string[] = []; +const clients: JournalClient[] = []; + +afterEach(async () => { + for (const client of clients.splice(0)) client.close(); + for (const daemon of daemons.splice(0)) { + if (daemon.exitCode === null && daemon.signalCode === null) { + await new Promise((resolveExit) => { + daemon.once('exit', () => resolveExit()); + daemon.kill('SIGTERM'); + }); + } + } + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +it('runs surface f.on through the local daemon event path and journals its buffered wake', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'flows-live-event-activity-')); + directories.push(dataDir); + await startDaemon(dataDir); + const bodyClient = await client(dataDir); + const routerClient = await client(dataDir); + const root = await bodyClient.runStart(toKernelSpec(compileYaml(` +version: '0.1.0' +steps: + - id: park + type: agent + instruction: park for activity +`))); + + const execution = executeAuthoredFlow(flow('surface-activity', async (f) => { + const activity = f.on(webhook('github_pull_request'), { idle: '1h', deadline: '1d' }); + const wake = await activity.next(); + expect(wake).toEqual({ + kind: 'events', events: [{ type: 'github_pull_request', payload: { number: 42 } }], offset: 1, + }); + f.done('success'); + }), bodyClient, undefined, { rootRunId: root.run_id }); + + await waitFor(() => routerClient.journalRead(root.run_id, 1, 100).then(({ entries }) => + (entries as Array<{ entry_type?: string }>).some(entry => entry.entry_type === 'subscription.opened'), + )); + expect((await routerClient.eventEmit( + root.run_id, 'github_pull_request', { number: 42 }, { delivery_id: 'live-event-42', actor: 'reviewer' }, + )).matched).toBe(1); + await expect(execution).resolves.toMatchObject({ completionReason: 'success' }); + const entries = await routerClient.journalRead(root.run_id, 1, 100); + expect((entries.entries as Array<{ entry_type?: string }>).map(entry => entry.entry_type)).toEqual(expect.arrayContaining([ + 'subscription.opened', 'stream.appended', 'wait.completed', 'subscription.closed', + ])); +}, 20_000); + +it('survives SIGKILL after stream.appended and delivers the frame once after daemon restart', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'flows-live-event-restart-')); + directories.push(dataDir); + const firstDaemon = await startDaemon(dataDir); + const before = await client(dataDir); + const root = await before.runStart(toKernelSpec(compileYaml(` +version: '0.1.0' +steps: + - id: park + type: agent + instruction: park for activity +`))); + await before.subscriptionOpen({ + run_id: root.run_id, subscription_id: 'restart', event_types: ['github_pull_request'], + settle_ms: 0, idle_ms: 60_000, deadline_ms: 86_400_000, include_self: false, + }); + expect((await before.eventEmit( + root.run_id, 'github_pull_request', { number: 99 }, { delivery_id: 'kill-window-99', actor: 'reviewer' }, + )).matched).toBe(1); + await stop(firstDaemon, 'SIGKILL'); + const secondDaemon = await startDaemon(dataDir); + expect(secondDaemon.exitCode).toBeNull(); + const after = await client(dataDir); + await expect(after.subscriptionNext({ run_id: root.run_id, subscription_id: 'restart' })).resolves.toEqual({ + kind: 'events', events: [{ type: 'github_pull_request', payload: { number: 99 } }], offset: 1, + acknowledge_wait_id: 'restart/next/0', + }); + const entries = await after.journalRead(root.run_id, 1, 100); + expect((entries.entries as Array<{ entry_type?: string }>).filter(entry => entry.entry_type === 'stream.appended')).toHaveLength(1); +}, 20_000); + +async function startDaemon(dataDir: string): Promise { + if (!existsSync(RELAYFLOWD)) throw new Error(`relayflowd binary is missing: ${RELAYFLOWD}`); + const daemon = spawn(RELAYFLOWD, ['--data-dir', dataDir, 'serve'], { stdio: 'ignore' }); + daemons.push(daemon); + await waitFor(async () => { + const socket = socketPathFor(dataDir); + if (!existsSync(socket) || !lstatSync(socket).isSocket()) return false; + const ready = new JournalClient(socket, { requestTimeoutMs: 250 }); + try { + await ready.connect(); + await ready.hello('live-event-activity-readiness'); + return true; + } catch { + return false; + } finally { + ready.close(); + } + }); + return daemon; +} + +async function client(dataDir: string): Promise { + const connected = new JournalClient(socketPathFor(dataDir), { requestTimeoutMs: 5_000 }); + clients.push(connected); + await connected.connect(); + await connected.hello('live-event-activities'); + return connected; +} + +async function waitFor(predicate: () => boolean | Promise): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error('timed out waiting for local event activity'); +} + +async function stop(daemon: ChildProcess, signal: NodeJS.Signals): Promise { + if (daemon.exitCode !== null || daemon.signalCode !== null) return; + await new Promise((resolveExit) => { + daemon.once('exit', () => resolveExit()); + daemon.kill(signal); + }); +} diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 8eb1254be..e92e317df 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -25,6 +25,9 @@ "tests/authored-flow-lifecycle-executor.test.ts", "tests/authored-flow-operation.test.ts", "tests/authored-flow.test.ts", + "tests/authored-activity.test.ts", + "tests/activity-preflight.test.ts", + "tests/live-event-activities.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", "tests/journal-client-loopback.ts", diff --git a/packages/surface/src/activity.ts b/packages/surface/src/activity.ts new file mode 100644 index 000000000..9ada24f75 --- /dev/null +++ b/packages/surface/src/activity.ts @@ -0,0 +1,36 @@ +/** A duration in whole milliseconds or an unambiguous wall-clock literal. */ +export type ActivityDuration = number | string; + +/** + * Opaque provider event preserved by the journal. Providers may add fields; + * bodies must re-read provider state instead of treating this payload as truth. + */ +export interface EventFrame { + readonly type: string; + readonly payload?: unknown; + readonly [field: string]: unknown; +} + +export type Wake = + | { readonly kind: "events"; readonly events: readonly EventFrame[]; readonly offset: number } + | { readonly kind: "idle" } + | { readonly kind: "deadline"; readonly pending: { readonly from: number; readonly to: number } | null } + | { readonly kind: "overflow"; readonly retained: number; readonly bytes: number; readonly from: number }; + +/** Bounds are required so a body-level subscription cannot keep a run open forever. */ +export interface ActivityOptions { + /** Coalesce a burst until this long after its most recent frame. Defaults to zero. */ + readonly settle?: ActivityDuration; + /** Per wake: return idle after this long without a buffered matching frame. */ + readonly idle: ActivityDuration; + /** Absolute cap fixed when the subscription is opened. */ + readonly deadline: ActivityDuration; + /** Include events caused by the run identity. Defaults to false. */ + readonly includeSelf?: boolean; +} + +/** A durable, bounded cursor over matching provider events. */ +export interface Activity { + next(): Promise; + close(): Promise; +} diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index e924ad6b9..b7c585fc9 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -3,6 +3,8 @@ import type { MemoryHelper } from "./memory.js"; import type { CloudHelper } from "./cloud.js"; import type { FlowCompletionReason } from "./completion.js"; import type { Step } from "./step.js"; +import type { Activity, ActivityOptions } from "./activity.js"; +import type { TriggerSource } from "./triggers.js"; export interface AgentResult { summary: string; @@ -45,6 +47,8 @@ export interface Ctx extends Helpers { /** JSON Schema validates the value at runtime; narrow unknown in author code. */ llm(prompt: string, options: LlmOptions): Step; agent(name: string, options: AgentOptions): Step; + /** Open a durable, bounded event subscription for this running body. */ + on(source: TriggerSource, options: ActivityOptions): Activity; human(question: string, options: { to: string }): Promise; dispatch(flow: string, input: unknown): Promise; done(reason: FlowCompletionReason): void; diff --git a/packages/surface/src/index.ts b/packages/surface/src/index.ts index 619664e22..0470072f6 100644 --- a/packages/surface/src/index.ts +++ b/packages/surface/src/index.ts @@ -8,6 +8,7 @@ export type { CloudHelper, } from "./cloud.js"; export type { AgentOptions, AgentResult, LlmOptions, Ctx } from "./context.js"; +export type { Activity, ActivityDuration, ActivityOptions, EventFrame, Wake } from "./activity.js"; export { COMPLETION_REASONS, RUN_COMPLETION_REASONS, diff --git a/packages/surface/tests/activity.test.ts b/packages/surface/tests/activity.test.ts new file mode 100644 index 000000000..c8491e8af --- /dev/null +++ b/packages/surface/tests/activity.test.ts @@ -0,0 +1,24 @@ +import { expectTypeOf, it } from 'vitest'; +import { type Activity, type Ctx, type EventFrame, type Wake, webhook } from '@relayflows/surface'; + +it('exposes a bounded body-level Activity and every Wake variant', () => { + const body = (f: Ctx): Activity => f.on(webhook('pull_request'), { + settle: '2m', idle: '72h', deadline: '14d', includeSelf: false, + }); + void body; + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf['events']>().toEqualTypeOf(); + + const missingIdle = (f: Ctx) => { + // @ts-expect-error idle is a required bound. + return f.on(webhook('pull_request'), { deadline: '14d' }); + }; + const missingDeadline = (f: Ctx) => { + // @ts-expect-error deadline is a required bound. + return f.on(webhook('pull_request'), { idle: '72h' }); + }; + void missingIdle; + void missingDeadline; +});