From 0218ddb2bd3dd092f725e950b4cebec95a1b2dd3 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:14:23 +0200 Subject: [PATCH 01/34] ops: add event-await overnight implementation flow Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- ops/event-await-overnight/README.md | 17 + .../implement-event-await.flow.ts | 105 + ops/event-await-overnight/package-lock.json | 1892 +++++++++++++++++ ops/event-await-overnight/package.json | 8 + 4 files changed, 2022 insertions(+) create mode 100644 ops/event-await-overnight/README.md create mode 100644 ops/event-await-overnight/implement-event-await.flow.ts create mode 100644 ops/event-await-overnight/package-lock.json create mode 100644 ops/event-await-overnight/package.json 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" + } +} From 73f32ad13abc0d99c79f14f8b99fbce3de03d82f Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:24:24 +0200 Subject: [PATCH 02/34] feat(surface): add bounded event activities Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- packages/sdk/src/authored-activity.ts | 189 ++++++++++++++++++ packages/sdk/src/authored-flow-error.ts | 2 + packages/sdk/src/authored-flow-executor.ts | 8 + packages/sdk/src/cli.ts | 7 +- packages/sdk/src/cli/check-activities.ts | 50 +++++ packages/sdk/src/index.ts | 6 + packages/sdk/src/journal-client.ts | 15 ++ packages/sdk/src/protocol.ts | 42 ++++ packages/sdk/tests/activity-preflight.test.ts | 18 ++ packages/sdk/tests/authored-activity.test.ts | 112 +++++++++++ packages/sdk/tsconfig.tests.json | 2 + packages/surface/src/activity.ts | 36 ++++ packages/surface/src/context.ts | 4 + packages/surface/src/index.ts | 1 + packages/surface/tests/activity.test.ts | 24 +++ 15 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/src/authored-activity.ts create mode 100644 packages/sdk/src/cli/check-activities.ts create mode 100644 packages/sdk/tests/activity-preflight.test.ts create mode 100644 packages/sdk/tests/authored-activity.test.ts create mode 100644 packages/surface/src/activity.ts create mode 100644 packages/surface/tests/activity.test.ts diff --git a/packages/sdk/src/authored-activity.ts b/packages/sdk/src/authored-activity.ts new file mode 100644 index 000000000..9bf24e3ba --- /dev/null +++ b/packages/sdk/src/authored-activity.ts @@ -0,0 +1,189 @@ +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 opened = false; + private closed = false; + + 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'), + }); + } + + 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 wake = decodeWake(await this.journal.subscriptionNext({ + run_id: this.runId, + subscription_id: this.subscriptionId, + })); + if (wake.kind === 'deadline' || wake.kind === 'overflow') this.closed = true; + return wake; + } + + private async ensureOpen(): Promise { + if (this.opened) return; + 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, + }); + this.opened = true; + } +} + +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..5852a9810 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(); } @@ -401,6 +408,7 @@ export async function executeAuthoredFlow( } try { await verifyAuthoredOperations(definition.name, authoredSteps, lifecycle); + 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..278202982 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -401,6 +401,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..c3ab727ec 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'; @@ -359,6 +362,42 @@ 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; +} +export type SubscriptionNextResult = + | { kind: 'events'; events: unknown[]; offset: number } + | { kind: 'idle' } + | { 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 +442,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..58d894e6c --- /dev/null +++ b/packages/sdk/tests/authored-activity.test.ts @@ -0,0 +1,112 @@ +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' }); + 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.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/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 8eb1254be..dc95d3d24 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -25,6 +25,8 @@ "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/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; +}); From fd1c77ffb85e25e13779619be91d5fc294d9d830 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:25:41 +0200 Subject: [PATCH 03/34] docs(evidence): record event activity surface slice Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- .../event-await-implementation/surface-sdk.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/evidence/event-await-implementation/surface-sdk.md 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..e163b7bac --- /dev/null +++ b/docs/evidence/event-await-implementation/surface-sdk.md @@ -0,0 +1,84 @@ +# Event Await — Surface and SDK slice + +Commit: `73f32ad13abc0d99c79f14f8b99fbce3de03d82f` (`feat(surface): add bounded event activities`) + +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) 6ms + ✓ tests/authored-activity.test.ts (8 tests) 20ms + ✓ tests/authored-flow.test.ts (25 tests) 686ms + + 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()`. From ee3f3452ba377aaaafcacfd4923d477342a2d68d Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:26:49 +0200 Subject: [PATCH 04/34] fix(sdk): open activities before body work Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- packages/sdk/src/authored-activity.ts | 13 ++++++++++--- packages/sdk/tests/authored-activity.test.ts | 2 ++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/authored-activity.ts b/packages/sdk/src/authored-activity.ts index 9bf24e3ba..170a63856 100644 --- a/packages/sdk/src/authored-activity.ts +++ b/packages/sdk/src/authored-activity.ts @@ -43,7 +43,7 @@ export class AuthoredActivities { class JournalActivity implements OpenActivity { readonly activity: Activity; - private opened = false; + private readonly openPromise: Promise; private closed = false; constructor( @@ -57,6 +57,11 @@ class JournalActivity implements OpenActivity { 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 { @@ -82,7 +87,10 @@ class JournalActivity implements OpenActivity { } private async ensureOpen(): Promise { - if (this.opened) return; + await this.openPromise; + } + + private async openNow(): Promise { await this.journal.subscriptionOpen({ run_id: this.runId, subscription_id: this.subscriptionId, @@ -93,7 +101,6 @@ class JournalActivity implements OpenActivity { deadline_ms: this.options.deadlineMs, include_self: this.options.includeSelf, }); - this.opened = true; } } diff --git a/packages/sdk/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts index 58d894e6c..a1371c2a5 100644 --- a/packages/sdk/tests/authored-activity.test.ts +++ b/packages/sdk/tests/authored-activity.test.ts @@ -52,6 +52,8 @@ describe('authored event activities', () => { try { const result = await executeAuthoredFlow(flow('activity', async (f) => { const activity = f.on(webhook('pull_request'), { settle: '2m', idle: '72h', deadline: '14d' }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(calls.map(call => call.verb)).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'); From df433f576bbd747a6e300c4db5e4ec23c8322c4d Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:27:12 +0200 Subject: [PATCH 05/34] docs(evidence): add activity opening fix Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- .../event-await-implementation/surface-sdk.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/evidence/event-await-implementation/surface-sdk.md b/docs/evidence/event-await-implementation/surface-sdk.md index e163b7bac..07e333062 100644 --- a/docs/evidence/event-await-implementation/surface-sdk.md +++ b/docs/evidence/event-await-implementation/surface-sdk.md @@ -1,6 +1,9 @@ # Event Await — Surface and SDK slice -Commit: `73f32ad13abc0d99c79f14f8b99fbce3de03d82f` (`feat(surface): add bounded event activities`) +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 @@ -63,9 +66,9 @@ cd packages/sdk && npm run typecheck && npx vitest run tests/authored-flow.test. RUN v2.1.9 .../packages/sdk - ✓ tests/activity-preflight.test.ts (1 test) 6ms - ✓ tests/authored-activity.test.ts (8 tests) 20ms - ✓ tests/authored-flow.test.ts (25 tests) 686ms + ✓ 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) From 5742029e02ac66d306cc19d1267fe92f2e6ba6c4 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:43:52 +0200 Subject: [PATCH 06/34] feat(kernel): add durable event activities Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- kernel/relayflowd-core/src/entry.rs | 79 ++++ kernel/relayflowd-core/src/state.rs | 34 +- kernel/relayflowd/src/engine.rs | 10 +- kernel/relayflowd/src/engine/drive.rs | 13 + kernel/relayflowd/src/engine/remote.rs | 1 + kernel/relayflowd/src/engine/subscriptions.rs | 376 ++++++++++++++++++ kernel/relayflowd/src/server.rs | 26 ++ kernel/relayflowd/src/server/wire.rs | 31 +- kernel/relayflowd/tests/event_activities.rs | 112 ++++++ 9 files changed, 668 insertions(+), 14 deletions(-) create mode 100644 kernel/relayflowd/src/engine/subscriptions.rs create mode 100644 kernel/relayflowd/tests/event_activities.rs diff --git a/kernel/relayflowd-core/src/entry.rs b/kernel/relayflowd-core/src/entry.rs index 3dbb952cf..8a07bdb9e 100644 --- a/kernel/relayflowd-core/src/entry.rs +++ b/kernel/relayflowd-core/src/entry.rs @@ -25,6 +25,17 @@ 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, #[serde(rename = "step.routed")] StepRouted, #[serde(rename = "step.attempt.started")] @@ -70,6 +81,9 @@ 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::StepRouted => "step.routed", Self::StepAttemptStarted => "step.attempt.started", Self::StepCompleted => "step.completed", @@ -98,6 +112,9 @@ 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, "step.routed" => Self::StepRouted, "step.attempt.started" => Self::StepAttemptStarted, "step.completed" => Self::StepCompleted, @@ -436,6 +453,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 +501,56 @@ 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, } /// 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..0453a8f18 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,9 @@ 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::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..71f32928b 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, }, ), )?; diff --git a/kernel/relayflowd/src/engine/subscriptions.rs b/kernel/relayflowd/src/engine/subscriptions.rs new file mode 100644 index 000000000..41fa5e653 --- /dev/null +++ b/kernel/relayflowd/src/engine/subscriptions.rs @@ -0,0 +1,376 @@ +//! 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, StreamAppendedPayload, 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, +} + +impl SubscriptionState { + fn stream(&self) -> &str { &self.opened.stream } +} + +impl Engine { + 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; + } + } + 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 { + 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")?; + if let Some(reason) = state.closed { + return self.closed_wake(&journal, state, reason); + } + if let Some(completed) = &state.ready { + return wake_from_completed(&journal, state, completed); + } + let entries = journal.scan_all()?; + let unread = unread_frames(&entries, state)?; + let now = self.clock.now_ms(); + 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)); + self.complete_events(&mut journal, state, &wait, &unread, now)?; + return events_wake(&unread); + } + } + 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}), + }; + self.complete_wait(journal, wait, WaitCompletionReason::Timeout, 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 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 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 }); + } + 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::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); } + } + } + 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()); + if let Some(next) = completed.result.get("next_offset").and_then(Value::as_u64) { state.acknowledged_offset = next; } + } + } + } + _ => {} + } + } + 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, now), 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 { + 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..6cee9f6f6 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -479,6 +479,32 @@ 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. + to_value(engine.next_subscription(¶ms.run_id, ¶ms.subscription_id).map_err(internal_error)?) + } + "subscription.close" => { + let params: SubscriptionCloseParams = decode_params(request.params)?; + 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) } diff --git a/kernel/relayflowd/src/server/wire.rs b/kernel/relayflowd/src/server/wire.rs index 9768b6b4f..281277d14 100644 --- a/kernel/relayflowd/src/server/wire.rs +++ b/kernel/relayflowd/src/server/wire.rs @@ -1,4 +1,4 @@ -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; @@ -160,6 +160,35 @@ 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, +} + +#[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..ff3947c8d --- /dev/null +++ b/kernel/relayflowd/tests/event_activities.rs @@ -0,0 +1,112 @@ +//! 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, RunSpec, SimClock}; +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 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"); +} From dfba2b7d1f1f9198904ee0950287854baf2258a3 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:44:02 +0200 Subject: [PATCH 07/34] docs(evidence): record event activity kernel slice Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- .../kernel-daemon.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/evidence/event-await-implementation/kernel-daemon.md 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..1d11f418b --- /dev/null +++ b/docs/evidence/event-await-implementation/kernel-daemon.md @@ -0,0 +1,86 @@ +# Event Await — Kernel and local daemon slice + +Implementation commit: `5742029e02ac66d306cc19d1267fe92f2e6ba6c4` +(`feat(kernel): add durable event activities`). + +## 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. + +## 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. From d06eabdf422430d977604374e1530e7c6f6ec18e Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:45:11 +0200 Subject: [PATCH 08/34] fix(daemon): restrict activity close reasons Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- kernel/relayflowd/src/server.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index 6cee9f6f6..9d4b094e7 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -500,6 +500,17 @@ fn handle_request( } "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)?; From a1e57bb0bb6b794105775e6fa899ee735eded589 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:45:23 +0200 Subject: [PATCH 09/34] docs(evidence): record activity close boundary Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/evidence/event-await-implementation/kernel-daemon.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/evidence/event-await-implementation/kernel-daemon.md b/docs/evidence/event-await-implementation/kernel-daemon.md index 1d11f418b..264df3dbf 100644 --- a/docs/evidence/event-await-implementation/kernel-daemon.md +++ b/docs/evidence/event-await-implementation/kernel-daemon.md @@ -1,7 +1,11 @@ # Event Await — Kernel and local daemon slice -Implementation commit: `5742029e02ac66d306cc19d1267fe92f2e6ba6c4` -(`feat(kernel): add durable event activities`). +Implementation commits: + +- `5742029e02ac66d306cc19d1267fe92f2e6ba6c4` + (`feat(kernel): add durable event activities`) +- `d06eabdf422430d977604374e1530e7c6f6ec18e` + (`fix(daemon): restrict activity close reasons`) ## Scope and transport boundary From c5f547332b975a6e68a9789b995aa8c576b42035 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:45:52 +0200 Subject: [PATCH 10/34] fix(daemon): reserve subscription streams Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- kernel/relayflowd/src/server.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index 9d4b094e7..d48304f48 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -521,6 +521,12 @@ fn handle_request( } "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)?; From 4fbb3a58c5d01f8ef730dbc355bd07b83bba66eb Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 07:46:31 +0200 Subject: [PATCH 11/34] docs(evidence): record reserved stream boundary Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/evidence/event-await-implementation/kernel-daemon.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/evidence/event-await-implementation/kernel-daemon.md b/docs/evidence/event-await-implementation/kernel-daemon.md index 264df3dbf..66f81de52 100644 --- a/docs/evidence/event-await-implementation/kernel-daemon.md +++ b/docs/evidence/event-await-implementation/kernel-daemon.md @@ -6,6 +6,8 @@ Implementation commits: (`feat(kernel): add durable event activities`) - `d06eabdf422430d977604374e1530e7c6f6ec18e` (`fix(daemon): restrict activity close reasons`) +- `c5f547332b975a6e68a9789b995aa8c576b42035` + (`fix(daemon): reserve subscription streams`) ## Scope and transport boundary @@ -31,7 +33,9 @@ transport boundary is: completes the close without reopening the cursor. No tenant id, provider SDK, installation lookup, or authorization policy was -added to the kernel. +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 From ec4345a8c474bbe16b9727e5a0d2dd874e399569 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:10:43 +0200 Subject: [PATCH 12/34] feat(event-await): integrate local activity ingress Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- .../event-await-implementation/README.md | 138 +++++++++++++++++ kernel/relayflowd-core/src/entry.rs | 15 ++ kernel/relayflowd-core/src/state.rs | 1 + kernel/relayflowd/src/engine/remote.rs | 18 ++- kernel/relayflowd/src/engine/subscriptions.rs | 78 +++++++++- kernel/relayflowd/src/server.rs | 8 +- kernel/relayflowd/src/server/wire.rs | 8 +- kernel/relayflowd/tests/event_activities.rs | 72 ++++++++- packages/sdk/src/journal-client.ts | 11 +- packages/sdk/src/protocol.ts | 4 + packages/sdk/tests/journal-client-loopback.ts | 3 + .../sdk/tests/live-event-activities.test.ts | 145 ++++++++++++++++++ packages/sdk/tsconfig.tests.json | 1 + 13 files changed, 488 insertions(+), 14 deletions(-) create mode 100644 docs/evidence/event-await-implementation/README.md create mode 100644 packages/sdk/tests/live-event-activities.test.ts diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md new file mode 100644 index 000000000..386dad26d --- /dev/null +++ b/docs/evidence/event-await-implementation/README.md @@ -0,0 +1,138 @@ +# Event-await local implementation evidence + +Commit: pending local commit at the time this evidence was written. + +## 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 +Finished `test` profile [unoptimized + debuginfo] target(s) in 1.35s +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 accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next ... ok +test idle_wait_is_durable_and_fires_without_an_event ... 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.99s + +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/kernel/relayflowd-core/src/entry.rs b/kernel/relayflowd-core/src/entry.rs index 8a07bdb9e..90c8f9b02 100644 --- a/kernel/relayflowd-core/src/entry.rs +++ b/kernel/relayflowd-core/src/entry.rs @@ -36,6 +36,10 @@ pub enum EntryType { /// 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")] @@ -84,6 +88,7 @@ impl EntryType { 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", @@ -115,6 +120,7 @@ impl EntryType { "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, @@ -553,6 +559,15 @@ pub struct SubscriptionOverflowFencedPayload { 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 /// writeback; this entry says the elected attempt performed it. Until it /// exists the election is provisional, and a later attempt may reclaim it — diff --git a/kernel/relayflowd-core/src/state.rs b/kernel/relayflowd-core/src/state.rs index 0453a8f18..988559f2e 100644 --- a/kernel/relayflowd-core/src/state.rs +++ b/kernel/relayflowd-core/src/state.rs @@ -224,6 +224,7 @@ impl RunState { | EntryType::SubscriptionOpened | EntryType::SubscriptionClosed | EntryType::SubscriptionOverflowFenced + | EntryType::SubscriptionAcknowledged | EntryType::ChannelAppended | EntryType::ChannelDelivered | EntryType::ChannelAcknowledged diff --git a/kernel/relayflowd/src/engine/remote.rs b/kernel/relayflowd/src/engine/remote.rs index 71f32928b..b202e79f1 100644 --- a/kernel/relayflowd/src/engine/remote.rs +++ b/kernel/relayflowd/src/engine/remote.rs @@ -295,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")?; @@ -328,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 index 41fa5e653..1fe4c45f5 100644 --- a/kernel/relayflowd/src/engine/subscriptions.rs +++ b/kernel/relayflowd/src/engine/subscriptions.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use anyhow::{Context, Result, bail}; use relayflowd_core::{ - Clock, EntryType, JournalEntry, StreamAppendedPayload, SubscriptionClosedPayload, + Clock, EntryType, JournalEntry, RunSpawnedPayload, StreamAppendedPayload, SubscriptionAcknowledgedPayload, SubscriptionClosedPayload, SubscriptionCompletionReason, SubscriptionOpenedPayload, WaitCompletedPayload, SubscriptionOverflowFencedPayload, WaitCompletionReason, WaitEventPayload, }; @@ -52,6 +52,30 @@ impl SubscriptionState { } 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)?; } @@ -182,6 +206,7 @@ impl Engine { claimed += 1; } } + claimed += self.claim_non_activity_wait_timeouts_in_journal(&mut journal, now)?; Ok(claimed) } @@ -193,21 +218,25 @@ impl Engine { let mut journal = self.open_run(run_id)?; let states = subscriptions(&journal)?; let state = states.get(subscription_id).context("unknown subscription")?; - if let Some(reason) = state.closed { - return self.closed_wake(&journal, state, reason); - } + let now = self.clock.now_ms(); if let Some(completed) = &state.ready { - return wake_from_completed(&journal, state, completed); + let wake = wake_from_completed(&journal, state, completed)?; + self.acknowledge_normal_wake(&mut journal, state, completed, now)?; + return Ok(wake); } + if let Some(reason) = state.closed { return self.closed_wake(&journal, state, reason); } let entries = journal.scan_all()?; let unread = unread_frames(&entries, state)?; - let now = self.clock.now_ms(); 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)); self.complete_events(&mut journal, state, &wait, &unread, now)?; + self.acknowledge_normal_wake(&mut journal, state, &WaitCompletedPayload { + wait_id: wait.wait_id, completion_reason: WaitCompletionReason::EventReceived, + result: json!({"next_offset": unread.last().expect("nonempty").1.offset.saturating_add(1)}), + }, now)?; return events_wake(&unread); } } @@ -269,6 +298,30 @@ impl Engine { 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); @@ -282,6 +335,12 @@ impl Engine { Ok(()) } + fn acknowledge_normal_wake(&self, journal: &mut SqliteJournal, state: &SubscriptionState, completed: &WaitCompletedPayload, now: i64) -> Result<()> { + self.append(journal, &JournalEntry::new(EntryType::SubscriptionAcknowledged, journal.run_id(), None, None, now, + SubscriptionAcknowledgedPayload { subscription_id: state.opened.subscription_id.clone(), wait_id: completed.wait_id.clone(), next_offset: completed.result.get("next_offset").and_then(Value::as_u64) }))?; + Ok(()) + } + fn closed_wake(&self, journal: &SqliteJournal, state: &SubscriptionState, reason: SubscriptionCompletionReason) -> Result { let unread = unread_frames(&journal.scan_all()?, state)?; Ok(match reason { @@ -311,6 +370,13 @@ fn subscriptions(journal: &SqliteJournal) -> Result { + 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 { diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index d48304f48..7fb242a74 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})) } diff --git a/kernel/relayflowd/src/server/wire.rs b/kernel/relayflowd/src/server/wire.rs index 281277d14..170378d37 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, SubscriptionCompletionReason}; +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)] diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs index ff3947c8d..33956b24a 100644 --- a/kernel/relayflowd/tests/event_activities.rs +++ b/kernel/relayflowd/tests/event_activities.rs @@ -4,7 +4,8 @@ use relayflowd::Engine; use relayflowd::engine::{PendingRange, SubscriptionWake}; -use relayflowd_core::{Clock, EntryType, RunSpec, SimClock}; +use relayflowd_core::{Clock, EntryType, Journal, JournalEntry, RunSpec, SimClock, WaitHumanPayload}; +use relayflowd_journal::SqliteJournal; use serde_json::json; use std::sync::{Arc, atomic::{AtomicI64, Ordering}}; @@ -110,3 +111,72 @@ fn cancel_closes_an_open_activity_before_the_terminal_run_record() { 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(&run_id, "timed").unwrap(), SubscriptionWake::Idle); + clock.set(20); + assert!(matches!(engine.next_subscription(&run_id, "timed").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(&run_id, "pr-42").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 })); +} diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 278202982..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 }); } /** diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index c3ab727ec..870ee64cf 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -336,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; 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..3732fe4d0 --- /dev/null +++ b/packages/sdk/tests/live-event-activities.test.ts @@ -0,0 +1,145 @@ +import { existsSync, lstatSync, mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { 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 = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const RELAYFLOWD = resolve(process.env['RELAYFLOWD_BIN'] ?? join( + process.env['CARGO_TARGET_DIR'] ?? join(homedir(), '.relayflows-toolchain', 'target', '1398563233'), + '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.acknowledged', '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, + }); + 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 dc95d3d24..e92e317df 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -27,6 +27,7 @@ "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", From 2293bed76c4b87a409616262e07a8f5d3ba8e474 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:10:55 +0200 Subject: [PATCH 13/34] docs(evidence): record event await local slice Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/evidence/event-await-implementation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md index 386dad26d..6306bb573 100644 --- a/docs/evidence/event-await-implementation/README.md +++ b/docs/evidence/event-await-implementation/README.md @@ -1,6 +1,6 @@ # Event-await local implementation evidence -Commit: pending local commit at the time this evidence was written. +Implementation commit: `ec4345a8c474bbe16b9727e5a0d2dd874e399569`. ## Scope and acceptance map From 6e8c3cb697e911ddcba89999bd58bf3b0f603228 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:11:43 +0200 Subject: [PATCH 14/34] test(event-await): pin unread lifetime accounting Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- kernel/relayflowd/tests/event_activities.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs index 33956b24a..3b7c37404 100644 --- a/kernel/relayflowd/tests/event_activities.rs +++ b/kernel/relayflowd/tests/event_activities.rs @@ -4,7 +4,7 @@ use relayflowd::Engine; use relayflowd::engine::{PendingRange, SubscriptionWake}; -use relayflowd_core::{Clock, EntryType, Journal, JournalEntry, RunSpec, SimClock, WaitHumanPayload}; +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}}; @@ -179,4 +179,13 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { 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()); } From ef692235f230426e0b81bdf46a1910f28eeee4c6 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:11:59 +0200 Subject: [PATCH 15/34] docs(evidence): update event await acceptance capture Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/evidence/event-await-implementation/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md index 6306bb573..11c1a8a8d 100644 --- a/docs/evidence/event-await-implementation/README.md +++ b/docs/evidence/event-await-implementation/README.md @@ -1,6 +1,7 @@ # Event-await local implementation evidence -Implementation commit: `ec4345a8c474bbe16b9727e5a0d2dd874e399569`. +Implementation commits: `ec4345a8c474bbe16b9727e5a0d2dd874e399569` and +`6e8c3cb697e911ddcba89999bd58bf3b0f603228`. ## Scope and acceptance map @@ -30,18 +31,20 @@ PATH=/Users/khaliqgant/.relayflows-toolchain/rustup/toolchains/local/bin:$PATH C Captured output: ```text -Finished `test` profile [unoptimized + debuginfo] target(s) in 1.35s +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 accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next ... 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.99s +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.91s EXIT=0 ``` From 3236011f20f918aa347a576426b6530a3e52e107 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:28:17 +0200 Subject: [PATCH 16/34] fix(event-await): preserve durable activity wake recovery Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- .../audit-pass-1.md | 119 ++++++++++++++++++ kernel/relayflowd/src/engine/subscriptions.rs | 35 +++++- kernel/relayflowd/tests/event_activities.rs | 44 +++++++ packages/sdk/src/authored-flow-executor.ts | 10 ++ packages/sdk/tests/authored-activity.test.ts | 23 +++- 5 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 docs/evidence/event-await-implementation/audit-pass-1.md 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..6592f986e --- /dev/null +++ b/docs/evidence/event-await-implementation/audit-pass-1.md @@ -0,0 +1,119 @@ +# 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. + +## 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/kernel/relayflowd/src/engine/subscriptions.rs b/kernel/relayflowd/src/engine/subscriptions.rs index 1fe4c45f5..6d4ae2c69 100644 --- a/kernel/relayflowd/src/engine/subscriptions.rs +++ b/kernel/relayflowd/src/engine/subscriptions.rs @@ -45,6 +45,7 @@ struct SubscriptionState { active_wait: Option, ready: Option, overflow_fence: Option, + next_wait_sequence: u64, } impl SubscriptionState { @@ -221,7 +222,9 @@ impl Engine { let now = self.clock.now_ms(); if let Some(completed) = &state.ready { let wake = wake_from_completed(&journal, state, completed)?; - self.acknowledge_normal_wake(&mut journal, state, completed, now)?; + if matches!(wake, SubscriptionWake::Events { .. } | SubscriptionWake::Idle) { + self.acknowledge_normal_wake(&mut journal, state, completed, now)?; + } return Ok(wake); } if let Some(reason) = state.closed { return self.closed_wake(&journal, state, reason); } @@ -232,6 +235,16 @@ impl Engine { 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)?; self.acknowledge_normal_wake(&mut journal, state, &WaitCompletedPayload { wait_id: wait.wait_id, completion_reason: WaitCompletionReason::EventReceived, @@ -360,7 +373,7 @@ fn subscriptions(journal: &SqliteJournal) -> Result { 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 }); + 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)?; @@ -380,7 +393,10 @@ fn subscriptions(journal: &SqliteJournal) -> Result { 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); } + 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 => { @@ -400,10 +416,10 @@ fn subscriptions(journal: &SqliteJournal) -> Result WaitEventPayload { +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, now), event_key: state.opened.subscription_id.clone(), timeout_at_ms: Some(state.opened.deadline_at_ms), + 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), } } @@ -428,6 +444,15 @@ fn events_wake(unread: &[(JournalEntry, StreamAppendedPayload)]) -> Result 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()) }), diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs index 3b7c37404..fe793ffc5 100644 --- a/kernel/relayflowd/tests/event_activities.rs +++ b/kernel/relayflowd/tests/event_activities.rs @@ -99,6 +99,50 @@ fn overflow_closes_before_the_1001st_unread_frame_and_recovery_never_reopens_it( 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, + }); +} + +#[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()); + assert!(matches!(engine.next_subscription(&run_id, "pr-42").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(); diff --git a/packages/sdk/src/authored-flow-executor.ts b/packages/sdk/src/authored-flow-executor.ts index 5852a9810..2fce681cc 100644 --- a/packages/sdk/src/authored-flow-executor.ts +++ b/packages/sdk/src/authored-flow-executor.ts @@ -401,6 +401,7 @@ export async function executeAuthoredFlow( ); try { await stopAuthoredOperations(authoredSteps, missingCompletion); + await activities.closeAll('canceled'); } finally { lifecycle.close(); } @@ -408,6 +409,15 @@ 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/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts index a1371c2a5..79b8487f2 100644 --- a/packages/sdk/tests/authored-activity.test.ts +++ b/packages/sdk/tests/authored-activity.test.ts @@ -52,8 +52,8 @@ describe('authored event activities', () => { try { const result = await executeAuthoredFlow(flow('activity', async (f) => { const activity = f.on(webhook('pull_request'), { settle: '2m', idle: '72h', deadline: '14d' }); - await new Promise(resolve => setTimeout(resolve, 0)); - expect(calls.map(call => call.verb)).toEqual(['subscription.open']); + 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'); @@ -98,6 +98,25 @@ describe('authored event activities', () => { } 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 }], From c9cb5de2bf644aa7c291063c1375d920ab0d918f Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:28:32 +0200 Subject: [PATCH 17/34] docs(evidence): record event-await audit commit Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/evidence/event-await-implementation/audit-pass-1.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/evidence/event-await-implementation/audit-pass-1.md b/docs/evidence/event-await-implementation/audit-pass-1.md index 6592f986e..3043d99ad 100644 --- a/docs/evidence/event-await-implementation/audit-pass-1.md +++ b/docs/evidence/event-await-implementation/audit-pass-1.md @@ -5,6 +5,9 @@ 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.** From 651d07a3a2d818782f2f453173d3f001626be511 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:44:59 +0200 Subject: [PATCH 18/34] fix(event-await): retain normal wakes until acknowledged Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- kernel/relayflowd/src/engine/subscriptions.rs | 75 +++++++++++++++---- kernel/relayflowd/src/server.rs | 12 ++- kernel/relayflowd/src/server/wire.rs | 2 + kernel/relayflowd/tests/event_activities.rs | 37 ++++++++- packages/sdk/src/authored-activity.ts | 20 ++++- packages/sdk/src/protocol.ts | 6 +- packages/sdk/tests/authored-activity.test.ts | 28 +++++++ .../sdk/tests/live-event-activities.test.ts | 10 ++- 8 files changed, 161 insertions(+), 29 deletions(-) diff --git a/kernel/relayflowd/src/engine/subscriptions.rs b/kernel/relayflowd/src/engine/subscriptions.rs index 6d4ae2c69..29f04d2f0 100644 --- a/kernel/relayflowd/src/engine/subscriptions.rs +++ b/kernel/relayflowd/src/engine/subscriptions.rs @@ -214,20 +214,50 @@ impl Engine { /// 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)?; - if matches!(wake, SubscriptionWake::Events { .. } | SubscriptionWake::Idle) { - self.acknowledge_normal_wake(&mut journal, state, completed, now)?; - } - return Ok(wake); + 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); } + 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() { @@ -246,11 +276,7 @@ impl Engine { ))?; } self.complete_events(&mut journal, state, &wait, &unread, now)?; - self.acknowledge_normal_wake(&mut journal, state, &WaitCompletedPayload { - wait_id: wait.wait_id, completion_reason: WaitCompletionReason::EventReceived, - result: json!({"next_offset": unread.last().expect("nonempty").1.offset.saturating_add(1)}), - }, now)?; - return events_wake(&unread); + return events_wake(&unread).map(|wake| (wake, Some(wait.wait_id))); } } if let Some(wait) = &state.active_wait { @@ -276,7 +302,12 @@ impl Engine { SubscriptionCompletionReason::Deadline => json!({"subscription_id": subscription_id, "timeout": "deadline", "pending": pending(&unread_frames(&journal.scan_all()?, state)?) }), _ => json!({"subscription_id": subscription_id, "closed": true}), }; - self.complete_wait(journal, wait, WaitCompletionReason::Timeout, result, now)?; + 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 }))?; @@ -348,9 +379,24 @@ impl Engine { Ok(()) } - fn acknowledge_normal_wake(&self, journal: &mut SqliteJournal, state: &SubscriptionState, completed: &WaitCompletedPayload, now: i64) -> Result<()> { - self.append(journal, &JournalEntry::new(EntryType::SubscriptionAcknowledged, journal.run_id(), None, None, now, - SubscriptionAcknowledgedPayload { subscription_id: state.opened.subscription_id.clone(), wait_id: completed.wait_id.clone(), next_offset: completed.result.get("next_offset").and_then(Value::as_u64) }))?; + 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(()) } @@ -406,7 +452,6 @@ fn subscriptions(journal: &SqliteJournal) -> Result { let params: SubscriptionCloseParams = decode_params(request.params)?; diff --git a/kernel/relayflowd/src/server/wire.rs b/kernel/relayflowd/src/server/wire.rs index 170378d37..5742002e9 100644 --- a/kernel/relayflowd/src/server/wire.rs +++ b/kernel/relayflowd/src/server/wire.rs @@ -185,6 +185,8 @@ pub(super) struct SubscriptionOpenParams { pub(super) struct SubscriptionNextParams { pub run_id: String, pub subscription_id: String, + #[serde(default)] + pub acknowledge_wait_id: Option, } #[derive(Deserialize)] diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs index fe793ffc5..2bdcb4b85 100644 --- a/kernel/relayflowd/tests/event_activities.rs +++ b/kernel/relayflowd/tests/event_activities.rs @@ -122,6 +122,10 @@ fn overflow_of_a_parked_next_returns_overflow_after_recovery() { 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] @@ -133,7 +137,8 @@ fn immediate_event_wakes_have_durable_distinct_wait_boundaries() { 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()); - assert!(matches!(engine.next_subscription(&run_id, "pr-42").unwrap(), SubscriptionWake::Events { .. })); + 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() @@ -184,9 +189,9 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { clock.set(8); assert!(matches!(engine.next_subscription(&run_id, "timed").unwrap(), SubscriptionWake::Events { offset: 3, .. })); clock.set(18); - assert_eq!(engine.next_subscription(&run_id, "timed").unwrap(), SubscriptionWake::Idle); + 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(&run_id, "timed").unwrap(), SubscriptionWake::Deadline { .. })); + 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. @@ -218,7 +223,7 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { 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(&run_id, "pr-42").unwrap(), SubscriptionWake::Overflow { .. })); + 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()); @@ -233,3 +238,27 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { 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/packages/sdk/src/authored-activity.ts b/packages/sdk/src/authored-activity.ts index 170a63856..ed56e04ff 100644 --- a/packages/sdk/src/authored-activity.ts +++ b/packages/sdk/src/authored-activity.ts @@ -45,6 +45,8 @@ 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, @@ -78,11 +80,18 @@ class JournalActivity implements OpenActivity { private async next(): Promise { if (this.closed) throw new AuthoredFlowExecutionError('activity_closed', 'activity is already closed'); await this.ensureOpen(); - const wake = decodeWake(await this.journal.subscriptionNext({ + const result = await this.journal.subscriptionNext({ run_id: this.runId, subscription_id: this.subscriptionId, - })); - if (wake.kind === 'deadline' || wake.kind === 'overflow') this.closed = true; + ...(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; } @@ -104,6 +113,11 @@ class JournalActivity implements OpenActivity { } } +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; diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 870ee64cf..80fb9d49c 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -386,10 +386,12 @@ export interface SubscriptionOpenResult { 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 } - | { kind: 'idle' } + | { 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 }; diff --git a/packages/sdk/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts index 79b8487f2..6fd82d681 100644 --- a/packages/sdk/tests/authored-activity.test.ts +++ b/packages/sdk/tests/authored-activity.test.ts @@ -98,6 +98,34 @@ describe('authored event activities', () => { } 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 }); diff --git a/packages/sdk/tests/live-event-activities.test.ts b/packages/sdk/tests/live-event-activities.test.ts index 3732fe4d0..99b3c0a60 100644 --- a/packages/sdk/tests/live-event-activities.test.ts +++ b/packages/sdk/tests/live-event-activities.test.ts @@ -1,7 +1,7 @@ import { existsSync, lstatSync, mkdtempSync, rmSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { spawn, type ChildProcess } from 'node:child_process'; +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'; @@ -10,9 +10,10 @@ import { socketPathFor } from '../src/daemon-connection.js'; import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; import { JournalClient } from '../src/journal-client.js'; -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +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', '1398563233'), + process.env['CARGO_TARGET_DIR'] ?? join(homedir(), '.relayflows-toolchain', 'target', worktreeKey), 'debug', 'relayflowd', )); const daemons: ChildProcess[] = []; @@ -64,7 +65,7 @@ steps: 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.acknowledged', 'subscription.closed', + 'subscription.opened', 'stream.appended', 'wait.completed', 'subscription.closed', ])); }, 20_000); @@ -93,6 +94,7 @@ steps: 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); From 9986618eafc1a706fb0becb6a6dd64da314135b8 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 08:45:14 +0200 Subject: [PATCH 19/34] docs(evidence): record event-await audit pass 2 Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- .../audit-pass-2.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/evidence/event-await-implementation/audit-pass-2.md 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 +``` From a59806fc1b159c5130b27fcf59e818eb1bbf8ecd Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 20:43:54 +0200 Subject: [PATCH 20/34] feat: add durable event await suspension protocol Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/EVENT-AWAIT.md | 59 ++--- kernel/relayflowd-core/src/entry.rs | 23 ++ kernel/relayflowd-core/src/state.rs | 1 + kernel/relayflowd/src/engine.rs | 2 +- .../mod.rs} | 223 +++++++----------- .../src/engine/subscriptions/state.rs | 133 +++++++++++ kernel/relayflowd/src/server.rs | 22 +- kernel/relayflowd/src/server/wire.rs | 9 + kernel/relayflowd/tests/event_activities.rs | 78 +++++- packages/sdk/src/authored-activity.ts | 26 +- packages/sdk/src/authored-flow-error.ts | 10 + packages/sdk/src/authored-flow-executor.ts | 10 + packages/sdk/src/authored-node-entry.ts | 3 +- packages/sdk/src/authored-node-runner.ts | 11 +- packages/sdk/src/authored-root.ts | 22 +- packages/sdk/src/cli.ts | 2 +- packages/sdk/src/cli/direct-run.ts | 4 + packages/sdk/src/cli/run.ts | 32 ++- packages/sdk/src/journal-client.ts | 9 +- packages/sdk/src/protocol.ts | 27 ++- packages/sdk/tests/authored-activity.test.ts | 24 +- .../sdk/tests/live-event-activities.test.ts | 23 +- 22 files changed, 567 insertions(+), 186 deletions(-) rename kernel/relayflowd/src/engine/{subscriptions.rs => subscriptions/mod.rs} (72%) create mode 100644 kernel/relayflowd/src/engine/subscriptions/state.rs diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index b80976ae2..2fa41a7f8 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -168,28 +168,32 @@ body-level `on` without both, with `unbounded_subscription`. ## 5. Kernel (additive) -No new step kind and no new verb. The kernel vocabulary stays closed -(decision 13). +No new step kind or surface verb. The daemon adds the internal +`subscription.activate` handoff verb; the kernel's step vocabulary stays +closed (decision 13). -1. **`subscription.opened`** — `subscription_id` (deterministic from run id, +1. **`subscription.prepared`** — `subscription_id` (deterministic from run id, step id and declaration), `event_types`, `pattern` (the recursive-subset match already used by `TriggerSpec.pattern`), `stream` - (`subscription/`), `deadline_at_ms`, `include_self`, and - the immutable provider binding: integration installation, canonical - resource scope, authorization snapshot, router binding generation, and - durable ingress offset. Opening is a two-party handshake: Cloud first - records the fenced binding at that ingress offset, then the journal appends - `subscription.opened`; `f.on()` is not visible to the body until both have - completed. Recovery first honors any durable `closing: overflow` fence for - that generation: it completes the close and its active wait, never restores - or replays the binding. Absent that fence, recovery removes a prepared - binding that has no matching journal entry, and otherwise restores the same - generation and replays ingress after its offset before acknowledging the - body. This closes the journal-to-router race without delivering frames that - predate opening. -2. **`subscription.closed`** — `subscription_id`, `completionReason` + (`subscription/`), `deadline_at_ms`, and `include_self`. + It is an immutable request, never an open cursor and never eligible for + ingress. The daemon returns a `suspended` outcome at this boundary, so no + resident daemon thread waits for a Cloud binding. +2. **`subscription.opened`** — the prepared request plus immutable provider + binding: integration installation, canonical resource scope, authorization + snapshot, router binding generation, and durable ingress offset. Cloud must + first persist its binding and ingress cursor, then invoke the idempotent + `subscription.activate` daemon verb, which appends this entry. Only an + active entry is visible when the body is resumed. Recovery honors any + durable `closing: overflow` fence for that generation; it completes the + close and its active wait, never restores or replays the binding. Cloud + removes a prepared registry record that lacks a matching active journal + entry, and otherwise restores the same generation and replays ingress + strictly after its cursor. This closes the journal-to-router race without + delivering frames that predate opening. +3. **`subscription.closed`** — `subscription_id`, `completionReason` (`closed` \| `run_completed` \| `canceled` \| `deadline` \| `overflow`). -3. **Buffered delivery** — matching events become `stream.appended` on the +4. **Buffered delivery** — matching events become `stream.appended` on the subscription's stream, carrying the provider delivery id as the idempotency key. A stream holds at most **1,000 unread frames or 1 MiB of unread encoded frame bytes**, measured after this subscription consumer's acknowledged @@ -209,7 +213,7 @@ No new step kind and no new verb. The kernel vocabulary stays closed the fenced binding; it never restores that generation as open. The would-exceed frame is unappended. Events for a closed or unknown subscription are refused, not buffered. -4. **`wait.event` extension** — alongside `event_key`, a wait may name +5. **`wait.event` extension** — alongside `event_key`, a wait may name `stream`, `from_offset`, `settle_ms`, `idle_at_ms`, and `deadline_at_ms`. It completes with `event_received` and `result: { from_offset, next_offset }` once the stream has entries at or past `from_offset` and `settle_ms` has @@ -220,10 +224,10 @@ No new step kind and no new verb. The kernel vocabulary stays closed exist; idle completes with `result: { timeout: "idle" }` only when no buffered entries won the serialized race. Adding result fields keeps `wait.completed`'s reason enum unchanged. -5. **Timeouts are enforced.** The scheduler arms `timeout_at_ms`, +6. **Timeouts are enforced.** The scheduler arms `timeout_at_ms`, `idle_at_ms`, and `deadline_at_ms` as durable timers for every open wait, including `wait.human`. This closes the gap in §2 for existing waits too. -6. **Epoch summary** carries open subscriptions with their stream offsets and +7. **Epoch summary** carries open subscriptions with their stream offsets and deadlines alongside `open_waits`. ## 6. Router contract (Cloud) @@ -231,12 +235,13 @@ No new step kind and no new verb. The kernel vocabulary stays closed The event router is Cloud's, not the kernel's (decision 15: the kernel is tenant-unaware). -- A `subscription.opened` entry is projected to the router as a fenced binding - of `(run_id, subscription_id, generation, ingress_offset)` to its event - types, pattern, provider installation, and canonical resource scope. It is - removed on `subscription.closed`. The open handshake records the binding and - ingress offset before the body can observe the subscription; recovery - replays ingress strictly after that offset before acknowledging the binding. +- A `subscription.prepared` entry tells Cloud to create a fenced binding of + `(run_id, subscription_id, generation, ingress_offset)` to its event types, + pattern, provider installation, and canonical resource scope. Cloud writes + that binding and cursor durably, calls `subscription.activate`, then resumes + the body; it removes the binding on `subscription.closed`. A prepared record + alone never accepts ingress. Recovery replays ingress strictly after the + activated cursor before acknowledging the body. - The router matches incoming `EventFrameV1` frames against open bindings, first proving the frame came through the bound installation and is within the bound resource scope. It then applies the self-actor filter and calls diff --git a/kernel/relayflowd-core/src/entry.rs b/kernel/relayflowd-core/src/entry.rs index 90c8f9b02..0f974b80e 100644 --- a/kernel/relayflowd-core/src/entry.rs +++ b/kernel/relayflowd-core/src/entry.rs @@ -25,6 +25,11 @@ pub enum EntryType { /// "Native silent-death" answer at the journal level. #[serde(rename = "subscription.stale")] SubscriptionStale, + /// The cell has recorded the exact subscription request but Cloud has not + /// yet fenced its provider binding. A prepared record is intentionally + /// invisible to the authored body and never accepts frames. + #[serde(rename = "subscription.prepared")] + SubscriptionPrepared, /// A body-local event cursor. This is distinct from trigger-plane /// `subscription.registered`: it never creates a run. #[serde(rename = "subscription.opened")] @@ -85,6 +90,7 @@ impl EntryType { Self::SubscriptionRegistered => "subscription.registered", Self::SubscriptionMatched => "subscription.matched", Self::SubscriptionStale => "subscription.stale", + Self::SubscriptionPrepared => "subscription.prepared", Self::SubscriptionOpened => "subscription.opened", Self::SubscriptionClosed => "subscription.closed", Self::SubscriptionOverflowFenced => "subscription.overflow.fenced", @@ -117,6 +123,7 @@ impl EntryType { "subscription.registered" => Self::SubscriptionRegistered, "subscription.matched" => Self::SubscriptionMatched, "subscription.stale" => Self::SubscriptionStale, + "subscription.prepared" => Self::SubscriptionPrepared, "subscription.opened" => Self::SubscriptionOpened, "subscription.closed" => Self::SubscriptionClosed, "subscription.overflow.fenced" => Self::SubscriptionOverflowFenced, @@ -534,6 +541,22 @@ pub struct SubscriptionOpenedPayload { pub router_binding: Value, } +/// Immutable request handed to the Cloud router before it creates the +/// provider binding. `subscription.opened` is appended only after Cloud +/// returns its fenced binding receipt and ingress offset. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SubscriptionPreparedPayload { + 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, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SubscriptionCompletionReason { diff --git a/kernel/relayflowd-core/src/state.rs b/kernel/relayflowd-core/src/state.rs index 988559f2e..f844e8bbd 100644 --- a/kernel/relayflowd-core/src/state.rs +++ b/kernel/relayflowd-core/src/state.rs @@ -221,6 +221,7 @@ impl RunState { // state machine — it never affects run/step state, so state // folding ignores it here. | EntryType::SubscriptionStale + | EntryType::SubscriptionPrepared | EntryType::SubscriptionOpened | EntryType::SubscriptionClosed | EntryType::SubscriptionOverflowFenced diff --git a/kernel/relayflowd/src/engine.rs b/kernel/relayflowd/src/engine.rs index 2dea7a05b..a24d42eef 100644 --- a/kernel/relayflowd/src/engine.rs +++ b/kernel/relayflowd/src/engine.rs @@ -89,7 +89,7 @@ mod model; mod placement; mod remote; mod subscriptions; -pub use subscriptions::{PendingRange, SubscriptionWake}; +pub use subscriptions::{PendingRange, SubscriptionNext, SubscriptionOpen, SubscriptionWake}; mod wake; pub use channels::ChannelCommandError; pub use model::{RunOutcome, RunSnapshot, RunStatus, StepSnapshot, StepStatus}; diff --git a/kernel/relayflowd/src/engine/subscriptions.rs b/kernel/relayflowd/src/engine/subscriptions/mod.rs similarity index 72% rename from kernel/relayflowd/src/engine/subscriptions.rs rename to kernel/relayflowd/src/engine/subscriptions/mod.rs index 29f04d2f0..7ad4fbca1 100644 --- a/kernel/relayflowd/src/engine/subscriptions.rs +++ b/kernel/relayflowd/src/engine/subscriptions/mod.rs @@ -1,15 +1,16 @@ //! 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. +//! Provider bindings are deliberately not implemented here. Cloud first +//! records a prepared request, then fences its tenant/provider binding and +//! calls `subscription.activate`; this module owns only journal ordering, +//! cursor state, and timers. use std::collections::BTreeMap; use anyhow::{Context, Result, bail}; use relayflowd_core::{ Clock, EntryType, JournalEntry, RunSpawnedPayload, StreamAppendedPayload, SubscriptionAcknowledgedPayload, SubscriptionClosedPayload, - SubscriptionCompletionReason, SubscriptionOpenedPayload, WaitCompletedPayload, + SubscriptionCompletionReason, SubscriptionOpenedPayload, SubscriptionPreparedPayload, WaitCompletedPayload, SubscriptionOverflowFencedPayload, WaitCompletionReason, WaitEventPayload, }; use relayflowd_journal::SqliteJournal; @@ -18,6 +19,9 @@ use serde_json::{Value, json}; use super::Engine; +mod state; +use state::*; + const MAX_UNREAD_FRAMES: usize = 1_000; const MAX_UNREAD_BYTES: usize = 1_024 * 1_024; @@ -30,6 +34,22 @@ pub enum SubscriptionWake { Overflow { retained: u64, bytes: u64, from: u64 }, } +/// A daemon response never sleeps while waiting for an external event. The +/// caller publishes this durable boundary and returns to its control plane. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SubscriptionNext { + Wake(SubscriptionWake), + Suspended { subscription_id: String, stream: String, deadline_at_ms: i64 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SubscriptionOpen { + Prepared { subscription_id: String, stream: String, deadline_at_ms: i64 }, + Active { subscription_id: String, stream: String, deadline_at_ms: i64 }, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PendingRange { pub from: u64, @@ -37,19 +57,19 @@ pub struct PendingRange { } #[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, +pub(super) struct SubscriptionState { + pub(super) opened: SubscriptionOpenedPayload, + pub(super) closed: Option, + pub(super) acknowledged_offset: u64, + pub(super) last_wake_at_ms: i64, + pub(super) active_wait: Option, + pub(super) ready: Option, + pub(super) overflow_fence: Option, + pub(super) next_wait_sequence: u64, } impl SubscriptionState { - fn stream(&self) -> &str { &self.opened.stream } + pub(super) fn stream(&self) -> &str { &self.opened.stream } } impl Engine { @@ -93,7 +113,7 @@ impl Engine { idle_ms: i64, deadline_ms: i64, include_self: bool, - ) -> Result<(String, i64)> { + ) -> Result { 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") @@ -104,24 +124,53 @@ impl Engine { 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)); + return Ok(SubscriptionOpen::Active { subscription_id: subscription_id.to_owned(), stream: existing.opened.stream, deadline_at_ms: existing.opened.deadline_at_ms }); } bail!("subscription {subscription_id} is closed") } + if let Some(prepared) = prepared_subscriptions(&journal)?.remove(subscription_id) { + return Ok(SubscriptionOpen::Prepared { subscription_id: subscription_id.to_owned(), stream: prepared.stream, deadline_at_ms: prepared.deadline_at_ms }); + } 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 { + EntryType::SubscriptionPrepared, run_id, None, None, now, + SubscriptionPreparedPayload { 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"}), + settle_ms, idle_ms, deadline_at_ms, include_self, }, ))?; - Ok((stream, deadline_at_ms)) + Ok(SubscriptionOpen::Prepared { subscription_id: subscription_id.to_owned(), stream, deadline_at_ms }) + } + + /// Commit the second half of the open handshake after Cloud has persisted + /// its binding receipt and ingress cursor. Retries are idempotent. + pub fn activate_subscription( + &self, run_id: &str, subscription_id: &str, ingress_offset: u64, router_binding: Value, + ) -> Result { + let mut journal = self.open_run(run_id)?; + if let Some(active) = subscriptions(&journal)?.remove(subscription_id) { + return Ok(SubscriptionOpen::Active { subscription_id: subscription_id.to_owned(), stream: active.opened.stream, deadline_at_ms: active.opened.deadline_at_ms }); + } + let prepared = prepared_subscriptions(&journal)?.remove(subscription_id) + .context("subscription activation requires a prepared binding")?; + self.append(&mut journal, &JournalEntry::new( + EntryType::SubscriptionOpened, run_id, None, None, self.clock.now_ms(), + SubscriptionOpenedPayload { + subscription_id: prepared.subscription_id, + event_types: prepared.event_types, + pattern: prepared.pattern, + stream: prepared.stream.clone(), + settle_ms: prepared.settle_ms, + idle_ms: prepared.idle_ms, + deadline_at_ms: prepared.deadline_at_ms, + include_self: prepared.include_self, + ingress_offset, + router_binding, + }, + ))?; + Ok(SubscriptionOpen::Active { subscription_id: subscription_id.to_owned(), stream: prepared.stream, deadline_at_ms: prepared.deadline_at_ms }) } pub fn close_subscription( @@ -211,8 +260,8 @@ impl Engine { 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. + /// Return only a durable wake. Callers that need to wait must hand the + /// suspended outcome to their control plane rather than occupying a daemon. pub fn next_subscription(&self, run_id: &str, subscription_id: &str) -> Result { self.next_subscription_after_ack(run_id, subscription_id, None) } @@ -240,6 +289,18 @@ impl Engine { subscription_id: &str, acknowledge_wait_id: Option<&str>, ) -> Result<(SubscriptionWake, Option)> { + match self.next_subscription_outcome(run_id, subscription_id, acknowledge_wait_id)? { + (SubscriptionNext::Wake(wake), receipt) => Ok((wake, receipt)), + (SubscriptionNext::Suspended { subscription_id, .. }, _) => bail!("subscription {subscription_id} is durably suspended"), + } + } + + pub fn next_subscription_outcome( + &self, + run_id: &str, + subscription_id: &str, + acknowledge_wait_id: Option<&str>, + ) -> Result<(SubscriptionNext, Option)> { let mut acknowledge_wait_id = acknowledge_wait_id; loop { self.claim_subscription_timeouts(run_id)?; @@ -255,9 +316,9 @@ impl Engine { 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)); + return Ok((SubscriptionNext::Wake(wake), receipt)); } - if let Some(reason) = state.closed { return self.closed_wake(&journal, state, reason).map(|wake| (wake, None)); } + if let Some(reason) = state.closed { return self.closed_wake(&journal, state, reason).map(|wake| (SubscriptionNext::Wake(wake), None)); } let entries = journal.scan_all()?; let unread = unread_frames(&entries, state)?; if !unread.is_empty() { @@ -276,19 +337,15 @@ impl Engine { ))?; } self.complete_events(&mut journal, state, &wait, &unread, now)?; - return events_wake(&unread).map(|wake| (wake, Some(wait.wait_id))); + return events_wake(&unread).map(|wake| (SubscriptionNext::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; + return Ok((SubscriptionNext::Suspended { subscription_id: subscription_id.to_owned(), stream: state.stream().to_owned(), deadline_at_ms: wait.deadline_at_ms.unwrap_or(state.opened.deadline_at_ms) }, None)); } let wait = activity_wait(state, now); self.append(&mut journal, &JournalEntry::new(EntryType::WaitEvent, run_id, None, None, now, wait))?; + return Ok((SubscriptionNext::Suspended { subscription_id: subscription_id.to_owned(), stream: state.stream().to_owned(), deadline_at_ms: state.opened.deadline_at_ms }, None)); } } @@ -412,101 +469,3 @@ impl Engine { }) } } - -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/engine/subscriptions/state.rs b/kernel/relayflowd/src/engine/subscriptions/state.rs new file mode 100644 index 000000000..6da7a0b6e --- /dev/null +++ b/kernel/relayflowd/src/engine/subscriptions/state.rs @@ -0,0 +1,133 @@ +//! Durable journal folds for body-local event subscriptions. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use relayflowd_core::{ + EntryType, JournalEntry, StreamAppendedPayload, SubscriptionAcknowledgedPayload, + SubscriptionClosedPayload, SubscriptionOpenedPayload, SubscriptionOverflowFencedPayload, + SubscriptionPreparedPayload, WaitCompletedPayload, WaitEventPayload, +}; +use relayflowd_journal::SqliteJournal; +use serde_json::Value; + +use super::{PendingRange, SubscriptionState, SubscriptionWake}; + +pub(super) 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) +} + +/// Prepared records are intentionally absent from the active cursor fold. +/// Cloud owns prepared-only cleanup; this fold permits activation retry after +/// a cell crash without exposing the request to ingress. +pub(super) fn prepared_subscriptions(journal: &SqliteJournal) -> Result> { + let mut prepared = BTreeMap::new(); + for entry in journal.scan_all()? { + match entry.entry_type { + EntryType::SubscriptionPrepared => { + let value: SubscriptionPreparedPayload = serde_json::from_value(entry.payload)?; + prepared.insert(value.subscription_id.clone(), value); + } + EntryType::SubscriptionOpened | EntryType::SubscriptionClosed => { + let id = entry.payload.get("subscription_id").and_then(Value::as_str) + .context("subscription lifecycle entry has no subscription_id")?; + prepared.remove(id); + } + _ => {} + } + } + Ok(prepared) +} + +pub(super) 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), + } +} +pub(super) 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) +} + +pub(super) 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) +} + +pub(super) 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) }) +} +pub(super) 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 }) +} +pub(super) fn unread_bytes(unread: &[(JournalEntry, StreamAppendedPayload)]) -> usize { unread.iter().filter_map(|(_, append)| serde_json::to_vec(&append.message).ok()).map(|bytes| bytes.len()).sum() } +pub(super) 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 fecaf2df5..fbc35a41b 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use relayflowd_core::{CompletionReason, PROTOCOL_VERSION, RunSpec, StepType}; use serde_json::{Value, json}; +use crate::engine::SubscriptionNext; use crate::{Engine, OutOfBandCompletion}; #[cfg(unix)] @@ -490,24 +491,37 @@ fn handle_request( 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 + let opened = 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})) + to_value(opened) + } + "subscription.activate" => { + let params: SubscriptionActivateParams = 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)?; + to_value(engine.activate_subscription( + ¶ms.run_id, ¶ms.subscription_id, params.ingress_offset, params.router_binding, + ).map_err(internal_error)?) } "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( + let (outcome, acknowledge_wait_id) = engine.next_subscription_outcome( ¶ms.run_id, ¶ms.subscription_id, params.acknowledge_wait_id.as_deref(), ).map_err(internal_error)?; - let mut result = to_value(wake)?; + let mut result = match outcome { + SubscriptionNext::Wake(wake) => to_value(wake)?, + SubscriptionNext::Suspended { subscription_id, stream, deadline_at_ms } => + json!({"kind": "suspended", "subscription_id": subscription_id, "stream": stream, "deadline_at_ms": deadline_at_ms}), + }; 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)); diff --git a/kernel/relayflowd/src/server/wire.rs b/kernel/relayflowd/src/server/wire.rs index 5742002e9..5d2af6566 100644 --- a/kernel/relayflowd/src/server/wire.rs +++ b/kernel/relayflowd/src/server/wire.rs @@ -189,6 +189,15 @@ pub(super) struct SubscriptionNextParams { pub acknowledge_wait_id: Option, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SubscriptionActivateParams { + pub run_id: String, + pub subscription_id: String, + pub ingress_offset: u64, + pub router_binding: Value, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct SubscriptionCloseParams { diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs index 2bdcb4b85..cd30ef19a 100644 --- a/kernel/relayflowd/tests/event_activities.rs +++ b/kernel/relayflowd/tests/event_activities.rs @@ -3,7 +3,7 @@ //! matters; no mock bypasses recovery. use relayflowd::Engine; -use relayflowd::engine::{PendingRange, SubscriptionWake}; +use relayflowd::engine::{PendingRange, SubscriptionNext, SubscriptionOpen, SubscriptionWake}; use relayflowd_core::{Clock, EntryType, Journal, JournalEntry, RunSpec, SimClock, StreamAppendedPayload, SubscriptionAcknowledgedPayload, WaitHumanPayload}; use relayflowd_journal::SqliteJournal; use serde_json::json; @@ -29,6 +29,64 @@ fn open(engine: &Engine, run_id: &str, deadline_ms run_id, "pr-42", vec!["github.pull_request".to_owned()], None, 0, 10, deadline_ms, false, ).unwrap(); + activate(engine, run_id, "pr-42"); +} + +/// Unit coverage uses a local stand-in for Cloud's durable router registry. +/// Production must persist this receipt and ingress fence before making the +/// body visible again through `subscription.activate`. +fn activate(engine: &Engine, run_id: &str, subscription_id: &str) { + engine.activate_subscription( + run_id, + subscription_id, + 0, + json!({"transport": "test-router", "generation": "test"}), + ).unwrap(); +} + +#[test] +fn prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_suspends() { + let directory = tempfile::tempdir().unwrap(); + let clock = TestClock::new(100); + let engine = Engine::with_clock(directory.path(), clock.clone()); + let run_id = parked_run(&engine); + + assert!(matches!(engine.open_subscription( + &run_id, "handoff", vec!["github.pull_request".into()], None, 0, 10, 1_000, false, + ).unwrap(), SubscriptionOpen::Prepared { .. })); + assert_eq!(engine.append_local_subscription_event( + &run_id, "github.pull_request", json!({"number": 1}), Some("before-activate"), Some("reviewer"), + ).unwrap(), 0); + + let resumed = Engine::with_clock(directory.path(), clock); + assert!(matches!(resumed.open_subscription( + &run_id, "handoff", vec!["github.pull_request".into()], None, 0, 10, 1_000, false, + ).unwrap(), SubscriptionOpen::Prepared { .. })); + assert!(matches!(resumed.activate_subscription( + &run_id, "handoff", 41, json!({"binding_id": "binding-1", "generation": 7}), + ).unwrap(), SubscriptionOpen::Active { .. })); + assert!(matches!(resumed.activate_subscription( + &run_id, "handoff", 41, json!({"binding_id": "ignored-on-retry"}), + ).unwrap(), SubscriptionOpen::Active { .. })); + + assert!(matches!(resumed.next_subscription_outcome(&run_id, "handoff", None).unwrap().0, + SubscriptionNext::Suspended { ref subscription_id, ref stream, deadline_at_ms: 1_100 } + if subscription_id == "handoff" && stream == "subscription/handoff")); + let entries = resumed.journal_entries(&run_id, 1, 100).unwrap(); + assert_eq!(entries.iter().filter(|entry| entry.entry_type == EntryType::SubscriptionPrepared).count(), 1); + assert_eq!(entries.iter().filter(|entry| entry.entry_type == EntryType::SubscriptionOpened).count(), 1); + assert_eq!(entries.iter().filter(|entry| entry.entry_type == EntryType::WaitEvent).count(), 1); + + let recovered = Engine::with_clock(directory.path(), TestClock::new(100)); + assert!(matches!(recovered.next_subscription_outcome(&run_id, "handoff", None).unwrap().0, + SubscriptionNext::Suspended { .. })); + assert_eq!(recovered.journal_entries(&run_id, 1, 100).unwrap().iter() + .filter(|entry| entry.entry_type == EntryType::WaitEvent).count(), 1); + assert!(recovered.append_subscription_frame( + &run_id, "handoff", "after-activate", json!({"type": "github.pull_request", "number": 2}), + ).unwrap()); + assert!(matches!(recovered.next_subscription_outcome(&run_id, "handoff", None).unwrap().0, + SubscriptionNext::Wake(SubscriptionWake::Events { offset: 1, .. }))); } #[test] @@ -55,9 +113,15 @@ fn accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next() #[test] fn idle_wait_is_durable_and_fires_without_an_event() { let directory = tempfile::tempdir().unwrap(); - let engine = Engine::new(directory.path()); + 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, "quiet", vec!["github.pull_request".to_owned()], None, 0, 1, 100, false).unwrap(); + activate(&engine, &run_id, "quiet"); + assert!(matches!(engine.next_subscription_outcome(&run_id, "quiet", None).unwrap().0, + SubscriptionNext::Suspended { .. })); + clock.set(1); + assert_eq!(engine.claim_subscription_timeouts(&run_id).unwrap(), 1); 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)); } @@ -176,6 +240,7 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { let self_run = parked_run(&engine); engine.open_subscription(&self_run, "self", vec!["github.pull_request".into()], None, 0, 10, 100, false).unwrap(); + activate(&engine, &self_run, "self"); 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 @@ -185,11 +250,15 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { 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(); + activate(&engine, &run_id, "timed"); 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, .. })); + assert!(matches!(engine.next_subscription_outcome(&run_id, "timed", Some("timed/next/0")).unwrap().0, + SubscriptionNext::Suspended { .. })); clock.set(18); - assert_eq!(engine.next_subscription_after_ack(&run_id, "timed", Some("timed/next/0")).unwrap(), SubscriptionWake::Idle); + assert_eq!(engine.claim_subscription_timeouts(&run_id).unwrap(), 1); + assert_eq!(engine.next_subscription(&run_id, "timed").unwrap(), SubscriptionWake::Idle); clock.set(20); assert!(matches!(engine.next_subscription_after_ack(&run_id, "timed", Some("timed/next/1")).unwrap(), SubscriptionWake::Deadline { .. })); @@ -200,6 +269,7 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { 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(); + activate(&engine, &run_id, "idle-restart"); 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); @@ -226,10 +296,12 @@ fn remaining_event_await_acceptance_cases_use_the_real_journal() { 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(); + activate(&engine, &bytes_run, "bytes"); 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(); + activate(&engine, &keeps_up, "keeps-up"); 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(); diff --git a/packages/sdk/src/authored-activity.ts b/packages/sdk/src/authored-activity.ts index ed56e04ff..8d5cffb39 100644 --- a/packages/sdk/src/authored-activity.ts +++ b/packages/sdk/src/authored-activity.ts @@ -7,7 +7,7 @@ import type { Wake, } from '@relayflows/surface'; import type { SubscriptionNextResult } from './protocol.js'; -import { AuthoredFlowExecutionError } from './authored-flow-error.js'; +import { AuthoredFlowExecutionError, type AuthoredFlowSuspension } from './authored-flow-error.js'; import { JournalClient } from './journal-client.js'; type CloseReason = 'closed' | 'run_completed' | 'canceled'; @@ -85,6 +85,12 @@ class JournalActivity implements OpenActivity { subscription_id: this.subscriptionId, ...(this.acknowledgeWaitId === undefined ? {} : { acknowledge_wait_id: this.acknowledgeWaitId }), }); + if (result.kind === 'suspended') { + throw suspended({ + kind: 'event_wait', subscriptionId: result.subscription_id, + stream: result.stream, deadlineAtMs: result.deadline_at_ms, + }, this.runId); + } const wake = decodeWake(result); if (wake.kind === 'deadline' || wake.kind === 'overflow') { this.closed = true; @@ -100,7 +106,7 @@ class JournalActivity implements OpenActivity { } private async openNow(): Promise { - await this.journal.subscriptionOpen({ + const result = await this.journal.subscriptionOpen({ run_id: this.runId, subscription_id: this.subscriptionId, event_types: [this.source.name], @@ -110,9 +116,25 @@ class JournalActivity implements OpenActivity { deadline_ms: this.options.deadlineMs, include_self: this.options.includeSelf, }); + if (result.state === 'prepared') { + throw suspended({ + kind: 'activation', subscriptionId: result.subscription_id, + stream: result.stream, deadlineAtMs: result.deadline_at_ms, + }, this.runId); + } } } +function suspended(value: AuthoredFlowSuspension, runId: string): AuthoredFlowExecutionError { + return new AuthoredFlowExecutionError( + 'subscription_suspended', + `subscription ${value.subscriptionId} is durably suspended for ${value.kind}`, + undefined, + runId, + value, + ); +} + 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; diff --git a/packages/sdk/src/authored-flow-error.ts b/packages/sdk/src/authored-flow-error.ts index 325bc562b..76ab06b2c 100644 --- a/packages/sdk/src/authored-flow-error.ts +++ b/packages/sdk/src/authored-flow-error.ts @@ -29,6 +29,7 @@ export type AuthoredFlowExecutionErrorCode = | 'unsupported_workspace_permission' | 'unbounded_subscription' | 'activity_closed' + | 'subscription_suspended' | 'unawaited_step' | 'unsupported_verb'; @@ -38,8 +39,17 @@ export class AuthoredFlowExecutionError extends Error { message: string, readonly completionReason?: ProtocolCompletionReason | ProtocolRunCompletionReason, readonly runId?: string, + readonly suspension?: AuthoredFlowSuspension, ) { super(`${code}: ${message}`); this.name = 'AuthoredFlowExecutionError'; } } + +/** Serialized into the CLI report so Cloud can atomically finish activation or wait for a wake. */ +export type AuthoredFlowSuspension = { + readonly kind: 'activation' | 'event_wait'; + readonly subscriptionId: string; + readonly stream: string; + readonly deadlineAtMs: number; +}; diff --git a/packages/sdk/src/authored-flow-executor.ts b/packages/sdk/src/authored-flow-executor.ts index 2fce681cc..b5553992f 100644 --- a/packages/sdk/src/authored-flow-executor.ts +++ b/packages/sdk/src/authored-flow-executor.ts @@ -32,6 +32,7 @@ import type { GetFlowDefinition } from './authored-flow-loader.js'; import type { RunLifecycleOptions } from './cli/run.js'; import { AuthoredFlowExecutionError, + type AuthoredFlowSuspension, type AuthoredFlowExecutionErrorCode, } from './authored-flow-error.js'; import { @@ -81,6 +82,7 @@ export interface AuthoredExecutionRuntime { } export interface AuthoredFlowExecutionResult { + readonly state?: undefined; readonly executionRuntime?: AuthoredExecutionRuntime; readonly rootRunId?: string; readonly name: string; @@ -88,6 +90,14 @@ export interface AuthoredFlowExecutionResult { readonly journalSteps: readonly AuthoredFlowJournalStep[]; } +/** A body reached a durable event boundary and released its worker lease. */ +export interface AuthoredFlowSuspendedResult { + readonly state: 'suspended'; + readonly name: string; + readonly suspension: AuthoredFlowSuspension; + readonly journalSteps: readonly AuthoredFlowJournalStep[]; +} + type ExecutionResultUsesFlowCompletionReason = Assert< Equal >; diff --git a/packages/sdk/src/authored-node-entry.ts b/packages/sdk/src/authored-node-entry.ts index 3259d697f..817d818ee 100644 --- a/packages/sdk/src/authored-node-entry.ts +++ b/packages/sdk/src/authored-node-entry.ts @@ -82,7 +82,8 @@ try { const prefix = error instanceof AuthoredFlowExecutionError ? `${error.code}: ` : ''; send({ type: 'error', message: prefix && message.startsWith(prefix) ? message.slice(prefix.length) : message, ...(error instanceof AuthoredFlowExecutionError ? { code: error.code, - completionReason: error.completionReason, runId: error.runId } : {}) }); + completionReason: error.completionReason, runId: error.runId, + ...(error.suspension === undefined ? {} : { suspension: error.suspension }) } : {}) }); process.exitCode = 1; } finally { finished = true; await watchdog.terminate(); client?.close(); process.stdin.destroy(); diff --git a/packages/sdk/src/authored-node-runner.ts b/packages/sdk/src/authored-node-runner.ts index 05ce203d0..d0f4379f6 100644 --- a/packages/sdk/src/authored-node-runner.ts +++ b/packages/sdk/src/authored-node-runner.ts @@ -132,7 +132,8 @@ export async function runAuthoredInNode( else if (message.type === 'error') { failure = typeof message.code === 'string' ? new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode, - message.message, message.completionReason, message.runId) + message.message, message.completionReason, message.runId, + isSuspension(message.suspension) ? message.suspension : undefined) : new Error(message.message); } else throw new Error('unknown authored runtime message'); } catch (error) { stop(error instanceof Error ? error : new Error('invalid authored runtime message')); } @@ -155,6 +156,14 @@ export async function runAuthoredInNode( } finally { await rm(directory, { recursive: true, force: true }); } } +function isSuspension(value: unknown): value is import('./authored-flow-error.js').AuthoredFlowSuspension { + return typeof value === 'object' && value !== null && !Array.isArray(value) + && ((value as { kind?: unknown }).kind === 'activation' || (value as { kind?: unknown }).kind === 'event_wait') + && typeof (value as { subscriptionId?: unknown }).subscriptionId === 'string' + && typeof (value as { stream?: unknown }).stream === 'string' + && typeof (value as { deadlineAtMs?: unknown }).deadlineAtMs === 'number'; +} + /** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */ export async function verifyAuthoredNodeResult( result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata, diff --git a/packages/sdk/src/authored-root.ts b/packages/sdk/src/authored-root.ts index 5c7fb59a0..0555bc7e6 100644 --- a/packages/sdk/src/authored-root.ts +++ b/packages/sdk/src/authored-root.ts @@ -4,7 +4,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { canonicalize } from './canonical.js'; import { compileSpec, toKernelSpec } from './compile.js'; -import { executeAuthoredFlow, type AuthoredFlowExecutionResult } from './authored-flow-executor.js'; +import { executeAuthoredFlow, type AuthoredFlowExecutionResult, type AuthoredFlowSuspendedResult } from './authored-flow-executor.js'; import { loadAuthoredFlow, type LoadedAuthoredFlow, @@ -16,6 +16,11 @@ import { SPEC_SCHEMA_VERSION } from './spec.js'; import type { RunLifecycleOptions } from './cli/run.js'; import { withWorkerLease } from './worker-lease.js'; import { isSurfaceCompletionReason } from './authored-step-output.js'; +import { AuthoredFlowExecutionError } from './authored-flow-error.js'; + +export type DurableAuthoredFlowResult = + | (AuthoredFlowExecutionResult & { readonly rootRunId: string }) + | (AuthoredFlowSuspendedResult & { readonly rootRunId: string }); const ROOT_KIND = 'relayflows.authored-root.v1'; @@ -50,7 +55,7 @@ export async function executeDurableAuthoredFlow( journal: JournalClient, input: unknown, options: DurableAuthoredOptions, -): Promise { +): Promise { assertAuthoredRuntimeAvailable(); const source = await readFile(loaded.sourcePath); const sources = await Promise.all(loaded.graph.map(async node => Object.freeze({ @@ -108,7 +113,7 @@ export async function resumeDurableAuthoredFlow( rootRunId: string, journal: JournalClient, options: Omit, -): Promise<(AuthoredFlowExecutionResult & { readonly rootRunId: string }) | undefined> { +): Promise { const metadata = await readAuthoredRootMetadata(journal, rootRunId); if (metadata === undefined) return undefined; assertAuthoredRuntimeAvailable(); @@ -167,7 +172,7 @@ async function driveRoot( peer: JournalClient, dispatch: StepDispatchEvent, options: Omit, -): Promise { +): Promise { try { const result = await withWorkerLease(peer, dispatch, async rootSignal => { const callerSignal = options.lifecycle?.signal; @@ -206,6 +211,15 @@ async function driveRoot( ); return Object.freeze({ ...result, rootRunId: dispatch.run_id }); } catch (error) { + if (error instanceof AuthoredFlowExecutionError + && error.code === 'subscription_suspended' + && error.suspension !== undefined) { + // Do not complete the root step: its durable running state is the + // resume token. The worker lease ends with this process, and Cloud + // publishes the journal before activating or waking it. + return Object.freeze({ state: 'suspended' as const, name: metadata.flowName, + suspension: error.suspension, journalSteps: Object.freeze([]), rootRunId: dispatch.run_id }); + } await terminalizeRootFailure(peer, dispatch, error); throw error; } diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index aa4b239e6..509e96c17 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -45,7 +45,7 @@ export interface CliIo { stderr(line: string): void; } -type CliExitCode = 0 | 1 | 2 | 3; +type CliExitCode = 0 | 1 | 2 | 3 | 4; type ParsedArgs = | { command: 'add'; value: string } | ReplayArgs diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index fb34ec459..6e6b63228 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -19,6 +19,7 @@ import { emptyReport, fromCheckReport, protocolFailure, + suspendedExecution, socketFor, type RunExecution, type RunLifecycleOptions, @@ -93,6 +94,9 @@ export async function runDirectFlow( }, }, ); + if (result.state === 'suspended') { + return suspendedExecution('run', base, socketPath, result.rootRunId, result); + } const terminal = result.journalSteps.at(-1); if (terminal === undefined) { return protocolFailure('run', base, socketPath, new Error( diff --git a/packages/sdk/src/cli/run.ts b/packages/sdk/src/cli/run.ts index 6a4dcfbd3..b21508cf6 100644 --- a/packages/sdk/src/cli/run.ts +++ b/packages/sdk/src/cli/run.ts @@ -16,6 +16,7 @@ import { JournalClient, JournalProtocolError } from '../journal-client.js'; import { attachLocalAgent } from '../local-agent.js'; import { LlmWorker } from '../llm-worker.js'; import { readAuthoredRootMetadata, resumeDurableAuthoredFlow } from '../authored-root.js'; +import type { AuthoredFlowSuspendedResult } from '../authored-flow-executor.js'; import type { RunCompletionReason, RunOutcome, @@ -27,7 +28,7 @@ import { type CheckReport, } from './check.js'; -export type RunExitCode = 0 | 1 | 2 | 3; +export type RunExitCode = 0 | 1 | 2 | 3 | 4; export type RunCommand = 'run' | 'resume'; export interface ParkedStep { @@ -37,7 +38,7 @@ export interface ParkedStep { export interface RunDiagnostic extends StepFailedDetails { severity: 'refusal' | 'failure' | 'parked' | 'warning'; - kind: RunFailureKind | RunWarningKind | RunCompletionReason; + kind: RunFailureKind | RunWarningKind | RunCompletionReason | 'subscription_suspended'; message: string; } @@ -47,7 +48,9 @@ export interface RunReport { path?: string; runId?: string; socketPath?: string; - status?: RunStatus; + status?: RunStatus | 'suspended'; + /** Cloud consumes this exact durable boundary before launching a resume. */ + suspension?: AuthoredFlowSuspendedResult['suspension']; completionReason?: RunCompletionReason; completedSteps?: number; reuse?: { fromRunId: string; reusedSteps: number; executedSteps: number }; @@ -62,6 +65,26 @@ export interface RunExecution { report: RunReport; } +export function suspendedExecution( + command: RunCommand, + base: CheckReport | RunReport, + socketPath: string, + runId: string, + result: AuthoredFlowSuspendedResult & { readonly rootRunId: string }, +): RunExecution { + return { + exitCode: 4, + report: { + ...fromBase(command, base), ok: false, runId, socketPath, status: 'suspended', + suspension: result.suspension, completedSteps: result.journalSteps.length, + diagnostics: [...base.diagnostics, { + severity: 'warning', kind: 'subscription_suspended', + message: `Flow "${result.name}" suspended for ${result.suspension.kind}.`, + }], + }, + }; +} + export interface RunProgress { runId: string; stepId: string; @@ -191,6 +214,9 @@ export async function resumeFlow( lifecycle: options, }); if (result === undefined) throw new Error('authored root disappeared during resume'); + if (result.state === 'suspended') { + return suspendedExecution('resume', base, socketPath, runId, result); + } return { exitCode: result.completionReason === 'needs_human' ? 3 : 0, report: { diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 79990914d..a07c5814a 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -406,12 +406,17 @@ 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. */ + /** Prepare a body subscription. Cloud must activate the returned request before a body can observe it. */ subscriptionOpen(params: VerbContract['subscription.open']['params']): Promise { return this.request('subscription.open', params, null); } - /** Park for the next journaled subscription wake. */ + /** Commit Cloud's durable binding receipt and ingress fence. */ + subscriptionActivate(params: VerbContract['subscription.activate']['params']): Promise { + return this.request('subscription.activate', params, null); + } + + /** Return a durable wake, or an explicit suspension with no daemon-side sleep. */ subscriptionNext(params: VerbContract['subscription.next']['params']): Promise { return this.request('subscription.next', params, null); } diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 80fb9d49c..9840a7d59 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -55,6 +55,7 @@ export type Verb = | 'event.emit' | 'event.submit' | 'subscription.open' + | 'subscription.activate' | 'subscription.next' | 'subscription.close' | 'stream.append' @@ -377,7 +378,29 @@ export interface SubscriptionOpenParams { deadline_ms: number; include_self: boolean; } -export interface SubscriptionOpenResult { +export type SubscriptionOpenResult = + | { + state: 'prepared'; + subscription_id: string; + stream: string; + deadline_at_ms: number; + } + | { + state: 'active'; + subscription_id: string; + stream: string; + deadline_at_ms: number; + }; + +/** Cloud supplies this receipt only after it has durably fenced its binding. */ +export interface SubscriptionActivateParams { + run_id: string; + subscription_id: string; + ingress_offset: number; + router_binding: Record; +} +export interface SubscriptionActivateResult { + state: 'active'; subscription_id: string; stream: string; deadline_at_ms: number; @@ -390,6 +413,7 @@ export interface SubscriptionNextParams { acknowledge_wait_id?: string; } export type SubscriptionNextResult = + | { kind: 'suspended'; subscription_id: string; stream: string; deadline_at_ms: number } | { kind: 'events'; events: unknown[]; offset: number; acknowledge_wait_id?: string } | { kind: 'idle'; acknowledge_wait_id?: string } | { kind: 'deadline'; pending: { from: number; to: number } | null } @@ -449,6 +473,7 @@ export interface VerbContract { 'event.emit': { params: EventEmitParams; result: EventEmitResult }; 'event.submit': { params: EventSubmitParams; result: EventSubmitResult }; 'subscription.open': { params: SubscriptionOpenParams; result: SubscriptionOpenResult }; + 'subscription.activate': { params: SubscriptionActivateParams; result: SubscriptionActivateResult }; 'subscription.next': { params: SubscriptionNextParams; result: SubscriptionNextResult }; 'subscription.close': { params: SubscriptionCloseParams; result: SubscriptionCloseResult }; 'stream.append': { params: StreamAppendParams; result: StreamAppendResult }; diff --git a/packages/sdk/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts index 6fd82d681..56ac40196 100644 --- a/packages/sdk/tests/authored-activity.test.ts +++ b/packages/sdk/tests/authored-activity.test.ts @@ -19,7 +19,11 @@ describe('authored event activities', () => { 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 }); + if (params.run_id === 'root-prepared') { + sendResult(ctx, { state: 'prepared', subscription_id: params.subscription_id, stream: 'subscription/activity-1', deadline_at_ms: 99 }); + return; + } + sendResult(ctx, { state: 'active', subscription_id: params.subscription_id, stream: 'subscription/activity-1', deadline_at_ms: 99 }); }, 'subscription.next': (ctx, params) => { calls.push({ verb: 'subscription.next', params }); @@ -78,6 +82,24 @@ describe('authored event activities', () => { }), journal, undefined, { rootRunId: 'root-unbounded' })).rejects.toMatchObject({ code: 'unbounded_subscription' }); }); + it('surfaces the prepare handoff before the body can await an event', async () => { + calls.length = 0; + const journal = new JournalClient(path, { requestTimeoutMs: 2_000 }); + await journal.connect(); + await journal.hello('authored-activity-prepared-test'); + try { + await expect(executeAuthoredFlow(flow('prepared-activity', async (f) => { + const activity = f.on(webhook('pull_request'), { idle: '1h', deadline: '1d' }); + await activity.next(); + f.done('success'); + }), journal, undefined, { rootRunId: 'root-prepared' })) + .rejects.toMatchObject({ code: 'subscription_suspended', suspension: { + kind: 'activation', subscriptionId: 'activity-1', stream: 'subscription/activity-1', deadlineAtMs: 99, + } }); + expect(calls.map(call => call.verb)).toEqual(['subscription.open']); + } finally { journal.close(); } + }); + it('does not reopen a cursor after explicit close', async () => { calls.length = 0; const journal = new JournalClient(path, { requestTimeoutMs: 2_000 }); diff --git a/packages/sdk/tests/live-event-activities.test.ts b/packages/sdk/tests/live-event-activities.test.ts index 99b3c0a60..8986536cc 100644 --- a/packages/sdk/tests/live-event-activities.test.ts +++ b/packages/sdk/tests/live-event-activities.test.ts @@ -47,18 +47,31 @@ steps: instruction: park for activity `))); - const execution = executeAuthoredFlow(flow('surface-activity', async (f) => { + const definition = 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 }); + }); + + // The first body attempt writes only subscription.prepared and releases + // itself. The local test adapter performs the Cloud-owned binding handoff, + // then a resumed attempt sees the active cursor. + const preparedAttempt = executeAuthoredFlow(definition, bodyClient, undefined, { rootRunId: root.run_id }); + void preparedAttempt.catch(() => undefined); await waitFor(() => routerClient.journalRead(root.run_id, 1, 100).then(({ entries }) => - (entries as Array<{ entry_type?: string }>).some(entry => entry.entry_type === 'subscription.opened'), + (entries as Array<{ entry_type?: string }>).some(entry => entry.entry_type === 'subscription.prepared'), )); + await expect(preparedAttempt).rejects.toMatchObject({ code: 'subscription_suspended' }); + await routerClient.subscriptionActivate({ + run_id: root.run_id, subscription_id: 'activity-1', ingress_offset: 0, + router_binding: { transport: 'local-test-router', generation: 'test' }, + }); + const execution = executeAuthoredFlow(definition, bodyClient, undefined, { rootRunId: root.run_id }); + void execution.catch(() => undefined); expect((await routerClient.eventEmit( root.run_id, 'github_pull_request', { number: 42 }, { delivery_id: 'live-event-42', actor: 'reviewer' }, )).matched).toBe(1); @@ -85,6 +98,10 @@ steps: 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, }); + await before.subscriptionActivate({ + run_id: root.run_id, subscription_id: 'restart', ingress_offset: 0, + router_binding: { transport: 'local-test-router', generation: 'test' }, + }); expect((await before.eventEmit( root.run_id, 'github_pull_request', { number: 99 }, { delivery_id: 'kill-window-99', actor: 'reviewer' }, )).matched).toBe(1); From a2d3218d48933699fdaa8b69aebf0373ba118965 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 17 Sep 2026 22:02:57 +0200 Subject: [PATCH 21/34] feat: report immutable prepared event subscription facts Session-Id: 01a09c40-ce3b-7f11-a7df-b6b7ccab6fd9 --- docs/EVENT-AWAIT.md | 13 +++++-- .../src/engine/subscriptions/mod.rs | 39 +++++++++++++++---- kernel/relayflowd/tests/event_activities.rs | 22 +++++++++++ packages/sdk/src/authored-activity.ts | 5 ++- packages/sdk/src/authored-flow-error.ts | 30 +++++++++++--- packages/sdk/src/authored-node-runner.ts | 29 ++++++++++++-- packages/sdk/src/protocol.ts | 6 +++ packages/sdk/tests/authored-activity.test.ts | 15 +++++-- .../sdk/tests/authored-node-runtime.test.ts | 12 +++++- 9 files changed, 144 insertions(+), 27 deletions(-) diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index 2fa41a7f8..8dd968672 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -175,10 +175,15 @@ closed (decision 13). 1. **`subscription.prepared`** — `subscription_id` (deterministic from run id, step id and declaration), `event_types`, `pattern` (the recursive-subset match already used by `TriggerSpec.pattern`), `stream` - (`subscription/`), `deadline_at_ms`, and `include_self`. - It is an immutable request, never an open cursor and never eligible for - ingress. The daemon returns a `suspended` outcome at this boundary, so no - resident daemon thread waits for a Cloud binding. + (`subscription/`), `settle_ms`, `idle_ms`, + `deadline_at_ms`, and `include_self`. It is an immutable request, never an + open cursor and never eligible for ingress. The daemon returns a `suspended` + outcome at this boundary, carrying this exact prepared snapshot to Cloud + (`eventTypes`, canonical `pattern`, bounds, and `includeSelf`); Cloud never + parses the sandbox SQLite journal. Cloud assigns the binding generation and + ingress cursor after persisting its registry row, so those receipts are not + author-controlled suspension fields. No resident daemon thread waits for a + Cloud binding. 2. **`subscription.opened`** — the prepared request plus immutable provider binding: integration installation, canonical resource scope, authorization snapshot, router binding generation, and durable ingress offset. Cloud must diff --git a/kernel/relayflowd/src/engine/subscriptions/mod.rs b/kernel/relayflowd/src/engine/subscriptions/mod.rs index 7ad4fbca1..9bce1a593 100644 --- a/kernel/relayflowd/src/engine/subscriptions/mod.rs +++ b/kernel/relayflowd/src/engine/subscriptions/mod.rs @@ -46,7 +46,20 @@ pub enum SubscriptionNext { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] pub enum SubscriptionOpen { - Prepared { subscription_id: String, stream: String, deadline_at_ms: i64 }, + /// The exact immutable request that Cloud must bind before the body can + /// resume. These fields come from `subscription.prepared`, never from the + /// authored source on a retry, so Cloud need not parse a sandbox journal. + Prepared { + subscription_id: String, + event_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pattern: Option, + stream: String, + settle_ms: i64, + idle_ms: i64, + deadline_at_ms: i64, + include_self: bool, + }, Active { subscription_id: String, stream: String, deadline_at_ms: i64 }, } @@ -129,19 +142,29 @@ impl Engine { bail!("subscription {subscription_id} is closed") } if let Some(prepared) = prepared_subscriptions(&journal)?.remove(subscription_id) { - return Ok(SubscriptionOpen::Prepared { subscription_id: subscription_id.to_owned(), stream: prepared.stream, deadline_at_ms: prepared.deadline_at_ms }); + return Ok(SubscriptionOpen::Prepared { + subscription_id: subscription_id.to_owned(), event_types: prepared.event_types, + pattern: prepared.pattern, stream: prepared.stream, settle_ms: prepared.settle_ms, + idle_ms: prepared.idle_ms, deadline_at_ms: prepared.deadline_at_ms, + include_self: prepared.include_self, + }); } 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}"); + let prepared = SubscriptionPreparedPayload { + subscription_id: subscription_id.to_owned(), event_types, pattern, stream, + settle_ms, idle_ms, deadline_at_ms, include_self, + }; self.append(&mut journal, &JournalEntry::new( - EntryType::SubscriptionPrepared, run_id, None, None, now, - SubscriptionPreparedPayload { - subscription_id: subscription_id.to_owned(), event_types, pattern, stream: stream.clone(), - settle_ms, idle_ms, deadline_at_ms, include_self, - }, + EntryType::SubscriptionPrepared, run_id, None, None, now, prepared.clone(), ))?; - Ok(SubscriptionOpen::Prepared { subscription_id: subscription_id.to_owned(), stream, deadline_at_ms }) + Ok(SubscriptionOpen::Prepared { + subscription_id: prepared.subscription_id, event_types: prepared.event_types, + pattern: prepared.pattern, stream: prepared.stream, settle_ms: prepared.settle_ms, + idle_ms: prepared.idle_ms, deadline_at_ms: prepared.deadline_at_ms, + include_self: prepared.include_self, + }) } /// Commit the second half of the open handshake after Cloud has persisted diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs index cd30ef19a..c5c14ea4d 100644 --- a/kernel/relayflowd/tests/event_activities.rs +++ b/kernel/relayflowd/tests/event_activities.rs @@ -44,6 +44,28 @@ fn activate(engine: &Engine, run_id: &str, subscri ).unwrap(); } +#[test] +fn prepared_open_response_replays_the_immutable_binding_snapshot() { + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), TestClock::new(100)); + let run_id = parked_run(&engine); + let prepared = engine.open_subscription( + &run_id, "immutable", vec!["github.pull_request".into(), "github.issue".into()], + Some(json!({"action": "opened", "repository": {"id": 7}})), 50, 10, 1_000, true, + ).unwrap(); + assert_eq!(prepared, SubscriptionOpen::Prepared { + subscription_id: "immutable".into(), event_types: vec!["github.pull_request".into(), "github.issue".into()], + pattern: Some(json!({"action": "opened", "repository": {"id": 7}})), stream: "subscription/immutable".into(), + settle_ms: 50, idle_ms: 10, deadline_at_ms: 1_100, include_self: true, + }); + + // A process retry cannot silently substitute new authored source facts for + // the prepared record Cloud is about to bind. + assert_eq!(engine.open_subscription( + &run_id, "immutable", vec!["wrong.event".into()], None, 0, 99, 9_999, false, + ).unwrap(), prepared); +} + #[test] fn prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_suspends() { let directory = tempfile::tempdir().unwrap(); diff --git a/packages/sdk/src/authored-activity.ts b/packages/sdk/src/authored-activity.ts index 8d5cffb39..d6d6ae7ca 100644 --- a/packages/sdk/src/authored-activity.ts +++ b/packages/sdk/src/authored-activity.ts @@ -119,7 +119,10 @@ class JournalActivity implements OpenActivity { if (result.state === 'prepared') { throw suspended({ kind: 'activation', subscriptionId: result.subscription_id, - stream: result.stream, deadlineAtMs: result.deadline_at_ms, + eventTypes: Object.freeze([...result.event_types]), + ...(result.pattern === undefined ? {} : { pattern: Object.freeze({ ...result.pattern }) }), + stream: result.stream, settleMs: result.settle_ms, idleMs: result.idle_ms, + deadlineAtMs: result.deadline_at_ms, includeSelf: result.include_self, }, this.runId); } } diff --git a/packages/sdk/src/authored-flow-error.ts b/packages/sdk/src/authored-flow-error.ts index 76ab06b2c..b618da6b6 100644 --- a/packages/sdk/src/authored-flow-error.ts +++ b/packages/sdk/src/authored-flow-error.ts @@ -47,9 +47,27 @@ export class AuthoredFlowExecutionError extends Error { } /** Serialized into the CLI report so Cloud can atomically finish activation or wait for a wake. */ -export type AuthoredFlowSuspension = { - readonly kind: 'activation' | 'event_wait'; - readonly subscriptionId: string; - readonly stream: string; - readonly deadlineAtMs: number; -}; +export type AuthoredFlowSuspension = + /** + * Exact durable `subscription.prepared` facts Cloud must persist before it + * fences provider ingress and invokes `subscription.activate`. Binding + * generation and ingress cursor are Cloud-assigned receipts, deliberately + * absent from the authored request. + */ + | { + readonly kind: 'activation'; + readonly subscriptionId: string; + readonly eventTypes: readonly string[]; + readonly pattern?: Readonly>; + readonly stream: string; + readonly settleMs: number; + readonly idleMs: number; + readonly deadlineAtMs: number; + readonly includeSelf: boolean; + } + | { + readonly kind: 'event_wait'; + readonly subscriptionId: string; + readonly stream: string; + readonly deadlineAtMs: number; + }; diff --git a/packages/sdk/src/authored-node-runner.ts b/packages/sdk/src/authored-node-runner.ts index d0f4379f6..6ade83d41 100644 --- a/packages/sdk/src/authored-node-runner.ts +++ b/packages/sdk/src/authored-node-runner.ts @@ -157,11 +157,32 @@ export async function runAuthoredInNode( } function isSuspension(value: unknown): value is import('./authored-flow-error.js').AuthoredFlowSuspension { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const record = value as Record; + const common = typeof record['subscriptionId'] === 'string' && record['subscriptionId'].length > 0 + && typeof record['stream'] === 'string' && record['stream'].length > 0 + && Number.isSafeInteger(record['deadlineAtMs']) && (record['deadlineAtMs'] as number) >= 0; + if (!common) return false; + if (record['kind'] === 'event_wait') return true; + return record['kind'] === 'activation' + && Array.isArray(record['eventTypes']) && record['eventTypes'].length > 0 + && record['eventTypes'].every(type => typeof type === 'string' && type.length > 0) + && (record['pattern'] === undefined || isJsonRecord(record['pattern'])) + && Number.isSafeInteger(record['settleMs']) && (record['settleMs'] as number) >= 0 + && Number.isSafeInteger(record['idleMs']) && (record['idleMs'] as number) > 0 + && typeof record['includeSelf'] === 'boolean'; +} + +function isJsonRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) - && ((value as { kind?: unknown }).kind === 'activation' || (value as { kind?: unknown }).kind === 'event_wait') - && typeof (value as { subscriptionId?: unknown }).subscriptionId === 'string' - && typeof (value as { stream?: unknown }).stream === 'string' - && typeof (value as { deadlineAtMs?: unknown }).deadlineAtMs === 'number'; + && Object.values(value).every(isJsonValue); +} + +function isJsonValue(value: unknown): boolean { + return value === null || typeof value === 'boolean' || typeof value === 'string' + || (typeof value === 'number' && Number.isFinite(value)) + || (Array.isArray(value) && value.every(isJsonValue)) + || isJsonRecord(value); } /** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */ diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 9840a7d59..8924bc903 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -380,10 +380,16 @@ export interface SubscriptionOpenParams { } export type SubscriptionOpenResult = | { + /** Immutable snapshot from the durable `subscription.prepared` entry. */ state: 'prepared'; subscription_id: string; + event_types: string[]; + pattern?: Record; stream: string; + settle_ms: number; + idle_ms: number; deadline_at_ms: number; + include_self: boolean; } | { state: 'active'; diff --git a/packages/sdk/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts index 56ac40196..bf2726fd4 100644 --- a/packages/sdk/tests/authored-activity.test.ts +++ b/packages/sdk/tests/authored-activity.test.ts @@ -20,7 +20,12 @@ describe('authored event activities', () => { 'subscription.open': (ctx, params) => { calls.push({ verb: 'subscription.open', params }); if (params.run_id === 'root-prepared') { - sendResult(ctx, { state: 'prepared', subscription_id: params.subscription_id, stream: 'subscription/activity-1', deadline_at_ms: 99 }); + sendResult(ctx, { + state: 'prepared', subscription_id: params.subscription_id, + event_types: params.event_types, ...(params.pattern === undefined ? {} : { pattern: params.pattern }), + stream: 'subscription/activity-1', settle_ms: params.settle_ms, idle_ms: params.idle_ms, + deadline_at_ms: 99, include_self: params.include_self, + }); return; } sendResult(ctx, { state: 'active', subscription_id: params.subscription_id, stream: 'subscription/activity-1', deadline_at_ms: 99 }); @@ -89,12 +94,16 @@ describe('authored event activities', () => { await journal.hello('authored-activity-prepared-test'); try { await expect(executeAuthoredFlow(flow('prepared-activity', async (f) => { - const activity = f.on(webhook('pull_request'), { idle: '1h', deadline: '1d' }); + const activity = f.on(webhook('pull_request', { action: 'opened', repository: { id: 7 } }), { + settle: '2m', idle: '1h', deadline: '1d', includeSelf: true, + }); await activity.next(); f.done('success'); }), journal, undefined, { rootRunId: 'root-prepared' })) .rejects.toMatchObject({ code: 'subscription_suspended', suspension: { - kind: 'activation', subscriptionId: 'activity-1', stream: 'subscription/activity-1', deadlineAtMs: 99, + kind: 'activation', subscriptionId: 'activity-1', eventTypes: ['pull_request'], + pattern: { action: 'opened', repository: { id: 7 } }, stream: 'subscription/activity-1', + settleMs: 120_000, idleMs: 3_600_000, deadlineAtMs: 99, includeSelf: true, } }); expect(calls.map(call => call.verb)).toEqual(['subscription.open']); } finally { journal.close(); } diff --git a/packages/sdk/tests/authored-node-runtime.test.ts b/packages/sdk/tests/authored-node-runtime.test.ts index 9c0b6c628..441a3e68d 100644 --- a/packages/sdk/tests/authored-node-runtime.test.ts +++ b/packages/sdk/tests/authored-node-runtime.test.ts @@ -66,7 +66,7 @@ if(request){appendFileSync('agent-effects','once\\n');await new Promise(r=>setTi `); chmodSync(wrapper, 0o755); writeFileSync(join(directory, 'flows.json'), JSON.stringify({ cli: wrapper })); - writeFileSync(join(directory, 'case.flow.ts'), `import {flow} from '@relayflows/surface'; + writeFileSync(join(directory, 'case.flow.ts'), `import {flow,webhook} from '@relayflows/surface'; import {appendFileSync,existsSync,writeFileSync,writeSync} from 'node:fs'; export default flow('runtime-case',async f=>{${body}}); `); @@ -92,6 +92,16 @@ async function entries(directory: string, runId: string) { } describe('Bun 1.4.0 standalone → native Node authored lifecycle', () => { + it('serializes the immutable prepared binding facts through the Node and CLI boundary', () => { + const f = fixture(`const activity=f.on(webhook('github_pull_request',{action:'opened',repository:{id:7}}),{settle:'2m',idle:'1h',deadline:'1d',includeSelf:true});await activity.next();f.done('success');`); + const result = f.run(); expect(result.status, result.stderr + result.stdout).toBe(4); + expect(JSON.parse(result.stdout)).toMatchObject({ ok: false, status: 'suspended', suspension: { + kind: 'activation', subscriptionId: 'activity-1', eventTypes: ['github_pull_request'], + pattern: { action: 'opened', repository: { id: 7 } }, settleMs: 120_000, + idleMs: 3_600_000, includeSelf: true, + } }); + }, 60_000); + it('awaits agent plus three run steps and resumes without repeating effects', async () => { const f = fixture(sequential + `f.done('success');`); const first = f.run(); expect(first.status, first.stderr + first.stdout).toBe(0); From f557189c263631535d28582253a360386247da27 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 20:48:52 -0700 Subject: [PATCH 22/34] docs(events): replace stale handoff claims with captured CLI evidence --- .../event-await-implementation/README.md | 211 ++++++++---------- .../audit-pass-1.md | 122 ---------- .../audit-pass-2.md | 162 -------------- .../kernel-daemon.md | 94 -------- .../event-await-implementation/surface-sdk.md | 87 -------- .../src/engine/subscriptions/parking.rs | 112 +++++++--- .../src/engine/subscriptions/replay.rs | 29 ++- .../src/engine/subscriptions/state.rs | 169 +++++++++++--- .../tests/event_activity_parking.rs | 174 ++++++++++++--- ops/event-await-overnight/README.md | 9 +- 10 files changed, 488 insertions(+), 681 deletions(-) delete mode 100644 docs/evidence/event-await-implementation/audit-pass-1.md delete mode 100644 docs/evidence/event-await-implementation/audit-pass-2.md delete mode 100644 docs/evidence/event-await-implementation/kernel-daemon.md delete mode 100644 docs/evidence/event-await-implementation/surface-sdk.md diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md index 11c1a8a8d..dcc3456e8 100644 --- a/docs/evidence/event-await-implementation/README.md +++ b/docs/evidence/event-await-implementation/README.md @@ -1,141 +1,110 @@ -# Event-await local implementation evidence +# Event-await verification, 2026-09-19 -Implementation commits: `ec4345a8c474bbe16b9727e5a0d2dd874e399569` and -`6e8c3cb697e911ddcba89999bd58bf3b0f603228`. +This replaces the earlier implementation notes and inconsistent test transcript. +The current protocol and its Cloud integration boundary are specified in +[EVENT-AWAIT.md](../../EVENT-AWAIT.md). -## Scope and acceptance map +The handshake is `subscription.open` → `subscription.prepared` → Cloud persists +its binding/cursor → `subscription.activate` → `subscription.opened`. +`subscription.park` releases the root lease on a durable wait. A local router +adapter supplies events for the CLI probe; it does not establish a deployed +Cloud provider path. Cloud binding/authorization, wake scheduling, and epoch +compaction acceptance remain unverified. No complete acceptance claim is made. -`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: +## Kernel parking and replay ```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): +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo test --locked -p relayflowd --test event_activity_parking + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.48s + Running tests/event_activity_parking.rs (target/debug/deps/event_activity_parking-d8e0d6eeb684776f) -```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: +running 3 tests +test replay_keeps_each_acknowledged_batch_addressable_by_body_call_ordinal ... ok +test activation_and_delivery_racing_the_lease_handoff_are_not_lost ... ok +test parked_attempt_survives_restart_and_only_a_ready_subscription_redispatches_it ... ok -```text -$ tsc --noEmit && tsc -p tsconfig.type-tests.json -$ tsc && node scripts/make-cli-executable.mjs -$ tsc -p tsconfig.tests.json +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s - 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 +exit status: 0 - 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 +## SDK types and targeted integration/runtime tests -Command (exit 0): +```text +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ sh -c 'export PATH=/tmp/flows-pr-cleanup/toolchain/node_modules/node/bin:/tmp/flows-pr-cleanup/toolchain/node_modules/.bin:$PATH RELAYFLOWD_BIN=/tmp/flows-pr-followup/pr441/kernel/target/debug/relayflowd; npm run typecheck && npm run typecheck:tests && npx vitest run tests/authored-activity.test.ts tests/activity-preflight.test.ts tests/live-event-activities.test.ts tests/event-await-cli.test.ts tests/authored-root.test.ts tests/authored-human.test.ts tests/authored-node-runtime.test.ts tests/journal-client.test.ts' + +> @relayflows/sdk@2.0.22 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +> @relayflows/sdk@2.0.22 typecheck:tests +> tsc -p tsconfig.tests.json + + + RUN v2.1.9 /tmp/flows-pr-followup/pr441/packages/sdk + + ✓ tests/journal-client.test.ts (15 tests) 90ms + ✓ tests/activity-preflight.test.ts (1 test) 9ms + ✓ tests/authored-activity.test.ts (13 tests) 93ms + ✓ tests/authored-human.test.ts (13 tests) 115ms + ✓ tests/authored-root.test.ts (12 tests) 174ms + ✓ tests/live-event-activities.test.ts (2 tests) 186ms + ✓ tests/event-await-cli.test.ts (1 test) 9603ms + ✓ parks, restarts, and replays two event wakes through the actual CLI 9602ms + ✓ tests/authored-node-runtime.test.ts (14 tests) 73430ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > serializes the immutable prepared binding facts through the Node and CLI boundary 1284ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > awaits agent plus three run steps and resumes without repeating effects 1899ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > accepts a predicate-gated flow: the `.gate` child is journaled, verified, and not counted as an authored step 1918ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > parks an f.human across the IPC boundary, answers it, and resumes the Node body with the answer 2976ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent SIGKILL and replays completed children before success 2388ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent SIGTERM and replays completed children before success 2502ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent blocked-SIGKILL and replays completed children before success 2469ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent SIGKILL and replays completed children before declined 2294ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses unawaited rather than reporting terminal success 13458ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses manual then rather than reporting terminal success 14316ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > loads captured graph bytes before preserving the unsupported-use refusal 13186ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > rejects a forged result frame without durable completion 12851ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses missing Node before body effects or root admission 412ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses an old Node candidate before body effects 462ms + + Test Files 8 passed (8) + Tests 71 passed (71) + Start at 20:41:12 + Duration 74.60s (transform 1.12s, setup 0ms, collect 6.05s, tests 83.70s, environment 2ms, prepare 545ms) + + +exit status: 0 -```sh -cd packages/surface && PATH=/Users/khaliqgant/.bun/bin:$PATH /Users/khaliqgant/.bun/bin/bun run test ``` -Captured output: +## Actual CLI restart and replay probe ```text -$ bun run build && tsc -p tsconfig.test.json && vitest run -$ tsc +cwd: /tmp/flows-pr-followup/pr441 +$ env RELAYFLOWD_BIN=/tmp/flows-pr-followup/pr441/kernel/target/debug/relayflowd /tmp/flows-pr-cleanup/toolchain/node_modules/node/bin/node packages/sdk/tests/fixtures/event-await-cli-probe.mjs /tmp/flows-pr-followup/pr441 +{"args":["run","await.flow.ts","--input","{}"],"status":4,"stdout":"{\"ok\":false,\"command\":\"run\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"path\":\"await.flow.ts\",\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"eventTypes\":[\"e2e_event\"],\"stream\":\"subscription/activity-1\",\"settleMs\":0,\"idleMs\":3600000,\"deadlineAtMs\":1789962028741,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for activation.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"activation\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741,\"eventTypes\":[\"e2e_event\"],\"settleMs\":0,\"idleMs\":3600000,\"includeSelf\":false},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for activation.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for event_wait.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"event_wait\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for event_wait.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for event_wait.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"event_wait\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for event_wait.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":4,"stdout":"{\"ok\":false,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[{\"severity\":\"warning\",\"kind\":\"subscription_suspended\",\"message\":\"Flow \\\"event-await-cli\\\" suspended for event_wait.\"}],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"status\":\"suspended\",\"suspension\":{\"kind\":\"event_wait\",\"subscriptionId\":\"activity-1\",\"stream\":\"subscription/activity-1\",\"deadlineAtMs\":1789962028741},\"completedSteps\":0}\n","stderr":"WARNING [subscription_suspended] Flow \"event-await-cli\" suspended for event_wait.\n"} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":0,"stdout":"{\"ok\":true,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"completedSteps\":4,\"status\":\"completed\",\"completionReason\":\"success\"}\n","stderr":""} +{"args":["resume","01M2YEDANJN68QR2M0HYGBQSC5"],"status":0,"stdout":"{\"ok\":true,\"command\":\"resume\",\"resolutions\":[],\"diagnostics\":[],\"runId\":\"01M2YEDANJN68QR2M0HYGBQSC5\",\"socketPath\":\"/run/user/1000/relayflowd-048d675f69db.sock\",\"completedSteps\":4,\"status\":\"completed\",\"completionReason\":\"success\"}\n","stderr":""} +E2E_PASS: repeated park, SIGKILL/restart, two wakes replayed in order, deduped delivery, exactly-once child effects, zero crash retries + +exit status: 0 - 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 deleted file mode 100644 index 3043d99ad..000000000 --- a/docs/evidence/event-await-implementation/audit-pass-1.md +++ /dev/null @@ -1,122 +0,0 @@ -# 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 deleted file mode 100644 index 018ec888d..000000000 --- a/docs/evidence/event-await-implementation/audit-pass-2.md +++ /dev/null @@ -1,162 +0,0 @@ -# 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 deleted file mode 100644 index 66f81de52..000000000 --- a/docs/evidence/event-await-implementation/kernel-daemon.md +++ /dev/null @@ -1,94 +0,0 @@ -# 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 deleted file mode 100644 index 07e333062..000000000 --- a/docs/evidence/event-await-implementation/surface-sdk.md +++ /dev/null @@ -1,87 +0,0 @@ -# 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/src/engine/subscriptions/parking.rs b/kernel/relayflowd/src/engine/subscriptions/parking.rs index ff63ab449..73a3f7435 100644 --- a/kernel/relayflowd/src/engine/subscriptions/parking.rs +++ b/kernel/relayflowd/src/engine/subscriptions/parking.rs @@ -2,20 +2,28 @@ use std::collections::BTreeMap; use anyhow::{Context, Result, bail}; -use relayflowd_core::{Clock, EntryType, JournalEntry, StepState, WaitCompletedPayload, - WaitCompletionReason, WaitEventPayload}; +use relayflowd_core::{ + Clock, EntryType, JournalEntry, StepState, WaitCompletedPayload, WaitCompletionReason, + WaitEventPayload, +}; use relayflowd_journal::SqliteJournal; use serde::{Deserialize, Serialize}; use serde_json::json; -use super::{Engine, state::{prepared_subscriptions, subscriptions}}; +use super::{ + Engine, + state::{prepared_subscriptions, subscriptions}, +}; use crate::engine::{DriveOptions, RunOutcome}; pub const PARK_PREFIX: &str = "subscription.park:"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum SubscriptionWaitPhase { Activation, EventWait } +pub enum SubscriptionWaitPhase { + Activation, + EventWait, +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -26,8 +34,12 @@ pub struct SubscriptionPark { impl Engine { pub fn park_subscription_step( - &self, run_id: &str, step_id: &str, attempt: u32, - idempotency_key: &str, park: SubscriptionPark, + &self, + run_id: &str, + step_id: &str, + attempt: u32, + idempotency_key: &str, + park: SubscriptionPark, ) -> Result { let mut journal = self.open_run(run_id)?; let spec = journal.run_spec()?; @@ -35,33 +47,63 @@ impl Engine { if state.cancel_requested.is_some() || state.completion.is_some() { bail!("run {run_id} no longer accepts subscription waits"); } - let runtime = state.steps.get(step_id).context("unknown subscription waiter step")?; + let runtime = state + .steps + .get(step_id) + .context("unknown subscription waiter step")?; if !matches!(&runtime.state, StepState::Running { attempt: active, idempotency_key: key, .. } - if *active == attempt && key == idempotency_key) { + if *active == attempt && key == idempotency_key) + { bail!("subscription wait does not match the active lease"); } let prepared = prepared_subscriptions(&journal)?; let active = subscriptions(&journal)?; let known = match park.phase { - SubscriptionWaitPhase::Activation => prepared.contains_key(&park.subscription_id) - || active.contains_key(&park.subscription_id), - SubscriptionWaitPhase::EventWait => active.get(&park.subscription_id) - .is_some_and(|s| s.active_wait.is_some() || s.ready.is_some() || s.closed.is_some()), + SubscriptionWaitPhase::Activation => { + prepared.contains_key(&park.subscription_id) + || active.contains_key(&park.subscription_id) + } + SubscriptionWaitPhase::EventWait => { + active.get(&park.subscription_id).is_some_and(|s| { + s.active_wait.is_some() || s.ready.is_some() || s.closed.is_some() + }) + } }; - if !known { bail!("subscription has no durable boundary to park on"); } + if !known { + bail!("subscription has no durable boundary to park on"); + } let event_key = format!("{PARK_PREFIX}{}", serde_json::to_string(&park)?); let wait_id = format!("{step_id}/subscription-park/{attempt}"); - self.append(&mut journal, &JournalEntry::new( - EntryType::WaitEvent, run_id, Some(step_id.to_owned()), Some(attempt), self.clock.now_ms(), - WaitEventPayload { wait_id, event_key, timeout_at_ms: None, stream: None, - from_offset: None, settle_ms: None, idle_at_ms: None, deadline_at_ms: None }, - ))?; + self.append( + &mut journal, + &JournalEntry::new( + EntryType::WaitEvent, + run_id, + Some(step_id.to_owned()), + Some(attempt), + self.clock.now_ms(), + WaitEventPayload { + wait_id, + event_key, + timeout_at_ms: None, + stream: None, + from_offset: None, + settle_ms: None, + idle_at_ms: None, + deadline_at_ms: None, + }, + ), + )?; // Do not redispatch to the departing worker, even if activation or a // delivery raced the handoff. Resume reconciles the recorded boundary. self.drive(journal, spec, DriveOptions::default()) } - pub(super) fn wake_subscription_steps(&self, journal: &mut SqliteJournal, now: i64) -> Result { + pub(super) fn wake_subscription_steps( + &self, + journal: &mut SqliteJournal, + now: i64, + ) -> Result { let mut parked = BTreeMap::new(); for entry in journal.scan_all()? { match entry.entry_type { @@ -82,16 +124,30 @@ impl Engine { let active = subscriptions(journal)?; let mut count = 0; for (wait_id, (step_id, attempt, park)) in parked { - let ready = active.get(&park.subscription_id).is_some_and(|state| match park.phase { - SubscriptionWaitPhase::Activation => true, - SubscriptionWaitPhase::EventWait => state.ready.is_some() || state.closed.is_some(), - }); + let ready = active + .get(&park.subscription_id) + .is_some_and(|state| match park.phase { + SubscriptionWaitPhase::Activation => true, + SubscriptionWaitPhase::EventWait => { + state.ready.is_some() || state.closed.is_some() + } + }); if ready { - self.append(journal, &JournalEntry::new( - EntryType::WaitCompleted, journal.run_id(), step_id, attempt, now, - WaitCompletedPayload { wait_id, completion_reason: WaitCompletionReason::EventReceived, - result: json!({"subscription_id": park.subscription_id}) }, - ))?; + self.append( + journal, + &JournalEntry::new( + EntryType::WaitCompleted, + journal.run_id(), + step_id, + attempt, + now, + WaitCompletedPayload { + wait_id, + completion_reason: WaitCompletionReason::EventReceived, + result: json!({"subscription_id": park.subscription_id}), + }, + ), + )?; count += 1; } } diff --git a/kernel/relayflowd/src/engine/subscriptions/replay.rs b/kernel/relayflowd/src/engine/subscriptions/replay.rs index fff7fef8e..e090f8556 100644 --- a/kernel/relayflowd/src/engine/subscriptions/replay.rs +++ b/kernel/relayflowd/src/engine/subscriptions/replay.rs @@ -1,30 +1,45 @@ //! A body re-executes from the beginning; each pull must replay its own wake. -use anyhow::{Result, Context, bail}; +use anyhow::{Context, Result, bail}; use relayflowd_core::{Clock, EntryType, WaitCompletedPayload}; -use super::{Engine, SubscriptionWake, state::{subscriptions, wake_from_completed}}; +use super::{ + Engine, SubscriptionWake, + state::{subscriptions, wake_from_completed}, +}; impl Engine { pub fn replay_subscription_wake( - &self, run_id: &str, subscription_id: &str, sequence: u64, + &self, + run_id: &str, + subscription_id: &str, + sequence: u64, ) -> Result)>> { let journal = self.open_run(run_id)?; let states = subscriptions(&journal)?; - let state = states.get(subscription_id).context("unknown subscription")?; + let state = states + .get(subscription_id) + .context("unknown subscription")?; let wait_id = format!("{subscription_id}/next/{sequence}"); for entry in journal.scan_all()? { if entry.entry_type == EntryType::WaitCompleted { let completed: WaitCompletedPayload = serde_json::from_value(entry.payload)?; if completed.wait_id == wait_id { let wake = wake_from_completed(&journal, state, &completed)?; - let receipt = matches!(wake, SubscriptionWake::Events { .. } | SubscriptionWake::Idle) - .then(|| wait_id.clone()); + let receipt = matches!( + wake, + SubscriptionWake::Events { .. } | SubscriptionWake::Idle + ) + .then(|| wait_id.clone()); return Ok(Some((wake, receipt))); } } } if sequence != state.next_wait_sequence - && !state.active_wait.as_ref().is_some_and(|wait| wait.wait_id == wait_id) { + && !state + .active_wait + .as_ref() + .is_some_and(|wait| wait.wait_id == wait_id) + { bail!("subscription pull sequence has no replayable wake"); } Ok(None) diff --git a/kernel/relayflowd/src/engine/subscriptions/state.rs b/kernel/relayflowd/src/engine/subscriptions/state.rs index 6da7a0b6e..5ab30843d 100644 --- a/kernel/relayflowd/src/engine/subscriptions/state.rs +++ b/kernel/relayflowd/src/engine/subscriptions/state.rs @@ -13,33 +13,59 @@ use serde_json::Value; use super::{PendingRange, SubscriptionState, SubscriptionWake}; -pub(super) fn subscriptions(journal: &SqliteJournal) -> Result> { +pub(super) 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 }); + 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); } + 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); } + 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)?; + 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); } + 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) { + 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); } @@ -48,7 +74,11 @@ pub(super) fn subscriptions(journal: &SqliteJournal) -> Result { 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) { + 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()); @@ -64,7 +94,9 @@ pub(super) fn subscriptions(journal: &SqliteJournal) -> Result Result> { +pub(super) fn prepared_subscriptions( + journal: &SqliteJournal, +) -> Result> { let mut prepared = BTreeMap::new(); for entry in journal.scan_all()? { match entry.entry_type { @@ -73,7 +105,10 @@ pub(super) fn prepared_subscriptions(journal: &SqliteJournal) -> Result { - let id = entry.payload.get("subscription_id").and_then(Value::as_str) + let id = entry + .payload + .get("subscription_id") + .and_then(Value::as_str) .context("subscription lifecycle entry has no subscription_id")?; prepared.remove(id); } @@ -86,13 +121,28 @@ pub(super) fn prepared_subscriptions(journal: &SqliteJournal) -> Result 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), + 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), } } -pub(super) fn unread_frames(entries: &[JournalEntry], state: &SubscriptionState) -> Result> { +pub(super) 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) { + 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 { @@ -103,13 +153,39 @@ pub(super) fn unread_frames(entries: &[JournalEntry], state: &SubscriptionState) } pub(super) 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) + 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) } -pub(super) 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) }) +pub(super) 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), + }) } -pub(super) fn wake_from_completed(journal: &SqliteJournal, state: &SubscriptionState, completed: &WaitCompletedPayload) -> Result { +pub(super) 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(); @@ -121,13 +197,52 @@ pub(super) fn wake_from_completed(journal: &SqliteJournal, state: &SubscriptionS } 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()) }), + 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 }) + 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, + }) +} +pub(super) fn unread_bytes(unread: &[(JournalEntry, StreamAppendedPayload)]) -> usize { + unread + .iter() + .filter_map(|(_, append)| serde_json::to_vec(&append.message).ok()) + .map(|bytes| bytes.len()) + .sum() +} +pub(super) fn pending(unread: &[(JournalEntry, StreamAppendedPayload)]) -> Option { + Some(PendingRange { + from: unread.first()?.1.offset, + to: unread.last()?.1.offset.saturating_add(1), + }) } -pub(super) fn unread_bytes(unread: &[(JournalEntry, StreamAppendedPayload)]) -> usize { unread.iter().filter_map(|(_, append)| serde_json::to_vec(&append.message).ok()).map(|bytes| bytes.len()).sum() } -pub(super) 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/tests/event_activity_parking.rs b/kernel/relayflowd/tests/event_activity_parking.rs index 066a79272..8d1f85ab0 100644 --- a/kernel/relayflowd/tests/event_activity_parking.rs +++ b/kernel/relayflowd/tests/event_activity_parking.rs @@ -1,15 +1,24 @@ -use relayflowd::{Engine, engine::{SubscriptionPark, SubscriptionWaitPhase, StepStatus}, - worker::{DispatchOutcome, JournalObserver, StepDispatch, StepDispatcher}}; +use relayflowd::{ + Engine, + engine::{StepStatus, SubscriptionPark, SubscriptionWaitPhase}, + worker::{DispatchOutcome, JournalObserver, StepDispatch, StepDispatcher}, +}; use relayflowd_core::{EntryType, JournalEntry, RunSpec, SimClock, StepType}; use serde_json::json; use std::sync::{Arc, Mutex}; #[derive(Default)] struct Worker(Mutex>); -impl JournalObserver for Worker { fn appended(&self, _: &JournalEntry) {} } +impl JournalObserver for Worker { + fn appended(&self, _: &JournalEntry) {} +} impl StepDispatcher for Worker { - fn executor(&self, _: StepType) -> Option { Some("test".into()) } - fn available(&self, _: StepType) -> bool { true } + fn executor(&self, _: StepType) -> Option { + Some("test".into()) + } + fn available(&self, _: StepType) -> bool { + true + } fn dispatch(&self, dispatch: StepDispatch) -> anyhow::Result { self.0.lock().unwrap().push(dispatch); Ok(DispatchOutcome::Dispatched) @@ -35,38 +44,102 @@ fn check_parking(race: bool) { let worker = Arc::new(Worker::default()); let engine = Engine::with_runtime(directory.path(), worker.clone(), worker.clone()); let id = engine.start(spec(), "test", None).unwrap().run_id; - engine.open_subscription(&id, "events", vec!["test".into()], None, 0, 60_000, 3_600_000, false).unwrap(); + engine + .open_subscription( + &id, + "events", + vec!["test".into()], + None, + 0, + 60_000, + 3_600_000, + false, + ) + .unwrap(); let dispatch = worker.0.lock().unwrap()[0].clone(); - let park = SubscriptionPark { subscription_id: "events".into(), phase: SubscriptionWaitPhase::Activation }; + let park = SubscriptionPark { + subscription_id: "events".into(), + phase: SubscriptionWaitPhase::Activation, + }; let before = engine.journal_entries(&id, 1, 500).unwrap().len(); - assert!(engine.park_subscription_step(&id, "body", dispatch.attempt, "wrong-key", park.clone()).is_err()); + assert!( + engine + .park_subscription_step(&id, "body", dispatch.attempt, "wrong-key", park.clone()) + .is_err() + ); assert_eq!(engine.journal_entries(&id, 1, 500).unwrap().len(), before); - if race { engine.activate_subscription(&id, "events", 0, json!({"generation":1})).unwrap(); } - engine.park_subscription_step(&id, "body", dispatch.attempt, &dispatch.idempotency_key, park).unwrap(); - assert_eq!(engine.snapshot(&id).unwrap().steps["body"].state, StepStatus::Waiting); - assert_eq!(engine.snapshot(&id).unwrap().steps["body"].lease_deadline_ms, None); + if race { + engine + .activate_subscription(&id, "events", 0, json!({"generation":1})) + .unwrap(); + } + engine + .park_subscription_step( + &id, + "body", + dispatch.attempt, + &dispatch.idempotency_key, + park, + ) + .unwrap(); + assert_eq!( + engine.snapshot(&id).unwrap().steps["body"].state, + StepStatus::Waiting + ); + assert_eq!( + engine.snapshot(&id).unwrap().steps["body"].lease_deadline_ms, + None + ); drop(engine); let engine = Engine::with_runtime(directory.path(), worker.clone(), worker.clone()); if !race { - for _ in 0..10 { engine.resume(&id, None).unwrap(); } + for _ in 0..10 { + engine.resume(&id, None).unwrap(); + } assert_eq!(worker.0.lock().unwrap().len(), 1); - engine.activate_subscription(&id, "events", 0, json!({"generation":1})).unwrap(); + engine + .activate_subscription(&id, "events", 0, json!({"generation":1})) + .unwrap(); } engine.resume(&id, None).unwrap(); assert_eq!(worker.0.lock().unwrap().len(), 2); - engine.next_subscription_outcome(&id, "events", None).unwrap(); + engine + .next_subscription_outcome(&id, "events", None) + .unwrap(); if race { - engine.append_subscription_frame(&id, "events", "first", json!({"payload":1})).unwrap(); + engine + .append_subscription_frame(&id, "events", "first", json!({"payload":1})) + .unwrap(); engine.claim_subscription_timeouts(&id).unwrap(); } let dispatch = worker.0.lock().unwrap()[1].clone(); - engine.park_subscription_step(&id, "body", dispatch.attempt, &dispatch.idempotency_key, - SubscriptionPark { subscription_id: "events".into(), phase: SubscriptionWaitPhase::EventWait }).unwrap(); - if !race { engine.append_subscription_frame(&id, "events", "first", json!({"payload":1})).unwrap(); } + engine + .park_subscription_step( + &id, + "body", + dispatch.attempt, + &dispatch.idempotency_key, + SubscriptionPark { + subscription_id: "events".into(), + phase: SubscriptionWaitPhase::EventWait, + }, + ) + .unwrap(); + if !race { + engine + .append_subscription_frame(&id, "events", "first", json!({"payload":1})) + .unwrap(); + } engine.resume(&id, None).unwrap(); assert_eq!(worker.0.lock().unwrap().len(), 3); - assert!(!engine.journal_entries(&id, 1, 500).unwrap().iter().any(|entry| - entry.entry_type == EntryType::StepCompleted && entry.payload["completionReason"] == "crashed")); + assert!( + !engine + .journal_entries(&id, 1, 500) + .unwrap() + .iter() + .any(|entry| entry.entry_type == EntryType::StepCompleted + && entry.payload["completionReason"] == "crashed") + ); } #[test] @@ -74,16 +147,55 @@ fn replay_keeps_each_acknowledged_batch_addressable_by_body_call_ordinal() { let directory = tempfile::tempdir().unwrap(); let engine = Engine::with_clock(directory.path(), SimClock::new(0)); let id = engine.start(spec(), "test", None).unwrap().run_id; - engine.open_subscription(&id, "events", vec!["test".into()], None, 0, 60_000, 3_600_000, false).unwrap(); - engine.activate_subscription(&id, "events", 0, json!({"generation":1})).unwrap(); - engine.append_subscription_frame(&id, "events", "first", json!({"payload":1})).unwrap(); - let first = engine.next_subscription_outcome(&id, "events", None).unwrap(); - engine.next_subscription_outcome(&id, "events", first.1.as_deref()).unwrap(); - engine.append_subscription_frame(&id, "events", "second", json!({"payload":2})).unwrap(); - engine.next_subscription_outcome(&id, "events", None).unwrap(); - let first_replayed = engine.replay_subscription_wake(&id, "events", 0).unwrap().unwrap(); + engine + .open_subscription( + &id, + "events", + vec!["test".into()], + None, + 0, + 60_000, + 3_600_000, + false, + ) + .unwrap(); + engine + .activate_subscription(&id, "events", 0, json!({"generation":1})) + .unwrap(); + engine + .append_subscription_frame(&id, "events", "first", json!({"payload":1})) + .unwrap(); + let first = engine + .next_subscription_outcome(&id, "events", None) + .unwrap(); + engine + .next_subscription_outcome(&id, "events", first.1.as_deref()) + .unwrap(); + engine + .append_subscription_frame(&id, "events", "second", json!({"payload":2})) + .unwrap(); + engine + .next_subscription_outcome(&id, "events", None) + .unwrap(); + let first_replayed = engine + .replay_subscription_wake(&id, "events", 0) + .unwrap() + .unwrap(); assert_eq!(first_replayed.1, first.1); - assert_eq!(serde_json::to_value(first_replayed.0).unwrap()["events"], json!([{"payload":1}])); - assert_eq!(serde_json::to_value(engine.replay_subscription_wake(&id, "events", 1).unwrap().unwrap().0).unwrap()["events"], json!([{"payload":2}])); + assert_eq!( + serde_json::to_value(first_replayed.0).unwrap()["events"], + json!([{"payload":1}]) + ); + assert_eq!( + serde_json::to_value( + engine + .replay_subscription_wake(&id, "events", 1) + .unwrap() + .unwrap() + .0 + ) + .unwrap()["events"], + json!([{"payload":2}]) + ); assert!(engine.replay_subscription_wake(&id, "events", 99).is_err()); } diff --git a/ops/event-await-overnight/README.md b/ops/event-await-overnight/README.md index ca6c0d093..a882a322b 100644 --- a/ops/event-await-overnight/README.md +++ b/ops/event-await-overnight/README.md @@ -1,7 +1,7 @@ # 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 +the proposed `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. @@ -11,7 +11,12 @@ 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", + "repoRoot": "/absolute/path/to/an/isolated/flows/worktree", "auditPasses": 2 }' ``` + +This is the historical implementation driver, not the acceptance flow for this +PR. Its pinned SDK dependencies describe that driver’s execution environment. +Use the current CLI probe documented in `docs/EVENT-AWAIT.md` to verify the +local event-wait path. From e8d3c3158acab878ab6a6079d7229a92b9cd8911 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 20:51:40 -0700 Subject: [PATCH 23/34] fix(events): isolate subscription waits and record cancellation correctly --- .../event-await-implementation/README.md | 38 ++++++++++ kernel/relayflowd/src/engine/remote.rs | 4 +- .../src/engine/subscriptions/local_router.rs | 58 +++++++++++++++ .../src/engine/subscriptions/mod.rs | 62 +++------------- .../src/engine/subscriptions/wait_timers.rs | 59 +++++++++++++++ .../tests/event_activity_parking.rs | 72 +++++++++++++++++++ 6 files changed, 240 insertions(+), 53 deletions(-) create mode 100644 kernel/relayflowd/src/engine/subscriptions/local_router.rs create mode 100644 kernel/relayflowd/src/engine/subscriptions/wait_timers.rs diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md index dcc3456e8..1c1009e5f 100644 --- a/docs/evidence/event-await-implementation/README.md +++ b/docs/evidence/event-await-implementation/README.md @@ -108,3 +108,41 @@ E2E_PASS: repeated park, SIGKILL/restart, two wakes replayed in order, deduped d exit status: 0 ``` + +## Review regressions: event isolation and close reasons + +```text +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo test --locked -p relayflowd --test event_activity_parking --test event_activities + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.60s + Running tests/event_activities.rs (target/debug/deps/event_activities-ed1739dfa94f36b5) + +running 11 tests +test prepared_open_response_replays_the_immutable_binding_snapshot ... ok +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test cancel_closes_an_open_activity_before_the_terminal_run_record ... ok +test overflow_of_a_parked_next_returns_overflow_after_recovery ... 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 immediate_event_wakes_have_durable_distinct_wait_boundaries ... ok +test normal_wake_is_not_acknowledged_until_the_following_next ... ok +test prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_suspends ... 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. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.73s + + Running tests/event_activity_parking.rs (target/debug/deps/event_activity_parking-d8e0d6eeb684776f) + +running 4 tests +test replay_keeps_each_acknowledged_batch_addressable_by_body_call_ordinal ... ok +test intentional_close_cancels_the_pending_pull_without_claiming_a_timeout ... ok +test activation_and_delivery_racing_the_lease_handoff_are_not_lost ... ok +test parked_attempt_survives_restart_and_only_a_ready_subscription_redispatches_it ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + +exit status: 0 +``` diff --git a/kernel/relayflowd/src/engine/remote.rs b/kernel/relayflowd/src/engine/remote.rs index 6af7b1633..c0b8a83d8 100644 --- a/kernel/relayflowd/src/engine/remote.rs +++ b/kernel/relayflowd/src/engine/remote.rs @@ -395,7 +395,9 @@ impl Engine { for entry in &entries { if entry.entry_type == EntryType::WaitEvent { let wait: WaitEventPayload = serde_json::from_value(entry.payload.clone())?; - if wait.event_key == event_key { + if wait.stream.is_none() + && !wait.event_key.starts_with(super::subscriptions::PARK_PREFIX) + && wait.event_key == event_key { open.push(( wait.wait_id, entry.step_id.clone(), diff --git a/kernel/relayflowd/src/engine/subscriptions/local_router.rs b/kernel/relayflowd/src/engine/subscriptions/local_router.rs new file mode 100644 index 000000000..5313b8864 --- /dev/null +++ b/kernel/relayflowd/src/engine/subscriptions/local_router.rs @@ -0,0 +1,58 @@ +//! Local test router adapter; Cloud supplies its own authorized ingress. +use super::*; + +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) + } +} diff --git a/kernel/relayflowd/src/engine/subscriptions/mod.rs b/kernel/relayflowd/src/engine/subscriptions/mod.rs index 9353d6d43..8a6855c52 100644 --- a/kernel/relayflowd/src/engine/subscriptions/mod.rs +++ b/kernel/relayflowd/src/engine/subscriptions/mod.rs @@ -23,7 +23,10 @@ mod state; use state::*; mod parking; mod replay; +mod local_router; +mod wait_timers; pub use parking::{SubscriptionPark, SubscriptionWaitPhase}; +pub(super) use parking::PARK_PREFIX; const MAX_UNREAD_FRAMES: usize = 1_000; const MAX_UNREAD_BYTES: usize = 1_024 * 1_024; @@ -89,30 +92,6 @@ impl SubscriptionState { } 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)?; } @@ -385,10 +364,13 @@ impl Engine { 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 + let completion_reason = match reason { + SubscriptionCompletionReason::Overflow => WaitCompletionReason::EventReceived, + SubscriptionCompletionReason::Deadline => WaitCompletionReason::Timeout, + // Explicit close, root completion, and cancellation abandon + // the outstanding pull; none means its timer elapsed. + SubscriptionCompletionReason::Closed | SubscriptionCompletionReason::RunCompleted + | SubscriptionCompletionReason::Canceled => WaitCompletionReason::Canceled, }; self.complete_wait(journal, wait, completion_reason, result, now)?; } @@ -425,30 +407,6 @@ impl Engine { 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); diff --git a/kernel/relayflowd/src/engine/subscriptions/wait_timers.rs b/kernel/relayflowd/src/engine/subscriptions/wait_timers.rs new file mode 100644 index 000000000..a1c86e576 --- /dev/null +++ b/kernel/relayflowd/src/engine/subscriptions/wait_timers.rs @@ -0,0 +1,59 @@ +//! Timer claims for human and exact-event waits sharing the run journal. +use super::*; + +impl Engine { + pub(super) 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()) + } +} diff --git a/kernel/relayflowd/tests/event_activity_parking.rs b/kernel/relayflowd/tests/event_activity_parking.rs index 8d1f85ab0..b487f8549 100644 --- a/kernel/relayflowd/tests/event_activity_parking.rs +++ b/kernel/relayflowd/tests/event_activity_parking.rs @@ -90,6 +90,24 @@ fn check_parking(race: bool) { engine.snapshot(&id).unwrap().steps["body"].lease_deadline_ms, None ); + let reserved_key = format!( + "subscription.park:{}", + serde_json::to_string(&SubscriptionPark { + subscription_id: "events".into(), + phase: SubscriptionWaitPhase::Activation, + }) + .unwrap() + ); + assert_eq!( + engine + .emit_event(&id, &reserved_key, json!({"forged":true}), None, None) + .unwrap(), + 0 + ); + assert_eq!( + engine.snapshot(&id).unwrap().steps["body"].state, + StepStatus::Waiting + ); drop(engine); let engine = Engine::with_runtime(directory.path(), worker.clone(), worker.clone()); if !race { @@ -106,6 +124,12 @@ fn check_parking(race: bool) { engine .next_subscription_outcome(&id, "events", None) .unwrap(); + assert_eq!( + engine + .emit_event(&id, "events", json!({"forged":true}), None, None) + .unwrap(), + 0 + ); if race { engine .append_subscription_frame(&id, "events", "first", json!({"payload":1})) @@ -199,3 +223,51 @@ fn replay_keeps_each_acknowledged_batch_addressable_by_body_call_ordinal() { ); assert!(engine.replay_subscription_wake(&id, "events", 99).is_err()); } + +#[test] +fn intentional_close_cancels_the_pending_pull_without_claiming_a_timeout() { + use relayflowd_core::SubscriptionCompletionReason; + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), SimClock::new(0)); + let id = engine.start(spec(), "test", None).unwrap().run_id; + for (index, reason) in [ + SubscriptionCompletionReason::Closed, + SubscriptionCompletionReason::RunCompleted, + SubscriptionCompletionReason::Canceled, + ] + .into_iter() + .enumerate() + { + let subscription = format!("close-{index}"); + engine + .open_subscription( + &id, + &subscription, + vec!["test".into()], + None, + 0, + 60_000, + 3_600_000, + false, + ) + .unwrap(); + engine + .activate_subscription(&id, &subscription, 0, json!({"generation":1})) + .unwrap(); + engine + .next_subscription_outcome(&id, &subscription, None) + .unwrap(); + engine + .close_subscription(&id, &subscription, reason) + .unwrap(); + let entries = engine.journal_entries(&id, 1, 500).unwrap(); + let completion = entries + .iter() + .find(|entry| { + entry.entry_type == EntryType::WaitCompleted + && entry.payload["wait_id"] == format!("{subscription}/next/0") + }) + .unwrap(); + assert_eq!(completion.payload["completionReason"], "canceled"); + } +} From b4069f0cb3731468140d633ac1ca2796d63d94a9 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 20:56:56 -0700 Subject: [PATCH 24/34] fix(events): preserve body failures before subscription activation --- docs/SURFACE.md | 1 + .../event-await-implementation/README.md | 48 +++++++++++++++++++ .../src/engine/subscriptions/local_router.rs | 2 +- packages/sdk/src/authored-activity.ts | 11 ++++- packages/sdk/tests/authored-activity.test.ts | 15 ++++++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 40d77ce11..a8a75ba05 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -874,6 +874,7 @@ The exit codes are part of the surface contract: | `1` | The run failed with a declared `completionReason`, or a transport, runtime, or daemon protocol error left the outcome unknown. A `step_failed` run names the failing step and its per-step `completionReason`, plus the exit code and output tails the journal recorded for it. An authored `done("step_failed")` exits `1` as well, and says so without naming a step, because no step failed — the body declared the verdict. | | `2` | The command was refused before a journal write: invalid input, failed preflight, unreachable daemon, or a `run_not_found` resume target. | | `3` | The run parked. `PARKED [run_parked]` names the step and its `llm` or `agent` type, and distinguishes an unavailable worker from a `needs_human` recovery wait. An authored body parked on `f.human` reports the question, who it is for, and the `flows answer` invocation that records the decision (see *Human gates* below). | +| `4` | An authored body suspended at `f.on(...).next()` for subscription activation or event delivery. JSON reports `status: "suspended"` and the durable `suspension` boundary. The root releases its worker lease; unchanged waits can be resumed without consuming crash retries. This local SDK/daemon contract still requires Cloud router integration before hosted use; see [EVENT-AWAIT.md](EVENT-AWAIT.md). | Without an attached worker, reaching an `llm` or `agent` step returns a durable parked outcome. For authored TypeScript, `--local-agent` attaches both local diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md index 1c1009e5f..feb6f5e9f 100644 --- a/docs/evidence/event-await-implementation/README.md +++ b/docs/evidence/event-await-implementation/README.md @@ -144,5 +144,53 @@ test parked_attempt_survives_restart_and_only_a_ready_subscription_redispatches_ test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s +exit status: 0 +``` + +## Build provenance and cleanup regression + +The original targeted SDK run followed this build. The later cleanup regression +command also rebuilds the SDK before testing it. + +```text +cwd: /tmp/flows-pr-followup/pr441/kernel +$ sh -c 'cargo build --locked -p relayflowd && npm run build --prefix ../packages/sdk' + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.34s + +> @relayflows/sdk@2.0.22 build +> tsc && node scripts/make-cli-executable.mjs + + +exit status: 0 +``` + +```text +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo build --locked -p relayflowd + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.16s + +exit status: 0 +``` + +```text +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ sh -c 'npm run build && npx vitest run tests/authored-activity.test.ts' + +> @relayflows/sdk@2.0.22 build +> tsc && node scripts/make-cli-executable.mjs + + + RUN v2.1.9 /tmp/flows-pr-followup/pr441/packages/sdk + + ✓ tests/authored-activity.test.ts (15 tests) 79ms + + Test Files 1 passed (1) + Tests 15 passed (15) + Start at 20:56:03 + Duration 1.13s (transform 467ms, setup 0ms, collect 837ms, tests 79ms, environment 0ms, prepare 78ms) + + exit status: 0 ``` diff --git a/kernel/relayflowd/src/engine/subscriptions/local_router.rs b/kernel/relayflowd/src/engine/subscriptions/local_router.rs index 5313b8864..531321dbd 100644 --- a/kernel/relayflowd/src/engine/subscriptions/local_router.rs +++ b/kernel/relayflowd/src/engine/subscriptions/local_router.rs @@ -1,4 +1,4 @@ -//! Local test router adapter; Cloud supplies its own authorized ingress. +//! The local daemon's event.emit ingress; Cloud supplies its own authorized router. use super::*; impl Engine { diff --git a/packages/sdk/src/authored-activity.ts b/packages/sdk/src/authored-activity.ts index d5427521d..455817336 100644 --- a/packages/sdk/src/authored-activity.ts +++ b/packages/sdk/src/authored-activity.ts @@ -73,7 +73,16 @@ class JournalActivity implements OpenActivity { async close(reason: CloseReason): Promise { if (this.closed) return; - await this.ensureOpen(); + try { + await this.ensureOpen(); + } catch (error) { + // Failure cleanup has no active binding to close before activation. + // Re-throwing this handoff would disguise a real body failure as a + // normal suspension. Root termination fences any later activation. + if (reason !== 'closed' && error instanceof AuthoredFlowExecutionError + && error.code === 'subscription_suspended' && error.suspension?.kind === 'activation') return; + throw error; + } await this.journal.subscriptionClose({ run_id: this.runId, subscription_id: this.subscriptionId, diff --git a/packages/sdk/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts index fbdd9f441..6b5e3fbaa 100644 --- a/packages/sdk/tests/authored-activity.test.ts +++ b/packages/sdk/tests/authored-activity.test.ts @@ -159,6 +159,21 @@ describe('authored event activities', () => { } finally { journal.close(); } }); + it.each(['throws', 'missing-completion'])('preserves a body failure before activation: %s', async failure => { + calls.length = 0; + const journal = new JournalClient(path, { requestTimeoutMs: 2_000 }); + await journal.connect(); + await journal.hello('authored-activity-failure-before-activation'); + try { + const execution = executeAuthoredFlow(flow('failed-before-activation', async f => { + f.on(webhook('pull_request'), { idle: '1h', deadline: '1d' }); + if (failure === 'throws') throw new Error('original body failure'); + }), journal, undefined, { rootRunId: 'root-prepared' }); + await expect(execution).rejects.toThrow(failure === 'throws' ? 'original body failure' : 'missing_completion'); + expect(calls.map(call => call.verb)).toEqual(['subscription.open']); + } 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 }); From c06dfafb144df8da61b078cf5fb20e9438903f40 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 21:01:42 -0700 Subject: [PATCH 25/34] fix(events): fail closed when replaying corrupt journal data Reject malformed pending ranges, malformed stream entries, and missing frames. Remove the unused one-off implementation driver; the current runtime probe remains the acceptance fixture. --- .../event-await-implementation/README.md | 47 + .../src/engine/subscriptions/mod.rs | 23 +- .../src/engine/subscriptions/state.rs | 67 +- .../tests/event_activity_corruption.rs | 124 ++ ops/event-await-overnight/README.md | 22 - .../implement-event-await.flow.ts | 105 - ops/event-await-overnight/package-lock.json | 1892 ----------------- ops/event-await-overnight/package.json | 8 - 8 files changed, 225 insertions(+), 2063 deletions(-) create mode 100644 kernel/relayflowd/tests/event_activity_corruption.rs delete mode 100644 ops/event-await-overnight/README.md delete mode 100644 ops/event-await-overnight/implement-event-await.flow.ts delete mode 100644 ops/event-await-overnight/package-lock.json delete mode 100644 ops/event-await-overnight/package.json diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md index feb6f5e9f..af4e1bac5 100644 --- a/docs/evidence/event-await-implementation/README.md +++ b/docs/evidence/event-await-implementation/README.md @@ -192,5 +192,52 @@ $ sh -c 'npm run build && npx vitest run tests/authored-activity.test.ts' Duration 1.13s (transform 467ms, setup 0ms, collect 837ms, tests 79ms, environment 0ms, prepare 78ms) +exit status: 0 +``` + +## Corrupt journal rejection + +```text +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo test --locked -p relayflowd --test event_activity_corruption --test event_activity_parking --test event_activities + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.34s + Running tests/event_activities.rs (target/debug/deps/event_activities-ed1739dfa94f36b5) + +running 11 tests +test prepared_open_response_replays_the_immutable_binding_snapshot ... ok +test cancel_closes_an_open_activity_before_the_terminal_run_record ... ok +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test overflow_of_a_parked_next_returns_overflow_after_recovery ... 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 prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_suspends ... ok +test normal_wake_is_not_acknowledged_until_the_following_next ... ok +test immediate_event_wakes_have_durable_distinct_wait_boundaries ... 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. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.78s + + Running tests/event_activity_corruption.rs (target/debug/deps/event_activity_corruption-e16548e9ab56b153) + +running 3 tests +test a_completed_event_range_cannot_replay_with_missing_frames ... ok +test malformed_stream_frames_fail_replay_and_future_append ... ok +test malformed_deadline_range_is_an_error_while_null_is_an_empty_range ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/event_activity_parking.rs (target/debug/deps/event_activity_parking-d8e0d6eeb684776f) + +running 4 tests +test intentional_close_cancels_the_pending_pull_without_claiming_a_timeout ... ok +test replay_keeps_each_acknowledged_batch_addressable_by_body_call_ordinal ... ok +test activation_and_delivery_racing_the_lease_handoff_are_not_lost ... ok +test parked_attempt_survives_restart_and_only_a_ready_subscription_redispatches_it ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + exit status: 0 ``` diff --git a/kernel/relayflowd/src/engine/subscriptions/mod.rs b/kernel/relayflowd/src/engine/subscriptions/mod.rs index 8a6855c52..a06e3f23e 100644 --- a/kernel/relayflowd/src/engine/subscriptions/mod.rs +++ b/kernel/relayflowd/src/engine/subscriptions/mod.rs @@ -201,12 +201,11 @@ impl Engine { 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); + for entry in entries.iter().filter(|entry| entry.entry_type == EntryType::StreamAppended) { + let append: StreamAppendedPayload = serde_json::from_value(entry.payload.clone())?; + if 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)| { @@ -220,7 +219,7 @@ impl Engine { self.complete_fenced_overflows_in_journal(&mut journal, self.clock.now_ms())?; return Ok(false); } - let offset = next_stream_offset(&entries, state.stream()); + 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()) }, @@ -430,11 +429,11 @@ impl Engine { 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) - }); + let mut already_acknowledged = false; + for entry in journal.scan_all()?.into_iter().filter(|entry| entry.entry_type == EntryType::SubscriptionAcknowledged) { + let ack: SubscriptionAcknowledgedPayload = serde_json::from_value(entry.payload)?; + already_acknowledged |= 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); } diff --git a/kernel/relayflowd/src/engine/subscriptions/state.rs b/kernel/relayflowd/src/engine/subscriptions/state.rs index 5ab30843d..ab8236638 100644 --- a/kernel/relayflowd/src/engine/subscriptions/state.rs +++ b/kernel/relayflowd/src/engine/subscriptions/state.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use relayflowd_core::{ EntryType, JournalEntry, StreamAppendedPayload, SubscriptionAcknowledgedPayload, SubscriptionClosedPayload, SubscriptionOpenedPayload, SubscriptionOverflowFencedPayload, @@ -152,17 +152,23 @@ pub(super) fn unread_frames( Ok(unread) } -pub(super) fn next_stream_offset(entries: &[JournalEntry], stream: &str) -> u64 { - entries +pub(super) fn next_stream_offset(entries: &[JournalEntry], stream: &str) -> Result { + let mut next = 0; + for entry in 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) + { + let append: StreamAppendedPayload = serde_json::from_value(entry.payload.clone())?; + if append.stream == stream { + next = next.max( + append + .offset + .checked_add(1) + .context("subscription offset overflow")?, + ); + } + } + Ok(next) } pub(super) fn events_wake( @@ -199,11 +205,14 @@ pub(super) fn wake_from_completed( 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()), + pending: serde_json::from_value( + completed + .result + .get("pending") + .context("deadline completion lacks pending range")? + .clone(), + ) + .context("decode deadline pending range")?, }); } _ => {} @@ -218,16 +227,27 @@ pub(super) fn wake_from_completed( .get("next_offset") .and_then(Value::as_u64) .context("activity event completion lacks next_offset")?; - let events = journal + if next <= from { + bail!("activity completion has an invalid cursor range"); + } + let mut events = Vec::new(); + for entry in 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(); + { + let append: StreamAppendedPayload = serde_json::from_value(entry.payload) + .context("decode stream.appended while replaying wake")?; + if append.stream == state.stream() && append.offset >= from && append.offset < next { + if append.offset != from + events.len() as u64 { + bail!("activity wake has missing or unordered frames"); + } + events.push(append.message); + } + } + if next - from != events.len() as u64 { + bail!("activity wake is missing journaled frames"); + } Ok(SubscriptionWake::Events { events, offset: next, @@ -236,8 +256,7 @@ pub(super) fn wake_from_completed( pub(super) fn unread_bytes(unread: &[(JournalEntry, StreamAppendedPayload)]) -> usize { unread .iter() - .filter_map(|(_, append)| serde_json::to_vec(&append.message).ok()) - .map(|bytes| bytes.len()) + .map(|(_, append)| append.message.to_string().len()) .sum() } pub(super) fn pending(unread: &[(JournalEntry, StreamAppendedPayload)]) -> Option { diff --git a/kernel/relayflowd/tests/event_activity_corruption.rs b/kernel/relayflowd/tests/event_activity_corruption.rs new file mode 100644 index 000000000..4e2c6b3de --- /dev/null +++ b/kernel/relayflowd/tests/event_activity_corruption.rs @@ -0,0 +1,124 @@ +use relayflowd::Engine; +use relayflowd_core::{ + EntryType, Journal, JournalEntry, RunSpec, SimClock, WaitCompletedPayload, WaitCompletionReason, +}; +use relayflowd_journal::SqliteJournal; +use serde_json::{Value, json}; + +fn fixture() -> (tempfile::TempDir, Engine, String) { + let directory = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(directory.path(), SimClock::new(0)); + let spec = + RunSpec::parse(&json!({"steps":[{"id":"body","type":"llm","prompt":"body"}]})).unwrap(); + let id = engine.start(spec, "test", None).unwrap().run_id; + engine + .open_subscription( + &id, + "events", + vec!["test".into()], + None, + 0, + 60_000, + 3_600_000, + false, + ) + .unwrap(); + engine + .activate_subscription(&id, "events", 0, json!({"generation":1})) + .unwrap(); + engine + .next_subscription_outcome(&id, "events", None) + .unwrap(); + (directory, engine, id) +} + +fn completion(directory: &std::path::Path, id: &str, result: Value) { + let mut journal = + SqliteJournal::open(directory.join("runs").join(format!("{id}.sqlite3"))).unwrap(); + journal + .append(&JournalEntry::new( + EntryType::WaitCompleted, + id, + None, + None, + 0, + WaitCompletedPayload { + wait_id: "events/next/0".into(), + completion_reason: WaitCompletionReason::Timeout, + result, + }, + )) + .unwrap(); +} + +#[test] +fn malformed_deadline_range_is_an_error_while_null_is_an_empty_range() { + for pending in [json!("corrupt"), Value::Null] { + let (directory, engine, id) = fixture(); + completion( + directory.path(), + &id, + json!({"timeout":"deadline","pending":pending}), + ); + let replay = engine.replay_subscription_wake(&id, "events", 0); + if pending.is_null() { + assert!(replay.is_ok()); + } else { + assert!(replay.unwrap_err().to_string().contains("pending range")); + } + } +} + +#[test] +fn a_completed_event_range_cannot_replay_with_missing_frames() { + let (directory, engine, id) = fixture(); + completion( + directory.path(), + &id, + json!({"from_offset":0,"next_offset":1}), + ); + assert!( + engine + .replay_subscription_wake(&id, "events", 0) + .unwrap_err() + .to_string() + .contains("missing journaled frames") + ); +} + +#[test] +fn malformed_stream_frames_fail_replay_and_future_append() { + let (directory, engine, id) = fixture(); + engine + .append_subscription_frame(&id, "events", "first", json!({"payload":1})) + .unwrap(); + completion( + directory.path(), + &id, + json!({"from_offset":0,"next_offset":1}), + ); + // The writer refuses malformed frames. Simulate damaged stored bytes to + // prove readers fail closed too, instead of silently skipping that row. + let connection = + rusqlite::Connection::open(directory.path().join("runs").join(format!("{id}.sqlite3"))) + .unwrap(); + connection + .execute( + "UPDATE entries SET payload = ?1 WHERE entry_type = 'stream.appended'", + [json!({"stream":"subscription/events","offset":"corrupt"}).to_string()], + ) + .unwrap(); + drop(connection); + assert!( + engine + .replay_subscription_wake(&id, "events", 0) + .unwrap_err() + .to_string() + .contains("decode stream.appended") + ); + assert!( + engine + .append_subscription_frame(&id, "events", "next", json!({"payload":1})) + .is_err() + ); +} diff --git a/ops/event-await-overnight/README.md b/ops/event-await-overnight/README.md deleted file mode 100644 index a882a322b..000000000 --- a/ops/event-await-overnight/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Event-await overnight flows - -`implement-event-await.flow.ts` is a local, journaled implementation loop for -the proposed `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": "/absolute/path/to/an/isolated/flows/worktree", - "auditPasses": 2 -}' -``` - -This is the historical implementation driver, not the acceptance flow for this -PR. Its pinned SDK dependencies describe that driver’s execution environment. -Use the current CLI probe documented in `docs/EVENT-AWAIT.md` to verify the -local event-wait path. diff --git a/ops/event-await-overnight/implement-event-await.flow.ts b/ops/event-await-overnight/implement-event-await.flow.ts deleted file mode 100644 index e647e0171..000000000 --- a/ops/event-await-overnight/implement-event-await.flow.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 7a01837dc..000000000 --- a/ops/event-await-overnight/package-lock.json +++ /dev/null @@ -1,1892 +0,0 @@ -{ - "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 deleted file mode 100644 index b634200b3..000000000 --- a/ops/event-await-overnight/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "event-await-overnight", - "private": true, - "type": "module", - "dependencies": { - "@relayflows/surface": "2.0.14" - } -} From 07be662d4d5bc390f0ca60c1f35f7b3a979e0c84 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 21:08:19 -0700 Subject: [PATCH 26/34] docs(events): specify stable call-order subscription identity --- docs/EVENT-AWAIT.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index 0272b3fbb..f1ce67f64 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -20,6 +20,13 @@ Completed wakes replay by that ordinal, including after acknowledgment; a body re-executing from the beginning must not receive its second wake at its first call. Replaying a closed handle does not reopen provider ingress. +Subscription handles are run-scoped call ordinals (`activity-1`, `activity-2`, +...). Authors must keep `f.on()` call order stable on replay, just as child +step call order must be stable. These ids are not derived from source locations: +reordering declarations or branching on unjournaled external state does not +preserve an activity's identity. The pinned body and replayed results provide +the supported deterministic ordering. + Cloud's durable binding registry, ingress fencing, suspended-result handling, and wake scheduling remain integration work. The CLI integration probe uses a local router adapter, not a deployed provider webhook. It lives at @@ -200,8 +207,8 @@ No new step kind or surface verb. The daemon adds the internal `subscription.activate` handoff verb; the kernel's step vocabulary stays closed (decision 13). -1. **`subscription.prepared`** — `subscription_id` (deterministic from run id, - step id and declaration), `event_types`, `pattern` (the recursive-subset +1. **`subscription.prepared`** — `subscription_id` (the run-scoped `f.on()` call + ordinal, whose order must remain stable on replay), `event_types`, `pattern` (the recursive-subset match already used by `TriggerSpec.pattern`), `stream` (`subscription/`), `settle_ms`, `idle_ms`, `deadline_at_ms`, and `include_self`. It is an immutable request, never an From 80a2fe8dd7021f03f4e548b29b263fa2ee4a4730 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 21:24:29 -0700 Subject: [PATCH 27/34] fix(cli): load activity compiler only for authored checks --- .../event-await-implementation/README.md | 57 +++++++++++++++++++ packages/sdk/src/cli.ts | 4 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/evidence/event-await-implementation/README.md b/docs/evidence/event-await-implementation/README.md index af4e1bac5..321298334 100644 --- a/docs/evidence/event-await-implementation/README.md +++ b/docs/evidence/event-await-implementation/README.md @@ -239,5 +239,62 @@ test parked_attempt_survives_restart_and_only_a_ready_subscription_redispatches_ test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s +exit status: 0 +``` + +## CLI startup and unchanged watcher checks + +The activity checker now loads only for authored TypeScript checks. These are +one-shot import measurements, not a statistical benchmark. The watcher test +limits and assertions are unchanged. + +```text +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ /tmp/flows-pr-cleanup/toolchain/node_modules/node/bin/node --input-type=module -e 'const start = performance.now(); await import("./dist/cli.js"); console.log(JSON.stringify({cliImportMs: performance.now() - start, rssBytes: process.memoryUsage().rss}));' +{"cliImportMs":480.42877,"rssBytes":172797952} + +exit status: 0 +``` + +```text +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ /tmp/flows-pr-cleanup/toolchain/node_modules/node/bin/node --input-type=module -e 'const start = performance.now(); await import("./dist/cli.js"); console.log(JSON.stringify({cliImportMs: performance.now() - start, rssBytes: process.memoryUsage().rss}));' +{"cliImportMs":261.441739,"rssBytes":121061376} + +exit status: 0 +``` + +```text +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ sh -c 'export PATH=/tmp/flows-pr-cleanup/toolchain/node_modules/node/bin:/tmp/flows-pr-cleanup/toolchain/node_modules/.bin:$PATH; npm run build && npm run typecheck && npx vitest run tests/cli-watch.test.ts tests/activity-preflight.test.ts' + +> @relayflows/sdk@2.0.22 build +> tsc && node scripts/make-cli-executable.mjs + + +> @relayflows/sdk@2.0.22 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + + RUN v2.1.9 /tmp/flows-pr-followup/pr441/packages/sdk + + ✓ tests/activity-preflight.test.ts (1 test) 9ms + ✓ tests/cli-watch.test.ts (10 tests) 12411ms + ✓ flows check --watch > rechecks syntax errors, clears once, and returns the last refusal on Ctrl-C 958ms + ✓ flows check --watch > streams JSON lines without ANSI, recovers after atomic saves, and exits zero after repair 1339ms + ✓ flows check --watch > coalesces 20 concurrent saves into at most two rechecks 1516ms + ✓ flows check --watch > watches transitive relative use imports, cycles, and nearest config changes 1815ms + ✓ flows check --watch > refreshes the import graph and notices missing imports being created 1860ms + ✓ flows check --watch > reloads authored TypeScript instead of reusing the first imported definition 1418ms + ✓ flows check --watch > detects a nearer config appearing and falls back after it is deleted 1368ms + ✓ flows check --watch > keeps watching after the target is deleted and recreated 1361ms + ✓ flows check --watch > queues changes during a slow check without overlapping checks 772ms + + Test Files 2 passed (2) + Tests 11 passed (11) + Start at 21:23:28 + Duration 13.83s (transform 765ms, setup 0ms, collect 1.98s, tests 12.42s, environment 0ms, prepare 104ms) + + exit status: 0 ``` diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index a6d30a7cf..62e50302c 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -2,7 +2,6 @@ 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 { describeFlowRequirements } from './flow-requirements.js'; import { renderProgress, type ProgressEvent } from './progress.js'; @@ -338,6 +337,9 @@ export async function runCli( async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckReport }> { const helper = await checkHelperBody(path); if (!helper.report.ok) return helper; + // The activity checker loads the TypeScript compiler. YAML checks and + // unrelated CLI commands should not pay that startup cost on every run. + const { checkAuthoredActivities } = await import('./cli/check-activities.js'); const activities = await checkAuthoredActivities(path); if (!activities.report.ok) return activities; const mcp = await checkTypeScriptFlow(path); From c50312d5f6af7760e024658772c2e60edd942024 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sun, 20 Sep 2026 03:03:40 -0700 Subject: [PATCH 28/34] feat(events): fence router delivery and expose durable subscription metadata --- docs/EVENT-AWAIT.md | 29 ++ evidence/pr441-router-2026-09-20.md | 22 + evidence/pr441-router-2026-09-20/build.txt | 6 + .../kernel-workspace.txt | 465 ++++++++++++++++++ .../router-targeted.txt | 14 + .../pr441-router-2026-09-20/router-wire.txt | 145 ++++++ kernel/relayflowd/src/engine.rs | 2 +- .../src/engine/subscriptions/mod.rs | 5 + .../engine/subscriptions/router_delivery.rs | 112 +++++ kernel/relayflowd/src/server.rs | 32 +- kernel/relayflowd/src/server/protocol.rs | 8 + kernel/relayflowd/src/server/tests.rs | 1 + .../src/server/tests/subscription_router.rs | 81 +++ kernel/relayflowd/src/server/wire.rs | 24 + kernel/relayflowd/tests/event_activities.rs | 2 +- .../tests/subscription_router_delivery.rs | 193 ++++++++ packages/sdk/src/authored-root.ts | 3 + packages/sdk/src/cli.ts | 6 +- packages/sdk/src/cli/direct-run.ts | 1 + packages/sdk/src/cli/run.ts | 8 +- packages/sdk/src/cli/subscription-report.ts | 51 ++ packages/sdk/src/journal-client.ts | 15 + packages/sdk/src/journal-reader.ts | 2 + packages/sdk/src/protocol.ts | 42 ++ packages/sdk/tests/cli-replay.test.ts | 15 + packages/sdk/tests/cli.test.ts | 2 + .../tests/fixtures/event-await-cli-probe.mjs | 48 +- packages/sdk/tests/journal-client-loopback.ts | 3 + .../journal-client-subscriptions.test.ts | 39 ++ packages/sdk/tests/observer-link.test.ts | 1 + packages/sdk/tests/run-from-digest.test.ts | 1 + .../sdk/tests/subscription-report.test.ts | 63 +++ 32 files changed, 1432 insertions(+), 9 deletions(-) create mode 100644 evidence/pr441-router-2026-09-20.md create mode 100644 evidence/pr441-router-2026-09-20/build.txt create mode 100644 evidence/pr441-router-2026-09-20/kernel-workspace.txt create mode 100644 evidence/pr441-router-2026-09-20/router-targeted.txt create mode 100644 evidence/pr441-router-2026-09-20/router-wire.txt create mode 100644 kernel/relayflowd/src/engine/subscriptions/router_delivery.rs create mode 100644 kernel/relayflowd/src/server/tests/subscription_router.rs create mode 100644 kernel/relayflowd/tests/subscription_router_delivery.rs create mode 100644 packages/sdk/src/cli/subscription-report.ts create mode 100644 packages/sdk/tests/journal-client-subscriptions.test.ts create mode 100644 packages/sdk/tests/subscription-report.test.ts diff --git a/docs/EVENT-AWAIT.md b/docs/EVENT-AWAIT.md index f1ce67f64..bb9a01f9e 100644 --- a/docs/EVENT-AWAIT.md +++ b/docs/EVENT-AWAIT.md @@ -290,6 +290,35 @@ tenant-unaware). sleeping cell wakes the cell. - The router never decides whether the flow is done. It only delivers. +### Targeted router protocol + +Cloud delivers through `subscription.deliver`, supplying `run_id`, +`subscription_id`, the exact immutable `router_binding`, a stable `delivery_id`, +and the authorized `frame`. This targets only that subscription. The local +`event.emit` adapter broadcasts to matching subscriptions and is not the Cloud +router boundary. The kernel refuses unknown, prepared, closed, and stale-binding +recipients. Duplicates return `{ appended: false, reason: "duplicate" }`; an +append that triggers the unread limit returns `reason: "overflow"` after the +journaled overflow close. A successful append returns `{ appended: true }`. +Provider installation, resource scope, event matching, and self-actor checks +remain Cloud's responsibility before this call; the receipt is a fence, not an +authorization credential. + +An activation retry must carry the same ingress cursor and complete binding +receipt as the original journal entry. Changing either is +`subscription_binding_mismatch`, never a replacement of the active binding. +`subscription.fence_overflow` carries the same receipt and completes Cloud's +persisted overflow fence idempotently under the per-run sequencer. + +`subscription.inspect` takes `run_id` and returns a read-only `subscriptions` +projection: identity, prepared/active/closed state, closing reason, binding and +activation cursor, unread frame/byte counts, settle duration, and absolute idle +and deadline instants. Idle is taken from the durable wait or the last journaled +wake, never the time of this query. This gives Cloud a protocol surface for +scheduling and cleanup without reading sandbox SQLite files. A delivery response +alone does not authorize Cloud to advance its durable ingress acknowledgment: +the containing journal must first cross the durable publication barrier. + ## 7. Acceptance The crash-injection suite is the gate (AGENTS.md standard 5). A conforming diff --git a/evidence/pr441-router-2026-09-20.md b/evidence/pr441-router-2026-09-20.md new file mode 100644 index 000000000..0c0f4ae76 --- /dev/null +++ b/evidence/pr441-router-2026-09-20.md @@ -0,0 +1,22 @@ +# Targeted Event Await router protocol + +Cloud integration review found that event.emit broadcast could deliver a frame to +other matching subscriptions without checking their immutable binding receipts. +The new subscription.deliver verb targets one subscription, fences the full +receipt, and shares the existing journal deduplication/overflow path. Activation +retries now reject changed bindings or ingress cursors. subscription.inspect +projects durable closure, unread counts, and absolute timers; fence_overflow +completes the external router fence under the run sequencer. + +Verification commands and literal outputs are in the adjacent +[pr441-router-2026-09-20](pr441-router-2026-09-20/) directory. The two new engine +regressions cover targeted isolation, stale/prepared/closed rejection, duplicate +replay after engine restart, stable timer instants, and overflow recovery. The +protocol-handler test covers the new wire verbs and refusal codes. The complete +Rust workspace test command exits zero. These are local engine/protocol checks, +not proof of deployed Cloud provider delivery or publication barriers. + +The existing activation-retry test now repeats the original receipt. Its old +changed-receipt input relied on the unfenced behavior being repaired; the new +regression explicitly requires rejection of changed receipts and unchanged +journal length after rejection. diff --git a/evidence/pr441-router-2026-09-20/build.txt b/evidence/pr441-router-2026-09-20/build.txt new file mode 100644 index 000000000..fee80c3d3 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/build.txt @@ -0,0 +1,6 @@ +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo build -p relayflowd + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.46s + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/kernel-workspace.txt b/evidence/pr441-router-2026-09-20/kernel-workspace.txt new file mode 100644 index 000000000..cf98b03a7 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/kernel-workspace.txt @@ -0,0 +1,465 @@ +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo test --workspace + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.80s + Running unittests src/lib.rs (target/debug/deps/relayflowd-f043db0bb3534a16) + +running 52 tests +test engine::remote::worker_failure_detail_tests::a_null_or_blank_output_yields_no_detail ... ok +test engine::remote::worker_failure_detail_tests::a_string_output_is_carried_verbatim_and_trimmed ... ok +test engine::remote::worker_failure_detail_tests::a_non_string_output_is_rendered_rather_than_dropped ... ok +test engine::boot_identity_tests::every_engine_in_this_process_shares_one_boot_id ... ok +test engine::remote::worker_failure_detail_tests::an_output_at_the_boundary_is_not_truncated ... ok +test server::client::tests::resume_waits_while_the_heartbeat_renewed_lease_is_live ... ok +test engine::remote::worker_failure_detail_tests::truncation_does_not_split_a_multi_byte_char ... ok +test server::channels::tests::unknown_verb_never_falls_through_to_receive ... ok +test server::liveness::tests::sweep_id_buckets_by_the_interval ... ok +test server::tests::agent::contract::an_agent_worker_attaching_without_pins_is_refused_at_attach ... ok +test engine::remote::worker_failure_detail_tests::render_is_bounded_before_allocation_for_hostile_string ... ok +test server::tests::agent::contract::an_oversized_trajectory_tail_is_refused_at_step_complete ... ok +test exec_det::tests::captures_deterministic_output ... ok +test exec_det::tests::failed_command_evidence_survives_completion ... ok +test server::tests::hello_enforces_protocol_version ... ok +test engine::remote::worker_failure_detail_tests::render_is_bounded_before_allocation_for_hostile_json ... ok +test exec_det::tests::lease_override_bounds_execution_and_preserves_command_timeout ... ok +test exec_det::tests::timeout_has_an_explicit_completion_reason ... ok +test server::tests::run_resume_asks_the_registry_instead_of_treating_an_orphan_file_as_a_run ... ok +test server::liveness::tests::sweep_pass_latches_after_journaling_and_next_bucket_is_empty ... ok +test server::tests::run_start_fails_closed_on_an_unknown_verification_key ... ok +test server::liveness::tests::sweep_pass_healthy_subscription_is_a_noop ... ok +test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok +test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok +test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok +test socket_path::tests::deep_data_dir_produces_short_socket_path ... ok +test socket_path::tests::different_data_dirs_yield_different_sockets ... ok +test socket_path::tests::relative_and_absolute_data_dirs_agree ... ok +test socket_path::tests::same_data_dir_yields_same_socket ... ok +test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok +test server::tests::run_start_refuses_invalid_admission_keys ... ok +test server::tests::run_resume_refuses_a_journal_that_never_recorded_its_run ... ok +test server::tests::agent::contract::agent_without_a_compatible_worker_parks_without_starting ... ok +test server::tests::agent::contract::an_agent_worker_missing_a_declared_surface_parks_the_run_instead_of_erroring ... ok +test server::tests::run_resume_refuses_a_valid_journal_that_belongs_to_another_run ... ok +test server::tests::run_start_admission_key_recovers_the_same_run_and_refuses_spec_drift ... ok +test server::tests::agent::pins::reset_worker_reporting_a_revision_other_than_its_pin_fails_closed_as_worker_error ... ok +test engine::boot_identity_tests::prior_boot_registered_undriven_admission_is_recovered_by_start_retry ... ok +test server::tests::agent::contract::an_llm_completion_claiming_an_effect_fails_closed_with_the_reason_journaled ... ok +test engine::boot_identity_tests::failure_after_workspace_binding_releases_admission_without_exposing_effects ... ok +test server::tests::agent::contract::a_transcript_digest_at_its_budget_rides_trajectory_tail_verbatim ... ok +test server::tests::a_failed_disconnect_journal_append_is_retained_and_retried_not_dropped ... ok +test server::tests::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok +test server::tests::agent::contract::a_replacement_worker_that_never_reported_the_pinned_surface_is_not_dispatched_to ... ok +test server::tests::stopped_heartbeats_past_the_deadline_journal_lease_expired_and_release_the_step ... ok +test server::tests::agent::contract::human_intervention_is_durable_and_resume_requires_explicit_override ... ok +test server::tests::agent::pins::consecutive_agent_steps_on_different_surfaces_each_start_from_their_own_pins ... ok +test server::tests::step_wait_parks_the_attempt_and_a_human_answer_redispatches_it ... ok +test server::tests::subscription_router::targeted_router_verbs_validate_receipts_and_preserve_wire_metadata ... ok +test exec_det::tests::timeout_kills_the_whole_process_group ... ok +test server::tests::agent::pins::a_replacement_worker_at_a_different_revision_is_not_dispatched_the_stale_pins ... ok +test server::tests::an_entry_appended_during_watch_registration_is_delivered_exactly_once ... ok + +test result: ok. 52 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s + + Running unittests src/main.rs (target/debug/deps/relayflowd-6e3681176306c99e) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/budget_gate.rs (target/debug/deps/budget_gate-9607ae7c2db11b74) + +running 10 tests +test metering_flag_is_additive_on_the_wire ... ok +test prior_spend_metering_flag_is_additive_and_fails_closed_for_older_kernels ... ok +test daily_windows_reset_and_exact_limits_do_not_refuse ... ok +test carried_metered_dollars_still_stop_the_continuing_run ... ok +test unmetered_tokens_still_cross_a_token_ceiling ... ok +test carried_prior_spend_keeps_unknown_dollar_cost_unmetered ... ok +test unmetered_usage_may_not_claim_priced_dollars ... ok +test crossing_completion_is_durable_and_next_step_is_refused ... ok +test unmetered_spend_is_journaled_as_unknown_and_never_crosses_a_dollar_ceiling ... ok +test deterministic_spend_and_wallclock_limit_gate_parallel_batch_starts ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/crash_resume.rs (target/debug/deps/crash_resume-d2d102ec3e89b705) + +running 40 tests +test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok +test concurrency::run_start_dispatches_every_independent_lane_before_any_completion ... ok +test agent::rung_c_sigkill_after_final_effect_replays_results_without_redispatch ... ok +test channels::channels_reject_foreign_workers_stale_attempts_and_invalid_acknowledgements ... ok +test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok +test llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... ok +test concurrency::cancel_closes_the_lease_and_rejects_a_late_completion ... ok +test concurrency::live_resume_leaves_an_active_lease_running ... ok +test concurrency::server_restart_recovers_every_parallel_lease_without_duplicate_success ... ok +test concurrency::cancel_and_completion_race_has_one_terminal_fact ... ok +test pin_projection::rejected_completion_cannot_forge_inspect_retry_pins_over_the_real_socket ... ok +test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... ok +test agent::rung_c_reset_sigkill_mid_edit_restores_pins_dedupes_effect_and_explains_attempts ... ok +test memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once ... ok +test concurrency::concurrent_resumes_lease_exactly_one_attempt ... ok +test llm::failing_llm_verification_schedules_a_durable_retry_and_succeeds ... ok +test llm::llm_verification_exhaustion_is_a_declared_failure_kind ... ok +test protocol_admission::every_mutating_run_verb_refuses_terminal_before_changing_state ... ok +test placement::declared_placement_keeps_one_source_tree_across_resume ... ok +test sigkill_after_cancel_request_resumes_to_one_canceled_fact ... ok +test placement::sigkill_before_first_step_preserves_the_submitted_workspace ... ok +test llm::sigkill_after_the_final_rung_b_effect_resumes_without_redispatching_llm ... ok +test sigkill_mid_step_replaces_and_explains_the_dead_attempt ... ok +test llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step ... ok +test placement::sigkill_mid_step_keeps_the_route_and_source_tree ... ok +test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... ok +test llm::worker_killed_while_holding_a_lease_is_explained_and_released_on_cli_resume ... ok +test workspace_identity::workspace_aliases_are_refused_and_canonical_subtrees_serialize_over_real_sockets ... ok +test worker_capacity::two_workers_receive_a_deterministic_fair_capacity_bounded_batch ... ok +test surface_identity::aliases_are_rejected_and_external_ancestors_serialize_over_real_sockets ... ok +test sigkill_under_serve_resumes_the_socket_started_run ... ok +test llm::sigkill_under_serve_mid_llm_releases_the_lease_and_finishes_via_cli_resume ... ok +test parallel_lifecycle::terminal_failure_drains_or_explains_every_live_sibling ... ok +test worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash ... ok +test sigkill_sweep_covers_every_hello_step_boundary ... ok +test llm::sigkill_sweep_covers_before_and_between_the_rung_b_steps ... ok +test parallel_lifecycle::overlapping_agent_conflict_survives_server_crash_and_resume ... ok +test parallel_lifecycle::overlapping_agent_lanes_serialize_while_disjoint_lanes_merge_in_either_order ... ok +test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok +test parallel_lifecycle::renewed_parallel_leases_survive_the_original_grant_and_remain_distinct ... ok + +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 36.40s + + Running tests/daemon_lifecycle.rs (target/debug/deps/daemon_lifecycle-b705da9761b2a254) + +running 6 tests +test sigkill_leaves_a_stale_file_with_a_dead_pid ... ok +test connection_file_is_published_only_after_the_socket_is_live ... ok +test deep_data_dir_still_binds ... ok +test clean_shutdown_removes_advertisement_and_socket ... ok +test a_second_serve_on_a_served_data_dir_refuses_and_the_first_keeps_serving ... ok +test a_sigkilled_daemons_successor_starts_cleanly ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/event_activities.rs (target/debug/deps/event_activities-ed1739dfa94f36b5) + +running 11 tests +test prepared_open_response_replays_the_immutable_binding_snapshot ... ok +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test cancel_closes_an_open_activity_before_the_terminal_run_record ... 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 overflow_of_a_parked_next_returns_overflow_after_recovery ... ok +test immediate_event_wakes_have_durable_distinct_wait_boundaries ... ok +test prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_suspends ... ok +test normal_wake_is_not_acknowledged_until_the_following_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. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 13.52s + + Running tests/event_activity_corruption.rs (target/debug/deps/event_activity_corruption-e16548e9ab56b153) + +running 3 tests +test a_completed_event_range_cannot_replay_with_missing_frames ... ok +test malformed_stream_frames_fail_replay_and_future_append ... ok +test malformed_deadline_range_is_an_error_while_null_is_an_empty_range ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/event_activity_parking.rs (target/debug/deps/event_activity_parking-d8e0d6eeb684776f) + +running 4 tests +test replay_keeps_each_acknowledged_batch_addressable_by_body_call_ordinal ... ok +test intentional_close_cancels_the_pending_pull_without_claiming_a_timeout ... ok +test activation_and_delivery_racing_the_lease_handoff_are_not_lost ... ok +test parked_attempt_survives_restart_and_only_a_ready_subscription_redispatches_it ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + + Running tests/event_wake.rs (target/debug/deps/event_wake-b98778c1ad4de872) + +running 3 tests +test two_racing_deliveries_of_one_event_produce_exactly_one_run ... ok +test matching_event_wakes_once_with_fresh_context ... ok +test a_resumed_run_dispatches_the_original_wake_context ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + Running tests/hn_monitor_integration.rs (target/debug/deps/hn_monitor_integration-059c38eb4828898f) + +running 1 test +test hn_story_event_wakes_monitor_once_with_story_context ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/input_binding.rs (target/debug/deps/input_binding-4f132e6302508de8) + +running 2 tests +test binding_schema_is_additive_and_fails_closed ... ok +test sigkill_before_consumer_resolves_original_journal_output_without_reexecuting_source ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + + Running tests/invalid_schema_preflight.rs (target/debug/deps/invalid_schema_preflight-9c08869ea58d283f) + +running 3 tests +test invalid_json_schema_is_refused_before_journal_or_command ... ok +test unbounded_json_schema_is_refused_before_journal_or_command ... ok +test legitimately_recursive_json_schema_still_starts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.30s + + Running tests/memoization.rs (target/debug/deps/memoization-e5537edfeea8b814) + +running 3 tests +test refuses_missing_wrong_flow_and_unreadable_journal_before_creating_run ... ok +test reused_prefix_survives_restart_without_source_and_never_mutates_prior ... ok +test actual_changed_input_invalidates_consumer_even_with_identical_consumer_spec ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + + Running tests/memory.rs (target/debug/deps/memory-fe5dbc6738ee15ff) + +running 5 tests +test rejected_journal_fact_releases_reservation_and_never_dispatches ... ok +test replay_and_resume_need_no_provider_and_script_receives_recorded_pack ... ok +test over_budget_and_provider_errors_fail_without_dispatch_or_charge ... ok +test llm_dispatch_receives_same_pack_after_resume_without_provider ... ok +test semantic_retry_reuses_memory_without_a_second_charge ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/memory_epoch.rs (target/debug/deps/memory_epoch-627e50d1c86c39e4) + +running 1 test +test epoch_carries_pack_and_exact_charge_and_refuses_duplicate_injection ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/parallel_driver.rs (target/debug/deps/parallel_driver-7a554ae88aa529f9) + +running 4 tests +test stop_after_one_holds_for_an_independent_deterministic_batch ... ok +test pause_before_second_independent_step_holds_the_driver_boundary ... ok +test backpressured_or_mismatched_lane_does_not_drop_a_later_dispatch ... ok +test crash_boundaries_resume_the_real_driver_with_one_effect_per_lane ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + + Running tests/placement_pins.rs (target/debug/deps/placement_pins-839e136752b4e5af) + +running 3 tests +test unsupported_local_pty_is_refused_before_an_earlier_step_can_run ... ok +test default_worker_pins_the_declared_worktree_base_commit_and_refuses_missing_source ... ok +test a_resumed_attempt_keeps_the_original_pin_after_the_worktree_head_moves ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running tests/placement_routing.rs (target/debug/deps/placement_routing-9cb694324a7d7bf1) + +running 3 tests +test a_failed_routing_append_never_starts_or_dispatches_work ... ok +test crash_between_routing_and_start_does_not_redecide ... ok +test worker_retry_consumes_the_original_routing_fact ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + Running tests/routing_diagnostics.rs (target/debug/deps/routing_diagnostics-7807262e1a1167ec) + +running 2 tests +test duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact ... ok +test malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/spec_review_routing.rs (target/debug/deps/spec_review_routing-032df51688a72750) + +running 4 tests +test attempt_scoped_route_is_rejected_at_append_and_replay ... ok +test epoch_cannot_drop_or_replace_a_durable_route ... ok +test malformed_epoch_routes_are_rejected_before_commit ... ok +test workspace_pin_peels_tags_and_refuses_non_commit_objects ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/subscription_liveness.rs (target/debug/deps/subscription_liveness-c3bbfd58dcafb336) + +running 3 tests +test a_fresh_arrival_re_arms_the_latch_and_the_next_silence_can_stale_again ... ok +test submit_event_upserts_subscription_row_and_sweep_flags_it_stale_after_budget ... ok +test stale_transition_is_journaled_as_subscription_stale_entry_in_the_last_known_run ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/subscription_router_delivery.rs (target/debug/deps/subscription_router_delivery-1e0031de1ef580ec) + +running 2 tests +test targeted_ingress_is_fenced_deduplicated_and_isolated_after_restart ... ok +test router_snapshots_keep_absolute_timers_and_overflow_fence_across_restart ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/trigger_watcher.rs (target/debug/deps/trigger_watcher-0504d7ed791030e6) + +running 3 tests +test retains_bad_and_unregistered_events_while_consuming_filter_nonmatches ... ok +test failed_archive_retries_the_same_durable_run ... ok +test journals_payload_and_filename_key_then_archives_and_dedupes_replay ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_core-e734d1d7b8cb7b12) + +running 65 tests +test entry::completion_reason_tests::every_journal_label_matches_serialized ... ok +test entry::completion_reason_tests::all_covers_every_serialized_label ... ok +test machine::tests::every_reason_label_matches_its_serialized_form ... ok +test channel::tests::delivery_replay_and_independent_acknowledged_offsets ... ok +test machine::tests::failed_deterministic_completion_preserves_exit_code_and_stderr ... ok +test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok +test machine::parallel_tests::failed_run_drains_open_siblings_before_terminal_entry ... ok +test machine::tests::every_failed_run_terminates_with_declared_completion_reasons ... ok +test machine::parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_order ... ok +test channel::tests::send_retry_is_stable_and_conflicting_content_is_rejected ... ok +test journal::tests::memory_journal_assigns_sequences_and_rolls_epochs ... ok +test machine::parallel_tests::disjoint_agent_lanes_merge_pins_in_either_completion_order ... ok +test clock::tests::simulated_clock_is_explicitly_advanced ... ok +test channel::tests::malformed_payloads_and_invalid_new_channel_appends_leave_state_unchanged ... ok +test machine::parallel_tests::parallel_lanes_do_not_cross_the_dependency_barrier_early ... ok +test machine::parallel_tests::machine_starts_every_runnable_step_in_authored_order ... ok +test machine::tests::cancel_request_closes_the_active_lease_before_the_terminal_fact ... ok +test machine::tests::deterministic_lease_rejects_invalid_and_foreign_fields ... ok +test channel::tests::forged_deliveries_and_acknowledgements_fail_closed ... ok +test machine::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::tests::repeated_cancel_request_is_idempotent ... ok +test machine::tests::all_backing_off_steps_return_timers ... ok +test machine::tests::manual_recovery_parks_needs_human_and_never_redispatches ... ok +test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... ok +test machine::parallel_tests::every_declared_mutable_surface_participates_in_conflict_selection ... ok +test machine::tests::successful_memo_is_never_scheduled_again ... ok +test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test spec::tests::external_surface_paths_must_have_one_canonical_spelling ... ok +test machine::tests::inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail ... ok +test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok +test retry::tests::jitter_is_repeatable_and_bounded ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test machine::tests::deterministic_lease_override_and_default_are_journaled ... ok +test machine::tests::worker_reported_failure_without_detail_still_records_a_verification ... ok +test memory::tests::caps_compare_exact_decimals_and_each_token_dimension ... ok +test machine::tests::verification_failure_schedules_a_durable_retry ... ok +test schema::tests::in_document_uri_references_resolve_to_the_node_they_name ... ok +test spec::tests::spec_version_is_semver_and_gated ... ok +test state::budget::tests::overflow_and_malformed_cost_leave_total_unchanged ... ok +test spec::tests::zero_agent_flow_is_valid ... ok +test state::tests::end_pin_chain_is_enforced_and_a_broken_chain_is_a_hard_error ... ok +test state::tests::a_completion_that_omits_a_surface_does_not_drop_it_from_the_pin_chain ... ok +test schema::tests::references_the_bound_leaves_opaque_are_refused_by_the_engine ... ok +test spec::tests::workspace_mounts_and_worktrees_must_have_one_canonical_spelling ... ok +test state::budget::tests::adds_costs_exactly_beyond_machine_decimal_precision ... ok +test state::tests::budget_decimal_strings_add_without_floats ... ok +test schema::tests::refusal_names_the_cycle_it_found ... ok +test verify::tests::an_unbounded_schema_in_a_journal_fails_its_gate_instead_of_aborting ... ok +test spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test state::tests::journal_replays_data_gate_verdict_without_rerunning_completed_code ... ok +test spec::tests::a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate ... ok +test spec::tests::unknown_root_and_nested_fields_are_rejected ... ok +test spec::tests::cycles_are_rejected ... ok +test spec::tests::preflight_data_is_fail_closed ... ok +test machine::tests::crashed_attempt_does_not_consume_an_iteration ... ok +test verify::tests::deterministic_output_requires_successful_exit_and_content ... ok +test verify::tests::json_schema_is_a_control_gate ... ok +test spec::tests::the_full_ladder_parses_in_the_one_dialect ... ok +test schema::tests::a_property_named_ref_is_not_a_reference ... ok +test schema::tests::shared_declarations_and_boolean_schemas_are_validated ... ok +test schema::tests::every_refused_corpus_schema_compiles_but_is_refused_by_the_bound ... ok +test spec::tests::sdk_boundary_rejects_a_10_000_step_cycle_with_a_typed_error ... ok +test schema::tests::every_accepted_corpus_schema_is_accepted ... ok +test spec::tests::sdk_boundary_accepts_a_valid_10_000_step_reverse_chain ... ok +test schema::tests::deeply_nested_schemas_do_not_overflow_the_checker ... ok + +test result: ok. 65 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.89s + + Running tests/memoization.rs (target/debug/deps/memoization-79f552e4cb727501) + +running 4 tests +test distinct_large_kernel_integers_do_not_alias_through_float_rounding ... ok +test changed_spec_or_input_dispatches_and_legacy_or_failed_records_miss ... ok +test match_reuses_output_with_provenance_and_zero_cost_without_dispatch ... ok +test canonical_corpus_agrees_with_typescript_and_key_permutations ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/spec_parity.rs (target/debug/deps/spec_parity-a9966affd5aca87f) + +running 10 tests +test placement_requirements_have_identical_canonical_bytes_and_hash ... ok +test memory_declaration_acceptance_matches_the_sdk_corpus ... ok +test placement_declaration_acceptance_matches_the_sdk_corpus ... ok +test step_memory_has_identical_canonical_bytes_and_hash ... ok +test the_kernel_round_trips_declared_agent_transports_and_rejects_unknown_values ... ok +test the_kernel_parses_the_deterministic_rung_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_event_triggered_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_b_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_journal-0286edd157fee7d6) + +running 32 tests +test subscriptions::tests::detect_without_latch_stays_available_for_the_next_sweep ... ok +test subscriptions::tests::upsert_after_stale_re_arms_and_next_silence_can_re_emit ... ok +test subscriptions::tests::prune_sweep_claims_deletes_only_rows_older_than_cutoff ... ok +test registry::tests::releasing_is_scoped_to_the_claiming_run ... ok +test subscriptions::tests::sweep_election_gives_the_first_caller_the_result_and_second_gets_empty ... ok +test registry::tests::a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage ... ok +test registry::tests::a_previous_boots_claim_with_no_run_is_repaired ... ok +test registry::tests::run_admission_reuses_registered_run_and_rejects_spec_drift ... ok +test subscriptions::tests::last_run_for_subscription_returns_the_lex_greatest_ulid_regardless_of_insertion ... ok +test subscriptions::tests::sweep_does_not_re_emit_the_same_stale_row_on_a_later_tick ... ok +test registry::tests::a_pre_migration_registry_gains_boot_id_and_its_claims_are_repairable ... ok +test channel::tests::stale_attempts_and_raw_forged_acknowledgements_cannot_change_offsets ... ok +test subscriptions::tests::latch_is_a_no_op_if_a_fresh_event_arrived_between_detect_and_latch ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test subscriptions::tests::sweep_ignores_subscriptions_whose_silence_is_still_within_budget ... ok +test registry::tests::registry_is_a_rebuildable_run_locator ... ok +test registry::tests::concurrent_new_boot_retries_have_one_recovery_owner ... ok +test registry::tests::a_registered_run_dedupes_across_boots ... ok +test subscriptions::tests::upsert_is_idempotent_across_bumps_and_preserves_event_type_updates ... ok +test registry::tests::releasing_a_claim_lets_the_same_boot_retry ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... ok +test registry::tests::concurrent_same_boot_run_admissions_have_one_owner ... ok +test subscriptions::tests::last_run_for_subscription_returns_none_before_first_arrival ... ok +test registry::tests::prior_boot_unregistered_run_admission_is_repaired ... ok +test tests::rollover_is_atomic_scaffolding_for_epoch_resume ... ok +test subscriptions::tests::sweep_marks_row_stale_when_silence_exceeds_budget ... ok +test tests::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::terminal_run_refuses_every_later_entry_atomically ... ok +test tests::an_unconfirmed_election_is_reclaimed_by_the_next_attempt_not_treated_as_done ... ok +test channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... ok +test channel::tests::failed_channel_writes_never_expose_delivery_or_advance_acknowledged_offset ... ok +test channel::tests::independent_connections_serialize_send_receive_and_acknowledgement ... ok + +test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + + Doc-tests relayflowd + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_core + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_journal + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/router-targeted.txt b/evidence/pr441-router-2026-09-20/router-targeted.txt new file mode 100644 index 000000000..abf6f1b13 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/router-targeted.txt @@ -0,0 +1,14 @@ +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo test -p relayflowd --test subscription_router_delivery + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.64s + Running tests/subscription_router_delivery.rs (target/debug/deps/subscription_router_delivery-1e0031de1ef580ec) + +running 2 tests +test targeted_ingress_is_fenced_deduplicated_and_isolated_after_restart ... ok +test router_snapshots_keep_absolute_timers_and_overflow_fence_across_restart ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/router-wire.txt b/evidence/pr441-router-2026-09-20/router-wire.txt new file mode 100644 index 000000000..2b0943c00 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/router-wire.txt @@ -0,0 +1,145 @@ +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo test -p relayflowd targeted_router_verbs + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.91s + Running unittests src/lib.rs (target/debug/deps/relayflowd-f043db0bb3534a16) + +running 1 test +test server::tests::subscription_router::targeted_router_verbs_validate_receipts_and_preserve_wire_metadata ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 51 filtered out; finished in 0.02s + + Running unittests src/main.rs (target/debug/deps/relayflowd-6e3681176306c99e) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/budget_gate.rs (target/debug/deps/budget_gate-9607ae7c2db11b74) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 10 filtered out; finished in 0.00s + + Running tests/crash_resume.rs (target/debug/deps/crash_resume-d2d102ec3e89b705) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 40 filtered out; finished in 0.00s + + Running tests/daemon_lifecycle.rs (target/debug/deps/daemon_lifecycle-b705da9761b2a254) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.00s + + Running tests/event_activities.rs (target/debug/deps/event_activities-ed1739dfa94f36b5) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 11 filtered out; finished in 0.00s + + Running tests/event_activity_corruption.rs (target/debug/deps/event_activity_corruption-e16548e9ab56b153) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Running tests/event_activity_parking.rs (target/debug/deps/event_activity_parking-d8e0d6eeb684776f) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 0.00s + + Running tests/event_wake.rs (target/debug/deps/event_wake-b98778c1ad4de872) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Running tests/hn_monitor_integration.rs (target/debug/deps/hn_monitor_integration-059c38eb4828898f) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s + + Running tests/input_binding.rs (target/debug/deps/input_binding-4f132e6302508de8) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s + + Running tests/invalid_schema_preflight.rs (target/debug/deps/invalid_schema_preflight-9c08869ea58d283f) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Running tests/memoization.rs (target/debug/deps/memoization-e5537edfeea8b814) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Running tests/memory.rs (target/debug/deps/memory-fe5dbc6738ee15ff) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out; finished in 0.00s + + Running tests/memory_epoch.rs (target/debug/deps/memory_epoch-627e50d1c86c39e4) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s + + Running tests/parallel_driver.rs (target/debug/deps/parallel_driver-7a554ae88aa529f9) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 0.00s + + Running tests/placement_pins.rs (target/debug/deps/placement_pins-839e136752b4e5af) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Running tests/placement_routing.rs (target/debug/deps/placement_routing-9cb694324a7d7bf1) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Running tests/routing_diagnostics.rs (target/debug/deps/routing_diagnostics-7807262e1a1167ec) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s + + Running tests/spec_review_routing.rs (target/debug/deps/spec_review_routing-032df51688a72750) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 0.00s + + Running tests/subscription_liveness.rs (target/debug/deps/subscription_liveness-c3bbfd58dcafb336) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + Running tests/subscription_router_delivery.rs (target/debug/deps/subscription_router_delivery-1e0031de1ef580ec) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s + + Running tests/trigger_watcher.rs (target/debug/deps/trigger_watcher-0504d7ed791030e6) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s + + +exit status: 0 diff --git a/kernel/relayflowd/src/engine.rs b/kernel/relayflowd/src/engine.rs index bb1ac4b70..0f1c878ea 100644 --- a/kernel/relayflowd/src/engine.rs +++ b/kernel/relayflowd/src/engine.rs @@ -105,7 +105,7 @@ mod model; mod placement; mod remote; mod subscriptions; -pub use subscriptions::{PendingRange, SubscriptionNext, SubscriptionOpen, SubscriptionWake, SubscriptionPark, SubscriptionWaitPhase}; +pub use subscriptions::{SubscriptionRouterError, PendingRange, SubscriptionNext, SubscriptionOpen, SubscriptionWake, SubscriptionPark, SubscriptionWaitPhase}; mod wake; pub use channels::ChannelCommandError; pub use model::{RunOutcome, RunSnapshot, RunStatus, StepSnapshot, StepStatus}; diff --git a/kernel/relayflowd/src/engine/subscriptions/mod.rs b/kernel/relayflowd/src/engine/subscriptions/mod.rs index a06e3f23e..aa57cd7e9 100644 --- a/kernel/relayflowd/src/engine/subscriptions/mod.rs +++ b/kernel/relayflowd/src/engine/subscriptions/mod.rs @@ -24,6 +24,8 @@ use state::*; mod parking; mod replay; mod local_router; +mod router_delivery; +pub use router_delivery::SubscriptionRouterError; mod wait_timers; pub use parking::{SubscriptionPark, SubscriptionWaitPhase}; pub(super) use parking::PARK_PREFIX; @@ -155,6 +157,9 @@ impl Engine { ) -> Result { let mut journal = self.open_run(run_id)?; if let Some(active) = subscriptions(&journal)?.remove(subscription_id) { + if active.opened.router_binding != router_binding || active.opened.ingress_offset != ingress_offset { + return Err(SubscriptionRouterError("subscription_binding_mismatch").into()); + } return Ok(SubscriptionOpen::Active { subscription_id: subscription_id.to_owned(), stream: active.opened.stream, deadline_at_ms: active.opened.deadline_at_ms }); } let prepared = prepared_subscriptions(&journal)?.remove(subscription_id) diff --git a/kernel/relayflowd/src/engine/subscriptions/router_delivery.rs b/kernel/relayflowd/src/engine/subscriptions/router_delivery.rs new file mode 100644 index 000000000..4434e1b0b --- /dev/null +++ b/kernel/relayflowd/src/engine/subscriptions/router_delivery.rs @@ -0,0 +1,112 @@ +//! Targeted ingress from the external authorized router, never a run broadcast. +use super::*; + +#[derive(Debug)] +pub struct SubscriptionRouterError(pub &'static str); +impl std::fmt::Display for SubscriptionRouterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} +impl std::error::Error for SubscriptionRouterError {} + +impl Engine { + /// The protocol run lock serializes receipt validation with append/close. + /// Cloud proves provider authority; the kernel fences the immutable receipt. + pub fn deliver_subscription_frame( + &self, + run_id: &str, + subscription_id: &str, + router_binding: &Value, + delivery_id: &str, + frame: Value, + ) -> Result { + let journal = self.open_run(run_id)?; + let states = subscriptions(&journal)?; + let state = states + .get(subscription_id) + .ok_or(SubscriptionRouterError("subscription_not_active"))?; + if &state.opened.router_binding != router_binding { + return Err(SubscriptionRouterError("subscription_binding_mismatch").into()); + } + if state.closed.is_some() || state.overflow_fence.is_some() { + return Err(SubscriptionRouterError("subscription_closed").into()); + } + drop(journal); + self.append_subscription_frame(run_id, subscription_id, delivery_id, frame) + } +} + +impl Engine { + /// Complete Cloud's durable overflow fence in the same journal sequencer. + /// A matching closed receipt is an idempotent no-op, including a normal + /// close that won the race before the external overflow signal. + pub fn fence_router_subscription_overflow( + &self, + run_id: &str, + subscription_id: &str, + router_binding: &Value, + ) -> Result<()> { + let mut journal = self.open_run(run_id)?; + let states = subscriptions(&journal)?; + let state = states + .get(subscription_id) + .ok_or(SubscriptionRouterError("subscription_not_active"))?; + if &state.opened.router_binding != router_binding { + return Err(SubscriptionRouterError("subscription_binding_mismatch").into()); + } + if state.closed.is_some() { + return Ok(()); + } + 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(), + )?; + self.complete_fenced_overflows_in_journal(&mut journal, self.clock.now_ms())?; + Ok(()) + } + + /// A read-only projection of durable subscription state for external + /// routers. Absolute idle instants must never be rebuilt from callback time. + pub fn inspect_subscriptions(&self, run_id: &str) -> Result> { + let journal = self.open_run(run_id)?; + let entries = journal.scan_all()?; + let mut result = Vec::new(); + for (_, prepared) in prepared_subscriptions(&journal)? { + result.push(json!({ + "subscriptionId": prepared.subscription_id, "state": "prepared", + "unreadFrames": 0, "unreadBytes": 0, + "settleMs": prepared.settle_ms, "deadlineAtMs": prepared.deadline_at_ms, + })); + } + for (id, state) in subscriptions(&journal)? { + let unread = unread_frames(&entries, &state)?; + let mut snapshot = json!({ + "subscriptionId": id, + "state": if state.closed.is_some() { "closed" } else { "active" }, + "routerBinding": state.opened.router_binding, + "ingressOffset": state.opened.ingress_offset, + "unreadFrames": unread.len(), "unreadBytes": unread_bytes(&unread), + "settleMs": state.opened.settle_ms, + "idleAtMs": state.active_wait.as_ref().and_then(|wait| wait.idle_at_ms) + .unwrap_or_else(|| state.last_wake_at_ms.saturating_add(state.opened.idle_ms)), + "deadlineAtMs": state.opened.deadline_at_ms, + }); + if let Some(reason) = state.closed { + snapshot["completionReason"] = serde_json::to_value(reason)?; + } + result.push(snapshot); + } + result.sort_by(|a, b| { + a["subscriptionId"] + .as_str() + .cmp(&b["subscriptionId"].as_str()) + }); + Ok(result) + } +} diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index aca39f3dc..d67e83f0c 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -559,7 +559,37 @@ fn handle_request( ensure_mutable(&engine, ¶ms.run_id)?; to_value(engine.activate_subscription( ¶ms.run_id, ¶ms.subscription_id, params.ingress_offset, params.router_binding, - ).map_err(internal_error)?) + ).map_err(subscription_router_error)?) + } + "subscription.deliver" => { + let params: SubscriptionDeliverParams = 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 appended = engine.deliver_subscription_frame( + ¶ms.run_id, ¶ms.subscription_id, ¶ms.router_binding, + ¶ms.delivery_id, params.frame, + ).map_err(subscription_router_error)?; + if appended { return Ok(json!({"appended": true})); } + let snapshots = engine.inspect_subscriptions(¶ms.run_id).map_err(internal_error)?; + let overflow = snapshots.iter().any(|s| s["subscriptionId"] == params.subscription_id + && s["completionReason"] == "overflow"); + Ok(json!({"appended": false, "reason": if overflow { "overflow" } else { "duplicate" }})) + } + "subscription.inspect" => { + let params: SubscriptionInspectParams = decode_params(request.params)?; + let lock = hub.run_lock(¶ms.run_id); + let _guard = lock.lock().expect("run lock"); + Ok(json!({"subscriptions": engine.inspect_subscriptions(¶ms.run_id).map_err(internal_error)?})) + } + "subscription.fence_overflow" => { + let params: SubscriptionFenceOverflowParams = decode_params(request.params)?; + let lock = hub.run_lock(¶ms.run_id); + let _guard = lock.lock().expect("run lock"); + engine.fence_router_subscription_overflow( + ¶ms.run_id, ¶ms.subscription_id, ¶ms.router_binding, + ).map_err(subscription_router_error)?; + Ok(json!({"fenced": true})) } "subscription.next" => { let params: SubscriptionNextParams = decode_params(request.params)?; diff --git a/kernel/relayflowd/src/server/protocol.rs b/kernel/relayflowd/src/server/protocol.rs index 3ce0bb141..d819375ab 100644 --- a/kernel/relayflowd/src/server/protocol.rs +++ b/kernel/relayflowd/src/server/protocol.rs @@ -82,6 +82,14 @@ pub(super) fn run_start_error(error: anyhow::Error) -> (&'static str, String) { } } +pub(super) fn subscription_router_error(error: anyhow::Error) -> (&'static str, String) { + if let Some(refusal) = error.downcast_ref::() { + (refusal.0, refusal.to_string()) + } else { + internal_error(error) + } +} + pub(super) fn internal_error(error: anyhow::Error) -> (&'static str, String) { if error .downcast_ref::() diff --git a/kernel/relayflowd/src/server/tests.rs b/kernel/relayflowd/src/server/tests.rs index c960de3c9..fdecc43b3 100644 --- a/kernel/relayflowd/src/server/tests.rs +++ b/kernel/relayflowd/src/server/tests.rs @@ -15,6 +15,7 @@ use super::*; use crate::worker::{JournalObserver, LeaseProbe}; mod agent; +mod subscription_router; fn shared_writer() -> (SharedWriter, UnixStream) { let (writer, peer) = UnixStream::pair().unwrap(); diff --git a/kernel/relayflowd/src/server/tests/subscription_router.rs b/kernel/relayflowd/src/server/tests/subscription_router.rs new file mode 100644 index 000000000..8573f30fd --- /dev/null +++ b/kernel/relayflowd/src/server/tests/subscription_router.rs @@ -0,0 +1,81 @@ +use super::*; + +#[test] +fn targeted_router_verbs_validate_receipts_and_preserve_wire_metadata() { + let directory = tempdir().unwrap(); + let hub = Arc::new(ProtocolHub::default()); + let run_id = start_llm_run(directory.path(), &hub); + let (writer, _peer) = shared_writer(); + let call = |verb: &str, params: Value| { + request( + directory.path(), + &hub, + 3, + &writer, + &json!({"id": "router", "verb": verb, "params": params}).to_string(), + ) + }; + let receipt = json!({"binding_id":"a","generation":7}); + for name in ["a", "b"] { + let response = call( + "subscription.open", + json!({"run_id":run_id,"subscription_id":name, + "event_types":["github"],"settle_ms":0,"idle_ms":60000,"deadline_ms":120000,"include_self":false}), + ); + assert!(response.ok, "{:?}", response.error); + let response = call( + "subscription.activate", + json!({"run_id":run_id,"subscription_id":name, + "ingress_offset":0,"router_binding":receipt}), + ); + assert!(response.ok, "{:?}", response.error); + } + let mut delivery = json!({"run_id":run_id,"subscription_id":"a","router_binding":receipt, + "delivery_id":"frame-1","frame":{"type":"github","payload":{"number":1}}}); + let response = call("subscription.deliver", delivery.clone()); + assert!(response.ok, "{:?}", response.error); + assert_eq!(response.result.unwrap(), json!({"appended":true})); + assert_eq!( + call("subscription.deliver", delivery.clone()) + .result + .unwrap(), + json!({"appended":false,"reason":"duplicate"}) + ); + delivery["router_binding"]["generation"] = json!(8); + let refused = call("subscription.deliver", delivery.clone()); + assert!(!refused.ok); + assert_eq!(refused.error.unwrap().code, "subscription_binding_mismatch"); + let mismatched_activation = call( + "subscription.activate", + json!({"run_id":run_id,"subscription_id":"a", + "ingress_offset":1,"router_binding":receipt}), + ); + assert_eq!( + mismatched_activation.error.unwrap().code, + "subscription_binding_mismatch" + ); + let snapshots = call("subscription.inspect", json!({"run_id":run_id})) + .result + .unwrap(); + assert_eq!(snapshots["subscriptions"][0]["unreadFrames"], 1); + assert_eq!(snapshots["subscriptions"][1]["unreadFrames"], 0); + assert!(snapshots["subscriptions"][0]["idleAtMs"].is_i64()); + let response = call( + "subscription.fence_overflow", + json!({"run_id":run_id,"subscription_id":"a","router_binding":receipt}), + ); + assert!(response.ok, "{:?}", response.error); + assert_eq!(response.result.unwrap(), json!({"fenced":true})); + delivery["router_binding"] = receipt; + assert_eq!( + call("subscription.deliver", delivery).error.unwrap().code, + "subscription_closed" + ); + let snapshots = call("subscription.inspect", json!({"run_id":run_id})) + .result + .unwrap(); + assert_eq!( + snapshots["subscriptions"][0]["completionReason"], + "overflow" + ); +} diff --git a/kernel/relayflowd/src/server/wire.rs b/kernel/relayflowd/src/server/wire.rs index e82d49e35..f8e5b001e 100644 --- a/kernel/relayflowd/src/server/wire.rs +++ b/kernel/relayflowd/src/server/wire.rs @@ -230,6 +230,30 @@ pub(super) struct SubscriptionActivateParams { pub router_binding: Value, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SubscriptionInspectParams { + pub run_id: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SubscriptionFenceOverflowParams { + pub run_id: String, + pub subscription_id: String, + pub router_binding: Value, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SubscriptionDeliverParams { + pub run_id: String, + pub subscription_id: String, + pub router_binding: Value, + pub delivery_id: String, + pub frame: Value, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct SubscriptionCloseParams { diff --git a/kernel/relayflowd/tests/event_activities.rs b/kernel/relayflowd/tests/event_activities.rs index c5c14ea4d..fd6d26404 100644 --- a/kernel/relayflowd/tests/event_activities.rs +++ b/kernel/relayflowd/tests/event_activities.rs @@ -88,7 +88,7 @@ fn prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_su &run_id, "handoff", 41, json!({"binding_id": "binding-1", "generation": 7}), ).unwrap(), SubscriptionOpen::Active { .. })); assert!(matches!(resumed.activate_subscription( - &run_id, "handoff", 41, json!({"binding_id": "ignored-on-retry"}), + &run_id, "handoff", 41, json!({"binding_id": "binding-1", "generation": 7}), ).unwrap(), SubscriptionOpen::Active { .. })); assert!(matches!(resumed.next_subscription_outcome(&run_id, "handoff", None).unwrap().0, diff --git a/kernel/relayflowd/tests/subscription_router_delivery.rs b/kernel/relayflowd/tests/subscription_router_delivery.rs new file mode 100644 index 000000000..2b2801c0f --- /dev/null +++ b/kernel/relayflowd/tests/subscription_router_delivery.rs @@ -0,0 +1,193 @@ +use relayflowd::Engine; +use relayflowd_core::{EntryType, RunSpec, SimClock, SubscriptionCompletionReason}; +use serde_json::json; + +#[test] +fn targeted_ingress_is_fenced_deduplicated_and_isolated_after_restart() { + let dir = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(dir.path(), SimClock::new(100)); + let spec = + RunSpec::parse(&json!({"steps":[{"id":"body","type":"llm","prompt":"body"}]})).unwrap(); + let id = engine.start(spec, "router-test", Some(0)).unwrap().run_id; + let receipt = json!({"binding_id":"one","generation":7}); + let frame = json!({"type":"github","payload":{"action":"opened"}}); + for subscription in ["one", "two"] { + engine + .open_subscription( + &id, + subscription, + vec!["github".into()], + None, + 0, + 100, + 1000, + false, + ) + .unwrap(); + } + assert!( + engine + .deliver_subscription_frame(&id, "one", &receipt, "event-1", frame.clone()) + .is_err() + ); + engine + .activate_subscription(&id, "one", 42, receipt.clone()) + .unwrap(); + engine + .activate_subscription(&id, "two", 42, json!({"binding_id":"two","generation":7})) + .unwrap(); + let before = engine.journal_entries(&id, 1, 500).unwrap().len(); + assert!( + engine + .activate_subscription(&id, "one", 42, json!({"binding_id":"one","generation":8})) + .is_err() + ); + assert!( + engine + .activate_subscription(&id, "one", 43, receipt.clone()) + .is_err() + ); + assert!( + engine + .deliver_subscription_frame( + &id, + "one", + &json!({"binding_id":"one","generation":8}), + "event-1", + frame.clone() + ) + .is_err() + ); + assert_eq!(engine.journal_entries(&id, 1, 500).unwrap().len(), before); + assert!( + engine + .deliver_subscription_frame(&id, "one", &receipt, "event-1", frame.clone()) + .unwrap() + ); + drop(engine); + + let restored = Engine::with_clock(dir.path(), SimClock::new(110)); + restored + .activate_subscription(&id, "one", 42, receipt.clone()) + .unwrap(); + assert!( + !restored + .deliver_subscription_frame(&id, "one", &receipt, "event-1", frame.clone()) + .unwrap() + ); + let entries = restored.journal_entries(&id, 1, 500).unwrap(); + let appends: Vec<_> = entries + .iter() + .filter(|e| e.entry_type == EntryType::StreamAppended) + .collect(); + assert_eq!(appends.len(), 1); + assert_eq!(appends[0].payload["stream"], "subscription/one"); + assert_eq!(appends[0].payload["provider_delivery_id"], "event-1"); + assert_eq!(appends[0].payload["message"], frame); + restored + .close_subscription(&id, "one", SubscriptionCompletionReason::Closed) + .unwrap(); + let before = restored.journal_entries(&id, 1, 500).unwrap().len(); + assert!( + restored + .deliver_subscription_frame(&id, "one", &receipt, "event-2", frame) + .is_err() + ); + assert_eq!(restored.journal_entries(&id, 1, 500).unwrap().len(), before); +} + +#[test] +fn router_snapshots_keep_absolute_timers_and_overflow_fence_across_restart() { + let dir = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(dir.path(), SimClock::new(100)); + let spec = + RunSpec::parse(&json!({"steps":[{"id":"body","type":"llm","prompt":"body"}]})).unwrap(); + let id = engine.start(spec, "router-test", Some(0)).unwrap().run_id; + let receipt = json!({"generation":7}); + engine + .open_subscription( + &id, + "one", + vec!["github".into()], + None, + 20, + 100, + 1000, + false, + ) + .unwrap(); + assert_eq!( + engine.inspect_subscriptions(&id).unwrap()[0]["state"], + "prepared" + ); + engine + .activate_subscription(&id, "one", 42, receipt.clone()) + .unwrap(); + engine.next_subscription_outcome(&id, "one", None).unwrap(); + let before = engine.inspect_subscriptions(&id).unwrap(); + assert_eq!(before[0]["idleAtMs"], 200); + assert_eq!(before[0]["deadlineAtMs"], 1100); + assert_eq!(before[0]["settleMs"], 20); + assert_eq!(before[0]["ingressOffset"], 42); + drop(engine); + let restored = Engine::with_clock(dir.path(), SimClock::new(150)); + restored + .next_subscription_outcome(&id, "one", None) + .unwrap(); + assert_eq!(restored.inspect_subscriptions(&id).unwrap(), before); + let frame = json!({"type":"github","payload":{"id":1}}); + restored + .deliver_subscription_frame(&id, "one", &receipt, "event-1", frame.clone()) + .unwrap(); + let snapshot = restored.inspect_subscriptions(&id).unwrap(); + assert_eq!(snapshot[0]["unreadFrames"], 1); + assert_eq!( + snapshot[0]["unreadBytes"], + serde_json::to_vec(&frame).unwrap().len() + ); + assert!( + restored + .fence_router_subscription_overflow(&id, "one", &json!({"generation":8})) + .is_err() + ); + restored + .fence_router_subscription_overflow(&id, "one", &receipt) + .unwrap(); + drop(restored); + let recovered = Engine::with_clock(dir.path(), SimClock::new(160)); + recovered + .fence_router_subscription_overflow(&id, "one", &receipt) + .unwrap(); + let snapshots = recovered.inspect_subscriptions(&id).unwrap(); + assert_eq!(snapshots[0]["state"], "closed"); + assert_eq!(snapshots[0]["completionReason"], "overflow"); + assert!(matches!( + recovered + .next_subscription_outcome(&id, "one", None) + .unwrap() + .0, + relayflowd::engine::SubscriptionNext::Wake( + relayflowd::engine::SubscriptionWake::Overflow { retained: 1, .. } + ) + )); + assert!( + recovered + .deliver_subscription_frame(&id, "one", &receipt, "event-2", frame) + .is_err() + ); + let entries = recovered.journal_entries(&id, 1, 500).unwrap(); + assert_eq!( + entries + .iter() + .filter(|e| e.entry_type == EntryType::SubscriptionOverflowFenced) + .count(), + 1 + ); + assert_eq!( + entries + .iter() + .filter(|e| e.entry_type == EntryType::SubscriptionClosed) + .count(), + 1 + ); +} diff --git a/packages/sdk/src/authored-root.ts b/packages/sdk/src/authored-root.ts index e6d6c5aa9..9327fa47d 100644 --- a/packages/sdk/src/authored-root.ts +++ b/packages/sdk/src/authored-root.ts @@ -46,6 +46,8 @@ export interface AuthoredRootSourceAuthority { } export interface DurableAuthoredOptions { + /** Preserve root authority even if a child later fails or parks. */ + readonly onAdmitted?: (runId: string) => void; readonly dataDir: string; readonly admissionKey?: string; readonly localAgentStream?: string; @@ -92,6 +94,7 @@ export async function executeDurableAuthoredFlow( workspace: [], streams: [{ stream, read_offset: 0 }], }); const outcome = await journal.runStart(spec, undefined, admissionKey); + options.onAdmitted?.(outcome.run_id); if (outcome.status === 'completed') { dispatchWait.cancel(); return await completedRootResult(journal, outcome.run_id); diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 62e50302c..ed42ef086 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { withSubscriptionMetadata } from './cli/subscription-report.js'; import { addPlugin } from './cli/add.js'; import { watchCheck } from './cli-watch.js'; import { checkHelperBody } from './cli/check-helper-body.js'; @@ -318,9 +319,10 @@ export async function runCli( // preflight) is worse than printing `Observer:` on a later line, so we // emit the run report immediately and finalize the observer link after. if (parsed.json) { + const reported = await withSubscriptionMetadata(execution); const observerUrl = await observerUrlFrom(observerMint, io); - emitRunReport(execution, parsed.json, io, observerUrl); - return execution.exitCode; + emitRunReport(reported, parsed.json, io, observerUrl); + return reported.exitCode; } emitRunReport(execution, parsed.json, io); await finalizeObserverLine(observerMint, io); diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index 062af4e20..d797c9827 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -89,6 +89,7 @@ export async function runDirectFlow( { dataDir, admissionKey: admissionIdentity, + onAdmitted: runId => { base.rootRunId = runId; }, localAgentStream: localAgent?.stream, lifecycle: { onProgress: options.onProgress, diff --git a/packages/sdk/src/cli/run.ts b/packages/sdk/src/cli/run.ts index 8fd8edb9b..79fa32b53 100644 --- a/packages/sdk/src/cli/run.ts +++ b/packages/sdk/src/cli/run.ts @@ -23,6 +23,7 @@ import type { RunCompletionReason, RunOutcome, RunStatus, + SubscriptionSnapshot, } from '../protocol.js'; import type { StepType } from '../spec.js'; import type { LoweredCompletionReason } from '../authored-flow-executor.js'; @@ -50,10 +51,14 @@ export interface RunReport { command: RunCommand; path?: string; runId?: string; + /** Subscription owner; runId may name a failed child for diagnostics. */ + rootRunId?: string; socketPath?: string; status?: RunStatus | 'suspended'; /** Cloud consumes this exact durable boundary before launching a resume. */ - suspension?: AuthoredFlowSuspendedResult['suspension']; + suspension?: AuthoredFlowSuspendedResult['suspension'] & { settleMs?: number; idleAtMs?: number }; + /** Durable subscription projection for Cloud routing and cleanup. */ + subscriptions?: SubscriptionSnapshot[]; completionReason?: RunCompletionReason; completedSteps?: number; reuse?: { fromRunId: string; reusedSteps: number; executedSteps: number }; @@ -201,6 +206,7 @@ export async function resumeFlow( try { const authoredRoot = await readAuthoredRootMetadata(client, runId); if (authoredRoot !== undefined) { + base.rootRunId = runId; if (authoredRoot.localAgentStream !== undefined && !options.localAgent) { throw new Error('authored root requires --local-agent to resume its pinned worker surface'); } diff --git a/packages/sdk/src/cli/subscription-report.ts b/packages/sdk/src/cli/subscription-report.ts new file mode 100644 index 000000000..f42a0effb --- /dev/null +++ b/packages/sdk/src/cli/subscription-report.ts @@ -0,0 +1,51 @@ +import { JournalClient } from '../journal-client.js'; +import type { SubscriptionInspectResult } from '../protocol.js'; +import type { RunExecution } from './run.js'; + +/** One JSON emission boundary covers authored/declarative run and resume outcomes. */ +export async function withSubscriptionMetadata( + execution: RunExecution, + inspect: (socket: string, runId: string) => Promise = inspectSubscriptions, +): Promise { + const { report } = execution; + const rootRunId = report.rootRunId ?? report.runId; + if (rootRunId === undefined || execution.exitCode === 2) { + return { ...execution, report: { ...report, subscriptions: [] } }; + } + try { + if (report.socketPath === undefined) throw new Error('run report has no daemon socket'); + const { subscriptions } = await inspect(report.socketPath, rootRunId); + let suspension = report.suspension; + if (suspension?.kind === 'event_wait') { + const state = subscriptions.find(item => item.subscriptionId === suspension!.subscriptionId); + if (state?.state !== 'active' || !Number.isSafeInteger(state.idleAtMs) + || !Number.isSafeInteger(state.settleMs) || !Number.isSafeInteger(state.deadlineAtMs)) { + throw new Error('event wait has no active durable timing snapshot'); + } + suspension = { ...suspension, settleMs: state.settleMs, + idleAtMs: state.idleAtMs, deadlineAtMs: state.deadlineAtMs }; + } + return { ...execution, report: { ...report, subscriptions, + ...(suspension === undefined ? {} : { suspension }) } }; + } catch (error) { + // Missing authority is not an empty snapshot: Cloud must not close bindings + // or schedule timers from invented state. Preserve the run's diagnostic, + // but fail this report instead of presenting a usable suspension/success. + const { suspension: _suspension, subscriptions: _subscriptions, ...failed } = report; + return { exitCode: 1, report: { ...failed, ok: false, diagnostics: [ + ...report.diagnostics, { severity: 'failure', kind: 'protocol_error', + message: `Cannot inspect durable subscriptions: ${error instanceof Error ? error.message : 'unknown error'}` }, + ] } }; + } +} + +async function inspectSubscriptions(socket: string, runId: string): Promise { + const client = new JournalClient(socket); + try { + await client.connect(); + await client.hello('flows-subscription-report'); + return await client.subscriptionInspect({ run_id: runId }); + } finally { + client.close(); + } +} diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 9c03c0ea7..7017c56c6 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -440,6 +440,21 @@ export class JournalClient extends EventEmitter { return this.request('subscription.activate', params, null); } + /** Append to one activated subscription under its immutable router receipt. */ + subscriptionDeliver(params: VerbContract['subscription.deliver']['params']): Promise { + return this.request('subscription.deliver', params, null); + } + + /** Read durable subscription state without changing timers or ingress. */ + subscriptionInspect(params: VerbContract['subscription.inspect']['params']): Promise { + return this.request('subscription.inspect', params); + } + + /** Fence the exact activated receipt after router-side overflow. */ + subscriptionFenceOverflow(params: VerbContract['subscription.fence_overflow']['params']): Promise { + return this.request('subscription.fence_overflow', params, null); + } + /** Return a durable wake, or an explicit suspension with no daemon-side sleep. */ subscriptionNext(params: VerbContract['subscription.next']['params']): Promise { return this.request('subscription.next', params, null); diff --git a/packages/sdk/src/journal-reader.ts b/packages/sdk/src/journal-reader.ts index 516327f58..8a3b56224 100644 --- a/packages/sdk/src/journal-reader.ts +++ b/packages/sdk/src/journal-reader.ts @@ -8,6 +8,8 @@ import { journalRecordOffset } from './journal-offset.js'; // Journal version 1's closed vocabulary (relayflowd-core/src/entry.rs). const ENTRY_TYPES = new Set([ 'run.spawned', 'run.cancel.requested', 'event.received', 'subscription.registered', + 'subscription.prepared', 'subscription.opened', 'subscription.closed', + 'subscription.overflow.fenced', 'subscription.acknowledged', 'subscription.matched', 'subscription.stale', 'step.routed', 'step.attempt.started', 'step.completed', 'wait.event', 'wait.human', 'sleep.until', 'wait.completed', 'stream.appended', 'memory.injected', 'channel.appended', 'channel.delivered', diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index eab157b53..5c92803c8 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -58,6 +58,9 @@ export type Verb = | 'event.submit' | 'subscription.open' | 'subscription.activate' + | 'subscription.deliver' + | 'subscription.inspect' + | 'subscription.fence_overflow' | 'subscription.next' | 'subscription.close' | 'stream.append' @@ -435,6 +438,42 @@ export interface SubscriptionActivateResult { deadline_at_ms: number; } +/** Targeted Cloud ingress; receipt must equal the journaled activation receipt. */ +export interface SubscriptionDeliverParams { + run_id: string; + subscription_id: string; + router_binding: Record; + delivery_id: string; + frame: unknown; +} +export interface SubscriptionDeliverResult { + /** False for an idempotent duplicate or a frame that closes on overflow. */ + appended: boolean; + reason?: 'duplicate' | 'overflow'; +} + +/** Read-only projection of the kernel's durable subscription records. */ +export interface SubscriptionSnapshot { + subscriptionId: string; + state: 'prepared' | 'active' | 'closed'; + completionReason?: 'closed' | 'run_completed' | 'canceled' | 'deadline' | 'overflow'; + routerBinding?: Record; + ingressOffset?: number; + unreadFrames: number; + unreadBytes: number; + settleMs: number; + idleAtMs?: number; + deadlineAtMs: number; +} +export interface SubscriptionInspectParams { run_id: string } +export interface SubscriptionInspectResult { subscriptions: SubscriptionSnapshot[] } +export interface SubscriptionFenceOverflowParams { + run_id: string; + subscription_id: string; + router_binding: Record; +} +export interface SubscriptionFenceOverflowResult { fenced: true } + export interface SubscriptionNextParams { run_id: string; subscription_id: string; @@ -511,6 +550,9 @@ export interface VerbContract { 'event.submit': { params: EventSubmitParams; result: EventSubmitResult }; 'subscription.open': { params: SubscriptionOpenParams; result: SubscriptionOpenResult }; 'subscription.activate': { params: SubscriptionActivateParams; result: SubscriptionActivateResult }; + 'subscription.deliver': { params: SubscriptionDeliverParams; result: SubscriptionDeliverResult }; + 'subscription.inspect': { params: SubscriptionInspectParams; result: SubscriptionInspectResult }; + 'subscription.fence_overflow': { params: SubscriptionFenceOverflowParams; result: SubscriptionFenceOverflowResult }; 'subscription.next': { params: SubscriptionNextParams; result: SubscriptionNextResult }; 'subscription.close': { params: SubscriptionCloseParams; result: SubscriptionCloseResult }; 'stream.append': { params: StreamAppendParams; result: StreamAppendResult }; diff --git a/packages/sdk/tests/cli-replay.test.ts b/packages/sdk/tests/cli-replay.test.ts index 75a8eebfd..bea13034b 100644 --- a/packages/sdk/tests/cli-replay.test.ts +++ b/packages/sdk/tests/cli-replay.test.ts @@ -60,6 +60,21 @@ function diskState(directory: string): unknown { } describe('flows replay', () => { + it('replays subscription records without dropping subsequent step telemetry', async () => { + const kinds = ['subscription.prepared', 'subscription.opened', 'subscription.acknowledged', + 'subscription.overflow.fenced', 'subscription.closed']; + const subscriptionEvents = kinds.map((entry_type, index) => ({ ...EVENTS[0]!, seq: index + 2, + entry_type, payload: { subscription_id: 'activity-1' } })); + const events = [EVENTS[0]!, ...subscriptionEvents, + ...EVENTS.slice(1).map((event, index) => ({ ...event, seq: kinds.length + index + 2 }))]; + const { dataDir, writer } = fixture(events); + writer.close(); + const result = await replay(dataDir, ['--json']); + expect(result.code).toBe(0); + expect(result.stdout.map(line => JSON.parse(line).event)).toEqual(events); + expect(result.stdout.some(line => JSON.parse(line).kind === 'step.completed')).toBe(true); + }); + it('full walk emits every journal event in order through the terminal event without a daemon or writes', async () => { const { dataDir, writer } = fixture(EVENTS, true); writer.close(); diff --git a/packages/sdk/tests/cli.test.ts b/packages/sdk/tests/cli.test.ts index d445cb9d4..a92881e25 100644 --- a/packages/sdk/tests/cli.test.ts +++ b/packages/sdk/tests/cli.test.ts @@ -705,6 +705,7 @@ describe('flows run/resume CLI over the journal protocol', () => { let dialectError: string | null | undefined; await startCliLoopback(dataDir, { hello: sendOk, + 'subscription.inspect': (ctx) => sendResult(ctx, { subscriptions: [] }), 'run.start': (ctx, params) => { dialectError = kernelDialectError(params['spec']); sendResult(ctx, { @@ -1005,6 +1006,7 @@ describe('flows run/resume CLI over the journal protocol', () => { const dataDir = temporaryProject('flows-resume-'); await startCliLoopback(dataDir, { hello: sendOk, + 'subscription.inspect': ctx => sendResult(ctx, { subscriptions: [] }), 'run.resume': (ctx, params) => { if (params['run_id'] === 'known-run') { sendResult(ctx, { diff --git a/packages/sdk/tests/fixtures/event-await-cli-probe.mjs b/packages/sdk/tests/fixtures/event-await-cli-probe.mjs index ec8d712cf..a38785d81 100644 --- a/packages/sdk/tests/fixtures/event-await-cli-probe.mjs +++ b/packages/sdk/tests/fixtures/event-await-cli-probe.mjs @@ -57,27 +57,69 @@ try { const first = invoke('run', 'await.flow.ts', '--input', '{}'); expectPark(first, 'activation'); const id = first.report.runId; + assert.equal(first.report.subscriptions[0].state, 'prepared'); // More resumes than the root's retry budget: none are failures or new work. for (let index = 0; index < 10; index++) expectPark(invoke('resume', id), 'activation'); await connect(); await client.subscriptionActivate({ run_id: id, subscription_id: 'activity-1', ingress_offset: 0, router_binding: { transport: 'local-test-router', generation: 'cli' } }); - expectPark(invoke('resume', id), 'event_wait'); + const waiting = invoke('resume', id); + expectPark(waiting, 'event_wait'); + assert.equal(waiting.report.subscriptions[0].state, 'active'); + assert.equal(waiting.report.suspension.idleAtMs, waiting.report.subscriptions[0].idleAtMs); + assert.equal(waiting.report.suspension.settleMs, waiting.report.subscriptions[0].settleMs); + assert.equal(waiting.report.subscriptions[0].routerBinding.generation, 'cli'); stopDaemon('SIGKILL'); await new Promise(resolve => setTimeout(resolve, 100)); - expectPark(invoke('resume', id), 'event_wait'); + const restarted = invoke('resume', id); + expectPark(restarted, 'event_wait'); + assert.equal(restarted.report.suspension.idleAtMs, waiting.report.suspension.idleAtMs); await connect(); assert.equal((await client.eventEmit(id, 'e2e_event', { index: 0 }, { delivery_id: 'first', actor: 'tester' })).matched, 1); expectPark(invoke('resume', id), 'event_wait'); assert.equal(readFileSync(join(root, 'effects'), 'utf8'), 'setup,event0,'); assert.equal((await client.eventEmit(id, 'e2e_event', { index: 0 }, { delivery_id: 'first', actor: 'tester' })).matched, 0); assert.equal((await client.eventEmit(id, 'e2e_event', { index: 1 }, { delivery_id: 'second', actor: 'tester' })).matched, 1); + const done = invoke('resume', id); + assert.equal(done.status, 0); + assert.equal(done.report.subscriptions[0].state, 'closed'); assert.equal(invoke('resume', id).status, 0); - assert.equal(invoke('resume', id).status, 0); + const replayed = spawnSync(process.execPath, [join(repo, 'packages/sdk/dist/cli.js'), 'replay', id, '--data-dir', data, '--json'], { cwd: root, encoding: 'utf8' }); + assert.equal(replayed.status, 0, replayed.stderr); + assert.ok(replayed.stdout.includes('subscription.prepared')); + assert.ok(replayed.stdout.includes('subscription.closed')); assert.equal(readFileSync(join(root, 'effects'), 'utf8'), 'setup,event0,event1,'); const { entries } = await client.journalRead(id, 1, 500); assert.equal(entries.filter(entry => entry.entry_type === 'step.completed' && entry.payload.completionReason === 'crashed').length, 0); + // Metadata belongs to the authored root even when failure diagnostics name + // a child, and remains available while an unrelated human wait parks it. + for (const [name, tail, expectedStatus] of [ + ['human', "await f.human('Proceed?', {to:'khaliq'});", 3], + ['failed', "await f.run('false');", 1], + ]) { + writeFileSync(join(root, `${name}.flow.ts`), `import {flow,webhook} from '@relayflows/surface'; +export default flow('${name}-subscription-report', async f => { + const activity = f.on(webhook('metadata_event'), {idle:'1h',deadline:'1d'}); + await activity.next(); + ${tail} + f.done('success'); +});`); + const admitted = invoke('run', `${name}.flow.ts`, '--input', '{}'); + expectPark(admitted, 'activation'); + const rootId = admitted.report.runId; + await client.subscriptionActivate({run_id:rootId,subscription_id:'activity-1',ingress_offset:0, + router_binding:{generation:name, transport:'local-test-router'}}); + expectPark(invoke('resume', rootId), 'event_wait'); + await client.subscriptionDeliver({run_id:rootId,subscription_id:'activity-1', + router_binding:{generation:name, transport:'local-test-router'},delivery_id:name, + frame:{type:'metadata_event',payload:{}}}); + const boundary = invoke('resume', rootId); + assert.equal(boundary.status, expectedStatus); + assert.equal(boundary.report.rootRunId, rootId); + assert.equal(boundary.report.subscriptions.length, 1); + assert.equal(boundary.report.subscriptions[0].state, name === 'failed' ? 'closed' : 'active'); + } console.log('E2E_PASS: repeated park, SIGKILL/restart, two wakes replayed in order, deduped delivery, exactly-once child effects, zero crash retries'); } finally { stopDaemon('SIGTERM'); diff --git a/packages/sdk/tests/journal-client-loopback.ts b/packages/sdk/tests/journal-client-loopback.ts index 4cd016ead..caa434a74 100644 --- a/packages/sdk/tests/journal-client-loopback.ts +++ b/packages/sdk/tests/journal-client-loopback.ts @@ -24,6 +24,9 @@ export interface FrameCtx { } export interface LoopbackHandlers { + 'subscription.inspect'?: (ctx: FrameCtx, params: Record) => void; + 'subscription.deliver'?: (ctx: FrameCtx, params: Record) => void; + 'subscription.fence_overflow'?: (ctx: FrameCtx, params: Record) => void; hello?: (ctx: FrameCtx) => void; 'run.start'?: (ctx: FrameCtx, params: Record) => void; 'run.resume'?: (ctx: FrameCtx, params: Record) => void; diff --git a/packages/sdk/tests/journal-client-subscriptions.test.ts b/packages/sdk/tests/journal-client-subscriptions.test.ts new file mode 100644 index 000000000..cbef14c6c --- /dev/null +++ b/packages/sdk/tests/journal-client-subscriptions.test.ts @@ -0,0 +1,39 @@ +import { rmSync } from 'node:fs'; +import { expect, it } from 'vitest'; +import { JournalClient } from '../src/journal-client.js'; +import { sendResult, sockPath, startLoopback } from './journal-client-loopback.js'; + +it('preserves full router receipts, inspect metadata and targeted delivery outcomes on the wire', async () => { + const path = sockPath(); + const receipt = { generation: 'g1', provider: 'github', scope: { repositoryId: 42 } }; + const snapshots = [{ subscriptionId: 's', state: 'active', routerBinding: receipt, + ingressOffset: 2, unreadFrames: 1, unreadBytes: 23, settleMs: 50, idleAtMs: 1234, deadlineAtMs: 9000 }]; + const received: unknown[] = []; + const server = startLoopback(path, { + 'subscription.inspect': (ctx, params) => { received.push(params); sendResult(ctx, { subscriptions: snapshots }); }, + 'subscription.deliver': (ctx, params) => { + received.push(params); + if (JSON.stringify(params.router_binding) !== JSON.stringify(receipt)) { + ctx.send({ id: ctx.id, ok: false, error: { code: 'subscription_binding_mismatch', message: 'stale receipt' } }); + } else sendResult(ctx, { appended: false, reason: 'duplicate' }); + }, + 'subscription.fence_overflow': (ctx, params) => { received.push(params); sendResult(ctx, { fenced: true }); }, + }); + const client = new JournalClient(path); + try { + await client.connect(); + expect(await client.subscriptionInspect({ run_id: 'root' })).toEqual({ subscriptions: snapshots }); + const delivery = { run_id: 'root', subscription_id: 's', router_binding: receipt, + delivery_id: 'd1', frame: { type: 'github', payload: { id: 1 } } }; + expect(await client.subscriptionDeliver(delivery)).toEqual({ appended: false, reason: 'duplicate' }); + expect(await client.subscriptionFenceOverflow({ run_id: 'root', subscription_id: 's', router_binding: receipt })).toEqual({ fenced: true }); + expect(received).toEqual([{ run_id: 'root' }, delivery, + { run_id: 'root', subscription_id: 's', router_binding: receipt }]); + await expect(client.subscriptionDeliver({ ...delivery, router_binding: { generation: 'g1' } })) + .rejects.toThrow('subscription_binding_mismatch'); + } finally { + client.close(); + await new Promise(resolve => server.close(() => resolve())); + rmSync(path, { force: true }); + } +}); diff --git a/packages/sdk/tests/observer-link.test.ts b/packages/sdk/tests/observer-link.test.ts index 129fd8151..912599355 100644 --- a/packages/sdk/tests/observer-link.test.ts +++ b/packages/sdk/tests/observer-link.test.ts @@ -705,6 +705,7 @@ describe('flows run: observer link integration', () => { const dataDir = temporaryProject(); await startCliLoopback(dataDir, { hello: sendOk, + 'subscription.inspect': (ctx) => sendResult(ctx, { subscriptions: [] }), 'run.start': (ctx) => sendResult(ctx, { run_id: 'run-observer-json', status: 'completed', diff --git a/packages/sdk/tests/run-from-digest.test.ts b/packages/sdk/tests/run-from-digest.test.ts index 0cef820c0..9046c67d0 100644 --- a/packages/sdk/tests/run-from-digest.test.ts +++ b/packages/sdk/tests/run-from-digest.test.ts @@ -28,6 +28,7 @@ describe('flows run digest input', () => { await rm(join(f.root, 'dist'), { recursive: true }); await rm(join(f.root, 'hello.yaml')); const dataDir = join(f.root, 'data'); let submitted: unknown; const server = startLoopback(socketPathFor(dataDir), { hello: sendOk, + 'subscription.inspect': (ctx) => sendResult(ctx, { subscriptions: [] }), 'run.start': (ctx, params) => { submitted = params['spec']; sendResult(ctx, { run_id: 'digest-run', status: 'completed', completion_reason: 'success', completed_steps: 1, }); }, diff --git a/packages/sdk/tests/subscription-report.test.ts b/packages/sdk/tests/subscription-report.test.ts new file mode 100644 index 000000000..6dccd666c --- /dev/null +++ b/packages/sdk/tests/subscription-report.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { withSubscriptionMetadata } from '../src/cli/subscription-report.js'; +import type { RunExecution } from '../src/cli/run.js'; +import type { SubscriptionSnapshot } from '../src/protocol.js'; + +const active: SubscriptionSnapshot = { + subscriptionId: 'activity-1', state: 'active', routerBinding: { generation: 'g1', repository: 'repo' }, + ingressOffset: 17, unreadFrames: 2, unreadBytes: 93, settleMs: 2000, idleAtMs: 30000, deadlineAtMs: 90000, +}; +function execution(status: 'suspended' | 'parked' | 'completed' | 'failed'): RunExecution { + return { exitCode: status === 'suspended' ? 4 : status === 'parked' ? 3 : status === 'failed' ? 1 : 0, + report: { ok: status === 'completed', command: 'resume', runId: 'root', socketPath: '/socket', status, + resolutions: [], diagnostics: [], ...(status === 'suspended' ? { suspension: { + kind: 'event_wait' as const, subscriptionId: 'activity-1', stream: 'subscription/activity-1', deadlineAtMs: 90000, + } } : {}) } }; +} +describe('durable subscription report metadata', () => { + it.each(['suspended', 'parked', 'completed', 'failed'] as const)('preserves the authoritative snapshot for %s', async status => { + const snapshot = status === 'completed' || status === 'failed' + ? { ...active, state: 'closed' as const, completionReason: status === 'completed' ? 'run_completed' as const : 'canceled' as const } + : active; + const inspect = vi.fn(async () => ({ subscriptions: [snapshot] })); + const original = execution(status); + const reported = await withSubscriptionMetadata(original, inspect); + expect(inspect).toHaveBeenCalledWith('/socket', 'root'); + expect(reported.exitCode).toBe(original.exitCode); + expect(reported.report.subscriptions).toEqual([snapshot]); + expect(original.report.subscriptions).toBeUndefined(); + }); + it('inspects the root rather than a failed child diagnostic run', async () => { + const failed = execution('failed'); + failed.report.runId = 'child'; + failed.report.rootRunId = 'root'; + const inspect = vi.fn(async () => ({ subscriptions: [{ ...active, state: 'closed' as const }] })); + await withSubscriptionMetadata(failed, inspect); + expect(inspect).toHaveBeenCalledWith('/socket', 'root'); + }); + it('keeps absolute idle/settle/deadline instants unchanged on repeated early wakes', async () => { + const inspect = async () => ({ subscriptions: [active] }); + const first = await withSubscriptionMetadata(execution('suspended'), inspect); + const resumed = await withSubscriptionMetadata(execution('suspended'), inspect); + expect(first.report.suspension).toEqual({ kind: 'event_wait', subscriptionId: 'activity-1', + stream: 'subscription/activity-1', settleMs: 2000, idleAtMs: 30000, deadlineAtMs: 90000 }); + expect(resumed.report.suspension).toEqual(first.report.suspension); + }); + it('does not disguise missing durable timing or an inspect error as usable suspension', async () => { + for (const inspect of [async () => ({ subscriptions: [] }), async () => { throw new Error('unavailable'); }]) { + const reported = await withSubscriptionMetadata(execution('suspended'), inspect); + expect(reported.exitCode).toBe(1); + expect(reported.report.ok).toBe(false); + expect(reported.report.suspension).toBeUndefined(); + expect(reported.report.subscriptions).toBeUndefined(); + expect(reported.report.diagnostics.at(-1)?.kind).toBe('protocol_error'); + } + }); + it('does not contact the daemon when preflight never created a run', async () => { + const inspect = vi.fn(); + const reported = await withSubscriptionMetadata({ exitCode: 2, + report: { ok: false, command: 'run', diagnostics: [], resolutions: [] } }, inspect); + expect(inspect).not.toHaveBeenCalled(); + expect(reported.report.subscriptions).toEqual([]); + }); +}); From a217c3f65fef4d9e9883b3c8e1ff3ce702934efb Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sun, 20 Sep 2026 03:12:22 -0700 Subject: [PATCH 29/34] fix(events): integrate main and verify subscription reporting boundaries --- evidence/pr441-router-2026-09-20.md | 15 + .../kernel-after-main.txt | 474 ++++++++++++++++++ .../merged-final-full-sdk.txt | 456 +++++++++++++++++ .../merged-final-patch.txt | 68 +++ .../merged-final-test-types.txt | 8 + .../merged-fixed-build.txt | 8 + .../merged-fixed-typecheck.txt | 8 + packages/sdk/src/authored-root.ts | 1 - packages/sdk/tests/authored-activity.test.ts | 1 + .../tests/fixtures/event-await-cli-probe.mjs | 16 +- 10 files changed, 1052 insertions(+), 3 deletions(-) create mode 100644 evidence/pr441-router-2026-09-20/kernel-after-main.txt create mode 100644 evidence/pr441-router-2026-09-20/merged-final-full-sdk.txt create mode 100644 evidence/pr441-router-2026-09-20/merged-final-patch.txt create mode 100644 evidence/pr441-router-2026-09-20/merged-final-test-types.txt create mode 100644 evidence/pr441-router-2026-09-20/merged-fixed-build.txt create mode 100644 evidence/pr441-router-2026-09-20/merged-fixed-typecheck.txt diff --git a/evidence/pr441-router-2026-09-20.md b/evidence/pr441-router-2026-09-20.md index 0c0f4ae76..86ea866ae 100644 --- a/evidence/pr441-router-2026-09-20.md +++ b/evidence/pr441-router-2026-09-20.md @@ -20,3 +20,18 @@ The existing activation-retry test now repeats the original receipt. Its old changed-receipt input relied on the unfenced behavior being repaired; the new regression explicitly requires rejection of changed receipts and unchanged journal length after rejection. + +## Integration with main (PR499 and PR500) + +Merged main at `e21caad1` into this branch (`62e78e19`). Preserved both structured step failure details and subscription suspension fields across authored Node IPC, and both channel and subscription journal verbs. Removed a duplicate import and updated the activity test daemon to answer the stream append used by predicate persistence. + +The new failed-child boundary fixture originally allowed only 30 seconds for seven semantic retry backoffs. A diagnostic run proved termination; only that newly added invocation now allows 75 seconds (maximum total jittered backoff 60,960 ms), and asserts exactly eight worker-error completions. The existing outer timeouts and gate commands are unchanged. + +Literal commands and captured output, including the exact source patch tested: + +- [Rust workspace and daemon build](pr441-router-2026-09-20/kernel-after-main.txt): exit 0. +- [Full SDK suite](pr441-router-2026-09-20/merged-final-full-sdk.txt): 162 files passed, 1 existing skipped; 2445 tests passed, 3 existing skipped; exit 0. Includes actual CLI activation, repeated wake, SIGKILL/restart, deduplication, exactly-once child effects, human pause, and terminal failed-child reporting. +- [SDK build](pr441-router-2026-09-20/merged-fixed-build.txt), [source types](pr441-router-2026-09-20/merged-fixed-typecheck.txt), [test types](pr441-router-2026-09-20/merged-final-test-types.txt): exit 0. +- [Residual patch exercised by SDK verification](pr441-router-2026-09-20/merged-final-patch.txt). + +These are local runtime results. Cloud's production router integration and hosted acceptance remain unfinished; this evidence does not establish hosted end-to-end operation. diff --git a/evidence/pr441-router-2026-09-20/kernel-after-main.txt b/evidence/pr441-router-2026-09-20/kernel-after-main.txt new file mode 100644 index 000000000..6386bb12b --- /dev/null +++ b/evidence/pr441-router-2026-09-20/kernel-after-main.txt @@ -0,0 +1,474 @@ +cwd: /tmp/flows-pr-followup/pr441/kernel +HEAD: 62e78e193c175a853f38c00923956c63e1a8c970 +$ cargo test --workspace + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.02s + Running unittests src/lib.rs (target/debug/deps/relayflowd-f043db0bb3534a16) + +running 54 tests +test engine::boot_identity_tests::every_engine_in_this_process_shares_one_boot_id ... ok +test server::channels::tests::unknown_verb_never_falls_through_to_receive ... ok +test engine::remote::worker_failure_detail_tests::an_output_at_the_boundary_is_not_truncated ... ok +test engine::remote::worker_failure_detail_tests::a_non_string_output_is_rendered_rather_than_dropped ... ok +test engine::remote::worker_failure_detail_tests::a_string_output_is_carried_verbatim_and_trimmed ... ok +test server::client::tests::resume_waits_while_the_heartbeat_renewed_lease_is_live ... ok +test engine::remote::worker_failure_detail_tests::a_null_or_blank_output_yields_no_detail ... ok +test server::liveness::tests::sweep_id_buckets_by_the_interval ... ok +test server::tests::agent::contract::an_agent_worker_attaching_without_pins_is_refused_at_attach ... ok +test engine::remote::worker_failure_detail_tests::truncation_does_not_split_a_multi_byte_char ... ok +test engine::remote::worker_failure_detail_tests::render_is_bounded_before_allocation_for_hostile_string ... ok +test exec_det::tests::captures_deterministic_output ... ok +test server::tests::agent::contract::an_oversized_trajectory_tail_is_refused_at_step_complete ... ok +test engine::remote::worker_failure_detail_tests::render_is_bounded_before_allocation_for_hostile_json ... ok +test exec_det::tests::timeout_has_an_explicit_completion_reason ... ok +test exec_det::tests::lease_override_bounds_execution_and_preserves_command_timeout ... ok +test server::tests::hello_enforces_protocol_version ... ok +test exec_det::tests::failed_command_evidence_survives_completion ... ok +test server::tests::agent::eligibility::required_streams_must_be_held_before_worker_registration ... ok +test server::liveness::tests::sweep_pass_latches_after_journaling_and_next_bucket_is_empty ... ok +test server::liveness::tests::sweep_pass_healthy_subscription_is_a_noop ... ok +test server::tests::run_resume_asks_the_registry_instead_of_treating_an_orphan_file_as_a_run ... ok +test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok +test server::tests::run_start_fails_closed_on_an_unknown_verification_key ... ok +test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok +test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok +test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok +test socket_path::tests::deep_data_dir_produces_short_socket_path ... ok +test socket_path::tests::different_data_dirs_yield_different_sockets ... ok +test socket_path::tests::relative_and_absolute_data_dirs_agree ... ok +test socket_path::tests::same_data_dir_yields_same_socket ... ok +test server::tests::run_start_refuses_invalid_admission_keys ... ok +test server::tests::run_resume_refuses_a_journal_that_never_recorded_its_run ... ok +test server::tests::agent::contract::an_agent_worker_missing_a_declared_surface_parks_the_run_instead_of_erroring ... ok +test server::tests::agent::contract::agent_without_a_compatible_worker_parks_without_starting ... ok +test server::tests::agent::eligibility::required_streams_keep_ordinary_steps_off_conversation_workers ... ok +test server::tests::run_start_admission_key_recovers_the_same_run_and_refuses_spec_drift ... ok +test server::tests::run_resume_refuses_a_valid_journal_that_belongs_to_another_run ... ok +test server::tests::agent::contract::a_transcript_digest_at_its_budget_rides_trajectory_tail_verbatim ... ok +test server::tests::agent::pins::reset_worker_reporting_a_revision_other_than_its_pin_fails_closed_as_worker_error ... ok +test engine::boot_identity_tests::failure_after_workspace_binding_releases_admission_without_exposing_effects ... ok +test engine::boot_identity_tests::prior_boot_registered_undriven_admission_is_recovered_by_start_retry ... ok +test server::tests::agent::contract::an_llm_completion_claiming_an_effect_fails_closed_with_the_reason_journaled ... ok +test server::tests::a_failed_disconnect_journal_append_is_retained_and_retried_not_dropped ... ok +test server::tests::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok +test server::tests::agent::contract::a_replacement_worker_that_never_reported_the_pinned_surface_is_not_dispatched_to ... ok +test server::tests::agent::contract::human_intervention_is_durable_and_resume_requires_explicit_override ... ok +test server::tests::stopped_heartbeats_past_the_deadline_journal_lease_expired_and_release_the_step ... ok +test server::tests::agent::pins::consecutive_agent_steps_on_different_surfaces_each_start_from_their_own_pins ... ok +test server::tests::step_wait_parks_the_attempt_and_a_human_answer_redispatches_it ... ok +test server::tests::subscription_router::targeted_router_verbs_validate_receipts_and_preserve_wire_metadata ... ok +test exec_det::tests::timeout_kills_the_whole_process_group ... ok +test server::tests::agent::pins::a_replacement_worker_at_a_different_revision_is_not_dispatched_the_stale_pins ... ok +test server::tests::an_entry_appended_during_watch_registration_is_delivered_exactly_once ... ok + +test result: ok. 54 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s + + Running unittests src/main.rs (target/debug/deps/relayflowd-6e3681176306c99e) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/budget_gate.rs (target/debug/deps/budget_gate-9607ae7c2db11b74) + +running 10 tests +test metering_flag_is_additive_on_the_wire ... ok +test prior_spend_metering_flag_is_additive_and_fails_closed_for_older_kernels ... ok +test daily_windows_reset_and_exact_limits_do_not_refuse ... ok +test carried_metered_dollars_still_stop_the_continuing_run ... ok +test unmetered_usage_may_not_claim_priced_dollars ... ok +test unmetered_tokens_still_cross_a_token_ceiling ... ok +test carried_prior_spend_keeps_unknown_dollar_cost_unmetered ... ok +test crossing_completion_is_durable_and_next_step_is_refused ... ok +test unmetered_spend_is_journaled_as_unknown_and_never_crosses_a_dollar_ceiling ... ok +test deterministic_spend_and_wallclock_limit_gate_parallel_batch_starts ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/crash_resume.rs (target/debug/deps/crash_resume-d2d102ec3e89b705) + +running 40 tests +test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok +test concurrency::run_start_dispatches_every_independent_lane_before_any_completion ... ok +test agent::rung_c_sigkill_after_final_effect_replays_results_without_redispatch ... ok +test channels::channels_reject_foreign_workers_stale_attempts_and_invalid_acknowledgements ... ok +test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok +test concurrency::cancel_closes_the_lease_and_rejects_a_late_completion ... ok +test concurrency::live_resume_leaves_an_active_lease_running ... ok +test llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... ok +test agent::rung_c_reset_sigkill_mid_edit_restores_pins_dedupes_effect_and_explains_attempts ... ok +test pin_projection::rejected_completion_cannot_forge_inspect_retry_pins_over_the_real_socket ... ok +test concurrency::cancel_and_completion_race_has_one_terminal_fact ... ok +test concurrency::server_restart_recovers_every_parallel_lease_without_duplicate_success ... ok +test llm::failing_llm_verification_schedules_a_durable_retry_and_succeeds ... ok +test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... ok +test concurrency::concurrent_resumes_lease_exactly_one_attempt ... ok +test llm::llm_verification_exhaustion_is_a_declared_failure_kind ... ok +test placement::declared_placement_keeps_one_source_tree_across_resume ... ok +test protocol_admission::every_mutating_run_verb_refuses_terminal_before_changing_state ... ok +test sigkill_after_cancel_request_resumes_to_one_canceled_fact ... ok +test placement::sigkill_before_first_step_preserves_the_submitted_workspace ... ok +test sigkill_mid_step_replaces_and_explains_the_dead_attempt ... ok +test memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once ... ok +test llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step ... ok +test llm::sigkill_after_the_final_rung_b_effect_resumes_without_redispatching_llm ... ok +test placement::sigkill_mid_step_keeps_the_route_and_source_tree ... ok +test sigkill_under_serve_resumes_the_socket_started_run ... ok +test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... ok +test worker_capacity::two_workers_receive_a_deterministic_fair_capacity_bounded_batch ... ok +test surface_identity::aliases_are_rejected_and_external_ancestors_serialize_over_real_sockets ... ok +test workspace_identity::workspace_aliases_are_refused_and_canonical_subtrees_serialize_over_real_sockets ... ok +test llm::worker_killed_while_holding_a_lease_is_explained_and_released_on_cli_resume ... ok +test llm::sigkill_under_serve_mid_llm_releases_the_lease_and_finishes_via_cli_resume ... ok +test parallel_lifecycle::terminal_failure_drains_or_explains_every_live_sibling ... ok +test worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash ... ok +test sigkill_sweep_covers_every_hello_step_boundary ... ok +test parallel_lifecycle::overlapping_agent_conflict_survives_server_crash_and_resume ... ok +test llm::sigkill_sweep_covers_before_and_between_the_rung_b_steps ... ok +test parallel_lifecycle::overlapping_agent_lanes_serialize_while_disjoint_lanes_merge_in_either_order ... ok +test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok +test parallel_lifecycle::renewed_parallel_leases_survive_the_original_grant_and_remain_distinct ... ok + +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 36.40s + + Running tests/daemon_lifecycle.rs (target/debug/deps/daemon_lifecycle-b705da9761b2a254) + +running 6 tests +test connection_file_is_published_only_after_the_socket_is_live ... ok +test clean_shutdown_removes_advertisement_and_socket ... ok +test sigkill_leaves_a_stale_file_with_a_dead_pid ... ok +test deep_data_dir_still_binds ... ok +test a_second_serve_on_a_served_data_dir_refuses_and_the_first_keeps_serving ... ok +test a_sigkilled_daemons_successor_starts_cleanly ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/event_activities.rs (target/debug/deps/event_activities-ed1739dfa94f36b5) + +running 11 tests +test prepared_open_response_replays_the_immutable_binding_snapshot ... ok +test cancel_closes_an_open_activity_before_the_terminal_run_record ... ok +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next ... ok +test overflow_of_a_parked_next_returns_overflow_after_recovery ... ok +test idle_wait_is_durable_and_fires_without_an_event ... ok +test normal_wake_is_not_acknowledged_until_the_following_next ... ok +test immediate_event_wakes_have_durable_distinct_wait_boundaries ... ok +test prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_suspends ... 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. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.86s + + Running tests/event_activity_corruption.rs (target/debug/deps/event_activity_corruption-e16548e9ab56b153) + +running 3 tests +test a_completed_event_range_cannot_replay_with_missing_frames ... ok +test malformed_stream_frames_fail_replay_and_future_append ... ok +test malformed_deadline_range_is_an_error_while_null_is_an_empty_range ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/event_activity_parking.rs (target/debug/deps/event_activity_parking-d8e0d6eeb684776f) + +running 4 tests +test replay_keeps_each_acknowledged_batch_addressable_by_body_call_ordinal ... ok +test intentional_close_cancels_the_pending_pull_without_claiming_a_timeout ... ok +test activation_and_delivery_racing_the_lease_handoff_are_not_lost ... ok +test parked_attempt_survives_restart_and_only_a_ready_subscription_redispatches_it ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + + Running tests/event_wake.rs (target/debug/deps/event_wake-b98778c1ad4de872) + +running 3 tests +test matching_event_wakes_once_with_fresh_context ... ok +test two_racing_deliveries_of_one_event_produce_exactly_one_run ... ok +test a_resumed_run_dispatches_the_original_wake_context ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/hn_monitor_integration.rs (target/debug/deps/hn_monitor_integration-059c38eb4828898f) + +running 1 test +test hn_story_event_wakes_monitor_once_with_story_context ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/input_binding.rs (target/debug/deps/input_binding-4f132e6302508de8) + +running 2 tests +test binding_schema_is_additive_and_fails_closed ... ok +test sigkill_before_consumer_resolves_original_journal_output_without_reexecuting_source ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + + Running tests/invalid_schema_preflight.rs (target/debug/deps/invalid_schema_preflight-9c08869ea58d283f) + +running 3 tests +test invalid_json_schema_is_refused_before_journal_or_command ... ok +test unbounded_json_schema_is_refused_before_journal_or_command ... ok +test legitimately_recursive_json_schema_still_starts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.33s + + Running tests/memoization.rs (target/debug/deps/memoization-e5537edfeea8b814) + +running 3 tests +test refuses_missing_wrong_flow_and_unreadable_journal_before_creating_run ... ok +test reused_prefix_survives_restart_without_source_and_never_mutates_prior ... ok +test actual_changed_input_invalidates_consumer_even_with_identical_consumer_spec ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + Running tests/memory.rs (target/debug/deps/memory-fe5dbc6738ee15ff) + +running 5 tests +test rejected_journal_fact_releases_reservation_and_never_dispatches ... ok +test over_budget_and_provider_errors_fail_without_dispatch_or_charge ... ok +test llm_dispatch_receives_same_pack_after_resume_without_provider ... ok +test semantic_retry_reuses_memory_without_a_second_charge ... ok +test replay_and_resume_need_no_provider_and_script_receives_recorded_pack ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/memory_epoch.rs (target/debug/deps/memory_epoch-627e50d1c86c39e4) + +running 1 test +test epoch_carries_pack_and_exact_charge_and_refuses_duplicate_injection ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/parallel_driver.rs (target/debug/deps/parallel_driver-7a554ae88aa529f9) + +running 4 tests +test stop_after_one_holds_for_an_independent_deterministic_batch ... ok +test backpressured_or_mismatched_lane_does_not_drop_a_later_dispatch ... ok +test pause_before_second_independent_step_holds_the_driver_boundary ... ok +test crash_boundaries_resume_the_real_driver_with_one_effect_per_lane ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/placement_pins.rs (target/debug/deps/placement_pins-839e136752b4e5af) + +running 3 tests +test unsupported_local_pty_is_refused_before_an_earlier_step_can_run ... ok +test default_worker_pins_the_declared_worktree_base_commit_and_refuses_missing_source ... ok +test a_resumed_attempt_keeps_the_original_pin_after_the_worktree_head_moves ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/placement_routing.rs (target/debug/deps/placement_routing-9cb694324a7d7bf1) + +running 3 tests +test a_failed_routing_append_never_starts_or_dispatches_work ... ok +test crash_between_routing_and_start_does_not_redecide ... ok +test worker_retry_consumes_the_original_routing_fact ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/routing_diagnostics.rs (target/debug/deps/routing_diagnostics-7807262e1a1167ec) + +running 2 tests +test duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact ... ok +test malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/spec_review_routing.rs (target/debug/deps/spec_review_routing-032df51688a72750) + +running 4 tests +test attempt_scoped_route_is_rejected_at_append_and_replay ... ok +test malformed_epoch_routes_are_rejected_before_commit ... ok +test epoch_cannot_drop_or_replace_a_durable_route ... ok +test workspace_pin_peels_tags_and_refuses_non_commit_objects ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/subscription_liveness.rs (target/debug/deps/subscription_liveness-c3bbfd58dcafb336) + +running 3 tests +test submit_event_upserts_subscription_row_and_sweep_flags_it_stale_after_budget ... ok +test stale_transition_is_journaled_as_subscription_stale_entry_in_the_last_known_run ... ok +test a_fresh_arrival_re_arms_the_latch_and_the_next_silence_can_stale_again ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/subscription_router_delivery.rs (target/debug/deps/subscription_router_delivery-1e0031de1ef580ec) + +running 2 tests +test targeted_ingress_is_fenced_deduplicated_and_isolated_after_restart ... ok +test router_snapshots_keep_absolute_timers_and_overflow_fence_across_restart ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/trigger_watcher.rs (target/debug/deps/trigger_watcher-0504d7ed791030e6) + +running 3 tests +test retains_bad_and_unregistered_events_while_consuming_filter_nonmatches ... ok +test failed_archive_retries_the_same_durable_run ... ok +test journals_payload_and_filename_key_then_archives_and_dedupes_replay ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_core-e734d1d7b8cb7b12) + +running 65 tests +test clock::tests::simulated_clock_is_explicitly_advanced ... ok +test entry::completion_reason_tests::every_journal_label_matches_serialized ... ok +test entry::completion_reason_tests::all_covers_every_serialized_label ... ok +test channel::tests::send_retry_is_stable_and_conflicting_content_is_rejected ... ok +test channel::tests::malformed_payloads_and_invalid_new_channel_appends_leave_state_unchanged ... ok +test channel::tests::forged_deliveries_and_acknowledgements_fail_closed ... ok +test journal::tests::memory_journal_assigns_sequences_and_rolls_epochs ... ok +test channel::tests::delivery_replay_and_independent_acknowledged_offsets ... ok +test machine::parallel_tests::machine_starts_every_runnable_step_in_authored_order ... ok +test machine::tests::deterministic_lease_rejects_invalid_and_foreign_fields ... ok +test machine::tests::cancel_request_closes_the_active_lease_before_the_terminal_fact ... ok +test machine::parallel_tests::every_declared_mutable_surface_participates_in_conflict_selection ... ok +test machine::tests::all_backing_off_steps_return_timers ... ok +test machine::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::tests::deterministic_lease_override_and_default_are_journaled ... ok +test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok +test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test retry::tests::jitter_is_repeatable_and_bounded ... ok +test machine::tests::repeated_cancel_request_is_idempotent ... ok +test memory::tests::caps_compare_exact_decimals_and_each_token_dimension ... ok +test machine::tests::worker_reported_failure_without_detail_still_records_a_verification ... ok +test machine::tests::crashed_attempt_does_not_consume_an_iteration ... ok +test machine::tests::verification_failure_schedules_a_durable_retry ... ok +test machine::parallel_tests::failed_run_drains_open_siblings_before_terminal_entry ... ok +test machine::parallel_tests::parallel_lanes_do_not_cross_the_dependency_barrier_early ... ok +test machine::tests::successful_memo_is_never_scheduled_again ... ok +test machine::parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_order ... ok +test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... ok +test machine::tests::every_reason_label_matches_its_serialized_form ... ok +test machine::tests::inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail ... ok +test schema::tests::in_document_uri_references_resolve_to_the_node_they_name ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test schema::tests::refusal_names_the_cycle_it_found ... ok +test machine::tests::failed_deterministic_completion_preserves_exit_code_and_stderr ... ok +test spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test machine::parallel_tests::disjoint_agent_lanes_merge_pins_in_either_completion_order ... ok +test spec::tests::a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate ... ok +test spec::tests::cycles_are_rejected ... ok +test spec::tests::spec_version_is_semver_and_gated ... ok +test machine::tests::manual_recovery_parks_needs_human_and_never_redispatches ... ok +test spec::tests::unknown_root_and_nested_fields_are_rejected ... ok +test spec::tests::preflight_data_is_fail_closed ... ok +test spec::tests::zero_agent_flow_is_valid ... ok +test state::budget::tests::adds_costs_exactly_beyond_machine_decimal_precision ... ok +test state::budget::tests::overflow_and_malformed_cost_leave_total_unchanged ... ok +test state::tests::budget_decimal_strings_add_without_floats ... ok +test verify::tests::deterministic_output_requires_successful_exit_and_content ... ok +test spec::tests::external_surface_paths_must_have_one_canonical_spelling ... ok +test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok +test verify::tests::an_unbounded_schema_in_a_journal_fails_its_gate_instead_of_aborting ... ok +test state::tests::journal_replays_data_gate_verdict_without_rerunning_completed_code ... ok +test machine::tests::every_failed_run_terminates_with_declared_completion_reasons ... ok +test spec::tests::workspace_mounts_and_worktrees_must_have_one_canonical_spelling ... ok +test state::tests::end_pin_chain_is_enforced_and_a_broken_chain_is_a_hard_error ... ok +test state::tests::a_completion_that_omits_a_surface_does_not_drop_it_from_the_pin_chain ... ok +test schema::tests::references_the_bound_leaves_opaque_are_refused_by_the_engine ... ok +test spec::tests::the_full_ladder_parses_in_the_one_dialect ... ok +test verify::tests::json_schema_is_a_control_gate ... ok +test schema::tests::a_property_named_ref_is_not_a_reference ... ok +test schema::tests::shared_declarations_and_boolean_schemas_are_validated ... ok +test schema::tests::every_accepted_corpus_schema_is_accepted ... ok +test schema::tests::every_refused_corpus_schema_compiles_but_is_refused_by_the_bound ... ok +test spec::tests::sdk_boundary_rejects_a_10_000_step_cycle_with_a_typed_error ... ok +test spec::tests::sdk_boundary_accepts_a_valid_10_000_step_reverse_chain ... ok +test schema::tests::deeply_nested_schemas_do_not_overflow_the_checker ... ok + +test result: ok. 65 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.71s + + Running tests/memoization.rs (target/debug/deps/memoization-79f552e4cb727501) + +running 4 tests +test distinct_large_kernel_integers_do_not_alias_through_float_rounding ... ok +test match_reuses_output_with_provenance_and_zero_cost_without_dispatch ... ok +test changed_spec_or_input_dispatches_and_legacy_or_failed_records_miss ... ok +test canonical_corpus_agrees_with_typescript_and_key_permutations ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/spec_parity.rs (target/debug/deps/spec_parity-a9966affd5aca87f) + +running 10 tests +test the_kernel_parses_the_event_triggered_spec_and_stamps_the_same_hash ... ok +test the_kernel_round_trips_declared_agent_transports_and_rejects_unknown_values ... ok +test step_memory_has_identical_canonical_bytes_and_hash ... ok +test placement_requirements_have_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_deterministic_rung_and_stamps_the_same_hash ... ok +test memory_declaration_acceptance_matches_the_sdk_corpus ... ok +test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... ok +test placement_declaration_acceptance_matches_the_sdk_corpus ... ok +test the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_b_spec_and_stamps_the_same_hash ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_journal-0286edd157fee7d6) + +running 32 tests +test registry::tests::a_previous_boots_claim_with_no_run_is_repaired ... ok +test registry::tests::a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage ... ok +test subscriptions::tests::upsert_after_stale_re_arms_and_next_silence_can_re_emit ... ok +test channel::tests::stale_attempts_and_raw_forged_acknowledgements_cannot_change_offsets ... ok +test channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... ok +test tests::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::an_unconfirmed_election_is_reclaimed_by_the_next_attempt_not_treated_as_done ... ok +test subscriptions::tests::last_run_for_subscription_returns_none_before_first_arrival ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... ok +test registry::tests::prior_boot_unregistered_run_admission_is_repaired ... ok +test registry::tests::releasing_a_claim_lets_the_same_boot_retry ... ok +test subscriptions::tests::detect_without_latch_stays_available_for_the_next_sweep ... ok +test registry::tests::releasing_is_scoped_to_the_claiming_run ... ok +test registry::tests::a_registered_run_dedupes_across_boots ... ok +test subscriptions::tests::upsert_is_idempotent_across_bumps_and_preserves_event_type_updates ... ok +test subscriptions::tests::sweep_does_not_re_emit_the_same_stale_row_on_a_later_tick ... ok +test registry::tests::registry_is_a_rebuildable_run_locator ... ok +test subscriptions::tests::latch_is_a_no_op_if_a_fresh_event_arrived_between_detect_and_latch ... ok +test subscriptions::tests::sweep_marks_row_stale_when_silence_exceeds_budget ... ok +test registry::tests::run_admission_reuses_registered_run_and_rejects_spec_drift ... ok +test subscriptions::tests::sweep_election_gives_the_first_caller_the_result_and_second_gets_empty ... ok +test subscriptions::tests::prune_sweep_claims_deletes_only_rows_older_than_cutoff ... ok +test subscriptions::tests::last_run_for_subscription_returns_the_lex_greatest_ulid_regardless_of_insertion ... ok +test subscriptions::tests::sweep_ignores_subscriptions_whose_silence_is_still_within_budget ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test registry::tests::a_pre_migration_registry_gains_boot_id_and_its_claims_are_repairable ... ok +test tests::rollover_is_atomic_scaffolding_for_epoch_resume ... ok +test tests::terminal_run_refuses_every_later_entry_atomically ... ok +test channel::tests::failed_channel_writes_never_expose_delivery_or_advance_acknowledged_offset ... ok +test registry::tests::concurrent_new_boot_retries_have_one_recovery_owner ... ok +test registry::tests::concurrent_same_boot_run_admissions_have_one_owner ... ok +test channel::tests::independent_connections_serialize_send_receive_and_acknowledgement ... ok + +test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s + + Doc-tests relayflowd + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_core + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_journal + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +exit status: 0 + +$ cargo build -p relayflowd + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.82s + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/merged-final-full-sdk.txt b/evidence/pr441-router-2026-09-20/merged-final-full-sdk.txt new file mode 100644 index 000000000..683e98ccf --- /dev/null +++ b/evidence/pr441-router-2026-09-20/merged-final-full-sdk.txt @@ -0,0 +1,456 @@ +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ env PATH=/home/khaliqgant/.local/share/mise/installs/node/22.23.2/bin:/home/khaliqgant/.local/share/mise/installs/codex/0.154.0/codex-path:/home/khaliqgant/.codex/tmp/arg0/codex-arg0Hpwx08:/home/khaliqgant/.local/share/mise/installs/codex/latest/bin:/home/khaliqgant/.local/share/mise/installs/claude/latest:/home/khaliqgant/.local/share/mise/installs/cursor-agent/latest/bin:/home/khaliqgant/.local/share/mise/installs/gh/latest/gh_2.100.0_linux_amd64/bin:/home/khaliqgant/.local/share/mise/installs/http-muse/latest:/home/khaliqgant/.local/share/mise/installs/node/26.8.1/bin:/home/khaliqgant/.local/share/mise/installs/npm-xai-official-grok/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-wrangler/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-neonctl/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/bun/latest/bin:/home/khaliqgant/.cargo/bin:/home/khaliqgant/.local/share/mise/installs/go/1.25.14/bin:/home/khaliqgant/.local/share/mise/installs/opencode/latest:/home/khaliqgant/.local/share/mise/installs/gemini/latest/node_modules/.bin:/home/khaliqgant/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/home/khaliqgant/.local/share/mise/shims:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl FLOWS_BUILD_BUN=/home/khaliqgant/.local/share/mise/installs/bun/1.4.0/bin/bun RELAYFLOWD_BIN=/tmp/flows-pr-followup/pr441/kernel/target/debug/relayflowd node node_modules/vitest/vitest.mjs run + + RUN v2.1.9 /tmp/flows-pr-followup/pr441/packages/sdk + + ✓ tests/run-state.test.ts (21 tests) 13ms + ✓ tests/agent-transcript.test.ts (29 tests) 279ms + ✓ tests/journal-client.test.ts (15 tests) 99ms + ✓ tests/tick-source.test.ts (33 tests) 57ms +stdout | tests/live-kernel.test.ts +LIVE_KERNEL relayflowd=/tmp/flows-pr-followup/pr441/kernel/target/debug/relayflowd +LIVE_KERNEL flows=/tmp/flows-pr-followup/pr441/packages/sdk/dist/cli.js + +(node:2470571) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ tests/cloud-read.test.ts (39 tests) 62ms + ✓ tests/authored-root.test.ts (12 tests) 241ms + ✓ tests/daemon-lifecycle.test.ts (42 tests) 74ms + ✓ tests/relay-cli-surface.test.ts (66 tests) 102ms + ✓ tests/validate.test.ts (68 tests) 32ms + ✓ tests/preflight.test.ts (57 tests) 105ms + ✓ tests/observer-link.test.ts (39 tests) 247ms + ✓ tests/close-pr-flow.test.ts (28 tests) 731ms + ✓ close-pr journaled repair loop > executes the deterministic commit and force-push steps against a local Git remote, including a no-op repair 358ms + ✓ tests/authored-flow.test.ts (25 tests) 933ms + ✓ tests/verb-field-lint.test.ts (96 tests) 878ms + ✓ tests/step-failure-diagnostic.test.ts (21 tests) 1203ms + ✓ step failure diagnostic > surfaces command exit, stderr and replay hint through the CLI (json=false) 614ms + ✓ step failure diagnostic > surfaces command exit, stderr and replay hint through the CLI (json=true) 576ms +(node:2471361) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ tests/cli-status.test.ts (26 tests) 1350ms + ✓ flows status > resolves the run with no arguments from inside a worker-spawned agent 1148ms + ✓ tests/cloud-run.test.ts (58 tests) 1731ms + ✓ tests/authored-human.test.ts (13 tests) 173ms + ✓ tests/authored-flow-lifecycle-executor.test.ts (27 tests) 757ms + ✓ tests/gate-contract.test.ts (20 tests) 294ms + ✓ tests/agent-relay-transport.test.ts (16 tests) 2323ms + ✓ Relay completion at the journal boundary > does not complete at readiness and journals exact output, receipt, and priced accounting 1012ms + ✓ Relay completion at the journal boundary > aborts polling on rejected renewal and never writes a stale completion 1004ms + ✓ tests/cli-hn-monitor.test.ts (16 tests) 121ms + ✓ tests/cli-replay.test.ts (38 tests) 1304ms + ✓ flows replay > --json is byte-identical across two CLI invocations (diff) 1094ms + ✓ tests/cloud-sync.test.ts (40 tests) 2830ms + ✓ flows run --cloud --sync-code / flows sync > runs an authored flow with --input and syncs the invoking directory 446ms + ✓ tests/authored-node-result.test.ts (38 tests) 44ms + ✓ tests/cloud-deploy.test.ts (40 tests) 3156ms + ✓ deployToCloud > refuses a declared harness Cloud cannot run instead of substituting Claude, unless --agents says so 477ms + ✓ deployToCloud > reports a missing or unloadable source as an input refusal (exit 2), before HTTP 411ms + ✓ deployToCloud > refuses non-authored sources, empty sources, duplicate providers and a blank approver before HTTP 570ms + ✓ flows deploy / flows deployments > deploys an authored flow as a listener and prints the sources 399ms + ✓ flows deploy / flows deployments > passes --agents and --draft through, and names a structured refusal 493ms + ✓ flows deploy / flows deployments > explains a 403 as a missing cli:auth login and exits 2 336ms + ✓ tests/backlog-picker.test.ts (14 tests) 65ms +(node:2473373) Warning: Transcript tail for run-9/analyze attempt 1 (stdout) could not be written; the step continues without it: EACCES: permission denied, mkdir '/tmp/transcript-tail-JZu3Jb/runs/run-9/steps' +(Use `node --trace-warnings ...` to show where the warning was created) + ✓ tests/authored-flow-slack.test.ts (7 tests) 2271ms + ✓ authored Slack helper effects > replays after SIGKILL before confirm with the same token and one successful completion 848ms + ✓ authored Slack helper effects > replays after SIGKILL before complete with the same token and one successful completion 618ms + ✓ authored Slack helper effects > writes two files for two calls and supports dm, reply, and react 334ms + ✓ tests/authored-activity.test.ts (15 tests) 166ms + ✓ tests/backlog-picker-flow.test.ts (6 tests) 449ms + ✓ tests/transcript-tail.test.ts (11 tests) 1048ms + ✓ direct agent spawn > tees stdout and stderr into tail files that name the dispatch 372ms + ✓ direct agent spawn > completes the step when the tail directory cannot be created 544ms + ✓ tests/tick-runner.test.ts (22 tests) 3587ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms fractional as an invocation error 492ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms exponent notation as an invocation error 597ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms hex as an invocation error 705ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms trailing text as an invocation error 630ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms empty as an invocation error 526ms + ✓ CLI argument parsing refuses coercion rather than accepting it > accepts an exact integer and proceeds past parsing 565ms + ✓ tests/authored-agent-artifacts.test.ts (4 tests) 554ms + ✓ tests/pr-review-post.test.ts (21 tests) 4137ms + ✓ workflows/pr-review-post.cjs > after a rebase (diverged) the more recently committed head wins 459ms + ✓ tests/cloud-connect.test.ts (24 tests) 4889ms + ✓ hosted verbs connect before they submit > flows deploy --json never prompts and reports the refusal as JSON 431ms + ✓ hosted verbs connect before they submit > flows deploy --draft skips the check, like Cloud does for a draft 354ms + ✓ hosted verbs connect before they submit > flows deploy appends the agent-relay cloud connect remedy to a harness refusal 420ms + ✓ hosted verbs connect before they submit > flows run --cloud submits once the prompt connected the integration 2326ms + ✓ hosted verbs connect before they submit > a flow with no integrations contacts nothing extra 361ms + ✓ tests/worker-transcript.test.ts (5 tests) 283ms + ✓ tests/preflight-permissions-unenforced.test.ts (17 tests) 773ms + ✓ an authored TypeScript body is out of reach, and spec.ts says so > checks clean on a .flow.ts whose body declares permissions 698ms + ✓ tests/cli.test.ts (65 tests) 5753ms + ✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 961ms + ✓ flows check CLI > refuses a typo model before probing or contacting relayflowd 602ms + ✓ flows check CLI > maps every input refusal path to its declared kind without raw exceptions 449ms + ✓ flows run/resume CLI over the journal protocol > exits 3 and names the parked llm step 583ms + ✓ flows run/resume CLI over the journal protocol > follows a dispatched worker step instead of reporting a protocol error 574ms + ✓ flows run/resume CLI over the journal protocol > resumes a parked run from snapshot step types without reading journal sequence one 496ms + ✓ tests/authored-run-failure-evidence.test.ts (8 tests) 879ms + ✓ the child index after the process that wrote it is gone > still names every child, with its own run id, after a daemon restart 502ms + ✓ tests/authored-flow-operation.test.ts (23 tests) 680ms + ✓ completes the gate in linear time over a body with 30000 ordinary awaits 513ms + ✓ tests/authored-step-failed.test.ts (10 tests) 51ms + ✓ tests/authored-step-index.test.ts (12 tests) 15ms + ✓ tests/webhook.test.ts (9 tests) 1222ms + ✓ webhook ingress > checks TS declarations against flows.json without invoking handlers 1025ms + ✓ tests/flow-requirements.test.ts (13 tests) 1292ms + ✓ flows check prints REQUIRES > names the helper, the harness and the mcp server of an authored flow 937ms + ✓ tests/work-package-consumer.test.ts (13 tests) 200ms + ✓ tests/budget-preflight.test.ts (25 tests) 37ms + ✓ tests/artifact-gates.test.ts (6 tests) 232ms + ✓ tests/stop-process-group.test.ts (6 tests) 6639ms + ✓ every stop reaches the process group, not just the direct child > exits the run after an execution-timeout stop 852ms + ✓ every stop reaches the process group, not just the direct child > exits the run after a protocol terminate stop 461ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after a protocol terminate stop 1769ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after an execution-timeout stop 2114ms + ✓ every stop reaches the process group, not just the direct child > holds the loop open long enough for the escalation to run 1143ms + ✓ tests/stuck-run-triage.test.ts (22 tests) 3236ms + ✓ stuck-run-triage shell text > collects tails with no GNU timeout on PATH, as on a stock macOS 3133ms + ✓ tests/authored-helpers.test.ts (6 tests) 3785ms + ✓ lowers the named acceptance helpers and Slack to confirmed journal effects 312ms + ✓ runs every available provider through the real kernel and resumes completed effects without a second write 1867ms + ✓ replays after SIGKILL before confirm with the same token and one successful completion 683ms + ✓ replays after SIGKILL before complete with the same token and one successful completion 704ms + ✓ tests/helpers-fanout.test.ts (96 tests) 161ms + ✓ tests/webhook-hardening.test.ts (11 tests) 132ms + ✓ tests/spec-parity.test.ts (31 tests) 828ms + ✓ tests/live-event-activities.test.ts (2 tests) 247ms + ✓ tests/human-to.test.ts (8 tests) 13ms + ✓ tests/provider-trigger-contract.test.ts (7 tests) 1576ms + ✓ provider trigger contract > accepts one subscription from each of five providers and refuses a bogus event on any of them 701ms + ✓ provider trigger contract > fails `flows check` before deployment and passes once the event is real 866ms + ✓ tests/budget-unmetered-live.test.ts (3 tests) 1729ms + ✓ unmetered budget spend through the live kernel > runs an unpriced step under a dollar budget without tripping it, journaling unknown dollars 747ms + ✓ unmetered budget spend through the live kernel > still counts an unpriced step toward a token budget 485ms + ✓ unmetered budget spend through the live kernel > accrues a priced step and stops the run when it crosses the dollar budget 496ms + ✓ tests/generate-triggers.test.ts (7 tests) 1675ms + ✓ discovers new adapters, preserves exact event names, and prefers adapter-local mappings 548ms + ✓ fails closed on malformed mappings and colliding method names before writing output 333ms + ✓ unions catalog events into every provider with the plain signature and never overrides a mapping-declared one 305ms + ✓ tests/redact.test.ts (35 tests) 13ms + ✓ tests/plugin-loader.test.ts (9 tests) 273ms + ✓ tests/worker-lease.test.ts (7 tests) 15ms + ✓ tests/yaml-helpers.test.ts (33 tests) 157ms + ✓ tests/cloud-schedule.test.ts (17 tests) 7193ms + ✓ schedule lowering > marks a non-grid cron as Cloud-only rather than approximating it, with a silence budget from its own cadence 2816ms + ✓ flows check prints declared schedules > shows the lowering for a fixed interval and the Cloud-only note for a real cron 2947ms + ✓ flows schedule / schedules / unschedule > schedules an authored flow from its declared schedule, sending exactly the run body inside the envelope 404ms + ✓ flows schedule / schedules / unschedule > refuses bad crons, bad zones, missing declarations and both flags before HTTP 377ms + ✓ flows schedule / schedules / unschedule > names a structured refusal from the schedule route 328ms + ✓ tests/communication.test.ts (10 tests) 17ms + ✓ tests/budget-attribution.test.ts (5 tests) 18ms + ✓ tests/typed-output.test.ts (14 tests) 480ms + ✓ tests/model-selection.test.ts (10 tests) 30ms + ✓ tests/mcp-lifecycle.test.ts (4 tests) 19ms + ✓ tests/effect-channel.test.ts (5 tests) 422ms + ✓ tests/relayflowd-path.test.ts (10 tests) 10ms + ✓ tests/authored-agent-permissions.test.ts (26 tests) 1109ms + ✓ tests/local-dev-ux.test.ts (8 tests) 36ms + ✓ tests/authored-plugin-effect.test.ts (6 tests) 121ms + ✓ tests/authored-declined.test.ts (13 tests) 89ms + ✓ tests/direct-input.test.ts (6 tests) 11065ms + ✓ direct .flow.ts input through the built CLI and live runtime > returns exit 3 for an authored human handoff and persists its outcome 1298ms + ✓ direct .flow.ts input through the built CLI and live runtime > returns exit 1 for an authored step_failed verdict and persists its outcome 836ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 2777ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 3957ms + ✓ direct .flow.ts input through the built CLI and live runtime > does not run the authored body before daemon availability 1168ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses oversized file input before contacting relayflowd 1027ms + ✓ tests/relay-cli-surface-live.test.ts (3 tests) 490ms + ✓ tests/bundle.test.ts (23 tests) 11314ms + ✓ immutable bundles > returns exit 2 naming a byte-flipped payload and refuses to reuse corruption 627ms + ✓ immutable bundles > verifies with --verify in any position and answers --json with one object 1089ms + ✓ immutable bundles > refuses --out with --verify rather than ignoring the destination 453ms + ✓ immutable bundles > builds and verifies the canonical YAML fixture through the compiled CLI 1558ms + ✓ immutable bundles > emits the ephemeral warning on CLI stderr and uses the default output directory 1109ms + ✓ immutable bundles > refuses build-provable CLI resolution errors without environment probes 529ms + ✓ immutable bundles > builds a standalone TS fixture twice with identical executable hashes 2674ms + ✓ immutable bundles > refuses to label installed dependency drift with lockfile pins 544ms + ✓ immutable bundles > refuses invalid CLI arguments %j 562ms + ✓ immutable bundles > refuses invalid CLI arguments "--out" 516ms + ✓ immutable bundles > refuses invalid CLI arguments "--verify" 502ms + ✓ immutable bundles > refuses invalid CLI arguments "--verify" 480ms + ✓ immutable bundles > refuses invalid CLI arguments "--out" 527ms + ✓ tests/f-memory.test.ts (7 tests) 1531ms + ✓ tests/resume-failure.test.ts (2 tests) 11ms + ✓ tests/communication-review.test.ts (5 tests) 340ms + ✓ tests/yaml-helper-effect.test.ts (4 tests) 81ms + ✓ tests/deterministic-llm.test.ts (5 tests) 142ms + ✓ tests/json-schema-bound.test.ts (71 tests) 3407ms + ✓ JSON Schema termination bound > walks a deep schema with an explicit stack rather than recursion 2451ms + ✓ tests/input-binding.test.ts (12 tests) 353ms + ✓ tests/human-live.test.ts (3 tests) 7799ms + ✓ f.human against a real daemon > parks with the question, refuses wrong answers, records one, and resumes to success 4960ms + ✓ f.human against a real daemon > a "no" is a value the body branches on: declined, exit 0, no effect 1716ms + ✓ f.human against a real daemon > refuses to answer a run the daemon does not know 1122ms + ✓ tests/dependency-validation.test.ts (6 tests) 708ms + ✓ dependency validation > accepts a valid 10,000-step reverse chain through every direct public boundary 430ms + ✓ tests/scope-compiler.test.ts (25 tests) 17ms + ✓ tests/hn-poller.test.ts (6 tests) 12ms + ✓ tests/scope-preflight.test.ts (6 tests) 15ms + ✓ tests/authored-step-failed-exit.test.ts (3 tests) 7ms + ✓ tests/direct-run-failure.test.ts (8 tests) 18ms + ✓ tests/dir-watcher-poller.test.ts (6 tests) 7ms + ✓ tests/subscription-report.test.ts (8 tests) 5ms + ✓ tests/model-pricing.test.ts (10 tests) 8ms + ✓ tests/build-gate.test.ts (3 tests) 1414ms + ✓ flows build gates on flows check green (#318) > refuses a flow with an unresolvable named-agent CLI and leaves no artifacts 440ms + ✓ flows build gates on flows check green (#318) > --json emits one CheckReport object on stdout on refusal, exits 2, no artifacts 467ms + ✓ flows build gates on flows check green (#318) > builds the bundle on success (regression: gate must not block valid flows) 505ms + ✓ tests/pty-sidechannel.test.ts (11 tests) 7205ms + ✓ view attach preserves worker completion and marks only drive 906ms + ✓ drive attach preserves worker completion and marks only drive 1029ms + ✓ passthrough attach preserves worker completion and marks only drive 1022ms + ✓ none attach preserves worker completion and marks only drive 988ms + ✓ none subscriber lets an unattended CLI read EOF 509ms + ✓ view subscriber lets an unattended CLI read EOF 519ms + ✓ passthrough subscriber lets an unattended CLI read EOF 511ms + ✓ incomplete subscriber lets an unattended CLI read EOF 391ms + ✓ rejects drive after EOF without marking human intervention 688ms + ✓ delivers all drive bytes in order across child stdin backpressure 635ms + ✓ tests/communication-worker.test.ts (15 tests) 1512ms + ✓ tests/webhook-live.test.ts (6 tests) 10526ms + ✓ executes and deduplicates 'app_mention' only for its provider and matching payload 1602ms + ✓ executes and deduplicates 'reaction_added' only for its provider and matching payload 1526ms + ✓ executes and deduplicates 'pull_request' only for its provider and matching payload 1623ms + ✓ flows serve-webhook writes JSON before the daemon starts, then journals and archives exactly once 1686ms + ✓ replays a dropped file after SIGKILL before spawn 615ms + ✓ resumes the same journal after SIGKILL after spawn and before acknowledgement 3473ms + ✓ tests/wrapper-artifacts-cwd.test.ts (2 tests) 89ms + ✓ tests/flow-executor-chain.test.ts (14 tests) 15756ms + ✓ flow executor LLM and output-binding chain > runs f.llm -> f.agent -> f.run with schema-verified journal output and the exact allowed model 1231ms + ✓ flow executor LLM and output-binding chain > runs a dollar-budgeted authored Claude agent with the same default used by preflight 1258ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: not JSON 466ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: {"message":7} 419ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: null 448ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: [1,2] 443ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: "hello" 326ms + ✓ flow executor LLM and output-binding chain > runs the exact authored flagship f.llm -> f.agent -> f.run path through the durable CLI root 2647ms + ✓ flow executor LLM and output-binding chain > resumes an interrupted durable authored root without replaying completed flagship effects 4281ms + ✓ flow executor LLM and output-binding chain > passes a declarative verified value through an agent into a deterministic artifact 870ms + ✓ flow executor LLM and output-binding chain > journals a missing optional field as a failure before the consuming command executes 386ms + ✓ flow executor LLM and output-binding chain > flows run consumes YAML bindings and resume reuses the original journal output 2626ms + ✓ tests/yaml-local-agent-live.test.ts (7 tests) 4759ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked step CLI and model and journals done 765ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked named CLI and model and journals done 644ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked flow CLI and model and journals done 776ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked project CLI and model and journals done 635ms + ✓ YAML --local-agent through the built CLI and real daemon > still parks without --local-agent 592ms + ✓ YAML --local-agent through the built CLI and real daemon > reports the agent process failure 672ms + ✓ YAML --local-agent through the built CLI and real daemon > preserves declared workspace surfaces that the local worker cannot pin 673ms + ✓ tests/hello-deterministic.test.ts (5 tests) 21ms + ✓ tests/provider-trigger-executor.test.ts (4 tests) 236ms + ✓ tests/cli-adapter.test.ts (4 tests) 9ms + ✓ tests/transcript-exclusion-timeout.test.ts (1 test) 188ms + ✓ tests/work-package-validator.test.ts (7 tests) 7ms + ✓ tests/daemon-lifecycle-live.test.ts (9 tests) 13987ms + ✓ flows run against a data dir with no daemon (§6 test 7) > cold start spawns exactly one daemon, the run succeeds, and the daemon outlives the CLI 1466ms + ✓ flows run against a data dir with no daemon (§6 test 7) > polls, bounded, for a daemon that holds the lock before it binds 1620ms + ✓ flows run against a data dir with no daemon (§6 test 7) > attaches to a serving daemon that has not published a connection file 1278ms + ✓ flows run against a data dir with no daemon (§6 test 7) > a second run attaches to the daemon the first one started, spawning nothing 2619ms + ✓ flows run against a data dir with no daemon (§6 test 7) > detects a stale connection file left by a hard kill and starts a fresh daemon 2387ms + ✓ concurrent invocations against one empty data dir (§6 test 15) > ends with exactly one daemon owning the socket, and both runs succeed 1186ms + ✓ refusals from a spawn that cannot produce a daemon > names relayflowd_not_found rather than falling through to PATH 1189ms + ✓ refusals from a spawn that cannot produce a daemon > names daemon_start_failed and quotes the daemon log when startup dies 1055ms + ✓ refusals from a spawn that cannot produce a daemon > refuses a daemon speaking another protocol version instead of binding over it 1185ms + ✓ tests/plugin-add.test.ts (7 tests) 1542ms + ✓ installs a real offline npm fixture and includes declarations 329ms + ✓ typechecks the augmented verb and rejects unknown namespaces 1195ms + ✓ tests/agent-relay-hardening.test.ts (12 tests) 27ms + ✓ tests/deploy.test.ts (11 tests) 6558ms + ✓ flows deploy file buckets > publishes the full signed layout byte-for-byte and redeploys as a noop 1082ms + ✓ flows deploy file buckets > answers --json with one object per outcome 1065ms + ✓ flows deploy file buckets > reports a refusal as JSON under --json 544ms + ✓ flows deploy file buckets > refuses a missing local bundle before creating the bucket 425ms + ✓ flows deploy file buckets > refuses an unreachable bucket before copying 534ms + ✓ flows deploy file buckets > refuses an unwritable bucket 509ms + ✓ flows deploy file buckets > refuses local tampering of spec.canonical.json 425ms + ✓ flows deploy file buckets > refuses local tampering of identity.json 400ms + ✓ flows deploy file buckets > refuses asset bundles instead of using daemon-relative files 463ms + ✓ flows deploy file buckets > never labels a corrupt existing deployment as a noop 1079ms + ✓ tests/bin.test.ts (7 tests) 2860ms + ✓ built flows binary > refuses through a symlink to the built artifact 498ms + ✓ built flows binary > refuses through a symlinked directory component 488ms + ✓ built flows binary > classifies a signal-terminated auth probe as probe_failed 454ms + ✓ built flows binary > classifies an unavailable PATH resolver as probe_failed 447ms + ✓ built flows binary > does not describe a present non-executable CLI as missing 394ms + ✓ built flows binary > runs one auth probe for three steps sharing a flow CLI 575ms + ✓ tests/yaml-helper-live.test.ts (1 test) 1280ms + ✓ runs compiled YAML helpers through the built CLI and kernel effect journal 1279ms + ✓ tests/agent-artifacts.test.ts (6 tests) 19ms + ✓ tests/communication-mixed-resume.test.ts (1 test) 195ms + ✓ tests/cli-answer.test.ts (15 tests) 11ms + ✓ tests/journal-client-subscriptions.test.ts (1 test) 9ms + ✓ tests/communication-preflight.test.ts (13 tests) 47ms + ✓ tests/transcript-tail-close.test.ts (2 tests) 1712ms + ✓ a stalled transcript-tail close > does not hold the spawn open past its bounded window 782ms + ✓ a stalled tail close beside a transcript that finished > still journals the transcript pointer 928ms + ✓ tests/communication-environment-preflight.test.ts (6 tests) 8ms + ↓ tests/real-cli-adapters.test.ts (3 tests | 3 skipped) + ✓ tests/parse-json-output.test.ts (7 tests) 4ms + ✓ tests/journal-client-completion.test.ts (4 tests) 106ms + ✓ tests/adapters/claude.test.ts (7 tests) 9ms + ✓ tests/authored-surface-authority.test.ts (2 tests) 22ms + ✓ tests/adapters/codex.test.ts (7 tests) 8ms + ✓ tests/authored-use-loader.test.ts (5 tests) 1508ms + ✓ authored use graph loader > loads a diamond in dependency order with one node per canonical path 561ms + ✓ tests/memoization.test.ts (57 tests) 259ms + ✓ tests/communication-history.test.ts (1 test) 3ms + ✓ tests/worker-cli-cwd.test.ts (2 tests) 289ms + ✓ tests/adapters/registry.test.ts (4 tests) 7ms + ✓ tests/bundle-preflight.test.ts (4 tests) 1294ms + ✓ bundle execution preflight > ignores surrounding cache configuration on a verified cache hit 638ms + ✓ bundle execution preflight > uses the built alias for a nameless flow even in a digest-only cache directory 625ms + ✓ tests/budget-authored-live.test.ts (2 tests) 160ms + ✓ tests/slack-writeback.test.ts (1 test) 267ms + ✓ tests/authored-declined-live.test.ts (1 test) 2206ms + ✓ runs an input guard and resumes its completed declined root without repeated effects 2203ms + ✓ tests/slack-block-kit.test.ts (5 tests) 29ms + ✓ tests/communication-tools.test.ts (1 test) 97ms + ✓ tests/placement.test.ts (54 tests) 42ms + ✓ tests/authored-declined-report.test.ts (6 tests) 15ms + ✓ tests/communication-lazy.test.ts (1 test) 7ms + ✓ tests/authored-admission.test.ts (2 tests) 4ms + ✓ tests/check-command-cwd.test.ts (1 test) 24ms + ✓ tests/communication-refusal.test.ts (1 test) 24ms + ✓ tests/worker-platform.test.ts (1 test) 5ms + ✓ tests/memory.test.ts (18 tests) 16ms + ✓ tests/classify-outcome.test.ts (2 tests) 2176ms + ✓ classifyOutcome > gives up and reports when a running run never becomes classifiable 2019ms + ✓ tests/run-from-digest.test.ts (6 tests) 5367ms + ✓ flows run digest input > submits the sealed canonical spec through the normal journal path without checkout 542ms + ✓ flows run digest input > uses a verified cache hit even after the bucket is removed 408ms + ✓ flows run digest input > resolves deploy.bucket from flows.json and honors explicit override 1461ms + ✓ flows run digest input > refuses an unconfigured bucket 1049ms + ✓ flows run digest input > refuses tampered spec.canonical.json before creating run data 1114ms + ✓ flows run digest input > refuses tampered identity.json before creating run data 791ms + ✓ tests/activity-preflight.test.ts (1 test) 13ms + ✓ tests/cli-progress-wait.test.ts (2 tests) 723ms + ✓ run starts the wait clock on its first observed lease 572ms + ✓ tests/run-digest-live.test.ts (1 test) 1018ms + ✓ executes a deployed digest on the real kernel after deleting the authoring tree 1017ms + ✓ tests/worker-cli-abort.test.ts (2 tests) 2940ms + ✓ stops claude and its process group when lease ownership is lost 1371ms + ✓ stops wrapper.mjs and its process group when lease ownership is lost 1569ms + ✓ tests/run-digest.test.ts (4 tests) 1569ms + ✓ digest run configuration refusals > reports config_invalid before fetching or starting a run for {invalid json 470ms + ✓ digest run configuration refusals > reports config_invalid before fetching or starting a run for {"deploy":{}} 316ms + ✓ digest run configuration refusals > reports config_invalid before fetching or starting a run for {"deploy":{"bucket":123}} 406ms + ✓ digest run configuration refusals > reports config_invalid before fetching or starting a run for {"deploy":{"bucket":""}} 376ms + ✓ tests/bundle-transport.test.ts (20 tests) 2366ms + ✓ digest references > accepts and deploys the build output for hello 499ms + ✓ digest references > accepts and deploys the build output for Hello 449ms + ✓ digest references > accepts and deploys the build output for hello.world 345ms + ✓ digest references > accepts and deploys the build output for 123 406ms + ✓ digest references > accepts and deploys the build output for A_b.c-1 375ms + ✓ tests/mcp.test.ts (30 tests) 21845ms + ✓ MCP preflight and transports > flows check refuses an undeclared server with exit 2 and no daemon 1336ms + ✓ MCP preflight and transports > flows check reports a refusing server and leaves no PID 1436ms + ✓ MCP preflight and transports > kills a SIGTERM-resistant silent child after a parent-owned handshake deadline 1330ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with inherit stdio before cleanup finishes 1157ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with ignore stdio before cleanup finishes 2080ms + ✓ MCP preflight and transports > reports malformed connection configuration as config_invalid 1269ms + ✓ authored MCP effects against the real kernel > journals one MCP receipt per call with args, result, stable logical key, and a confirmed effect 352ms + ✓ authored MCP effects against the real kernel > reports a dropped tool connection as a failed CLI run 12007ms + ✓ tests/cli-watch.test.ts (10 tests) 18554ms + ✓ flows check --watch > rechecks syntax errors, clears once, and returns the last refusal on Ctrl-C 1815ms + ✓ flows check --watch > streams JSON lines without ANSI, recovers after atomic saves, and exits zero after repair 2494ms + ✓ flows check --watch > coalesces 20 concurrent saves into at most two rechecks 2047ms + ✓ flows check --watch > watches transitive relative use imports, cycles, and nearest config changes 2734ms + ✓ flows check --watch > refreshes the import graph and notices missing imports being created 2811ms + ✓ flows check --watch > reloads authored TypeScript instead of reusing the first imported definition 2369ms + ✓ flows check --watch > detects a nearer config appearing and falls back after it is deleted 1778ms + ✓ flows check --watch > keeps watching after the target is deleted and recreated 1723ms + ✓ flows check --watch > queues changes during a slow check without overlapping checks 779ms + ✓ tests/worker-cli.test.ts (18 tests) 27103ms + ✓ registered CLI model defaults > passes the same priced Claude default to the real provider invocation 527ms + ✓ step discovery environment > names the run, step, attempt and an absolute data dir for a direct agent spawn 947ms + ✓ step discovery environment > exports none of the four without a data dir, even when the worker inherited them 738ms + ✓ wrapper discovery environment > sets the four names from the dispatch and still refuses ambient values and other secrets 1120ms + ✓ wrapper discovery environment > exports none of the four to a wrapper without a data dir, even when the worker inherited them 958ms + ✓ custom wrapper execution identity > passes an explicit safe environment at identification and execution 818ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 528ms + ✓ custom wrapper execution identity > bounds wrapper execution after acknowledgement 653ms + ✓ custom wrapper execution identity > bounds captured wrapper output 455ms + ✓ custom wrapper execution identity > refuses a duplicate execute protocol frame 597ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 2031ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 2045ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3483ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11430ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 397ms + ✓ tests/worker-cli-result-exit.test.ts (5 tests) 32877ms + ✓ a Claude agent step completes on its result, not only on process exit > settles a hung, successful run within the grace and stops its whole tree 31649ms + ✓ a Claude agent step completes on its result, not only on process exit > maps an error result on a hung run to a failed exit 31649ms + ✓ a Claude agent step completes on its result, not only on process exit > leaves a hang before any result to the existing stops 32018ms + ✓ an agent tree does not outlive the process that spawned it > kills the agent group when the run process is terminated by SIGTERM 778ms + ✓ tests/agent-transcript-live.test.ts (4 tests) 42229ms + ✓ the transcript digest through the built CLI, a real daemon and the local agent > preserves structured agent failure details and its completed root index 13682ms + ✓ the transcript digest through the built CLI, a real daemon and the local agent > preserves structured llm failure details and its completed root index 13991ms + ✓ the transcript digest through the built CLI, a real daemon and the local agent > journals the digest in trajectory_tail on a successful agent step and writes the file it points at 902ms + ✓ the transcript digest through the built CLI, a real daemon and the local agent > on a failed agent step, names the failure and the transcript in the terminal diagnostic, redacted 13653ms + ✓ tests/agent-artifacts-live.test.ts (5 tests) 44344ms + ✓ agent artifacts and gates through the built CLI, a real daemon and the local agent > journals the files the agent wrote, and both artifact gates pass on that journal 1457ms + ✓ agent artifacts and gates through the built CLI, a real daemon and the local agent > fails the run when the artifact_exists gate names a file the agent did not write 14163ms + ✓ agent artifacts and gates through the built CLI, a real daemon and the local agent > fails the run with the author reason when a predicate gate returns false, journaling the verdict 15192ms + ✓ review follow-ups > applies a predicate gate on a helper step too, and journals its verdict 12607ms + ✓ review follow-ups > records predicate verdicts on the root run so a resume reuses them instead of re-running the closure 924ms +stdout | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +LIVE_ANALYZER ready: claude -p --model claude-haiku-4-5-20251001 round-trip OK + +stdout | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +LIVE_ANALYZER analysis: {"reasoning":"This story is directly about an AI agent automating software development workflows by autonomously opening and reviewing pull requests, which is a core demonstration of agent capability and automation in modern development practices.","relevance_score":10,"story_title":"Show HN: an agent that opens and reviews its own pull requests [wake-nonce-7f3a91c4]"} + +stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once +LIVE_KERNEL kill -9 pid=2488603 run=01M2Z4R4E09ZK7EYE87WM52QE5 while step=two state=Running + + ✓ tests/live-kernel.test.ts (31 tests) 65356ms + ✓ built flows CLI against live relayflowd > twenty-six-step reuses 25 durable completions after editing the failed final step 3388ms + ✓ built flows CLI against live relayflowd > runs rung (a), parks rung (b), and keeps JSON report-shaped 5717ms + ✓ built flows CLI against live relayflowd > allows a deterministic run to exceed the bounded request timeout 32588ms + ✓ built flows CLI against live relayflowd > follows a live worker dispatch through flows run 993ms + ✓ built flows CLI against live relayflowd > f.agent lowers to a real agent step and dispatches through a live worker 305ms + ✓ built flows CLI against live relayflowd > can always get a parked run to a late-attaching worker 5567ms + ✓ built flows CLI against live relayflowd > reports a real manual-recovery NeedsHuman state as parked 626ms + ✓ built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI 8972ms + ✓ built flows CLI against live relayflowd > preflights before journaling and names an unreachable socket 1891ms + ✓ built flows CLI against live relayflowd > starts exactly one daemon when two runs race for one empty data dir 940ms + ✓ surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once 1735ms + ✓ tests/local-agent-live.test.ts (5 tests) 62024ms + ✓ built CLI local agent against a real daemon > dispatches through the wrapper and keeps --json stdout report-shaped 1096ms + ✓ built CLI local agent against a real daemon > runs beyond the initial 30-second lease without a second invocation 35968ms + ✓ built CLI local agent against a real daemon > renders actual agent completion in text output 1009ms + ✓ built CLI local agent against a real daemon > returns a failed run when the agent process fails 12606ms + ✓ built CLI local agent against a real daemon > refuses a workspace it cannot pin before invoking the agent 11342ms + ✓ tests/step-lease.test.ts (36 tests) 66406ms + ✓ f.run leases against the live kernel > enforces 10000 ms for 'sleep 5; printf ok' 5071ms + ✓ f.run leases against the live kernel > enforces 40000 ms for 'sleep 31; printf ok' 31052ms + ✓ f.run leases against the live kernel > enforces 30000 ms for 'sleep 31; printf ok' 30076ms + ✓ tests/event-await-cli.test.ts (1 test) 57078ms + ✓ parks, restarts, and replays two event wakes through the actual CLI 57077ms + ✓ tests/authored-node-runtime.test.ts (15 tests) 84424ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > serializes the immutable prepared binding facts through the Node and CLI boundary 1800ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > suppresses the loader warning while preserving authored experimental warnings 1833ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > awaits agent plus three run steps and resumes without repeating effects 2803ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > accepts a predicate-gated flow: the `.gate` child is journaled, verified, and not counted as an authored step 2786ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > parks an f.human across the IPC boundary, answers it, and resumes the Node body with the answer 3968ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent SIGKILL and replays completed children before success 3732ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent SIGTERM and replays completed children before success 3455ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent blocked-SIGKILL and replays completed children before success 2722ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > stops on parent SIGKILL and replays completed children before declined 2436ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses unawaited rather than reporting terminal success 14701ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses manual then rather than reporting terminal success 12890ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > loads captured graph bytes before preserving the unsupported-use refusal 14837ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > rejects a forged result frame without durable completion 14299ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses missing Node before body effects or root admission 358ms + ✓ Bun 1.4.0 standalone → native Node authored lifecycle > refuses an old Node candidate before body effects 365ms + + Test Files 162 passed | 1 skipped (163) + Tests 2445 passed | 3 skipped (2448) + Start at 03:09:45 + Duration 86.55s (transform 8.21s, setup 0ms, collect 99.63s, tests 729.87s, environment 36ms, prepare 10.29s) + + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/merged-final-patch.txt b/evidence/pr441-router-2026-09-20/merged-final-patch.txt new file mode 100644 index 000000000..02ba1e3b4 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/merged-final-patch.txt @@ -0,0 +1,68 @@ +Capture formatting note: trailing spaces on blank diff context lines removed. +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ git diff -- src/authored-root.ts tests/authored-activity.test.ts tests/fixtures/event-await-cli-probe.mjs +diff --git i/packages/sdk/src/authored-root.ts w/packages/sdk/src/authored-root.ts +index 4f374dad..975f6c12 100644 +--- i/packages/sdk/src/authored-root.ts ++++ w/packages/sdk/src/authored-root.ts +@@ -18,7 +18,6 @@ import { withWorkerLease } from './worker-lease.js'; + import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js'; + import { readOpenHumanWaits } from './authored-human.js'; + import { isSurfaceCompletionReason } from './authored-step-output.js'; +-import { AuthoredFlowExecutionError } from './authored-flow-error.js'; + import { readSubscriptionPark } from './authored-subscription-park.js'; + + export type DurableAuthoredFlowResult = +diff --git i/packages/sdk/tests/authored-activity.test.ts w/packages/sdk/tests/authored-activity.test.ts +index 6b5e3fba..36cd75c0 100644 +--- i/packages/sdk/tests/authored-activity.test.ts ++++ w/packages/sdk/tests/authored-activity.test.ts +@@ -17,6 +17,7 @@ describe('authored event activities', () => { + path = sockPath(); + server = startLoopback(path, { + hello: (ctx) => sendOk(ctx), ++ 'stream.append': (ctx) => sendResult(ctx, { offset: 0 }), + 'subscription.open': (ctx, params) => { + calls.push({ verb: 'subscription.open', params }); + if (params.run_id === 'root-prepared') { +diff --git i/packages/sdk/tests/fixtures/event-await-cli-probe.mjs w/packages/sdk/tests/fixtures/event-await-cli-probe.mjs +index a38785d8..98c6bb09 100644 +--- i/packages/sdk/tests/fixtures/event-await-cli-probe.mjs ++++ w/packages/sdk/tests/fixtures/event-await-cli-probe.mjs +@@ -27,8 +27,11 @@ export default flow('event-await-cli', async f => { + });`); + + function invoke(...args) { ++ return invokeWithin(30_000, args); ++} ++function invokeWithin(timeout, args) { + const result = spawnSync(process.execPath, [join(repo, 'packages/sdk/dist/cli.js'), ...args, +- '--data-dir', data, '--json', '--no-observer-link'], { cwd: root, encoding: 'utf8', timeout: 30_000 }); ++ '--data-dir', data, '--json', '--no-observer-link'], { cwd: root, encoding: 'utf8', timeout }); + console.log(JSON.stringify({ args, status: result.status, stdout: result.stdout, stderr: result.stderr })); + if (result.error) throw result.error; + return { status: result.status, report: JSON.parse(result.stdout) }; +@@ -114,11 +117,20 @@ export default flow('${name}-subscription-report', async f => { + await client.subscriptionDeliver({run_id:rootId,subscription_id:'activity-1', + router_binding:{generation:name, transport:'local-test-router'},delivery_id:name, + frame:{type:'metadata_event',payload:{}}}); +- const boundary = invoke('resume', rootId); ++ // Two subscription parks shift raw attempts to 3..10. Seven semantic ++ // retries sleep at most 60,960ms including 20% jitter. Keep ordinary ++ // invocations at 30s; this new failure case needs its full retry budget. ++ const boundary = name === 'failed' ++ ? invokeWithin(75_000, ['resume', rootId]) : invoke('resume', rootId); + assert.equal(boundary.status, expectedStatus); + assert.equal(boundary.report.rootRunId, rootId); + assert.equal(boundary.report.subscriptions.length, 1); + assert.equal(boundary.report.subscriptions[0].state, name === 'failed' ? 'closed' : 'active'); ++ if (name === 'failed') { ++ const { entries: failureEntries } = await client.journalRead(rootId, 1, 500); ++ assert.equal(failureEntries.filter(entry => entry.entry_type === 'step.completed' ++ && entry.payload.completionReason === 'worker_error').length, 8); ++ } + } + console.log('E2E_PASS: repeated park, SIGKILL/restart, two wakes replayed in order, deduped delivery, exactly-once child effects, zero crash retries'); + } finally { + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/merged-final-test-types.txt b/evidence/pr441-router-2026-09-20/merged-final-test-types.txt new file mode 100644 index 000000000..3f2edcfa9 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/merged-final-test-types.txt @@ -0,0 +1,8 @@ +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ env PATH=/home/khaliqgant/.local/share/mise/installs/node/22.23.2/bin:/home/khaliqgant/.local/share/mise/installs/codex/0.154.0/codex-path:/home/khaliqgant/.codex/tmp/arg0/codex-arg0Hpwx08:/home/khaliqgant/.local/share/mise/installs/codex/latest/bin:/home/khaliqgant/.local/share/mise/installs/claude/latest:/home/khaliqgant/.local/share/mise/installs/cursor-agent/latest/bin:/home/khaliqgant/.local/share/mise/installs/gh/latest/gh_2.100.0_linux_amd64/bin:/home/khaliqgant/.local/share/mise/installs/http-muse/latest:/home/khaliqgant/.local/share/mise/installs/node/26.8.1/bin:/home/khaliqgant/.local/share/mise/installs/npm-xai-official-grok/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-wrangler/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-neonctl/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/bun/latest/bin:/home/khaliqgant/.cargo/bin:/home/khaliqgant/.local/share/mise/installs/go/1.25.14/bin:/home/khaliqgant/.local/share/mise/installs/opencode/latest:/home/khaliqgant/.local/share/mise/installs/gemini/latest/node_modules/.bin:/home/khaliqgant/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/home/khaliqgant/.local/share/mise/shims:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl npm run typecheck:tests + +> @relayflows/sdk@2.0.22 typecheck:tests +> tsc -p tsconfig.tests.json + + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/merged-fixed-build.txt b/evidence/pr441-router-2026-09-20/merged-fixed-build.txt new file mode 100644 index 000000000..3aa87f3d6 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/merged-fixed-build.txt @@ -0,0 +1,8 @@ +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ env PATH=/home/khaliqgant/.local/share/mise/installs/node/22.23.2/bin:/home/khaliqgant/.local/share/mise/installs/codex/0.154.0/codex-path:/home/khaliqgant/.codex/tmp/arg0/codex-arg0Hpwx08:/home/khaliqgant/.local/share/mise/installs/codex/latest/bin:/home/khaliqgant/.local/share/mise/installs/claude/latest:/home/khaliqgant/.local/share/mise/installs/cursor-agent/latest/bin:/home/khaliqgant/.local/share/mise/installs/gh/latest/gh_2.100.0_linux_amd64/bin:/home/khaliqgant/.local/share/mise/installs/http-muse/latest:/home/khaliqgant/.local/share/mise/installs/node/26.8.1/bin:/home/khaliqgant/.local/share/mise/installs/npm-xai-official-grok/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-wrangler/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-neonctl/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/bun/latest/bin:/home/khaliqgant/.cargo/bin:/home/khaliqgant/.local/share/mise/installs/go/1.25.14/bin:/home/khaliqgant/.local/share/mise/installs/opencode/latest:/home/khaliqgant/.local/share/mise/installs/gemini/latest/node_modules/.bin:/home/khaliqgant/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/home/khaliqgant/.local/share/mise/shims:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl npm run build + +> @relayflows/sdk@2.0.22 build +> tsc && node scripts/make-cli-executable.mjs + + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/merged-fixed-typecheck.txt b/evidence/pr441-router-2026-09-20/merged-fixed-typecheck.txt new file mode 100644 index 000000000..f411eca27 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/merged-fixed-typecheck.txt @@ -0,0 +1,8 @@ +cwd: /tmp/flows-pr-followup/pr441/packages/sdk +$ env PATH=/home/khaliqgant/.local/share/mise/installs/node/22.23.2/bin:/home/khaliqgant/.local/share/mise/installs/codex/0.154.0/codex-path:/home/khaliqgant/.codex/tmp/arg0/codex-arg0Hpwx08:/home/khaliqgant/.local/share/mise/installs/codex/latest/bin:/home/khaliqgant/.local/share/mise/installs/claude/latest:/home/khaliqgant/.local/share/mise/installs/cursor-agent/latest/bin:/home/khaliqgant/.local/share/mise/installs/gh/latest/gh_2.100.0_linux_amd64/bin:/home/khaliqgant/.local/share/mise/installs/http-muse/latest:/home/khaliqgant/.local/share/mise/installs/node/26.8.1/bin:/home/khaliqgant/.local/share/mise/installs/npm-xai-official-grok/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-wrangler/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/npm-neonctl/latest/node_modules/.bin:/home/khaliqgant/.local/share/mise/installs/bun/latest/bin:/home/khaliqgant/.cargo/bin:/home/khaliqgant/.local/share/mise/installs/go/1.25.14/bin:/home/khaliqgant/.local/share/mise/installs/opencode/latest:/home/khaliqgant/.local/share/mise/installs/gemini/latest/node_modules/.bin:/home/khaliqgant/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/home/khaliqgant/.local/share/mise/shims:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl npm run typecheck + +> @relayflows/sdk@2.0.22 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +exit status: 0 diff --git a/packages/sdk/src/authored-root.ts b/packages/sdk/src/authored-root.ts index 4f374dad1..975f6c128 100644 --- a/packages/sdk/src/authored-root.ts +++ b/packages/sdk/src/authored-root.ts @@ -18,7 +18,6 @@ import { withWorkerLease } from './worker-lease.js'; import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js'; import { readOpenHumanWaits } from './authored-human.js'; import { isSurfaceCompletionReason } from './authored-step-output.js'; -import { AuthoredFlowExecutionError } from './authored-flow-error.js'; import { readSubscriptionPark } from './authored-subscription-park.js'; export type DurableAuthoredFlowResult = diff --git a/packages/sdk/tests/authored-activity.test.ts b/packages/sdk/tests/authored-activity.test.ts index 6b5e3fbaa..36cd75c01 100644 --- a/packages/sdk/tests/authored-activity.test.ts +++ b/packages/sdk/tests/authored-activity.test.ts @@ -17,6 +17,7 @@ describe('authored event activities', () => { path = sockPath(); server = startLoopback(path, { hello: (ctx) => sendOk(ctx), + 'stream.append': (ctx) => sendResult(ctx, { offset: 0 }), 'subscription.open': (ctx, params) => { calls.push({ verb: 'subscription.open', params }); if (params.run_id === 'root-prepared') { diff --git a/packages/sdk/tests/fixtures/event-await-cli-probe.mjs b/packages/sdk/tests/fixtures/event-await-cli-probe.mjs index a38785d81..98c6bb09e 100644 --- a/packages/sdk/tests/fixtures/event-await-cli-probe.mjs +++ b/packages/sdk/tests/fixtures/event-await-cli-probe.mjs @@ -27,8 +27,11 @@ export default flow('event-await-cli', async f => { });`); function invoke(...args) { + return invokeWithin(30_000, args); +} +function invokeWithin(timeout, args) { const result = spawnSync(process.execPath, [join(repo, 'packages/sdk/dist/cli.js'), ...args, - '--data-dir', data, '--json', '--no-observer-link'], { cwd: root, encoding: 'utf8', timeout: 30_000 }); + '--data-dir', data, '--json', '--no-observer-link'], { cwd: root, encoding: 'utf8', timeout }); console.log(JSON.stringify({ args, status: result.status, stdout: result.stdout, stderr: result.stderr })); if (result.error) throw result.error; return { status: result.status, report: JSON.parse(result.stdout) }; @@ -114,11 +117,20 @@ export default flow('${name}-subscription-report', async f => { await client.subscriptionDeliver({run_id:rootId,subscription_id:'activity-1', router_binding:{generation:name, transport:'local-test-router'},delivery_id:name, frame:{type:'metadata_event',payload:{}}}); - const boundary = invoke('resume', rootId); + // Two subscription parks shift raw attempts to 3..10. Seven semantic + // retries sleep at most 60,960ms including 20% jitter. Keep ordinary + // invocations at 30s; this new failure case needs its full retry budget. + const boundary = name === 'failed' + ? invokeWithin(75_000, ['resume', rootId]) : invoke('resume', rootId); assert.equal(boundary.status, expectedStatus); assert.equal(boundary.report.rootRunId, rootId); assert.equal(boundary.report.subscriptions.length, 1); assert.equal(boundary.report.subscriptions[0].state, name === 'failed' ? 'closed' : 'active'); + if (name === 'failed') { + const { entries: failureEntries } = await client.journalRead(rootId, 1, 500); + assert.equal(failureEntries.filter(entry => entry.entry_type === 'step.completed' + && entry.payload.completionReason === 'worker_error').length, 8); + } } console.log('E2E_PASS: repeated park, SIGKILL/restart, two wakes replayed in order, deduped delivery, exactly-once child effects, zero crash retries'); } finally { From 0e549c37a74cfd61e34936e3d664f2619d463d02 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sun, 20 Sep 2026 03:21:05 -0700 Subject: [PATCH 30/34] test(events): pin recovery of torn overflow fences --- evidence/pr441-router-2026-09-20.md | 6 + .../independent-router-review.md | 176 ++++++++++++++++++ .../independent-router-tests.txt | 29 +++ .../router-empty-delivery.txt | 13 ++ .../torn-fence-probe.txt | 74 ++++++++ .../torn-fence-regression-test.txt | 41 ++++ kernel/relayflowd/src/server.rs | 3 + .../src/server/tests/subscription_router.rs | 3 + .../tests/subscription_router_delivery.rs | 136 ++++++++++++++ 9 files changed, 481 insertions(+) create mode 100644 evidence/pr441-router-2026-09-20/independent-router-review.md create mode 100644 evidence/pr441-router-2026-09-20/independent-router-tests.txt create mode 100644 evidence/pr441-router-2026-09-20/router-empty-delivery.txt create mode 100644 evidence/pr441-router-2026-09-20/torn-fence-probe.txt create mode 100644 evidence/pr441-router-2026-09-20/torn-fence-regression-test.txt diff --git a/evidence/pr441-router-2026-09-20.md b/evidence/pr441-router-2026-09-20.md index 86ea866ae..b6c8c34a8 100644 --- a/evidence/pr441-router-2026-09-20.md +++ b/evidence/pr441-router-2026-09-20.md @@ -35,3 +35,9 @@ Literal commands and captured output, including the exact source patch tested: - [Residual patch exercised by SDK verification](pr441-router-2026-09-20/merged-final-patch.txt). These are local runtime results. Cloud's production router integration and hosted acceptance remain unfinished; this evidence does not establish hosted end-to-end operation. + +## Independent router review follow-up + +[Independent Rust review](pr441-router-2026-09-20/independent-router-review.md) found no blocking fencing/replay defect. Its [external torn-fence probe](pr441-router-2026-09-20/torn-fence-probe.txt) is now a deterministic regression for death between overflow fencing and close: receipt retry and ordinary resume both close once and deliver the overflow wake. [Captured regression run](pr441-router-2026-09-20/torn-fence-regression-test.txt). An empty delivery id now produces `bad_request`; the wire regression [passes](pr441-router-2026-09-20/router-empty-delivery.txt). + +A nonblocking projection limitation remains: an overflow fence whose close has not yet been appended still appears active in `subscription.inspect`; ingress is refused and resume/fence retry repairs its close. No journal facts or delivery permissions are lost. Cloud must rely on the delivered refusal and normal resume repair, not infer ingress permission from that snapshot alone. diff --git a/evidence/pr441-router-2026-09-20/independent-router-review.md b/evidence/pr441-router-2026-09-20/independent-router-review.md new file mode 100644 index 000000000..e01c2ca68 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/independent-router-review.md @@ -0,0 +1,176 @@ +# PR 441 — independent read-only review of the Rust router protocol additions + +Reviewer: flows-501-review-0920 (assigned by sf-frame). No repository edits. +Scope: `c50312d5` "feat(events): fence router delivery and expose durable +subscription metadata" as merged in `62e78e19` (merge of origin/main +`e21caad1`), plus the `docs/EVENT-AWAIT.md` §6 "Targeted router protocol" +contract. SDK changes were out of scope (SDK worker active). + +Exact heads: +- Reviewed: `62e78e193c175a853f38c00923956c63e1a8c970` +- Worktree HEAD moved to `a217c3f65fef4d9e9883b3c8e1ff3ce702934efb` during the + review (SDK-only commit). `git diff 62e78e19 a217c3f6 --stat -- kernel docs` + is empty, so every Rust/docs statement below holds for both heads and the + test runs are attributable to the reviewed kernel. +- Merge check: `git diff c50312d5 62e78e19 -- kernel/relayflowd/src/engine/subscriptions kernel/relayflowd/src/server.rs kernel/relayflowd/src/server/wire.rs kernel/relayflowd/src/server/protocol.rs` + shows only main's #500 worker-eligibility hunks (`required_streams`); the + router additions came through the merge untouched. + +Files read in full: `engine/subscriptions/{router_delivery,mod,state,local_router,parking,wait_timers}.rs`, +`server.rs` (new verbs + lock usage), `server/protocol.rs`, `server/wire.rs`, +`tests/subscription_router_delivery.rs`, `server/tests/subscription_router.rs`, +`engine.rs` resume path, `docs/EVENT-AWAIT.md` §5–§6. + +## Verdict + +No blocking correctness bug found in fencing, dedup, timers, journal or +restart handling for the three new verbs. The behaviour matches the §6 +contract on every point I could exercise, including the one crash window the +tests do not cover (fence torn from its close), which the code already +repairs. Findings below are one projection gap (medium-low), one error-code +mapping nit, and test-coverage gaps; none change journal facts. + +## Evidence (literal commands + complete output) + +- `/tmp/flows-441-review-evidence/router-tests-62e78e19.txt` — + `cargo test -p relayflowd --test subscription_router_delivery` (2/2), + `--lib server::tests::subscription_router` (1/1), + `--test event_activities` (11/11), all exit 0, run from a scratchpad + `CARGO_TARGET_DIR` so nothing was written into the PR worktree. +- `/tmp/flows-441-review-evidence/torn-fence-probe.txt` — external Rust probe + (path dependency on the PR kernel, source included in the file) producing + the torn state with the doc-hidden single-append + `Engine::fence_subscription_overflow` and printing what each verb does. + Output, verbatim: + + ``` + first next() -> true + after torn fence: fenced=1 closed=0 + deliver on torn fence -> Err(subscription_closed) + inspect on torn fence -> state="active" completionReason=null + after fence retry: fenced=1 closed=1 + inspect after retry -> state="closed" completionReason="overflow" + next() after retry -> Wake(Overflow { retained: 1, bytes: 35, from: 0 }) + resume on torn fence -> status=Parked closed=1 wait.completed=1 + PROBE_OK + ``` +- Owner's full post-merge kernel workspace run: + `/tmp/flows-fleet-evidence/pr441/kernel-after-main.txt` (exit status 0; + includes `subscription_router::targeted_router_verbs_validate_receipts_and_preserve_wire_metadata ... ok`). + +## What holds (checked against code, not the PR description) + +1. **Fencing.** `deliver_subscription_frame` refuses unknown/prepared + (`subscription_not_active`), stale receipt (`subscription_binding_mismatch`, + structural `serde_json::Value` equality, key-order independent), and closed + or overflow-fenced (`subscription_closed`) — all before any append, and the + refusal appends nothing (engine test asserts journal length unchanged). + Activation retry requires both `router_binding` and `ingress_offset` to + match; a subscription id is opened at most once per run, so a binding can + never be replaced. `#[serde(deny_unknown_fields)]` on all three param + structs. +2. **Dedup.** `provider_delivery_id` is checked per stream across the whole + journal *before* the overflow check, so a duplicate never triggers a fence + and returns `{appended:false, reason:"duplicate"}`; survives restart + (engine test). Same key space as the local `event.emit` adapter, so a frame + arriving through both paths is one frame. +3. **Overflow.** Bounds are `unread >= 1000` frames or `unread_bytes + + encoded > 1 MiB`, measured from `acknowledged_offset`; the would-exceed + frame is unappended; fence entry then close entry; the server maps the + post-close snapshot to `reason:"overflow"`. `fence_router_subscription_overflow` + is idempotent on a closed receipt (any reason — a normal close that won + the race is a no-op, as §6 requires) and completes a torn fence. +4. **Torn fence (crash between `subscription.overflow_fenced` and + `subscription.closed`).** Not covered by a test, but handled: deliver is + refused, a Cloud fence retry completes the close exactly once, and a plain + `resume` completes it via `claim_subscription_timeouts` → + `complete_fenced_overflows_in_journal`, settling the open `wait.event` with + `wake: overflow`. The probe output above is the proof. +5. **Timers.** `inspect` takes `idleAtMs` from the durable wait, else + `last_wake_at_ms + idle_ms` from the journal — never the query clock + (engine test pins snapshot equality across a restart with a moved clock). + No new timer is armed or claimed by the new verbs; delivery only appends, + consistent with "the router never decides whether the flow is done". +6. **Locking.** All three verbs take `hub.run_lock(run_id)` for the whole + validate-then-append sequence; `deliver` also `ensure_mutable`. Within the + daemon that serializes against `subscription.next`, `event.emit`, + activation, the reconciler's `resume_live` and worker abandonment. +7. **Restart.** Every decision is re-derived from the journal fold on each + call (`subscriptions()`/`prepared_subscriptions()`); the hub lock map being + process-local is fine because there is no in-memory router state to lose. + +## Findings + +### F1 (medium-low) — `subscription.inspect` hides the overflow fence +`inspect_subscriptions` reports `state: "active"` with no `completionReason` +for a subscription whose `SubscriptionOverflowFenced` entry landed but whose +`SubscriptionClosed` has not (probe line: `inspect on torn fence -> +state="active" completionReason=null`), while `subscription.deliver` on the +same state returns `subscription_closed`. §5.4 defines "fenced as +`closing: overflow`" as a distinct state and §6 says `inspect` exists "for +scheduling and cleanup"; a Cloud scheduler reading this projection would keep +the binding open and schedule idle wakes for a subscription the kernel already +refuses. Recovery is unaffected (F-holds 4), so this is a legibility/contract +gap, not data loss. Suggest `state: "closing"` (or an `overflowFenced: true` +field) plus `completionReason: "overflow"` when `state.overflow_fence.is_some() +&& state.closed.is_none()`, and a test asserting it. +Location: `kernel/relayflowd/src/engine/subscriptions/router_delivery.rs` +`inspect_subscriptions`, the `"state"` expression. + +### F2 (low) — empty `delivery_id` surfaces as `internal_error` +`append_subscription_frame` bails on an empty `delivery_id` with a plain +`anyhow` error, which `subscription_router_error` maps to the internal code; +Cloud sees an internal failure for a client mistake. `deliver_subscription_frame` +validates the receipt first and only then reaches this check, so it is +consistent but mis-coded. Suggest validating non-empty `delivery_id` in the +`subscription.deliver` handler as `bad_request`, like other param checks. + +### F3 (low) — unlocked engine use has a validate/append window +`deliver_subscription_frame` drops the journal after validation and +`append_subscription_frame` reopens it. Under the server's run lock this is +harmless. A caller that embeds `Engine` directly without that lock (the engine +API is `pub`) can interleave a close between the two; the append path +re-checks closed/fenced and returns `Ok(false)`, which the server layer would +label `"duplicate"`. Nothing is appended wrongly. Worth a doc comment on the +`pub fn` stating the lock precondition (the existing comment says "the +protocol run lock serializes…", which is true only for protocol callers). + +### F4 (test gaps, no code defect) +- No test tears the fence from its close (F-holds 4). The probe shows the + behaviour is right; it should be pinned, the same way #501 pinned its torn + park (`state::park_placeholder_wait_id` repair). A single-append + `fence_subscription_overflow` + restart + `deliver`/`resume`/fence-retry + assertion is ~30 lines in `tests/subscription_router_delivery.rs`. +- The wire-level `reason:"overflow"` branch of `subscription.deliver` + (server.rs) is not exercised; the engine-level overflow is + (`event_activities::overflow_closes_before_the_1001st_unread_frame…`). + The mapping is three lines, but it is the only place the doc's + `{appended:false, reason:"overflow"}` promise is implemented. +- `subscription.inspect` for a `prepared` id is asserted only in the engine + test; the wire test starts from two active ones. + +### F5 (doc/journal drift, pre-existing, not introduced by c50312d5) +§5.4 says the overflow settle journals `result: { wake: "overflow", retained, +bytes, from }`; `close_subscription_in_journal` journals +`{subscription_id, wake: "overflow"}` and the surface reads `retained/bytes/from` +from the fence entry instead. The body still receives the right values, so +this is the doc describing a shape the journal does not carry. Either journal +the three fields on the completion or amend §5.4 to say they come from +`subscription.overflow_fenced`. + +### Notes (no action needed) +- `inspect` sorts by `subscriptionId` as a string; ids are `f.on()` call + ordinals, so `"10"` sorts before `"2"`. Cloud should key by id, not index. +- A single frame larger than 1 MiB on an empty stream closes the subscription + as overflow with `retained: 0`. That follows the contract literally + ("would-exceed append"); flagging only so the behaviour is a known choice. +- `subscription.fence_overflow` does not call `ensure_mutable`; on a terminal + run the subscription is already closed by `close_subscriptions_for_terminal`, + so it returns `fenced: true` as an idempotent no-op — correct, just + asymmetric with `deliver`. +- The kernel arms no settle timer after a router delivery; a parked body is + woken by Cloud's resume (per §6) or by the durable idle/deadline instants. + `inspect` gives `settleMs`, `idleAtMs`, `deadlineAtMs` but not "settle due + at" — Cloud can derive it from its own delivery time. Pre-existing design, + consistent with the docs; recorded so nobody expects the daemon to wake on + settle by itself. diff --git a/evidence/pr441-router-2026-09-20/independent-router-tests.txt b/evidence/pr441-router-2026-09-20/independent-router-tests.txt new file mode 100644 index 000000000..d96d60727 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/independent-router-tests.txt @@ -0,0 +1,29 @@ +$ cd /tmp/flows-pr-followup/pr441/kernel && git rev-parse HEAD +a217c3f65fef4d9e9883b3c8e1ff3ce702934efb +$ cargo test -p relayflowd --test subscription_router_delivery + Finished `test` profile [unoptimized + debuginfo] target(s) in 11.47s + Running tests/subscription_router_delivery.rs (/tmp/claude-1000/-tmp-flows-fleet-501/144d3b43-0019-4de3-988a-7cd9ba4fc148/scratchpad/target-pr441/debug/deps/subscription_router_delivery-1e0031de1ef580ec) + +running 2 tests +test targeted_ingress_is_fenced_deduplicated_and_isolated_after_restart ... ok +test router_snapshots_keep_absolute_timers_and_overflow_fence_across_restart ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +exit_code=0 +$ cargo test -p relayflowd --lib -- server::tests::subscription_router +test server::tests::subscription_router::targeted_router_verbs_validate_receipts_and_preserve_wire_metadata ... ok +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 53 filtered out; finished in 0.02s +$ cargo test -p relayflowd --test event_activities +test prepared_open_response_replays_the_immutable_binding_snapshot ... ok +test cancel_closes_an_open_activity_before_the_terminal_run_record ... ok +test exact_deadline_tie_wins_and_reports_unread_range ... ok +test accepted_append_is_buffered_deduplicated_and_survives_a_restart_before_next ... ok +test overflow_of_a_parked_next_returns_overflow_after_recovery ... ok +test idle_wait_is_durable_and_fires_without_an_event ... ok +test immediate_event_wakes_have_durable_distinct_wait_boundaries ... ok +test prepared_binding_stays_invisible_across_a_crash_until_activation_then_next_suspends ... ok +test normal_wake_is_not_acknowledged_until_the_following_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. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.20s diff --git a/evidence/pr441-router-2026-09-20/router-empty-delivery.txt b/evidence/pr441-router-2026-09-20/router-empty-delivery.txt new file mode 100644 index 000000000..2c5d81dd1 --- /dev/null +++ b/evidence/pr441-router-2026-09-20/router-empty-delivery.txt @@ -0,0 +1,13 @@ +cwd: /tmp/flows-pr-followup/pr441/kernel +$ cargo test -p relayflowd --lib server::tests::subscription_router + Compiling relayflowd v0.1.0 (/tmp/flows-pr-followup/pr441/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s + Running unittests src/lib.rs (target/debug/deps/relayflowd-f043db0bb3534a16) + +running 1 test +test server::tests::subscription_router::targeted_router_verbs_validate_receipts_and_preserve_wire_metadata ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 53 filtered out; finished in 0.01s + + +exit status: 0 diff --git a/evidence/pr441-router-2026-09-20/torn-fence-probe.txt b/evidence/pr441-router-2026-09-20/torn-fence-probe.txt new file mode 100644 index 000000000..8b6f2614a --- /dev/null +++ b/evidence/pr441-router-2026-09-20/torn-fence-probe.txt @@ -0,0 +1,74 @@ +$ cat /tmp/claude-1000/-tmp-flows-fleet-501/144d3b43-0019-4de3-988a-7cd9ba4fc148/scratchpad/pr441-probe/src/main.rs +//! Read-only probe: an overflow fence torn between `subscription.overflow_fenced` +//! and `subscription.closed`. Uses the doc-hidden single-append fence to produce +//! the torn state, then asks what deliver / inspect / fence-retry / next do. +use relayflowd::Engine; +use relayflowd::engine::{SubscriptionNext, SubscriptionWake}; +use relayflowd_core::{EntryType, RunSpec, SimClock}; +use serde_json::json; + +fn count(engine: &Engine, id: &str, t: EntryType) -> usize { + engine.journal_entries(id, 1, 500).unwrap().iter().filter(|e| e.entry_type == t).count() +} + +fn main() { + let dir = tempfile::tempdir().unwrap(); + let engine = Engine::with_clock(dir.path(), SimClock::new(100)); + let spec = RunSpec::parse(&json!({"steps":[{"id":"body","type":"llm","prompt":"body"}]})).unwrap(); + let id = engine.start(spec, "probe", Some(0)).unwrap().run_id; + let receipt = json!({"generation":7}); + engine.open_subscription(&id, "one", vec!["github".into()], None, 0, 100, 1000, false).unwrap(); + engine.activate_subscription(&id, "one", 0, receipt.clone()).unwrap(); + // The body parks on its first next() (no frames yet): a durable wait.event exists. + let first = engine.next_subscription_outcome(&id, "one", None).unwrap().0; + println!("first next() -> {:?}", matches!(first, SubscriptionNext::Suspended { .. })); + assert!(engine.deliver_subscription_frame(&id, "one", &receipt, "event-1", json!({"type":"github","payload":{"n":1}})).unwrap()); + + // TORN: only the fence lands (process died before the close command). + engine.fence_subscription_overflow(&id, "one").unwrap(); + println!("after torn fence: fenced={} closed={}", count(&engine, &id, EntryType::SubscriptionOverflowFenced), count(&engine, &id, EntryType::SubscriptionClosed)); + + // 1. deliver on the torn state + let err = engine.deliver_subscription_frame(&id, "one", &receipt, "event-2", json!({"type":"github","payload":{"n":2}})).unwrap_err(); + println!("deliver on torn fence -> Err({err})"); + // 2. inspect on the torn state + let snap = engine.inspect_subscriptions(&id).unwrap(); + println!("inspect on torn fence -> state={} completionReason={}", snap[0]["state"], snap[0]["completionReason"]); + + // 3. restart, then Cloud retries the fence: must complete the close once. + drop(engine); + let restored = Engine::with_clock(dir.path(), SimClock::new(120)); + restored.fence_router_subscription_overflow(&id, "one", &receipt).unwrap(); + println!("after fence retry: fenced={} closed={}", count(&restored, &id, EntryType::SubscriptionOverflowFenced), count(&restored, &id, EntryType::SubscriptionClosed)); + let snap = restored.inspect_subscriptions(&id).unwrap(); + println!("inspect after retry -> state={} completionReason={}", snap[0]["state"], snap[0]["completionReason"]); + let (wake, _) = restored.next_subscription_outcome(&id, "one", None).unwrap(); + println!("next() after retry -> {:?}", wake); + assert!(matches!(wake, SubscriptionNext::Wake(SubscriptionWake::Overflow { retained: 1, .. }))); + + // 4. Alternative path: torn fence, then resume (no Cloud retry) completes it too. + let dir2 = tempfile::tempdir().unwrap(); + let e2 = Engine::with_clock(dir2.path(), SimClock::new(100)); + let spec = RunSpec::parse(&json!({"steps":[{"id":"body","type":"llm","prompt":"body"}]})).unwrap(); + let id2 = e2.start(spec, "probe", Some(0)).unwrap().run_id; + e2.open_subscription(&id2, "one", vec!["github".into()], None, 0, 100, 1000, false).unwrap(); + e2.activate_subscription(&id2, "one", 0, receipt.clone()).unwrap(); + e2.next_subscription_outcome(&id2, "one", None).unwrap(); + e2.fence_subscription_overflow(&id2, "one").unwrap(); + drop(e2); + let e2 = Engine::with_clock(dir2.path(), SimClock::new(130)); + let outcome = e2.resume(&id2, None).unwrap(); + println!("resume on torn fence -> status={:?} closed={} wait.completed={}", outcome.status, count(&e2, &id2, EntryType::SubscriptionClosed), count(&e2, &id2, EntryType::WaitCompleted)); + println!("PROBE_OK"); +} +$ cd /tmp/claude-1000/-tmp-flows-fleet-501/144d3b43-0019-4de3-988a-7cd9ba4fc148/scratchpad/pr441-probe && cargo run --quiet # relayflowd path dep at /tmp/flows-pr-followup/pr441/kernel (kernel identical at 62e78e19 and a217c3f6) +first next() -> true +after torn fence: fenced=1 closed=0 +deliver on torn fence -> Err(subscription_closed) +inspect on torn fence -> state="active" completionReason=null +after fence retry: fenced=1 closed=1 +inspect after retry -> state="closed" completionReason="overflow" +next() after retry -> Wake(Overflow { retained: 1, bytes: 35, from: 0 }) +resume on torn fence -> status=Parked closed=1 wait.completed=1 +PROBE_OK +exit_code=0 diff --git a/evidence/pr441-router-2026-09-20/torn-fence-regression-test.txt b/evidence/pr441-router-2026-09-20/torn-fence-regression-test.txt new file mode 100644 index 000000000..afbbd0bbb --- /dev/null +++ b/evidence/pr441-router-2026-09-20/torn-fence-regression-test.txt @@ -0,0 +1,41 @@ +# Regression test for the fence torn from its close, added at a217c3f6 (test file only; uncommitted per sf-frame). +$ cd /tmp/flows-pr-followup/pr441 && git rev-parse HEAD && git status --short +a217c3f65fef4d9e9883b3c8e1ff3ce702934efb + M kernel/relayflowd/src/server.rs + M kernel/relayflowd/src/server/tests/subscription_router.rs + M kernel/relayflowd/tests/subscription_router_delivery.rs +$ git -C /tmp/flows-pr-followup/pr441 diff --stat -- kernel + kernel/relayflowd/src/server.rs | 3 + + .../src/server/tests/subscription_router.rs | 3 + + .../tests/subscription_router_delivery.rs | 136 +++++++++++++++++++++ + 3 files changed, 142 insertions(+) +$ cd kernel && rustfmt --edition 2024 --check relayflowd/tests/subscription_router_delivery.rs; echo fmt_exit=$? +fmt_exit=0 +$ cd kernel && cargo test -p relayflowd --test subscription_router_delivery + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.80s + Running tests/subscription_router_delivery.rs (/tmp/claude-1000/-tmp-flows-fleet-501/144d3b43-0019-4de3-988a-7cd9ba4fc148/scratchpad/target-pr441/debug/deps/subscription_router_delivery-1e0031de1ef580ec) + +running 3 tests +test targeted_ingress_is_fenced_deduplicated_and_isolated_after_restart ... ok +test router_snapshots_keep_absolute_timers_and_overflow_fence_across_restart ... ok +test a_fence_torn_from_its_close_refuses_ingress_and_is_completed_once_on_retry_or_resume ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + +exit_code=0 +$ cd kernel && cargo test -p relayflowd --test subscription_router_delivery a_fence_torn -- --nocapture + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s + Running tests/subscription_router_delivery.rs (/tmp/claude-1000/-tmp-flows-fleet-501/144d3b43-0019-4de3-988a-7cd9ba4fc148/scratchpad/target-pr441/debug/deps/subscription_router_delivery-1e0031de1ef580ec) + +running 1 test +test a_fence_torn_from_its_close_refuses_ingress_and_is_completed_once_on_retry_or_resume ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.03s + +exit_code=0 + +# Attribution note: `git status` above shows two files I did not touch (`server.rs`, `server/tests/subscription_router.rs`) +# carrying someone else's uncommitted change — the F2 `delivery_id must not be empty` -> bad_request fix (server layer only). +# The new test is engine-level (`Engine::{deliver_subscription_frame, fence_subscription_overflow, +# fence_router_subscription_overflow, resume, next_subscription_outcome, inspect_subscriptions}`) and does not exercise server.rs; +# its outcome is independent of that change. My only edit in the worktree: kernel/relayflowd/tests/subscription_router_delivery.rs (appended one test). diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index 0e154f83a..a0f96f280 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -574,6 +574,9 @@ fn handle_request( } "subscription.deliver" => { let params: SubscriptionDeliverParams = decode_params(request.params)?; + if params.delivery_id.is_empty() { + return Err(("bad_request", "delivery_id must not be empty".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/tests/subscription_router.rs b/kernel/relayflowd/src/server/tests/subscription_router.rs index 8573f30fd..d786494bc 100644 --- a/kernel/relayflowd/src/server/tests/subscription_router.rs +++ b/kernel/relayflowd/src/server/tests/subscription_router.rs @@ -32,6 +32,9 @@ fn targeted_router_verbs_validate_receipts_and_preserve_wire_metadata() { } let mut delivery = json!({"run_id":run_id,"subscription_id":"a","router_binding":receipt, "delivery_id":"frame-1","frame":{"type":"github","payload":{"number":1}}}); + let mut invalid = delivery.clone(); + invalid["delivery_id"] = json!(""); + assert_eq!(call("subscription.deliver", invalid).error.unwrap().code, "bad_request"); let response = call("subscription.deliver", delivery.clone()); assert!(response.ok, "{:?}", response.error); assert_eq!(response.result.unwrap(), json!({"appended":true})); diff --git a/kernel/relayflowd/tests/subscription_router_delivery.rs b/kernel/relayflowd/tests/subscription_router_delivery.rs index 2b2801c0f..c62569af8 100644 --- a/kernel/relayflowd/tests/subscription_router_delivery.rs +++ b/kernel/relayflowd/tests/subscription_router_delivery.rs @@ -191,3 +191,139 @@ fn router_snapshots_keep_absolute_timers_and_overflow_fence_across_restart() { 1 ); } + +/// An overflow close is two appends — `subscription.overflow_fenced`, then +/// `subscription.closed` — and each append is its own transaction. Die +/// between them and the fence stands alone. The kernel must then refuse +/// ingress, complete the close exactly once whichever way it is next asked +/// (a Cloud fence retry or a plain resume), and hand the body `Overflow`. +/// `Engine::fence_subscription_overflow` writes only the fence, which is the +/// torn state without needing a crash injection. +#[test] +fn a_fence_torn_from_its_close_refuses_ingress_and_is_completed_once_on_retry_or_resume() { + let spec = + || RunSpec::parse(&json!({"steps":[{"id":"body","type":"llm","prompt":"body"}]})).unwrap(); + let receipt = json!({"generation": 7}); + let frame = json!({"type":"github","payload":{"n":1}}); + let count = |engine: &Engine, id: &str, entry_type: EntryType| { + engine + .journal_entries(id, 1, 500) + .unwrap() + .iter() + .filter(|entry| entry.entry_type == entry_type) + .count() + }; + let torn = |dir: &std::path::Path| { + let engine = Engine::with_clock(dir, SimClock::new(100)); + let id = engine.start(spec(), "router-test", Some(0)).unwrap().run_id; + engine + .open_subscription(&id, "one", vec!["github".into()], None, 0, 100, 1000, false) + .unwrap(); + engine + .activate_subscription(&id, "one", 0, receipt.clone()) + .unwrap(); + // The body is parked on its first `next()`: a durable wait.event exists. + assert!(matches!( + engine + .next_subscription_outcome(&id, "one", None) + .unwrap() + .0, + relayflowd::engine::SubscriptionNext::Suspended { .. } + )); + assert!( + engine + .deliver_subscription_frame(&id, "one", &receipt, "event-1", frame.clone()) + .unwrap() + ); + engine.fence_subscription_overflow(&id, "one").unwrap(); + assert_eq!( + count(&engine, &id, EntryType::SubscriptionOverflowFenced), + 1 + ); + assert_eq!(count(&engine, &id, EntryType::SubscriptionClosed), 0); + // Ingress is refused on the fence alone, and the refusal appends nothing. + let before = engine.journal_entries(&id, 1, 500).unwrap().len(); + let refused = engine + .deliver_subscription_frame(&id, "one", &receipt, "event-2", frame.clone()) + .unwrap_err(); + assert_eq!(refused.to_string(), "subscription_closed"); + assert_eq!(engine.journal_entries(&id, 1, 500).unwrap().len(), before); + id + }; + + // Path 1: the cell died after the fence; Cloud retries the fence. + let dir = tempfile::tempdir().unwrap(); + let id = torn(dir.path()); + let restored = Engine::with_clock(dir.path(), SimClock::new(120)); + restored + .fence_router_subscription_overflow(&id, "one", &receipt) + .unwrap(); + assert_eq!( + count(&restored, &id, EntryType::SubscriptionOverflowFenced), + 1 + ); + assert_eq!(count(&restored, &id, EntryType::SubscriptionClosed), 1); + let snapshot = restored.inspect_subscriptions(&id).unwrap(); + assert_eq!(snapshot[0]["state"], "closed"); + assert_eq!(snapshot[0]["completionReason"], "overflow"); + assert!(matches!( + restored + .next_subscription_outcome(&id, "one", None) + .unwrap() + .0, + relayflowd::engine::SubscriptionNext::Wake( + relayflowd::engine::SubscriptionWake::Overflow { + retained: 1, + from: 0, + .. + } + ) + )); + // A second retry adds nothing. + restored + .fence_router_subscription_overflow(&id, "one", &receipt) + .unwrap(); + assert_eq!(count(&restored, &id, EntryType::SubscriptionClosed), 1); + assert!( + restored + .deliver_subscription_frame(&id, "one", &receipt, "event-3", frame.clone()) + .is_err() + ); + + // Path 2: nobody retries the fence; a plain resume completes the close + // and settles the parked wait with the overflow wake. + let dir = tempfile::tempdir().unwrap(); + let id = torn(dir.path()); + let resumed = Engine::with_clock(dir.path(), SimClock::new(130)); + assert_eq!( + resumed.resume(&id, None).unwrap().status, + relayflowd::RunStatus::Parked + ); + assert_eq!( + count(&resumed, &id, EntryType::SubscriptionOverflowFenced), + 1 + ); + assert_eq!(count(&resumed, &id, EntryType::SubscriptionClosed), 1); + let settled = resumed + .journal_entries(&id, 1, 500) + .unwrap() + .into_iter() + .filter(|entry| entry.entry_type == EntryType::WaitCompleted) + .collect::>(); + assert_eq!(settled.len(), 1, "{settled:?}"); + assert_eq!(settled[0].payload["result"]["wake"], "overflow"); + assert!(matches!( + resumed + .next_subscription_outcome(&id, "one", None) + .unwrap() + .0, + relayflowd::engine::SubscriptionNext::Wake( + relayflowd::engine::SubscriptionWake::Overflow { retained: 1, .. } + ) + )); + assert!( + resumed + .deliver_subscription_frame(&id, "one", &receipt, "event-4", frame) + .is_err() + ); +} From a4f857589af930bd5d6ea365a0e0a4166417a125 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sun, 20 Sep 2026 10:22:47 -0700 Subject: [PATCH 31/34] fix(sdk): attach local workers to declared agent streams --- .../dev-declared-streams-2026-09-20/README.md | 27 ++ .../cloud-dev-drive-launch.log | 22 ++ .../cloud-dev-drive-logs.log | 12 + .../cloud-dev-drive-patch.log | 6 + .../cloud-dev-drive-status.log | 230 ++++++++++++++++++ .../drive-cancel.log | 5 + .../drive-export.log | 5 + .../drive-journal.log | 8 + .../stream-baseline-build.log | 8 + .../stream-diff-check.log | 4 + .../stream-fix-build.log | 8 + .../stream-regression-green-final.log | 38 +++ .../stream-regression-green.log | 59 +++++ .../stream-regression-red-with-resume.log | 178 ++++++++++++++ .../stream-types.log | 8 + packages/sdk/src/cli/run.ts | 11 +- packages/sdk/src/local-agent.ts | 20 +- .../local-agent-stream-selection.test.ts | 19 ++ .../tests/yaml-declared-streams-live.test.ts | 129 ++++++++++ 19 files changed, 791 insertions(+), 6 deletions(-) create mode 100644 evidence/dev-declared-streams-2026-09-20/README.md create mode 100644 evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-launch.log create mode 100644 evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-logs.log create mode 100644 evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-patch.log create mode 100644 evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-status.log create mode 100644 evidence/dev-declared-streams-2026-09-20/drive-cancel.log create mode 100644 evidence/dev-declared-streams-2026-09-20/drive-export.log create mode 100644 evidence/dev-declared-streams-2026-09-20/drive-journal.log create mode 100644 evidence/dev-declared-streams-2026-09-20/stream-baseline-build.log create mode 100644 evidence/dev-declared-streams-2026-09-20/stream-diff-check.log create mode 100644 evidence/dev-declared-streams-2026-09-20/stream-fix-build.log create mode 100644 evidence/dev-declared-streams-2026-09-20/stream-regression-green-final.log create mode 100644 evidence/dev-declared-streams-2026-09-20/stream-regression-green.log create mode 100644 evidence/dev-declared-streams-2026-09-20/stream-regression-red-with-resume.log create mode 100644 evidence/dev-declared-streams-2026-09-20/stream-types.log create mode 100644 packages/sdk/tests/local-agent-stream-selection.test.ts create mode 100644 packages/sdk/tests/yaml-declared-streams-live.test.ts diff --git a/evidence/dev-declared-streams-2026-09-20/README.md b/evidence/dev-declared-streams-2026-09-20/README.md new file mode 100644 index 000000000..105b481c6 --- /dev/null +++ b/evidence/dev-declared-streams-2026-09-20/README.md @@ -0,0 +1,27 @@ +# Dev #455 named-stream worker failure and fix + +Dev Cloud `3362eb8d43cb2882ddbb65309f02527624d6fb77` admitted runtime `0e549c37a74cfd61e34936e3d664f2619d463d02`. One scoped #455 fixture submission created Cloud run `46041b32-e6c8-488d-9c42-226bd356bf0d`, engine run `01M2ZWVRDBM0757P0578C4BT7S`. + +The actual hosted journal proves the deterministic sync step succeeded with `SYNC_MATERIALIZED=ok` and `SYNC_MODE=snapshot`. The flow then parked before its first agent attempt because the local worker held only a random stream, while the flow declared `flows-drive-cloud`. Cloud already supplied `--local-agent`; the missing flag was not the cause. No agent work package, final report, or diff was produced. Cloud recorded failed / `needs_human`; cancellation returned HTTP 409 `Run already failed`. That is not a cleanup-success claim. No durable state was deleted. + +The SDK now registers the ordinary agent steps' declared stream names, using offset zero because this worker has consumed no stream messages. Resume reads names from the immutable journaled spec. Workspace revision pins are never invented; kernel matching and offset fencing remain authoritative. Communication-owned stream registration remains with communication workers. Authored TypeScript behavior is unchanged. This change does not address the separate declarative LLM worker gap. + +## Captured commands and output + +Each `.log` contains its literal command and full output. Commands ran with `TMPDIR=/home/khaliqgant/.cache/dev-pr-proof` exported; the built daemon was explicitly selected with `RELAYFLOWD_BIN=/tmp/flows-pr-followup/pr441/kernel/target/debug/relayflowd`. SDK dependencies were shared read-only through the existing PR441 node_modules symlink, while this checkout has its own dist build. The untracked symlink is not committed. + +- `stream-baseline-build.log`: original implementation build success. +- `stream-regression-red-with-resume.log`: new named-stream run/resume tests reproduced no eligible worker, 6 failed / 2 passed. This is baseline regression evidence, not mutation verification. +- `stream-fix-build.log`: fixed SDK build success. +- `stream-regression-green.log`: intermediate 4-failure test run retained. Agent execution had succeeded; the new spec-immutability assertion incorrectly expected `workspace: []` where the authored spec omitted workspace. Corrected that new expectation to preserve the original shape. +- `stream-regression-green-final.log`: final real-daemon wrapper, named-stream run/resume, workspace refusal, communication worker and stream-selection suites: 5 files / 36 tests passed. The command also named a nonexistent `communication-spec.test.ts`; Vitest ran only the five listed files. No sixth suite is claimed. +- `stream-types.log`: test TypeScript compilation success. +- `stream-diff-check.log`: git diff check success before evidence was added. +- `cloud-dev-drive-launch.log`, `cloud-dev-drive-status.log`, `cloud-dev-drive-logs.log`, `cloud-dev-drive-patch.log`: actual hosted submission and failure evidence. +- `drive-journal.log`: read-only snapshot SQL, with run.spawned intentionally projected to names/types/surfaces; all other entries printed in full. +- `drive-export.log`: successful durable snapshot export digest. Export and SQLite retained locally in `/home/khaliqgant/.cache/dev-pr-proof/drive-export.bin`, `drive-snapshot.tar.gz`, `drive-run.sqlite3`. +- `drive-cancel.log`: HTTP409, not successful resource cleanup. + +An earlier build in `/tmp/flows-dev-stream-pins` failed because the user's tmpfs quota was exhausted, and even the evidence write failed. Its tool output reported TS5033 writes and OSError122. The checkout was copied/repaired to the home cache before the captured successful baseline build; no gate or dependency constraint was changed. + +This is local regression proof against a real daemon with a deterministic stub CLI. A fresh hosted #455 attempt on the new published artifact remains required for acceptance. diff --git a/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-launch.log b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-launch.log new file mode 100644 index 000000000..487ef88fc --- /dev/null +++ b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-launch.log @@ -0,0 +1,22 @@ +$ python3 /tmp/cloud-dev-run-cli.py cloud run workflows/drive-cloud-v2.yaml --relayflow-version v2 --sync-code --json + +Agent Relay collects usage telemetry to improve the product. +Run `agent-relay telemetry disable` to opt out. +Learn more: https://agentrelay.com/telemetry + +Validating workflow... +Preparing run... + Prepared in 5.1s +Creating tarball... + Tarball: 2729KB in 0.2s +Uploading to workflow storage... + Uploaded in 2.4s +Launching workflow... + Launched in 1.9s +{ + "runId": "46041b32-e6c8-488d-9c42-226bd356bf0d", + "status": "pending", + "launchJobId": "9cd2c631-4681-4fdd-9a60-1144bdcbcc70" +} + +exit=0 diff --git a/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-logs.log b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-logs.log new file mode 100644 index 000000000..1254b3737 --- /dev/null +++ b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-logs.log @@ -0,0 +1,12 @@ + +# 2026-09-20T17:12:11.930122+00:00 +$ python3 /tmp/dev-read.py /api/v1/workflows/runs/46041b32-e6c8-488d-9c42-226bd356bf0d/logs +{"path": "/api/v1/workflows/runs/46041b32-e6c8-488d-9c42-226bd356bf0d/logs", "http": 200, "response": {"content": "[bootstrap] Downloading code from S3 (code.tar.gz)...\n[bootstrap] Code extracted to /project/workflows/runs/b022def4-1fd6-4e7d-ba88-c30dd0a2879e\n[bootstrap] Mounted setup-token env for anthropic\n[bootstrap] Mounted credentials for openai at /home/daytona/.codex/auth.json\n[bootstrap] Installing ai-hist...\n\nadded 97 packages in 1s\n\n34 packages are looking for funding\n run `npm fund` for details\n[bootstrap] ai-hist installed; no relayhistory assertion was provided for this run\n[bootstrap] Setting up git baseline in /project/workflows/runs/b022def4-1fd6-4e7d-ba88-c30dd0a2879e...\n[bootstrap] Creating file manifest for baseline...\n[bootstrap] Baseline committed with 1328 tracked files (clean tree).\n[bootstrap] Seeding relayfile workspace with initial code (attempt 1/3)...\n[bootstrap] Relayfile seed complete in 28022ms\n[bootstrap] Started relayfile-mount daemon\n[bootstrap] Relayflow v2 helper mount root: /project\n", "offset": 921, "totalSize": 921, "done": false}} + +exit=0 + +# 2026-09-20T17:12:52.575309+00:00 +$ python3 /tmp/dev-read.py /api/v1/workflows/runs/46041b32-e6c8-488d-9c42-226bd356bf0d/logs +{"path": "/api/v1/workflows/runs/46041b32-e6c8-488d-9c42-226bd356bf0d/logs", "http": 200, "response": {"content": "[bootstrap] Downloading code from S3 (code.tar.gz)...\n[bootstrap] Code extracted to /project/workflows/runs/b022def4-1fd6-4e7d-ba88-c30dd0a2879e\n[bootstrap] Mounted setup-token env for anthropic\n[bootstrap] Mounted credentials for openai at /home/daytona/.codex/auth.json\n[bootstrap] Installing ai-hist...\n\nadded 97 packages in 1s\n\n34 packages are looking for funding\n run `npm fund` for details\n[bootstrap] ai-hist installed; no relayhistory assertion was provided for this run\n[bootstrap] Setting up git baseline in /project/workflows/runs/b022def4-1fd6-4e7d-ba88-c30dd0a2879e...\n[bootstrap] Creating file manifest for baseline...\n[bootstrap] Baseline committed with 1328 tracked files (clean tree).\n[bootstrap] Seeding relayfile workspace with initial code (attempt 1/3)...\n[bootstrap] Relayfile seed complete in 28022ms\n[bootstrap] Started relayfile-mount daemon\n[bootstrap] Relayflow v2 helper mount root: /project\n[bootstrap] Starting workflow execution (per-step-sandbox)\n[bootstrap] Relayflow v2 runtime verified {\n sourceCommit: '0e549c37a74cfd61e34936e3d664f2619d463d02',\n protocolVersion: '0'\n}\n[bootstrap] Flow parked, needs human recovery: Relayflow v2 run parked and needs human recovery: Run \"01M2ZWVRDBM0757P0578C4BT7S\" parked at step \"assess-1\" (agent): no worker is attached for step type \"agent\".\n", "offset": 1319, "totalSize": 1319, "done": true}} + +exit=0 diff --git a/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-patch.log b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-patch.log new file mode 100644 index 000000000..2ba4b1ad1 --- /dev/null +++ b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-patch.log @@ -0,0 +1,6 @@ + +# 2026-09-20T17:14:12.803842+00:00 +$ python3 /tmp/dev-read.py /api/v1/workflows/runs/46041b32-e6c8-488d-9c42-226bd356bf0d/patch +{"path": "/api/v1/workflows/runs/46041b32-e6c8-488d-9c42-226bd356bf0d/patch", "http": 200, "response": {"patch": "", "hasChanges": false}} + +exit=0 diff --git a/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-status.log b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-status.log new file mode 100644 index 000000000..2d7ed8481 --- /dev/null +++ b/evidence/dev-declared-streams-2026-09-20/cloud-dev-drive-status.log @@ -0,0 +1,230 @@ + +# 2026-09-20T17:09:18.234786+00:00 +$ python3 /tmp/cloud-dev-run-cli.py cloud status 46041b32-e6c8-488d-9c42-226bd356bf0d --json +{ + "runId": "46041b32-e6c8-488d-9c42-226bd356bf0d", + "sandboxId": "6bf08460-eb79-4d5c-8e4d-e6a08c90a4df", + "dispatchType": "sandbox", + "relayflowVersion": "v2", + "relayflowV2Authority": { + "source": { + "sha256": "0685c1462380e547379b315d18bb14a87ea504fbb44365fb78651bbc37cb6213", + "fileType": "yaml", + "byteLength": 28888, + "executionSha256": "5b248ca09e4619b6a75421ed28089cb96e63060662c1c3fc8c2a4663489dab49" + }, + "artifact": { + "key": "system/relayflow-v2/6ebfed9b28e28052276afea10604c11401eae4994640171899891279333657ec.tar.gz", + "sha256": "6ebfed9b28e28052276afea10604c11401eae4994640171899891279333657ec", + "sourceCommit": "0e549c37a74cfd61e34936e3d664f2619d463d02", + "protocolVersion": "0", + "manifestSchemaVersion": 1 + }, + "consumerEpoch": "relayflow-v2-2026-09-02.1", + "schemaVersion": 1, + "relayfileMount": { + "paths": [ + "/workflows/runs/b022def4-1fd6-4e7d-ba88-c30dd0a2879e" + ], + "scope": { + "key": "b022def4-1fd6-4e7d-ba88-c30dd0a2879e", + "kind": "run" + } + } + }, + "userId": "1eb0dd9f-509f-4025-8343-dfabd82b6b5c", + "workspaceId": "494ab549-4ace-4202-9127-6d15d946d580", + "workflow": "{\"version\":\"0.1.0\",\"name\":\"flows-drive-cloud\",\"description\":\"The Lead's tick, shaped for a cloud sandbox with the laptop closed.\\nA cloud sandbox has no git remote and no GitHub token, so this flow\\nnever delivers: it runs 1 full work-package cycles back to back\\nin ONE sandbox, committing each to the sandbox branch. Recover the work\\nwith `agent-relay cloud sync `. Nothing reaches main without a\\nhuman. GENERATED from workflows/drive.yaml by ops/gen-drive-cloud-v2.py.\\n\",\"budget\":{\"maxWallclockMs\":3600000},\"steps\":[{\"type\":\"deterministic\",\"command\":\"# Materialize the repo; never assume it. This step assumed a clone\\n# with an `origin` remote and so every cloud tick died here with\\n# `fatal: 'origin' does not appear to be a git repository` (runs\\n# 9fc8d996, ff35187a, 06505b94, 4cf36ea7, b33c2c9a).\\n#\\n# A cloud workflow sandbox does NOT get a clone. The platform's own\\n# materialization is the code sync: the CLI tars the `git ls-files`\\n# set and the bootstrap extracts it into the code mount, then runs\\n# `git init` over it. Files yes, `.git` history and remotes no.\\n# A checkout with a remote only exists on a host that already has one\\n# (laptop, fleet node). Both shapes are supported below; neither is\\n# assumed, and an unmaterialized sandbox fails closed and typed\\n# rather than failing later as an unexplained tool error.\\nset -eu\\necho \\\"SYNC_WORKDIR=$(pwd)\\\"\\n\\nmissing=\\\"\\\"\\nfor required in AGENTS.md docs/RFC-0001-everything-is-a-relayflow.md ops/DIRECTIVES.md kernel packages/sdk; do\\n [ -e \\\"$required\\\" ] || missing=\\\"$missing $required\\\"\\ndone\\nif [ -n \\\"$missing\\\" ]; then\\n echo \\\"SYNC_FAIL_NOT_MATERIALIZED: Cloud did not materialize the scheduled code snapshot.\\\" >&2\\n echo \\\" missing:$missing\\\" >&2\\n echo \\\" cwd: $(pwd)\\\" >&2\\n echo \\\" Recreate the schedule with a current agent-relay CLI so Cloud stores its code snapshot.\\\" >&2\\n exit 78\\nfi\\necho \\\"SYNC_MATERIALIZED=ok\\\"\\n\\n# Fail fast on a stale tree. A per-step sandbox can be seeded from an\\n# older orchestrator archive, and five consecutive runs burned ~20\\n# minutes each producing diffs that reverted merged work — a stale\\n# tree diffed against fresh main looks like a wholesale revert. The\\n# guards at delivery caught them, but only after the cost was paid.\\n#\\n# ops/FORBIDDEN_PATHS lists paths that must NOT exist. Their presence\\n# here means this sandbox is not the tree we uploaded, and nothing\\n# built on it can be trusted.\\nif [ -f ops/FORBIDDEN_PATHS ]; then\\n stale=\\\"\\\"\\n while IFS= read -r forbidden; do\\n case \\\"$forbidden\\\" in ''|\\\\#*) continue ;; esac\\n [ -e \\\"$forbidden\\\" ] && stale=\\\"$stale $forbidden\\\"\\n done < ops/FORBIDDEN_PATHS\\n if [ -n \\\"$stale\\\" ]; then\\n echo \\\"SYNC_FAIL_STALE_TREE: this sandbox contains paths that do not exist on the base:\\\" >&2\\n for p in $stale; do echo \\\" $p\\\" >&2; done\\n echo \\\" The workspace was seeded from an older archive, so it is not the tree\\\" >&2\\n echo \\\" that was uploaded. A diff computed from it reverts merged work.\\\" >&2\\n echo \\\" Failing now rather than spending a full cycle to produce an unusable diff.\\\" >&2\\n exit 75\\n fi\\nfi\\n\\n# The checkout must be rooted HERE: `git rev-parse --git-dir` also succeeds\\n# inside a parent repository, which would send fetch/checkout into the\\n# parent (scheduled steps run under /project/workflows/schedules/), and\\n# a leftover `.git` gitfile would make `git init` fail.\\nif [ \\\"$(git rev-parse --show-toplevel 2>/dev/null)\\\" != \\\"$(pwd -P)\\\" ]; then\\n if [ -f .git ]; then mv .git \\\".git.stale.$$\\\"; fi\\n git init -q\\nfi\\ngit config user.email \\\"lead@relayflows.local\\\"\\ngit config user.name \\\"Relayflow Lead\\\"\\n\\nif git remote get-url origin >/dev/null 2>&1; then\\n # Real checkout (laptop / fleet node): take the true origin/main.\\n echo \\\"SYNC_MODE=remote\\\"\\n git fetch --quiet origin\\n git checkout --quiet -B main origin/main\\n base=$(git rev-parse --short origin/main)\\nelse\\n # Sandbox snapshot: there is no remote to fetch and nothing to\\n # rebase onto. The snapshot IS the base. Commit it so the tick has\\n # a parent to diff against — `git diff main` in the review step\\n # needs a `main` that exists.\\n echo \\\"SYNC_MODE=snapshot\\\"\\n if ! git rev-parse --verify --quiet HEAD >/dev/null 2>&1; then\\n git add -A\\n git commit --quiet -m \\\"snapshot base for this tick\\\" || true\\n fi\\n git branch --quiet -f main HEAD 2>/dev/null || git checkout --quiet -b main\\n base=$(git rev-parse --short HEAD)\\nfi\\n\\ngit checkout --quiet -B \\\"flow/drive-${base}-$(date +%m%d%H%M)\\\"\\necho \\\"SYNC_BASE=$base\\\"\\necho \\\"SYNC_BRANCH=$(git rev-parse --abbrev-ref HEAD)\\\"\\necho SYNCED\\n\",\"id\":\"sync\"},{\"type\":\"agent\",\"dependsOn\":[\"sync\"],\"verification\":{\"type\":\"output_contains\",\"value\":\"ASSESS_DONE\"},\"id\":\"assess-1\",\"instruction\":\"The Relayflow Lead. Assesses state, plans one work package, reports honestly.\\n\\nYou are the Relayflow Lead (charter/LEAD.md). Assess the repo.\\n\\nYOUR SCOPE IS THE TASK YOU WERE GIVEN. Two launchers exist and they\\ndeliver it differently: ops/launch-gate.sh commits an ops/TARGET.md\\nnaming one gate, while the autodrive loop passes the task directly\\nand writes NO TARGET.md. If ops/TARGET.md is absent that is normal —\\nit is not missing context and there is nothing to go looking for.\\n\\nEither way: QUOTE the scope into ops/NEXT.md, never cite the path.\\nTARGET.md lives only in the throwaway launch worktree and is NOT in\\nthe delivered diff, so a reviewer sees a reference to a file that\\ndoes not exist. Review flagged that on PR #19 and again on #35, #40\\nand #48 — it is now enforced in verify, which REFUSES a NEXT.md that\\ncites a path not present in the tree. Anything you rely on must\\nappear in the package itself.\\n\\nAnd when you state that something passes, paste the literal command\\nand its output. \\\"Three tests pass\\\" with no captured output is not a\\nclaim a reviewer can check, and it was also flagged on PR #19. This\\nis AGENTS.md's central standard, applied to your own reporting. It is the operator's scoping decision and it overrides your\\nown judgement about priority — several runs execute in parallel, each\\npinned to a different gate, and a run that wanders outside its target\\nwill collide with a sibling. Stay inside it or, if the target is\\ngenuinely unreachable, say so in ops/NEEDS_HUMAN.md rather than\\nsilently choosing different work.\\nThen read ops/STATE.md — it is ground truth about gates and open\\nPRs for an environment with no git history, and it names the known\\nsandbox faults that are NOT reasons to block. Then read\\nops/DIRECTIVES.md — standing human directives outrank the backlog;\\nif one is unsatisfied, it IS the work package.\\nThen read docs/bootstrap-report.md and ops/DRIVE-LOG.md if they exist,\\n`git log --oneline -15`, `gh pr list --state open` and open PR review\\nstate, kernel/ and packages/sdk/ test status. Then write ops/NEXT.md: the\\nSINGLE highest-priority work package toward the current gate\\n(gate 1 until its done-when in RFC-0001 §3 holds), with: objective,\\nfiles in scope, definition of done (must include passing commands),\\nand what is explicitly OUT of scope for this tick. If an open PR is\\nawaiting fixes from review, the work package is fixing it — never\\nstart new work over unfinished work. If work is blocked on a human\\ndecision, write ops/NEEDS_HUMAN.md stating the exact question and the\\noptions — and then STILL end with ASSESS_DONE.\\n\\nCOMMIT YOUR WORK PACKAGE BEFORE YOU FINISH:\\n git add -A && git commit -m \\\"assess: work package for this tick\\\"\\nEach step runs in its OWN sandbox and files reach the next step only\\nthrough the executor's propagation, which is lossy: on runs a2089144\\nand 2560e02d your predecessor wrote ops/NEXT.md, said so truthfully,\\nand the file never arrived — one of those runs finished with a\\nzero-file patch. Committing puts the package in git history rather\\nthan leaving it as a loose working-tree file. If the commit fails,\\nsay so in your output rather than finishing silently. The assess-gate step\\nbelow reads that file and parks the run with a typed outcome.\\nALWAYS end with ASSESS_DONE, blocked or not: this gate cannot tell a\\ndifferent final token from a crashed agent, so on run 54ebd998 the\\nLead correctly reported BLOCKED_NEEDS_HUMAN three times and was\\nscored as failing three times. Saying you are blocked is a result,\\nnot a failure — but it must be said in the file, not the token.\\n\",\"cli\":\"claude\",\"surfaces\":{\"streams\":[{\"stream\":\"flows-drive-cloud\"}]}},{\"type\":\"deterministic\",\"dependsOn\":[\"assess-1\"],\"command\":\"# A typed park, not a crash. The assess step cannot express \\\"blocked\\\"\\n# in its final token (its gate only recognises ASSESS_DONE), so the\\n# Lead writes ops/NEEDS_HUMAN.md instead and this step reads it.\\nset -u\\n# An escalation is trusted only when TWO INDEPENDENT SIGNALS AGREE:\\n# the file exists AND this tick is what wrote it.\\n#\\n# Existence alone is not a signal. ops/NEEDS_HUMAN.md was committed to\\n# main on 2026-09-06 (082c62aa) and nothing in this repo has ever\\n# deleted it — no `rm`, no `git rm`, and `git log --diff-filter=D`\\n# over that path is empty. ops/launch-gate.sh builds each run's\\n# worktree from origin/main and does not strip it, so every tick from\\n# 2026-09-12 onward escalated here before doing any work, on a\\n# question a human had already answered. PRs #417, #420, #422, #424,\\n# #426, #427 and #428 are seven consecutive cloud runs whose entire\\n# diff is this file and ops/NEXT.md, re-litigating the same conflict.\\n# None merged. A full cloud run was burned on each.\\n#\\n# A stale file must never be able to masquerade as a live escalation.\\n# \\\"This tick\\\" is the same test the ops/NEXT.md freshness check below\\n# uses — a commit in `main..HEAD` — widened by the uncommitted case,\\n# because propagation between per-step sandboxes is lossy and assess\\n# may write the file and fail to commit it. Losing a live escalation\\n# is the worse error of the two, so an unproven-fresh file that is\\n# dirty in the working tree still parks the run.\\n#\\n# The default is to TRUST the escalation. Only a positive, SUCCESSFUL\\n# answer from git may downgrade it to stale, because \\\"git printed\\n# nothing\\\" and \\\"git could not answer\\\" look identical otherwise — and\\n# a sandbox is exactly where git cannot answer. SYNC_MODE=snapshot\\n# runs `git init` over an extracted tarball, so a step that runs\\n# before main exists, a missing .git, or any git failure would\\n# silently classify a LIVE escalation as stale and walk the builder\\n# straight past a human decision. That inverts the tradeoff above,\\n# so an unprovable escalation parks the run.\\nif [ -f ops/NEEDS_HUMAN.md ]; then\\n escalation=unprovable\\n if git rev-parse --git-dir >/dev/null 2>&1 \\\\\\n && git rev-parse --verify --quiet main >/dev/null 2>&1; then\\n tick_log=$(git log --oneline main..HEAD -- ops/NEEDS_HUMAN.md 2>/dev/null)\\n if [ $? -ne 0 ]; then\\n escalation=unprovable\\n elif [ -n \\\"$tick_log\\\" ]; then\\n escalation=this_tick_committed\\n else\\n tick_dirty=$(git status --porcelain -- ops/NEEDS_HUMAN.md 2>/dev/null)\\n if [ $? -ne 0 ]; then\\n escalation=unprovable\\n elif [ -n \\\"$tick_dirty\\\" ]; then\\n escalation=this_tick_uncommitted\\n else\\n escalation=stale\\n fi\\n fi\\n fi\\n if [ \\\"$escalation\\\" = stale ]; then\\n echo \\\"ASSESS_STALE_NEEDS_HUMAN_IGNORED: ops/NEEDS_HUMAN.md exists but this tick did not write it.\\\"\\n echo \\\" It is the committed record of an escalation that has already been answered,\\\"\\n echo \\\" not a live one, so it does not park this run. Delete it from main once its\\\"\\n echo \\\" question is resolved — a resolved escalation left in the tree is a lie the\\\"\\n echo \\\" next assessor has to spend a run disproving.\\\"\\n else\\n if [ \\\"$escalation\\\" = unprovable ]; then\\n echo \\\"ASSESS_ESCALATION_FRESHNESS_UNPROVABLE: git could not say whether this tick\\\"\\n echo \\\" wrote ops/NEEDS_HUMAN.md (no repo, no main, or git failed). Failing safe and\\\"\\n echo \\\" treating it as live: ignoring a real escalation is the worse of the two errors.\\\"\\n fi\\n echo \\\"ASSESS_BLOCKED_NEEDS_HUMAN: the Lead escalated a decision it cannot make ($escalation).\\\"\\n echo \\\"--- ops/NEEDS_HUMAN.md ---\\\"\\n cat ops/NEEDS_HUMAN.md\\n exit 75\\n fi\\nfi\\nif [ ! -f ops/NEXT.md ]; then\\n echo \\\"ASSESS_FAIL: no ops/NEXT.md — an assessment that named no work package did not assess\\\"\\n exit 1\\nfi\\n# The assessment must have WRITTEN this tick's package, not merely\\n# left the previous one in place. On run 457a6102 assess reported\\n# \\\"The work package is written to ops/NEXT.md\\\" and the very next step\\n# read the OLD file — the logs carry the reason:\\n# \\\"relayfile flush failed after the command succeeded (exit 1);\\n# a later agent step may see stale files\\\"\\n# The builder then correctly refused to invent scope, but only after\\n# a whole build step had been spent. Catch it here instead: if\\n# ops/NEXT.md is identical to the base, the assessment did not land,\\n# whoever is at fault.\\n# Look for the package in the working tree OR in a commit made this\\n# tick. Propagation between per-step sandboxes is lossy, so a package\\n# that exists only as a loose file may not arrive; one committed by\\n# the assess step travels in git history instead.\\nif git log --oneline main..HEAD -- ops/NEXT.md 2>/dev/null | grep -q .; then\\n echo \\\"ASSESS_PACKAGE_COMMITTED: found ops/NEXT.md change in this tick's history\\\"\\nelif git diff --quiet main -- ops/NEXT.md 2>/dev/null; then\\n # Warn, do not fail. This was fatal, and it killed four runs in six\\n # while the loop produced nothing — a worse outcome than the risk\\n # it guarded against.\\n #\\n # The risk it guarded was \\\"the builder gets scope nobody wrote this\\n # tick\\\". But scope does not actually come from ops/NEXT.md: it comes\\n # from ops/TARGET.md, which the launcher COMMITS into the uploaded\\n # tree, so it is present in every per-step sandbox and cannot be\\n # lost to the propagation fault. NEXT.md refines the target; it does\\n # not define it.\\n echo \\\"ASSESS_WARN_STALE_NEXT: ops/NEXT.md did not change from the base commit.\\\"\\n echo \\\" The assess step's package did not survive the step boundary (a known\\\"\\n echo \\\" platform fault: per-step sandboxes lose both loose files and git objects).\\\"\\n echo \\\" Proceeding, because ops/TARGET.md is committed in the tree and carries this\\\"\\n echo \\\" run's scope. The builder is not working blind — it is working from the\\\"\\n echo \\\" target rather than from a refinement of it.\\\"\\n if [ -f ops/TARGET.md ]; then\\n echo \\\"--- ops/TARGET.md (the scope that did survive) ---\\\"\\n head -8 ops/TARGET.md\\n else\\n echo \\\"ASSESS_FAIL_NO_SCOPE: neither a fresh ops/NEXT.md nor an ops/TARGET.md.\\\"\\n echo \\\" With no scope from either source the builder WOULD be working blind.\\\"\\n exit 1\\n fi\\nfi\\n# A package with no definition of done cannot be verified, and the\\n# builder cannot honestly report BUILD_DONE against it.\\n# A package must be verifiable, but do not dictate its wording. This\\n# check demanded the literal phrase \\\"definition of done\\\" and so\\n# rejected a CORRECT assessment three times on run 30475b25 — one\\n# that reported gate 2's primitives already complete and proposed\\n# moving to gate 3, and was right on both counts. A gate that\\n# rejects true reports is as bad as one that accepts false ones.\\n#\\n# Accept either shape: a runnable command (that is what \\\"verifiable\\\"\\n# actually means), or an explicit statement that this tick has no\\n# buildable package.\\nif grep -qiE \\\"definition of done|definition-of-done|done when|done-when|acceptance criteria\\\" ops/NEXT.md \\\\\\n || grep -qE \\\"(cargo|npm|node|sh|pytest) [a-z]\\\" ops/NEXT.md \\\\\\n || grep -qiE \\\"no buildable work|nothing to build|assessment only|gate .* is (green|complete)\\\" ops/NEXT.md; then\\n :\\nelse\\n echo \\\"ASSESS_FAIL_NO_DOD: ops/NEXT.md names neither a runnable command nor a\\\"\\n echo \\\" statement that this tick has no buildable package. A work package that\\\"\\n echo \\\" cannot be verified cannot be built against.\\\"\\n exit 1\\nfi\\necho \\\"ASSESS_GATE_PASS ($(grep -m1 -oE 'WP-[0-9]+[^|]*' ops/NEXT.md || echo 'work package'))\\\"\\n\",\"timeoutMs\":120000,\"id\":\"assess-gate-1\"},{\"type\":\"agent\",\"dependsOn\":[\"assess-gate-1\"],\"maxIterations\":3,\"verification\":{\"type\":\"output_contains\",\"value\":\"BUILD_DONE\"},\"id\":\"build-1\",\"instruction\":\"Implements the work package. Rust for kernel/, TypeScript for packages/sdk/.\\n\\nRead ops/NEXT.md, AGENTS.md, and the relevant parts of\\ndocs/RFC-0001-everything-is-a-relayflow.md. Implement exactly that\\nwork package — nothing more. Run the definition-of-done commands\\nyourself and iterate until they pass. Keep files small and\\nsingle-purpose. End with BUILD_DONE only when the definition of done\\npasses locally; paste the passing output.\\n\",\"cli\":\"codex\",\"surfaces\":{\"streams\":[{\"stream\":\"flows-drive-cloud\"}]}},{\"type\":\"deterministic\",\"dependsOn\":[\"build-1\"],\"command\":\"# CLOUD VARIANT (generated): a FAILED verify is recorded and the\\n# run continues. Nothing is delivered from a sandbox, so a failure\\n# here cannot ship; the next cycle's assess treats it as the work\\n# package. On a delivering environment verify stays fatal.\\n# A gate that cannot fail is not a gate. Never pipe a test command\\n# into tail inside the status check: the pipeline's status is tail's.\\nset -u\\nran=0; ok=0\\n\\n# Bound every long-running command, not just the suites. Run\\n# 6d045b23 sat in verify for 29+ minutes: its suites were bounded but\\n# `cargo build` and `npm ci` were not, so a cold sandbox installing a\\n# toolchain and compiling from scratch had no ceiling at all.\\n# Bounding half the step is not bounding the step.\\n#\\n# timeoutMs is NOT enforced by the platform —\\n# observed three times on 2026-08-28 (verify-1 at 31min against a\\n# 20min bound, review-1 at 36min against 30min, plus an unbounded\\n# toolchain install). And the kernel suite now contains a test that\\n# intermittently hangs under sandbox timing:\\n# an_entry_appended_during_watch_registration_is_delivered_exactly_once\\n# ran past 60s in cloud while passing locally in 0.54s. Without a\\n# bound here, one hanging test consumes the entire run budget.\\nrun_bounded() {\\n _label=\\\"$1\\\"; shift\\n if command -v timeout >/dev/null 2>&1; then\\n timeout \\\"${VERIFY_SUITE_TIMEOUT:-900}\\\" \\\"$@\\\"\\n elif command -v gtimeout >/dev/null 2>&1; then\\n gtimeout \\\"${VERIFY_SUITE_TIMEOUT:-900}\\\" \\\"$@\\\"\\n else\\n echo \\\"VERIFY_WARN: no timeout(1); $_label runs unbounded\\\" >&2\\n \\\"$@\\\"\\n fi\\n}\\n\\nif [ -d kernel ]; then\\n # Invoke through `sh`: in a cloud sandbox this script was present\\n # but not executable (observed on run 4cf36ea7). Git tracks it as\\n # mode 100755, so the exec bit is lost somewhere in materialization\\n # — which stage is NOT established, so no mechanism is claimed here.\\n # `sh