From fe82291ee5f95eb4b2398aa56110e68bf52875b4 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 07:00:14 +0200 Subject: [PATCH 1/4] test(qualification): close greenfield phase zero --- bun.lock | 4 +- bunfig.toml | 2 +- docs/api/endpoints.md | 54 +- docs/architecture/greenfield-rewrite.md | 8 +- .../application-architecture.md | 94 +- .../greenfield-rewrite/implementation-plan.md | 69 +- .../greenfield-rewrite/progress.md | 56 +- .../runtime-and-delivery.md | 72 +- docs/generated/packages-and-runtime.md | 4 +- package.json | 4 +- .../browser/queryCollectionAdapter.test.ts | 256 ++ .../browser/queryCollectionAdapter.ts | 152 ++ .../budgets/resourceBudgetCommand.ts | 199 ++ .../resourceBudgetOrchestration.test.ts | 39 + .../budgets/resourceBudgetOrchestration.ts | 497 ++++ .../budgets/resourceBudgetPolicy.test.ts | 242 ++ qualification/budgets/resourceBudgetPolicy.ts | 404 ++++ qualification/budgets/resourceBudgetUnit.ts | 463 ++++ .../budgets/runResourceBudgetEvidence.ts | 13 + .../runSafeChildCancellationEvidence.ts | 18 + .../build/fixtures/frontend/index.html | 13 + .../build/fixtures/frontend/src/LazyPanel.tsx | 7 + .../frontend/src/QualificationApp.tsx | 22 + .../build/fixtures/frontend/src/index.css | 5 + .../build/fixtures/frontend/src/main.tsx | 10 + .../build/frontendBuildQualification.test.ts | 121 + .../build/frontendBuildQualification.ts | 146 ++ .../build/runFrontendBuildQualification.ts | 93 + qualification/chat/chatBatching.test.ts | 80 + qualification/chat/chatBatchingModel.ts | 234 ++ .../chat/chatBatchingQualification.ts | 205 ++ .../chat/runChatBatchingQualification.ts | 49 + .../fixtures/2026.7.2-beta.7/agents.json | 19 + .../fixtures/2026.7.2-beta.7/chat.json | 90 + .../fixtures/2026.7.2-beta.7/cron.json | 18 + .../fixtures/2026.7.2-beta.7/gateway.json | 23 + .../fixtures/2026.7.2-beta.7/manifest.json | 177 ++ .../fixtures/2026.7.2-beta.7/sessions.json | 166 ++ .../fixtures/2026.7.2-beta.7/tasks.json | 76 + qualification/openclaw/reviewedFixtures.ts | 273 +++ qualification/openclaw/runSourceAudit.ts | 91 + qualification/openclaw/sourceAudit.test.ts | 451 ++++ qualification/openclaw/sourceAudit.ts | 1053 +++++++++ qualification/openclaw/sourceAuditSchemas.ts | 553 +++++ .../outbox/runSqliteOutboxEvidence.ts | 48 + qualification/outbox/sqliteOutboxChild.ts | 236 ++ qualification/outbox/sqliteOutboxProtocol.ts | 36 + .../outbox/sqliteOutboxQualification.test.ts | 238 ++ .../outbox/sqliteOutboxQualification.ts | 383 +++ qualification/outbox/sqliteOutboxStore.ts | 488 ++++ .../parity/fixtures/frontend-routes.json | 272 +++ .../parity/fixtures/greenfield-contracts.json | 178 ++ .../parity/fixtures/legacy-endpoints.json | 2069 +++++++++++++++++ .../parity/legacyBackendRouteInventory.ts | 159 ++ .../parity/parityFixtureCandidate.ts | 112 + qualification/parity/parityInventory.test.ts | 224 ++ .../parity/parityInventorySchemas.ts | 281 +++ .../parity/reviewedParityInventory.ts | 206 ++ .../parity/sourceParityInventory.test.ts | 145 ++ qualification/parity/sourceParityInventory.ts | 469 ++++ .../resources/pausedTlsSseClient.test.ts | 102 +- qualification/resources/pausedTlsSseClient.ts | 600 +++-- qualification/resources/sseMemoryScenario.ts | 22 +- .../completeShutdownQualification.test.ts | 143 ++ .../shutdown/completeShutdownQualification.ts | 689 ++++++ .../shutdown/runCompleteShutdownEvidence.ts | 6 + qualification/shutdown/shutdownDatabase.ts | 220 ++ qualification/shutdown/shutdownGrandchild.ts | 3 + .../shutdown/shutdownIdleHttpConnection.ts | 152 ++ qualification/shutdown/shutdownProtocol.ts | 182 ++ qualification/shutdown/shutdownService.ts | 311 +++ .../shutdown/shutdownServiceResources.test.ts | 127 + .../shutdown/shutdownServiceResources.ts | 575 +++++ qualification/test/asyncCleanupStack.test.ts | 121 + qualification/test/asyncCleanupStack.ts | 119 +- .../nativeWebSocketQualification.test.ts | 394 ++++ .../websocket/nativeWebSocketQualification.ts | 333 +++ .../websocket/rawWebSocketFixture.ts | 308 +++ .../websocket/rawWebSocketProtocol.ts | 343 +++ scripts/frontendBuild.ts | 2 +- .../qualification/legacyBackendRouteProbe.ts | 36 + src/app/server.ts | 53 +- .../runtime/applicationRuntime.test.ts | 165 +- .../platform/runtime/applicationRuntime.ts | 136 +- src/server/test/support/requestContext.ts | 4 + src/server/test/system/serverShutdown.test.ts | 68 +- 86 files changed, 16988 insertions(+), 395 deletions(-) create mode 100644 qualification/browser/queryCollectionAdapter.test.ts create mode 100644 qualification/browser/queryCollectionAdapter.ts create mode 100644 qualification/budgets/resourceBudgetCommand.ts create mode 100644 qualification/budgets/resourceBudgetOrchestration.test.ts create mode 100644 qualification/budgets/resourceBudgetOrchestration.ts create mode 100644 qualification/budgets/resourceBudgetPolicy.test.ts create mode 100644 qualification/budgets/resourceBudgetPolicy.ts create mode 100644 qualification/budgets/resourceBudgetUnit.ts create mode 100644 qualification/budgets/runResourceBudgetEvidence.ts create mode 100644 qualification/budgets/runSafeChildCancellationEvidence.ts create mode 100644 qualification/build/fixtures/frontend/index.html create mode 100644 qualification/build/fixtures/frontend/src/LazyPanel.tsx create mode 100644 qualification/build/fixtures/frontend/src/QualificationApp.tsx create mode 100644 qualification/build/fixtures/frontend/src/index.css create mode 100644 qualification/build/fixtures/frontend/src/main.tsx create mode 100644 qualification/build/frontendBuildQualification.test.ts create mode 100644 qualification/build/frontendBuildQualification.ts create mode 100644 qualification/build/runFrontendBuildQualification.ts create mode 100644 qualification/chat/chatBatching.test.ts create mode 100644 qualification/chat/chatBatchingModel.ts create mode 100644 qualification/chat/chatBatchingQualification.ts create mode 100644 qualification/chat/runChatBatchingQualification.ts create mode 100644 qualification/openclaw/fixtures/2026.7.2-beta.7/agents.json create mode 100644 qualification/openclaw/fixtures/2026.7.2-beta.7/chat.json create mode 100644 qualification/openclaw/fixtures/2026.7.2-beta.7/cron.json create mode 100644 qualification/openclaw/fixtures/2026.7.2-beta.7/gateway.json create mode 100644 qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json create mode 100644 qualification/openclaw/fixtures/2026.7.2-beta.7/sessions.json create mode 100644 qualification/openclaw/fixtures/2026.7.2-beta.7/tasks.json create mode 100644 qualification/openclaw/reviewedFixtures.ts create mode 100644 qualification/openclaw/runSourceAudit.ts create mode 100644 qualification/openclaw/sourceAudit.test.ts create mode 100644 qualification/openclaw/sourceAudit.ts create mode 100644 qualification/openclaw/sourceAuditSchemas.ts create mode 100644 qualification/outbox/runSqliteOutboxEvidence.ts create mode 100644 qualification/outbox/sqliteOutboxChild.ts create mode 100644 qualification/outbox/sqliteOutboxProtocol.ts create mode 100644 qualification/outbox/sqliteOutboxQualification.test.ts create mode 100644 qualification/outbox/sqliteOutboxQualification.ts create mode 100644 qualification/outbox/sqliteOutboxStore.ts create mode 100644 qualification/parity/fixtures/frontend-routes.json create mode 100644 qualification/parity/fixtures/greenfield-contracts.json create mode 100644 qualification/parity/fixtures/legacy-endpoints.json create mode 100644 qualification/parity/legacyBackendRouteInventory.ts create mode 100644 qualification/parity/parityFixtureCandidate.ts create mode 100644 qualification/parity/parityInventory.test.ts create mode 100644 qualification/parity/parityInventorySchemas.ts create mode 100644 qualification/parity/reviewedParityInventory.ts create mode 100644 qualification/parity/sourceParityInventory.test.ts create mode 100644 qualification/parity/sourceParityInventory.ts create mode 100644 qualification/shutdown/completeShutdownQualification.test.ts create mode 100644 qualification/shutdown/completeShutdownQualification.ts create mode 100644 qualification/shutdown/runCompleteShutdownEvidence.ts create mode 100644 qualification/shutdown/shutdownDatabase.ts create mode 100644 qualification/shutdown/shutdownGrandchild.ts create mode 100644 qualification/shutdown/shutdownIdleHttpConnection.ts create mode 100644 qualification/shutdown/shutdownProtocol.ts create mode 100644 qualification/shutdown/shutdownService.ts create mode 100644 qualification/shutdown/shutdownServiceResources.test.ts create mode 100644 qualification/shutdown/shutdownServiceResources.ts create mode 100644 qualification/test/asyncCleanupStack.test.ts create mode 100644 qualification/websocket/nativeWebSocketQualification.test.ts create mode 100644 qualification/websocket/nativeWebSocketQualification.ts create mode 100644 qualification/websocket/rawWebSocketFixture.ts create mode 100644 qualification/websocket/rawWebSocketProtocol.ts create mode 100644 scripts/qualification/legacyBackendRouteProbe.ts diff --git a/bun.lock b/bun.lock index 3aecd2a9b..5431a1cb0 100644 --- a/bun.lock +++ b/bun.lock @@ -15,8 +15,8 @@ "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "^1.2.1", - "@tanstack/react-db": "^0.1.95", + "@tanstack/query-db-collection": "1.2.1", + "@tanstack/react-db": "0.1.95", "@tanstack/react-form": "^1.33.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", diff --git a/bunfig.toml b/bunfig.toml index 4de6cdc6e..822bc082e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -14,5 +14,5 @@ coveragePathIgnorePatterns = [ ] [serve.static] -plugins = ["bun-plugin-tailwind", "./scripts/reactCompilerPlugin.ts"] +plugins = ["./scripts/reactCompilerPlugin.ts", "bun-plugin-tailwind"] environment = "PUBLIC_*" diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index f55e9acaf..dd29cc583 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -13,7 +13,9 @@ route files under `backend/src/routes/` for exact validation details. | Method | Path | Purpose | | ------ | ------------------------- | ------------------------------------------------------ | | `GET` | `/api/health/live` | Public web-process liveness. | +| `HEAD` | `/api/health/live` | Bodyless public web-process liveness probe. | | `GET` | `/api/health/ready` | Public activation readiness; `503` when not ready. | +| `HEAD` | `/api/health/ready` | Bodyless readiness probe with the same status as GET. | | `GET` | `/api/health/diagnostics` | Authenticated readiness details and dependency status. | | `GET` | `/api/sessions` | Normalized session snapshot from Gateway. | @@ -200,6 +202,13 @@ already-running execution finishes cooperatively. | `POST` | `/api/backup` | Creates config backup. | | `POST` | `/api/restart` | Queues an OpenClaw Gateway restart and waits for its persisted result. | +## Dashboard Settings + +| Method | Path | Purpose | +| ------ | --------------- | ------------------------------------------------------------- | +| `GET` | `/api/settings` | Reads Dashboard preferences plus current Gateway connection. | +| `PUT` | `/api/settings` | Updates the validated Dashboard preference subset atomically. | + ## Files, Config Files, Logs, Media | Method | Path | Purpose | @@ -210,8 +219,9 @@ already-running execution finishes cooperatively. | `GET` | `/api/config-files` | Lists OpenClaw config files. | | `GET` | `/api/config-files/*` | Reads a config file under OpenClaw root. | | `PUT` | `/api/config-files/*` | Writes a config file under OpenClaw root. | -| `GET` | `/api/logs/info` | Lists log files/metadata. | -| `GET` | `/api/logs/content` | Reads log content. | +| `GET` | `/api/logs/dashboard` | Reads the bounded Dashboard service log tail. | +| `GET` | `/api/logs/openclaw/files` | Lists readable OpenClaw log files and metadata. | +| `GET` | `/api/logs/openclaw/content` | Reads a bounded tail from one allowlisted OpenClaw log file. | | `GET` | `/api/media` | Serves or safely previews media bytes from OpenClaw media roots. | | `GET` | `/api/chat/media/outgoing/*` | Proxies an exact managed Gateway media path with backend-held auth. | @@ -232,31 +242,33 @@ upstream download metadata. ## Docker -| Method | Path | Purpose | -| -------- | ------------------------------------------------ | ----------------------------------------- | -| `GET` | `/api/docker/containers` | Lists containers. | -| `GET` | `/api/docker/containers/:containerId` | Reads container details. | -| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | -| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | -| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | -| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | -| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | -| `GET` | `/api/docker/images` | Lists images. | -| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | -| `GET` | `/api/docker/volumes` | Lists volumes. | -| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | -| `POST` | `/api/docker/prune` | Queues a Docker prune target. | -| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | -| `GET` | `/api/docker/updater/services` | Lists managed update services. | -| `GET` | `/api/docker/updater/events` | Lists update events. | -| `POST` | `/api/docker/updater/run` | Queues an updater scan. | -| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | +| Method | Path | Purpose | +| -------- | ------------------------------------------------ | ------------------------------------------- | +| `GET` | `/api/docker/containers` | Lists containers. | +| `GET` | `/api/docker/containers/stats` | Reads the current container stats snapshot. | +| `GET` | `/api/docker/containers/:containerId` | Reads container details. | +| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | +| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | +| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | +| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | +| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | +| `GET` | `/api/docker/images` | Lists images. | +| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | +| `GET` | `/api/docker/volumes` | Lists volumes. | +| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | +| `POST` | `/api/docker/prune` | Queues a Docker prune target. | +| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | +| `GET` | `/api/docker/updater/services` | Lists managed update services. | +| `GET` | `/api/docker/updater/events` | Lists update events. | +| `POST` | `/api/docker/updater/run` | Queues an updater scan. | +| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | ## Pull Requests And Deployments | Method | Path | Purpose | | ------ | -------------------------------------------- | ---------------------------------------------------------- | | `GET` | `/api/pull-requests` | Lists Dashboard PRs. | +| `POST` | `/api/pull-requests/stacks` | Creates one reviewed native GitHub PR stack. | | `POST` | `/api/pull-requests/:number/approve` | Queues merge, optionally followed by deploy. | | `POST` | `/api/pull-requests/:number/reject` | Queues reject/close. | | `POST` | `/api/pull-requests/:number/review-approval` | Queues review approval. | diff --git a/docs/architecture/greenfield-rewrite.md b/docs/architecture/greenfield-rewrite.md index 2be4053ff..be194525d 100644 --- a/docs/architecture/greenfield-rewrite.md +++ b/docs/architecture/greenfield-rewrite.md @@ -1,9 +1,11 @@ # Greenfield Rewrite Blueprint -> **Status:** implementation started. The rewrite is built beside the current production -> implementation and targets a fresh database with no compatibility layer. +> **Status:** implementation active. Phase 0 evidence is complete and Phase 2 is complete for its +> stated server scope; the remaining foundation, browser, domain, Gateway/chat, privileged, +> hardening, and cutover phases are not complete. The rewrite is built beside the current +> production implementation and targets a fresh database with no compatibility layer. > -> **Audit date:** 2026-08-04. Package versions and the Bun canary snapshot in this document +> **Audit date:** 2026-08-06. Package versions and the Bun canary snapshot in this document > are point-in-time facts. They are rechecked during an explicit candidate-promotion round, > not for ordinary feature or review commits. diff --git a/docs/architecture/greenfield-rewrite/application-architecture.md b/docs/architecture/greenfield-rewrite/application-architecture.md index c93a544da..02e3ed92b 100644 --- a/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/docs/architecture/greenfield-rewrite/application-architecture.md @@ -283,6 +283,29 @@ protocol again. Current-production Gateway/chat/session/agent/cron code supplies not protocol authority. The consolidated controls and executable evidence are in the [Phase 2 threat model](../../security/greenfield-phase-two-threat-model.md). +### Current-protocol Control UI projections + +The 2026-08-06 OpenClaw audit separates protocol authority from Control UI projection through 22 +hash-pinned, redacted distribution artifacts. The current behavior informs Phase 4, but Dashboard +must re-audit the installed source and use a typed protocol adapter rather than scrape, import, or +mirror Control UI implementation details: + +- plan/checklist state is projected from generic `agent` events and retained only on the active + in-flight run; it is not a durable plan record or a dedicated plan RPC; +- companion ask is labeled with `operator.read` upstream but starts new compute and is constrained + by process-local TTL and concurrency caps. Dashboard treats it as an explicit compute action, + preserves those bounded semantics, and does not cache it as read-only state; +- the background-task ledger supports list, detail, and cancellation. Cancellation is a write/admin + operation, can lose a race to normal completion, and must expose that result instead of claiming + a task was stopped; and +- `cancelled` and `timed_out` remain distinct protocol states even when a presentation groups both + with failures. + +The Phase 4 browser surface exposes the active plan/checklist, companion ask, and background-task +details/cancellation through that adapter, with authorization and race behavior covered by recorded +fixtures. These projections complement the persistent Dashboard chat journal; they do not make +ephemeral OpenClaw in-flight state durable by inference. + ### Raw HTTP exists only for protocol edges The explicit raw-route registry owns requests whose semantics are HTTP rather than domain RPC: @@ -343,12 +366,20 @@ constant failure markers. The web `ApplicationRuntime` merges the realtime pump and one process-scoped authentication-work service into the same `ManagedRuntime`. That authentication service owns separate bounded admission and active-work semaphores for Gateway verification, password/Argon2 work, TOTP AES/HMAC work, and -WebAuthn parsing/signature verification, plus a scoped fiber set for work that outlives an interrupted caller. Queued cancellation releases -admission immediately; active non-cooperative work retains its permit until settlement. Promise- -facing adapters fold typed capacity into explicit domain throttling outcomes, while Gateway +WebAuthn parsing/signature verification, plus a scoped fiber set for work that outlives an +interrupted caller. Queued cancellation releases admission immediately; active non-cooperative +work retains its permit until settlement. Promise-facing adapters fold typed capacity into +explicit domain throttling outcomes, while Gateway capacity, deadline, and unavailable tags are exhaustively translated before the tRPC procedure maps the resulting domain outcome. No request creates or disposes a runtime. +The same `ManagedRuntime` coordinates listener shutdown. An external `stop(true)` request crosses +the Promise-facing composition boundary as an abort signal; Effect owns the graceful-stop fiber, +deadline/force race, tagged stop and timeout failures, separately bounded force attempt, and +settlement of the original graceful operation before the runtime scope is disposed. A rejected +graceful stop receives one bounded best-effort force attempt while preserving the initiating +failure. No second runtime or manual timer/`Promise.race` shutdown system is created. + ## Realtime Architecture ### One browser stream @@ -417,11 +448,14 @@ an explicit local runtime state machine for active work: - `chat_runtime_snapshots` stores the latest compact projection needed for fast restart recovery. -Gateway token/thinking/tool deltas are coalesced into small ordered batches before a SQLite -transaction and SSE emission. The design never performs one durable commit per token. A final -Gateway history fetch reconciles the runtime projection without duplicating messages. On -restart, Dashboard restores the snapshot and remaining journal, reconnects to Gateway, and -reconciles again. +Gateway token and thinking deltas are coalesced into ordered 150 ms batches before a SQLite +transaction and SSE emission. The interval matches the audited OpenClaw source throttle and is the +smallest candidate that meets the measured write-rate, visual-delay, and crash-window policy for +one, four, and eight concurrent runs. Tool/item boundaries, terminal deltas, cancellation, and +completion flush immediately; the design never performs one durable commit per token. A final +Gateway history fetch reconciles the runtime projection without duplicating messages. On restart, +Dashboard restores the snapshot and remaining journal, reconnects to Gateway, and reconciles +again. This state machine must retain all current behavior: token streaming, thinking and tool row ordering, tool failure scoping, final-message reconciliation, cancel/retry, concurrent sends, @@ -447,6 +481,41 @@ small connection store may expose SSE/Gateway health without owning domain data. own reducer/state-machine store because its ordered transient events must survive route changes and reconnects. It is not combined with general server cache state. +### Browser Effect boundary + +Effect is available in the browser, but the same selective boundary applies as on the server. +TanStack and React continue to own rendering, server-state caches, normalized collections, +forms, URL state, and ordinary component state. Effect owns browser work only when asynchronous +lifetimes are themselves part of the correctness contract: scoped subscriptions or streams, +coordinated cancellation, bounded queues/concurrency, explicit retry/backoff, multi-step uploads, +and tagged operational failures. + +The first browser feature that needs such orchestration creates one browser-composition runtime +and disposes it during application/test teardown. Hooks and renders never create runtimes, fibers, +or duplicate retry loops. An Effect service publishes stable snapshots into the owning TanStack +Store, Query, or collection boundary; it does not become a second domain-state cache. Simple tRPC +query functions, Valibot parsing, reducers, deterministic state transitions, and individual event +handlers remain ordinary TypeScript. Existing tRPC/TanStack cancellation and retry behavior is +reused rather than wrapped merely because a function is asynchronous. + +### Evaluated browser dependency candidates + +The following registry/documentation review was performed on 2026-08-06. It records candidates, +not blanket installation approval. Every adopted pre-1.0 package is exact-pinned and requalified in +the vertical slice that first needs it; competing libraries are not shipped together. + +| Candidate | Current decision | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `react-resizable-panels` `4.12.2` | Likely adoption for accessible chat, file, log, and terminal split panes. Add it only with the first real pane layout, keyboard/focus tests, bounded persisted sizes, responsive fallback, and teardown evidence. | +| TanStack Markdown `0.0.13` | Alpha replacement candidate for the current `react-markdown`/remark/rehype chain. Before adoption, replay the complete chat/report/file corpus and verify the required GFM subset, accumulated AI streaming, unsafe HTML/URL handling, deterministic rendering, and bundle delta. | +| TanStack Highlight `0.0.10` | Alpha companion candidate for Markdown and code/file previews. Qualify the explicit language registry, embedded-language fidelity, line/range annotations, escaping, themes, and bundle delta against the current `react-syntax-highlighter`/`refractor` surface. Adopt Markdown and Highlight independently. | +| TanStack Charts + React adapter `0.6.5` | Preferred typed/accessibility candidate for Phase 3 metrics, but still pre-alpha. Compare representative time-series, categorical, tooltip, resize, keyboard, theme, export, and high-point-count cases against Recharts before selecting exactly one renderer. | +| Recharts `3.10.1` | Mature fallback if the TanStack Charts spike fails. Its larger dependency surface, including Redux infrastructure, must be justified by concrete parity or stability evidence; it is not installed alongside TanStack Charts. | +| TanStack Pacer / React Pacer `0.21.1`/`0.22.1` | Beta candidate only for repeated browser timing needs such as observable debounce, throttle, and UI batching. Server/process concurrency remains Effect-owned. The transitive `@tanstack/pacer-lite` used by TanStack DB is not an application API or reason to add the full package. | +| Motion `13.0.0` | Optional later dependency for complex gesture, shared-layout, or interruptible animation parity. CSS/Tailwind remains the default; any adoption uses the current `motion` entry point, route-level code splitting, and reduced-motion tests rather than a direct legacy `framer-motion` import. | +| `react-refresh` `0.18.0` | Not an application/runtime dependency. A future qualified custom HMR development path may own it as build tooling; the selected production AOT build does not ship it. | +| SWR `2.5.0` | Rejected. It duplicates tRPC/TanStack Query remote-state ownership and would introduce a second cache, retry policy, and invalidation model. | + ### What Query Collections are for A TanStack DB Query Collection is the bridge from a TanStack Query snapshot to a normalized, @@ -463,9 +532,12 @@ It is not used merely because data came from the server: - form drafts stay in TanStack Form; and - chat runtime events stay in the dedicated chat store/state machine. -Collections are created once per `QueryClient` and hidden behind a small Dashboard adapter -because TanStack DB is still pre-1.0. The package is exact-pinned. A server snapshot always -wins over conflicting speculative collection state. +Collections are created once per `QueryClient` and cache key and hidden behind a small Dashboard +adapter because TanStack DB is still pre-1.0. The exact-qualified package set is +`@tanstack/db@0.6.17`, `@tanstack/query-db-collection@1.2.1`, +`@tanstack/react-db@0.1.95`, and `@tanstack/query-core@5.101.4`. Route teardown disposes only the +route subscription; it does not destroy and recreate an asynchronous collection under the same +cache key. A server snapshot always wins over conflicting speculative collection state. ### Component and route rules diff --git a/docs/architecture/greenfield-rewrite/implementation-plan.md b/docs/architecture/greenfield-rewrite/implementation-plan.md index 12dc17a05..1b48817f3 100644 --- a/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -17,6 +17,10 @@ compatibility migration inside it. **Exit gate:** every architecture risk marked mandatory below has a passing executable spike. +**Status (2026-08-06): complete.** Exact-candidate qualification, source-derived parity, and the +eight executable spikes below pass. This closes evidence selection only; Phase 1 foundation and +the remaining rewrite phases are still incomplete. + ### Phase 1: foundation - create source boundaries, configuration, logging, errors, contract registry, Drizzle/native @@ -84,30 +88,37 @@ Given the current chat, auth, worker, delivery, and host-integration surface, th **50–80 focused engineer-days**, not a small transport refactor. Automation can reduce elapsed time, but it cannot remove the qualification, security, restore, and parity gates. -## Mandatory Spikes and Open Decisions - -The target choices are fixed unless one of these tests disproves the underlying assumption: - -1. **Bun full-stack build:** verify React Compiler-first transforms, Tailwind, lazy chunks, - source maps, CSP, asset hashes, precompression, and bundle budgets. Then select exactly one - production build path. -2. **tRPC SSE on exact Bun:** verify credentials, aborts, tracked resume, error shapes, - reverse-proxy behavior, deploy reconnect, and slow-consumer memory. -3. **SQLite outbox latency:** measure adaptive polling with web/worker processes under chat and - job load. Keep the database authoritative; change only the wakeup mechanism if latency or - I/O misses the target. -4. **Chat batching:** determine the smallest durable delta interval that preserves current - visual streaming while bounding SQLite writes and restart loss. -5. **TanStack DB adapter:** prove snapshot replacement, direct batch writes, query-cache - synchronization, optimistic-conflict handling, and route teardown without duplicate rows. -6. **Drizzle on Bun SQLite:** verify sync transactions, prepared statements, the `sql` tagged - template, partial/unique indexes, generated migrations, Valibot row schemas, native-client - access, and query plans on the exact pinned Drizzle version and resolved Bun qualification - candidate. -7. **Bun canary shutdown:** verify graceful SSE, Gateway, prepared-statement, worker lease, and - child-process cleanup under systemd stop/restart. -8. **Resource budgets:** measure build/test and representative privileged jobs in cgroups before - finalizing service/job limits. +## Mandatory Spikes and Decisions + +All eight Phase 0 spikes have executable evidence on the audited Bun candidate. Their selected +outcomes remain normative unless a later runtime or dependency qualification disproves them: + +1. **Passed — Bun full-stack build:** use one compiler-first Bun HTML ahead-of-time production + build. Tailwind, lazy chunks, CSP, hashes, precompression, source-map policy, and bundle budgets + pass in the mechanism fixture and actual frontend build; there is no fallback build path. +2. **Passed — tRPC SSE on exact Bun:** credentials, cancellation, tracked resume, typed errors, + proxy/TLS streaming, rolling reconnect, and bounded slow-consumer behavior pass. +3. **Passed — SQLite outbox latency:** separate web and worker processes deliver a WAL-backed + durable outbox without gaps or duplicates, classify real busy/locked outcomes, and recover an + expired claim after hard worker termination. +4. **Passed — chat batching:** use ordered 150 ms token/thinking batches. One, four, and eight + concurrent runs meet the selected write/delay policy, while tool/item, terminal, cancel, and + completion boundaries flush immediately. +5. **Passed — TanStack DB adapter:** the exact-pinned local adapter proves snapshot replacement, + direct batch writes, query-cache synchronization, optimistic-conflict resolution, forwarded + cancellation, and subscription teardown without duplicate rows. +6. **Passed — Drizzle on Bun SQLite:** synchronous transactions, prepared-statement lifetime, + native access, constraints/indexes/query plans, schema validation, migrations, checkpoint, + backup, restore, and integrity pass on the exact candidate. +7. **Passed — Bun canary shutdown:** two service generations prove readiness withdrawal, SSE and + Gateway closure, prepared-statement/database disposal, worker-lease recovery, process-group + cleanup, WAL recovery, and bounded Effect-owned graceful-to-force listener shutdown. +8. **Passed — resource budgets:** capped sequential build, test, SQLite, chat, shutdown, and child + cancellation runs complete without high/max/OOM events, memory pressure, or leaked resources. + +The exact candidate qualification additionally covers raw RFC 6455 continuation and fragmented +UTF-8 reassembly, protocol/application size closes, deterministic cancellation, and explicit +absence of reconnect. The source-derived inventory accounts for 156 HTTP operations plus `/ws`. The OpenClaw audit is deliberately point-in-time. The Phase 2 one-shot verifier records the installed `2026.7.2-beta.7 (dabe191)` protocol-v4 behavior, but it does not qualify persistent @@ -163,7 +174,7 @@ not package memory alone: - [`bun test`](https://bun.com/docs/test) - [Full-stack development server and HTML imports](https://bun.com/docs/bundler/fullstack) - [Official canary release asset](https://github.com/oven-sh/bun/releases/tag/canary) -- [Latest audited Bun commit](https://github.com/oven-sh/bun/commit/43783cedd5653fa29bb9ac83df34633eae10fe75) +- [Latest audited Bun commit](https://github.com/oven-sh/bun/commit/17d6843606d76620cb55d31424d7fb0aed51c367) ### tRPC and validation @@ -195,6 +206,14 @@ not package memory alone: - [TanStack Form validation](https://tanstack.com/form/latest/docs/framework/react/guides/validation) - [TanStack Store React quick start](https://tanstack.com/store/latest/docs/framework/react/quick-start) - [TanStack Virtual](https://tanstack.com/virtual/latest/docs/introduction) +- [TanStack Pacer](https://tanstack.com/pacer/latest/docs/overview) +- [TanStack Markdown](https://tanstack.com/markdown/latest/docs/overview) +- [TanStack Highlight](https://tanstack.com/highlight/latest/docs/overview) +- [TanStack Charts](https://tanstack.com/charts/latest/docs/overview) +- [Recharts](https://www.npmjs.com/package/recharts) +- [React Resizable Panels](https://www.npmjs.com/package/react-resizable-panels) +- [Motion for React](https://motion.dev/docs/react) +- [SWR](https://swr.vercel.app/) ### Database, security, and tooling diff --git a/docs/architecture/greenfield-rewrite/progress.md b/docs/architecture/greenfield-rewrite/progress.md index 66c6dd10a..98e1f8361 100644 --- a/docs/architecture/greenfield-rewrite/progress.md +++ b/docs/architecture/greenfield-rewrite/progress.md @@ -9,7 +9,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | Phase | Status | Current evidence and remaining gate | | ----------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Evidence and qualification | In progress | Bun/SQLite/tRPC/SSE and production-shaped realtime evidence passes; frontend build, TanStack DB, full shutdown, chat batching, and measured resource-budget spikes remain. | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`, including build, transport, database/outbox, browser data, chat batching, shutdown, parity, OpenClaw source audit, and capped resource evidence. | | 1 — Foundation | In progress | Server composition, migrations, contracts, raw HTTP policy, realtime outbox, and the current generated-doc subset exist; browser/worker roots, complete import enforcement, complete generated references, and release/rollback closure remain. | | 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | | 3 — Core operator domains | Started | Monitoring transaction/schema foundations exist; task, agent, report, incident, notification, schedule/job, cache/metrics procedures and browser parity are not complete. | @@ -586,11 +586,61 @@ closes a phase; dated entries below provide the evidence, not a second status so wrong-ID/contradictory frames, exact mismatch classification, header and URL secrecy, deterministic absence of retry after `startup-sidecars`, native connection refusal, close-confirmed terminal races, redaction, timeout, and real HTTP-to-Effect-to-socket - cancellation with zero user/session/audit/rate-limit publication. Raw continuation-frame - qualification remains an explicit Phase 0 gate. The consolidated + cancellation with zero user/session/audit/rate-limit publication. Phase 0 now separately + qualifies raw continuation frames and fragmented-message behavior. The consolidated [Phase 2 threat model](../../security/greenfield-phase-two-threat-model.md) maps the complete authentication, MFA, WebAuthn, automation, SSE, migration, and Gateway evidence to misuse cases and residual risks. - This closes Phase 2 only for its documented server-side scope. It does not claim full native persistent Gateway qualification. Phase 4 must re-audit the then-installed OpenClaw source and protocol before implementing persistent connection, event recovery, sessions, chat, or cron. + +### 2026-08-06 — Phase 0 evidence and qualification closed + +- Bun `1.4.0-canary.1+17d684360`, full revision + `17d6843606d76620cb55d31424d7fb0aed51c367`, passes qualification typecheck and the complete + qualification suite: 133 tests, 682 assertions, zero failures, and 30 files. This is the exact + audited candidate for the round, not a repository-wide source-revision pin. +- The selected frontend path is one compiler-first Bun HTML AOT build. Executable fixture and + actual-build evidence cover Tailwind, lazy chunks, CSP-compatible assets, hashes, + precompression, absent production source maps, and bundle budgets. The exact-pinned TanStack DB + adapter covers snapshot replacement, direct batch writes, query-cache synchronization, + optimistic conflicts, cancellation, and route-subscription teardown. +- File-backed WAL evidence uses separate web and worker processes and covers reader/writer and + writer/writer behavior, real busy/locked classification, no-gap/no-duplicate outbox delivery, + hard-kill claim recovery, savepoints, prepared-statement disposal, checkpoint, backup, restore, + and integrity. Chat qualification selects 150 ms ordered token/thinking batches for one, four, + and eight concurrent runs, with immediate tool/item, terminal, cancel, and completion flushes. +- Raw RFC 6455 tests cover continuation reassembly, a UTF-8 code point split across three frames, + orphan/interleaved-fragment `1002` closes, invalid-length and 64 KiB application-bound `1009` + closes, deterministic cancellation/close, partial writes, native refusal, and exactly one + connection attempt without reconnect. +- The two-generation shutdown test withdraws readiness before cleanup, closes SSE and the local + Gateway connection, disposes the statement and WAL database, recovers the worker lease, ends the + detached process group, and restarts on the same database without a leak. The candidate's + intentional keep-alive behavior requires a scoped Effect graceful-stop fiber followed by a + separately bounded force escalation; the candidate records `listener-force-stopped` and closes + every owned resource. The production listener now uses the same process `ManagedRuntime` for its + tagged graceful/deadline/force orchestration, including explicit force requests, original-fiber + settlement, and best-effort containment after graceful rejection. +- Source-derived parity now accounts for all 156 current HTTP operations plus `/ws`. The OpenClaw + audit pins 22 redacted source/protocol/UI artifacts for installed `2026.7.2-beta.7 (dabe191)`, + including the generic-event, ephemeral plan/checklist projection, compute-starting companion ask, + and background-task list/detail/cancel semantics. These are Phase 4 adapter requirements, not an + invitation to scrape the Control UI. +- The exact-candidate capped resource matrix passes without `high`, `max`, `oom`, or `oom_kill` + memory events, memory pressure, or leaked process, unit, or temporary state: + + | Scenario | Peak memory (bytes) | Elapsed (ms) | Peak tasks | + | --------------------- | ------------------: | -----------: | ---------: | + | Frontend build | 650,104,832 | 14,793 | 19 | + | Representative tests | 248,758,272 | 2,222 | 18 | + | SQLite outbox/restore | 101,896,192 | 1,218 | 20 | + | Chat batching | 42,676,224 | 97 | 12 | + | Complete shutdown | 128,774,144 | 3,133 | 25 | + | Child cancellation | 117,194,752 | 1,531 | 24 | + +- Phase 0 is complete, but the rewrite is not: Phase 1 remains in progress with browser/worker + roots, complete import enforcement, complete generated references, immutable release/rollback, + and end-to-end empty-database web/worker delivery still open. Final production load, restore, + cutover, and legacy-removal evidence remains in Phase 6. diff --git a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 7c6044438..7bf1d6f0f 100644 --- a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -11,9 +11,9 @@ | Repository channel | `canary` | | Required runtime version | `1.4.0` | | Running production release runtime | `1.4.0-canary.1+e82022145` | -| Audited qualification candidate | `1.4.0-canary.1+43783cedd` | -| Audited full revision | `43783cedd5653fa29bb9ac83df34633eae10fe75` | -| Audited commit date | 2026-08-03 22:02:12 UTC | +| Audited qualification candidate | `1.4.0-canary.1+17d684360` | +| Audited full revision | `17d6843606d76620cb55d31424d7fb0aed51c367` | +| Audited commit date | 2026-08-06 00:27:30 UTC | The audited revision is evidence for this qualification round, not a repository-wide pin. Normal CI resolves the `canary` channel and runs the complete gate set. Release creation then @@ -42,7 +42,7 @@ the Bun HTML pipeline, but workspace files and media retain explicit, policy-che ### Mandatory canary qualification -Before the new repository baseline is locked, run the following in an isolated, memory-capped +Before promoting a new repository baseline, run the following in an isolated, memory-capped environment against the exact candidate binary: 1. Fetch-adapter query and mutation tests, including cookies, aborts, response headers, and @@ -58,10 +58,49 @@ environment against the exact candidate binary: 6. `bun test --isolate` tests for fake timers, leaked handles, deterministic shutdown, and bounded concurrency. -The current one-shot Phase 2 verifier qualifies complete text `MessageEvent` delivery only. Raw -continuation-frame reassembly and fragmented-message behavior remain an explicit open Phase 0 -native-WebSocket gate and must be qualified against the then-current Bun candidate before the -repository baseline is locked. +The 2026-08-06 qualification round passes on exact revision +`17d6843606d76620cb55d31424d7fb0aed51c367`: qualification typecheck passes, and the full suite +reports 133 tests, 682 assertions, and zero failures across 30 files. Its executable evidence +includes: + +- compiler-first Bun HTML AOT output with Tailwind, lazy chunks, CSP-compatible assets, hashes, + precompression, no production source maps, and enforced bundle budgets; +- Fetch/tRPC/SSE cancellation, resume, proxy, rolling-restart, and slow-consumer behavior; +- raw RFC 6455 continuation reassembly with a UTF-8 code point split across frames, protocol-close + `1002`, application-bound `1009`, a 64 KiB limit, deterministic close, and exactly one connection + attempt without reconnect; +- WAL SQLite with separate web and worker processes, actual busy/locked behavior, durable outbox + delivery and lease recovery, statement disposal, checkpoint, backup, restore, and integrity; +- the exact-pinned TanStack DB adapter, snapshot/cache synchronization, batch writes, optimistic + conflict handling, cancellation, and route-subscription teardown; +- 150 ms chat-delta batching for one, four, and eight concurrent runs with immediate boundary and + terminal flushes; +- a two-generation shutdown with readiness withdrawal, SSE and Gateway closure, statement and + database disposal, worker-lease recovery, child-process-group cleanup, WAL recovery, and no + leaked process; and +- source-derived parity for 156 current HTTP operations plus `/ws`, together with 22 hash-pinned, + redacted OpenClaw protocol and Control UI audit artifacts. + +The candidate intentionally makes `server.stop(false)` wait for idle keep-alive connections. The +shutdown qualification therefore uses an Effect-scoped graceful-stop fiber with a bounded wait and +a separately bounded `server.stop(true)` escalation. The exact candidate records +`listener-force-stopped`, then closes SSE and every owned resource without a leak; the event model +permits exactly one graceful or forced terminal outcome. + +The candidate resource matrix also passes without `high`, `max`, `oom`, or `oom_kill` memory +events, memory pressure, or leaked process, unit, or temporary state: + +| Scenario | Peak memory (bytes) | Elapsed (ms) | Peak tasks | +| --------------------- | ------------------: | -----------: | ---------: | +| Frontend build | 650,104,832 | 14,793 | 19 | +| Representative tests | 248,758,272 | 2,222 | 18 | +| SQLite outbox/restore | 101,896,192 | 1,218 | 20 | +| Chat batching | 42,676,224 | 97 | 12 | +| Complete shutdown | 128,774,144 | 3,133 | 25 | +| Child-process cancel | 117,194,752 | 1,531 | 24 | + +These measurements qualify the mechanisms and current limits; Phase 6 still owns final +production-shaped load, restore, and cutover evidence. `.bun-version` selects the `canary` channel through the official `setup-bun` action. The serving process enforces Bun `1.4.0`, while the runtime revision remains diagnostic until release creation @@ -100,17 +139,12 @@ const server = Bun.serve({ }); ``` -The preferred frontend build uses Bun's HTML entrypoint and ahead-of-time production build. -The React Compiler plugin must run before other Babel transforms, followed by Bun and the -Tailwind plugin. Because Bun still labels the full-stack development server as work in -progress, phase 0 must choose one proven build mode: - -- preferred: Bun HTML import/full-stack entry with an AOT production build; or -- if the qualification fails: explicit browser and server `Bun.build` entrypoints. - -Only the selected mode is implemented. There is no production fallback or duplicate build -path. In either case, the release contains prebuilt assets, hashes, compressed variants, -source-map policy, and a manifest; production never compiles the frontend on request. +Phase 0 selects Bun's HTML entrypoint with an ahead-of-time production build. The production +devtools stub runs first when present, React Compiler then runs before Tailwind, and no runtime +full-stack development server is part of delivery. The executable fixture and actual frontend +build prove lazy chunks, CSP-compatible external assets, content hashes, absent production source +maps, precompressed variants, and bundle budgets. There is no production fallback or duplicate +build path: releases contain prebuilt assets and production never compiles the frontend on request. ## Configuration From Scratch diff --git a/docs/generated/packages-and-runtime.md b/docs/generated/packages-and-runtime.md index 4b3ccced4..a85167611 100644 --- a/docs/generated/packages-and-runtime.md +++ b/docs/generated/packages-and-runtime.md @@ -24,8 +24,8 @@ | `@simplewebauthn/server` | `13.3.2` | `13.3.2` | runtime | | `@tailwindcss/typography` | `^0.5.20` | `0.5.20` | runtime | | `@tanstack/query-core` | `5.101.4` | `5.101.4` | runtime | -| `@tanstack/query-db-collection` | `^1.2.1` | `1.2.1` | runtime | -| `@tanstack/react-db` | `^0.1.95` | `0.1.95` | runtime | +| `@tanstack/query-db-collection` | `1.2.1` | `1.2.1` | runtime | +| `@tanstack/react-db` | `0.1.95` | `0.1.95` | runtime | | `@tanstack/react-form` | `^1.33.3` | `1.33.3` | runtime | | `@tanstack/react-query` | `^5.101.4` | `5.101.4` | runtime | | `@tanstack/react-router` | `^1.170.18` | `1.170.18` | runtime | diff --git a/package.json b/package.json index a02aef397..8d4e63ea2 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,8 @@ "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "^1.2.1", - "@tanstack/react-db": "^0.1.95", + "@tanstack/query-db-collection": "1.2.1", + "@tanstack/react-db": "0.1.95", "@tanstack/react-form": "^1.33.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", diff --git a/qualification/browser/queryCollectionAdapter.test.ts b/qualification/browser/queryCollectionAdapter.test.ts new file mode 100644 index 000000000..882ebad39 --- /dev/null +++ b/qualification/browser/queryCollectionAdapter.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "bun:test"; + +import { QueryClient } from "@tanstack/query-core"; + +import { + createQualificationQueryCollection, + QualificationCollectionConflictError, + type QualificationPersistedUpdate, +} from "./queryCollectionAdapter"; + +interface QualificationItem { + id: string; + label: string; + version: number; +} + +const collectionKey = ["qualification", "items"] as const; + +describe("TanStack DB Query Collection adapter qualification", () => { + test("replaces snapshots and synchronizes direct batches with Query cache", async () => { + const queryClient = createQueryClient(); + let authoritative: QualificationItem[] = [ + { id: "a", label: "server-a", version: 1 }, + { id: "b", label: "server-b", version: 1 }, + ]; + const adapter = createQualificationQueryCollection({ + id: "qualification-items", + queryClient, + queryKey: collectionKey, + fetchSnapshot: () => Promise.resolve(structuredClone(authoritative)), + }); + + try { + await adapter.preload(); + expect(project(adapter.rows())).toEqual(authoritative); + expect(cachedItems(queryClient)).toEqual(authoritative); + + adapter.applyBatch([ + { + type: "upsert", + value: { id: "a", label: "delta-a", version: 2 }, + }, + { type: "delete", id: "b" }, + { + type: "upsert", + value: { id: "c", label: "delta-c", version: 1 }, + }, + ]); + const deltaRows = [ + { id: "a", label: "delta-a", version: 2 }, + { id: "c", label: "delta-c", version: 1 }, + ]; + expect(project(adapter.rows())).toEqual(deltaRows); + expect(cachedItems(queryClient)).toEqual(deltaRows); + + authoritative = [{ id: "a", label: "server-wins", version: 3 }]; + await adapter.refetchAuthoritative(); + expect(project(adapter.rows())).toEqual(authoritative); + expect(cachedItems(queryClient)).toEqual(authoritative); + } finally { + await adapter.dispose(); + } + }); + + test("lets the authoritative refetch win an optimistic version conflict", async () => { + const queryClient = createQueryClient(); + const persistence = Promise.withResolvers(); + const persistedUpdates: QualificationPersistedUpdate[][] = []; + const authoritative = [ + { id: "a", label: "authoritative", version: 2 }, + ] satisfies QualificationItem[]; + const adapter = createQualificationQueryCollection({ + id: "qualification-optimistic-items", + queryClient, + queryKey: collectionKey, + fetchSnapshot: () => Promise.resolve(structuredClone(authoritative)), + persistUpdates: async (updates) => { + persistedUpdates.push([...updates]); + await persistence.promise; + }, + }); + + try { + await adapter.preload(); + const update = adapter.updateOptimistically("a", 2, { + label: "speculative", + version: 3, + }); + expect(project(adapter.rows())).toEqual([ + { id: "a", label: "speculative", version: 3 }, + ]); + + persistence.resolve(); + await update; + expect(persistedUpdates).toEqual([ + [ + { + modified: { id: "a", label: "speculative", version: 3 }, + original: { id: "a", label: "authoritative", version: 2 }, + }, + ], + ]); + expect(project(adapter.rows())).toEqual(authoritative); + + let conflict: unknown; + try { + await adapter.updateOptimistically("a", 1, { + label: "stale", + version: 2, + }); + } catch (error) { + conflict = error; + } + expect(conflict).toBeInstanceOf(QualificationCollectionConflictError); + expect(project(adapter.rows())).toEqual(authoritative); + } finally { + await adapter.dispose(); + } + }); + + test("forwards AbortSignal and removes an in-flight query on teardown", async () => { + const queryClient = createQueryClient(); + const fetchStarted = Promise.withResolvers(); + const firstAdapter = createQualificationQueryCollection({ + id: "qualification-route-items", + queryClient, + queryKey: collectionKey, + fetchSnapshot: (signal) => { + fetchStarted.resolve(signal); + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true } + ); + }); + }, + }); + + const preload = firstAdapter.preload(); + const signal = await fetchStarted.promise; + expect(signal.aborted).toBeFalse(); + await firstAdapter.dispose(); + await preload; + expect(signal.aborted).toBeTrue(); + expect(firstAdapter.isDisposed).toBeTrue(); + expect(cachedItems(queryClient)).toBeUndefined(); + }); + + test("tears down route subscriptions without duplicate rows or listeners", async () => { + const queryClient = createQueryClient(); + const adapter = createQualificationQueryCollection({ + id: "qualification-route-items", + queryClient, + queryKey: collectionKey, + fetchSnapshot: () => + Promise.resolve([ + { id: "a", label: "first-route", version: 1 }, + ] satisfies QualificationItem[]), + }); + try { + await adapter.preload(); + let firstRouteNotifications = 0; + const unsubscribeFirstRoute = adapter.subscribe(() => { + firstRouteNotifications += 1; + }); + adapter.applyBatch([ + { + type: "upsert", + value: { id: "a", label: "first-update", version: 2 }, + }, + ]); + const notificationsAtTeardown = firstRouteNotifications; + unsubscribeFirstRoute(); + + let replacementRouteNotifications = 0; + const unsubscribeReplacementRoute = adapter.subscribe(() => { + replacementRouteNotifications += 1; + }); + adapter.applyBatch([ + { + type: "upsert", + value: { id: "a", label: "single-row", version: 3 }, + }, + ]); + unsubscribeReplacementRoute(); + + expect(notificationsAtTeardown).toBe(2); + expect(firstRouteNotifications).toBe(2); + expect(replacementRouteNotifications).toBe(2); + expect(project(adapter.rows())).toEqual([ + { id: "a", label: "single-row", version: 3 }, + ]); + expect(cachedItems(queryClient)).toEqual([ + { id: "a", label: "single-row", version: 3 }, + ]); + } finally { + await adapter.dispose(); + } + expect(cachedItems(queryClient)).toBeUndefined(); + }); + + test("runs against the exact installed TanStack dependency set", async () => { + expect(await readInstalledVersions()).toEqual({ + "@tanstack/db": "0.6.17", + "@tanstack/query-core": "5.101.4", + "@tanstack/query-db-collection": "1.2.1", + "@tanstack/react-db": "0.1.95", + }); + }); +}); + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +function project(items: readonly QualificationItem[]): QualificationItem[] { + return items.map(({ id, label, version }) => ({ id, label, version })); +} + +function cachedItems(queryClient: QueryClient): QualificationItem[] | undefined { + return queryClient.getQueryData(collectionKey); +} + +async function readInstalledVersions(): Promise> { + const packageNames = [ + "@tanstack/db", + "@tanstack/query-core", + "@tanstack/query-db-collection", + "@tanstack/react-db", + ] as const; + const versions: Record = {}; + for (const packageName of packageNames) { + const packageJsonUrl = new URL( + `../../node_modules/${packageName}/package.json`, + import.meta.url + ); + const parsed: unknown = JSON.parse(await Bun.file(packageJsonUrl).text()); + if ( + typeof parsed !== "object" || + parsed === null || + !("version" in parsed) || + typeof parsed.version !== "string" + ) { + throw new Error(`${packageName} has no package version`); + } + versions[packageName] = parsed.version; + } + return versions; +} diff --git a/qualification/browser/queryCollectionAdapter.ts b/qualification/browser/queryCollectionAdapter.ts new file mode 100644 index 000000000..cf6481cfe --- /dev/null +++ b/qualification/browser/queryCollectionAdapter.ts @@ -0,0 +1,152 @@ +import { QueryClient, type QueryKey } from "@tanstack/query-core"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection, type UpdateMutationFnParams } from "@tanstack/react-db"; + +export interface QualificationVersionedEntity { + id: string; + version: number; +} + +export type QualificationCollectionDelta = + | { type: "delete"; id: string } + | { type: "upsert"; value: T }; + +export interface QualificationPersistedUpdate { + modified: T; + original: T; +} + +export interface QualificationQueryCollectionOptions< + T extends QualificationVersionedEntity, +> { + fetchSnapshot: (signal: AbortSignal) => Promise; + id: string; + persistUpdates?: ( + updates: readonly QualificationPersistedUpdate[] + ) => Promise; + queryClient: QueryClient; + queryKey: QueryKey; +} + +export class QualificationCollectionConflictError extends Error { + readonly _tag = "QualificationCollectionConflictError"; +} + +/** + * Qualification-only seam around the current pre-1.0 Query Collection API. + * It exercises the intended browser ownership without creating production code. + * @param options Query client, snapshot, and persistence dependencies. + * @returns Qualification collection adapter. + */ +export function createQualificationQueryCollection< + T extends QualificationVersionedEntity, +>(options: QualificationQueryCollectionOptions) { + const stableQueryKey = [...options.queryKey]; + let disposed = false; + const onUpdate = options.persistUpdates + ? async ({ transaction }: UpdateMutationFnParams) => { + await options.persistUpdates?.( + transaction.mutations.map(({ modified, original }) => ({ + modified, + original, + })) + ); + } + : undefined; + + const collection = createCollection( + queryCollectionOptions({ + id: options.id, + queryClient: options.queryClient, + queryFn: async ({ signal }) => [...(await options.fetchSnapshot(signal))], + queryKey: stableQueryKey, + getKey: (entity: T) => entity.id, + ...(onUpdate ? { onUpdate } : {}), + }) + ); + + function assertActive(): void { + if (disposed) { + throw new Error(`Qualification collection ${options.id} is disposed`); + } + } + + return { + applyBatch(deltas: readonly QualificationCollectionDelta[]): void { + assertActive(); + collection.utils.writeBatch(() => { + for (const delta of deltas) { + if (delta.type === "delete") { + collection.utils.writeDelete(delta.id); + } else { + collection.utils.writeUpsert(delta.value); + } + } + }); + }, + async dispose(): Promise { + if (disposed) return; + disposed = true; + await collection.cleanup(); + options.queryClient.removeQueries({ + exact: true, + queryKey: stableQueryKey, + }); + }, + get(id: string): T | undefined { + assertActive(); + return collection.get(id); + }, + get isDisposed(): boolean { + return disposed; + }, + preload(): Promise { + assertActive(); + return collection.preload(); + }, + async refetchAuthoritative(): Promise { + assertActive(); + await collection.utils.refetch({ throwOnError: true }); + }, + rows(): readonly T[] { + assertActive(); + return collection.toArray; + }, + subscribe(listener: (rows: readonly T[]) => void): () => void { + assertActive(); + const subscription = collection.subscribeChanges( + () => listener(collection.toArray), + { includeInitialState: true } + ); + return () => subscription.unsubscribe(); + }, + async updateOptimistically( + id: string, + expectedVersion: number, + changes: Partial + ): Promise { + assertActive(); + if (!options.persistUpdates) { + throw new Error( + `Qualification collection ${options.id} has no mutation persistence` + ); + } + const current = collection.get(id); + if (!current || current.version !== expectedVersion) { + throw new QualificationCollectionConflictError( + `Expected ${id} at version ${expectedVersion}` + ); + } + if (changes.id !== undefined && changes.id !== id) { + throw new QualificationCollectionConflictError( + "An optimistic update cannot change its entity id" + ); + } + + const transaction = collection.update(id, (draft) => { + Object.assign(draft, changes); + }); + await transaction.isPersisted.promise; + }, + }; +} diff --git a/qualification/budgets/resourceBudgetCommand.ts b/qualification/budgets/resourceBudgetCommand.ts new file mode 100644 index 000000000..bb811033c --- /dev/null +++ b/qualification/budgets/resourceBudgetCommand.ts @@ -0,0 +1,199 @@ +import path from "node:path"; + +import { + assertResourceBudgetUnitName, + resourceBudgetPolicy, + type ResourceBudgetScenarioId, +} from "./resourceBudgetPolicy.ts"; + +const launcherEnvironmentNames = [ + "DBUS_SESSION_BUS_ADDRESS", + "HOME", + "LANG", + "PATH", + "XDG_RUNTIME_DIR", +] as const; + +export interface ResourceBudgetCommandOptions { + readonly bunExecutable: string; + readonly childEntrypoint: string; + readonly envExecutable: string; + readonly environment: Readonly>; + readonly repositoryRoot: string; + readonly resultPath: string; + readonly scenarioId: ResourceBudgetScenarioId; + readonly systemctlExecutable: string; + readonly systemdRunExecutable: string; + readonly temporaryDirectory: string; + readonly unitName: string; +} + +export interface ResourceBudgetLauncherCommand { + readonly argv: readonly string[]; + readonly environment: Readonly>; + readonly resultPath: string; + readonly scenarioId: ResourceBudgetScenarioId; + readonly systemctlExecutable: string; + readonly unitName: string; +} + +export interface ResourceBudgetWorkloadCommand { + readonly argv: readonly string[]; + readonly environment: Readonly>; +} + +function assertAbsolutePath(label: string, value: string): void { + if (!path.isAbsolute(value) || value.includes("\0")) { + throw new TypeError(`${label} must be an absolute path`); + } +} + +function sanitizedEnvironment( + source: Readonly>, + temporaryDirectory: string +): Readonly> { + const home = source.HOME; + if (!home) throw new Error("HOME is required for resource-budget qualification"); + return Object.freeze({ + CI: "1", + FORCE_COLOR: "0", + HOME: home, + LANG: "C.UTF-8", + NODE_ENV: "test", + NO_COLOR: "1", + PATH: "/usr/local/bin:/usr/bin:/bin", + TMPDIR: temporaryDirectory, + }); +} + +function childEnvironmentArguments( + environment: Readonly> +): string[] { + return Object.entries(environment) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => `${name}=${value}`); +} + +/** + * Builds the exact argv-only command executed by the capped wrapper. + * @param scenarioId Reviewed workload identity. + * @param repositoryRoot Absolute repository root. + * @param bunExecutable Absolute Bun executable. + * @param environment Sanitized workload environment. + * @returns Immutable argv-only workload command. + */ +export function buildResourceBudgetWorkloadCommand( + scenarioId: ResourceBudgetScenarioId, + repositoryRoot: string, + bunExecutable: string, + environment: Readonly> +): ResourceBudgetWorkloadCommand { + assertAbsolutePath("Repository root", repositoryRoot); + assertAbsolutePath("Bun executable", bunExecutable); + const qualification = (...segments: string[]) => + path.join(repositoryRoot, "qualification", ...segments); + const testFiles = [ + qualification("runtimeCandidate.test.ts"), + qualification("resources", "cgroupV2.test.ts"), + qualification("build", "frontendBuildQualification.test.ts"), + qualification("browser", "queryCollectionAdapter.test.ts"), + qualification("openclaw", "sourceAudit.test.ts"), + qualification("chat", "chatBatching.test.ts"), + ]; + const scenarioArguments: Record = { + "chat-batching": [qualification("chat", "runChatBatchingQualification.ts")], + "child-cancellation": [ + qualification("budgets", "runSafeChildCancellationEvidence.ts"), + ], + "complete-shutdown": [ + qualification("shutdown", "runCompleteShutdownEvidence.ts"), + ], + "frontend-build": [qualification("build", "runFrontendBuildQualification.ts")], + "representative-tests": ["test", ...testFiles], + "sqlite-outbox": [qualification("outbox", "runSqliteOutboxEvidence.ts"), "1"], + }; + return Object.freeze({ + argv: Object.freeze([bunExecutable, ...scenarioArguments[scenarioId]]), + environment, + }); +} + +/** + * Builds one transient user-systemd command with explicit cgroup v2 limits. + * @param options Resolved paths, unit identity, and sanitized host environment. + * @returns Immutable launcher command. + */ +export function buildResourceBudgetLauncherCommand( + options: ResourceBudgetCommandOptions +): ResourceBudgetLauncherCommand { + for (const [label, value] of [ + ["Bun executable", options.bunExecutable], + ["Child entrypoint", options.childEntrypoint], + ["env executable", options.envExecutable], + ["Repository root", options.repositoryRoot], + ["Result path", options.resultPath], + ["systemctl executable", options.systemctlExecutable], + ["systemd-run executable", options.systemdRunExecutable], + ["Temporary directory", options.temporaryDirectory], + ] as const) { + assertAbsolutePath(label, value); + } + assertResourceBudgetUnitName(options.unitName); + + const launcherEnvironment = Object.freeze( + Object.fromEntries( + launcherEnvironmentNames.flatMap((name) => { + const value = options.environment[name]; + return value === undefined ? [] : [[name, value]]; + }) + ) + ); + const workloadEnvironment = sanitizedEnvironment( + options.environment, + options.temporaryDirectory + ); + const limits = resourceBudgetPolicy.scenarios[options.scenarioId].limits; + const argv = Object.freeze([ + options.systemdRunExecutable, + "--user", + "--wait", + "--pipe", + "--collect", + "--quiet", + `--unit=${options.unitName}`, + "--slice=app.slice", + "--expand-environment=no", + "--nice=10", + `--working-directory=${options.repositoryRoot}`, + "--property=MemoryAccounting=yes", + "--property=CPUAccounting=yes", + "--property=TasksAccounting=yes", + `--property=MemoryHigh=${limits.memoryHighBytes}`, + `--property=MemoryMax=${limits.memoryMaxBytes}`, + `--property=MemorySwapMax=${limits.memorySwapMaxBytes}`, + `--property=TasksMax=${limits.tasksMax}`, + `--property=CPUQuota=${limits.cpuQuotaPercent}%`, + `--property=RuntimeMaxSec=${limits.runtimeMaxSeconds}s`, + "--property=OOMPolicy=kill", + "--property=KillMode=control-group", + "--property=TimeoutStopSec=5s", + options.envExecutable, + "-i", + ...childEnvironmentArguments(workloadEnvironment), + options.bunExecutable, + options.childEntrypoint, + `--repository=${options.repositoryRoot}`, + `--result=${options.resultPath}`, + `--scenario=${options.scenarioId}`, + `--unit=${options.unitName}`, + ]); + + return Object.freeze({ + argv, + environment: launcherEnvironment, + resultPath: options.resultPath, + scenarioId: options.scenarioId, + systemctlExecutable: options.systemctlExecutable, + unitName: options.unitName, + }); +} diff --git a/qualification/budgets/resourceBudgetOrchestration.test.ts b/qualification/budgets/resourceBudgetOrchestration.test.ts new file mode 100644 index 000000000..46f41d6d0 --- /dev/null +++ b/qualification/budgets/resourceBudgetOrchestration.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; + +import { classifyResourceBudgetUnitCollection } from "./resourceBudgetOrchestration.ts"; + +describe("resource-budget orchestration", () => { + test("accepts only an explicit not-found load state as collected", () => { + expect( + classifyResourceBudgetUnitCollection({ + exitCode: 0, + stderr: "", + stdout: "LoadState=not-found\n", + }) + ).toEqual({ state: "collected" }); + expect( + classifyResourceBudgetUnitCollection({ + exitCode: 0, + stderr: "", + stdout: "LoadState=loaded\n", + }) + ).toEqual({ state: "pending" }); + }); + + test("surfaces systemctl failures instead of treating them as cleanup", () => { + const result = classifyResourceBudgetUnitCollection({ + exitCode: 1, + stderr: "Failed to connect to bus", + stdout: "", + }); + + expect(result.state).toBe("failed"); + if (result.state !== "failed") throw new Error("Expected failed inspection"); + expect(result.error._tag).toBe("ResourceBudgetOrchestrationError"); + expect(result.error.operation).toBe("inspect-unit-collection"); + expect(result.error.cause).toEqual({ + exitCode: 1, + stderr: "Failed to connect to bus", + }); + }); +}); diff --git a/qualification/budgets/resourceBudgetOrchestration.ts b/qualification/budgets/resourceBudgetOrchestration.ts new file mode 100644 index 000000000..0a593d0c5 --- /dev/null +++ b/qualification/budgets/resourceBudgetOrchestration.ts @@ -0,0 +1,497 @@ +import { mkdir, mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { Data, Effect, Schedule, Scope } from "effect"; + +import { + buildResourceBudgetLauncherCommand, + type ResourceBudgetLauncherCommand, +} from "./resourceBudgetCommand.ts"; +import { + assessResourceBudgetEvidence, + createResourceBudgetUnitName, + expectedResourceBudgetCgroupPath, + parseResourceBudgetUnitReport, + resourceBudgetPolicy, + resourceBudgetScenarioIds, + type ResourceBudgetAssessment, + type ResourceBudgetScenarioEvidence, + type ResourceBudgetScenarioId, +} from "./resourceBudgetPolicy.ts"; + +type ManagedProcess = Bun.Subprocess<"ignore", "pipe", "pipe">; +const unitCollectionSchedule = Schedule.spaced("20 millis").pipe( + Schedule.upTo({ times: 100 }) +); + +export class ResourceBudgetOrchestrationError extends Data.TaggedError( + "ResourceBudgetOrchestrationError" +)<{ + readonly cause?: unknown; + readonly operation: string; + readonly scenarioId?: ResourceBudgetScenarioId; +}> {} + +export class ResourceBudgetOrchestrationDeadlineError extends Data.TaggedError( + "ResourceBudgetOrchestrationDeadlineError" +)<{ + readonly operation: string; + readonly scenarioId: ResourceBudgetScenarioId; +}> {} + +class ResourceBudgetUnitPendingError extends Data.TaggedError( + "ResourceBudgetUnitPendingError" +)<{ + readonly operation: string; +}> {} + +interface ProcessResult { + readonly exitCode: number; + readonly stderr: string; + readonly stdout: string; +} + +export type ResourceBudgetUnitCollectionInspection = + | Readonly<{ readonly state: "collected" }> + | Readonly<{ readonly state: "pending" }> + | Readonly<{ + readonly error: ResourceBudgetOrchestrationError; + readonly state: "failed"; + }>; + +interface ResourceBudgetExecutables { + readonly bun: string; + readonly env: string; + readonly systemctl: string; + readonly systemdRun: string; +} + +export interface ResourceBudgetQualificationReport { + readonly assessments: readonly Readonly[]; + readonly bunRevision: string; + readonly bunVersion: string; + readonly ciInvariants: readonly string[]; + readonly hostMeasurementsAreTimingGates: false; + readonly scenarioEvidence: readonly ResourceBudgetScenarioEvidence[]; +} + +function requiredExecutable(name: string): string { + const executable = Bun.which(name); + if (executable === null || !path.isAbsolute(executable)) { + throw new Error(`${name} is required for resource-budget qualification`); + } + return executable; +} + +function temporaryWorkspace() { + return Effect.acquireRelease( + Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetOrchestrationError({ + cause, + operation: "create-temporary-workspace", + }), + try: () => mkdtemp(path.join(tmpdir(), "mira-resource-budget-")), + }), + (directory) => + Effect.tryPromise(() => rm(directory, { force: true, recursive: true })).pipe( + Effect.orDie + ) + ); +} + +function awaitProcessExit(process_: ManagedProcess, operation: string) { + return Effect.tryPromise({ + catch: (cause) => new ResourceBudgetOrchestrationError({ cause, operation }), + try: () => process_.exited, + }); +} + +function stopProcess(process_: ManagedProcess): Effect.Effect { + if (process_.exitCode !== null || process_.signalCode !== null) return Effect.void; + return Effect.sync(() => process_.kill("SIGTERM")).pipe( + Effect.andThen(awaitProcessExit(process_, "stop-subprocess")), + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => + Effect.sync(() => process_.kill("SIGKILL")).pipe( + Effect.andThen(awaitProcessExit(process_, "kill-subprocess")) + ), + }), + Effect.asVoid, + Effect.orDie + ); +} + +function processResource( + argv: readonly string[], + environment: Readonly>, + maximumOutputBytes: number, + operation: string +): Effect.Effect { + return Effect.gen(function* () { + const signal = yield* Effect.abortSignal; + return yield* Effect.acquireRelease( + Effect.try({ + catch: (cause) => + new ResourceBudgetOrchestrationError({ cause, operation }), + try: () => + Bun.spawn([...argv], { + env: environment, + killSignal: "SIGTERM", + maxBuffer: maximumOutputBytes, + signal, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }), + }), + stopProcess + ); + }); +} + +function captureOutput(stream: ReadableStream, operation: string) { + return Effect.tryPromise({ + catch: (cause) => new ResourceBudgetOrchestrationError({ cause, operation }), + try: () => new Response(stream).text(), + }); +} + +function runBoundedProcess( + argv: readonly string[], + environment: Readonly>, + maximumOutputBytes: number, + deadlineMs: number, + operation: string, + scenarioId: ResourceBudgetScenarioId +): Effect.Effect< + ProcessResult, + ResourceBudgetOrchestrationError | ResourceBudgetOrchestrationDeadlineError +> { + return Effect.scoped( + Effect.gen(function* () { + const process_ = yield* processResource( + argv, + environment, + maximumOutputBytes, + operation + ); + const [exitCode, stderr, stdout] = yield* Effect.all( + [ + awaitProcessExit(process_, operation), + captureOutput(process_.stderr, `${operation}:stderr`), + captureOutput(process_.stdout, `${operation}:stdout`), + ] as const, + { concurrency: "unbounded" } + ).pipe( + Effect.timeoutOrElse({ + duration: deadlineMs, + orElse: () => + Effect.fail( + new ResourceBudgetOrchestrationDeadlineError({ + operation, + scenarioId, + }) + ), + }) + ); + return { exitCode, stderr, stdout }; + }) + ); +} + +function systemctl( + command: ResourceBudgetLauncherCommand, + arguments_: readonly string[], + operation: string +) { + return runBoundedProcess( + [ + command.systemctlExecutable, + "--user", + "--no-ask-password", + "--no-pager", + ...arguments_, + ], + command.environment, + 16 * 1024, + 3000, + operation, + command.scenarioId + ); +} + +/** + * Classifies one bounded `systemctl show` result without hiding transport failures. + * @param result Captured systemctl process result. + * @returns Explicit collected, pending, or failed inspection state. + */ +export function classifyResourceBudgetUnitCollection( + result: Readonly +): ResourceBudgetUnitCollectionInspection { + if (result.exitCode !== 0) { + return { + error: new ResourceBudgetOrchestrationError({ + cause: { + exitCode: result.exitCode, + stderr: result.stderr.trim(), + }, + operation: "inspect-unit-collection", + }), + state: "failed", + }; + } + return result.stdout.trim() === "LoadState=not-found" + ? { state: "collected" } + : { state: "pending" }; +} + +function unitIsCollected(command: ResourceBudgetLauncherCommand) { + return systemctl( + command, + ["show", `${command.unitName}.service`, "--property=LoadState"], + "inspect-unit-collection" + ).pipe( + Effect.flatMap( + ( + result + ): Effect.Effect< + boolean, + ResourceBudgetOrchestrationError | ResourceBudgetUnitPendingError + > => { + const inspection = classifyResourceBudgetUnitCollection(result); + switch (inspection.state) { + case "collected": { + return Effect.succeed(true); + } + case "failed": { + return Effect.fail(inspection.error); + } + case "pending": { + return Effect.fail( + new ResourceBudgetUnitPendingError({ + operation: "await-unit-collection", + }) + ); + } + } + } + ), + Effect.retry({ schedule: unitCollectionSchedule }), + Effect.catchTag("ResourceBudgetUnitPendingError", () => Effect.succeed(false)) + ); +} + +function cgroupIsRemoved(cgroupPath: string) { + const filesystemPath = path.join("/sys/fs/cgroup", `.${cgroupPath}`); + const attempt = Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetOrchestrationError({ + cause, + operation: "inspect-cgroup-removal", + }), + try: async () => { + try { + await stat(filesystemPath); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + }, + }).pipe( + Effect.flatMap((removed) => + removed + ? Effect.succeed(true) + : Effect.fail( + new ResourceBudgetUnitPendingError({ + operation: "await-cgroup-removal", + }) + ) + ) + ); + return attempt.pipe( + Effect.retry({ schedule: unitCollectionSchedule }), + Effect.catchTag("ResourceBudgetUnitPendingError", () => Effect.succeed(false)) + ); +} + +function cleanupTransientUnit(command: ResourceBudgetLauncherCommand) { + const unit = `${command.unitName}.service`; + return Effect.gen(function* () { + const stopped = yield* systemctl(command, ["stop", unit], "stop-unit"); + if (stopped.exitCode !== 0) { + yield* systemctl( + command, + ["kill", "--kill-whom=all", "--signal=SIGKILL", unit], + "kill-unit" + ); + yield* systemctl(command, ["stop", unit], "stop-killed-unit"); + } + yield* systemctl(command, ["reset-failed", unit], "reset-failed-unit"); + const collected = yield* unitIsCollected(command); + if (!collected) { + return yield* Effect.fail( + new ResourceBudgetOrchestrationError({ + operation: "collect-transient-unit", + scenarioId: command.scenarioId, + }) + ); + } + }).pipe(Effect.orDie); +} + +function transientUnitResource(command: ResourceBudgetLauncherCommand) { + return Effect.acquireRelease(Effect.succeed(command), cleanupTransientUnit); +} + +function readResult(command: ResourceBudgetLauncherCommand) { + return Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetOrchestrationError({ + cause, + operation: "read-unit-report", + scenarioId: command.scenarioId, + }), + try: async () => { + const resultFile = Bun.file(command.resultPath); + if (!(await resultFile.exists())) { + throw new Error("Resource-budget unit did not write a report"); + } + if (resultFile.size > resourceBudgetPolicy.resultMaxBytes) { + throw new Error("Resource-budget unit report exceeds its bound"); + } + return parseResourceBudgetUnitReport(await resultFile.text()); + }, + }); +} + +function runScenario( + scenarioId: ResourceBudgetScenarioId, + workspace: string, + executables: ResourceBudgetExecutables, + userId: number +) { + const unitName = createResourceBudgetUnitName(scenarioId); + const scenarioDirectory = path.join(workspace, scenarioId); + const temporaryDirectory = path.join(scenarioDirectory, "tmp"); + const resultPath = path.join(scenarioDirectory, "result.json"); + const command = buildResourceBudgetLauncherCommand({ + bunExecutable: executables.bun, + childEntrypoint: path.join(import.meta.dir, "resourceBudgetUnit.ts"), + envExecutable: executables.env, + environment: process.env, + repositoryRoot: path.resolve(import.meta.dir, "../.."), + resultPath, + scenarioId, + systemctlExecutable: executables.systemctl, + systemdRunExecutable: executables.systemdRun, + temporaryDirectory, + unitName, + }); + const cgroupPath = expectedResourceBudgetCgroupPath(userId, unitName); + + return Effect.gen(function* () { + yield* Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetOrchestrationError({ + cause, + operation: "create-scenario-directory", + scenarioId, + }), + try: () => mkdir(temporaryDirectory, { recursive: true }), + }); + const completed = yield* Effect.scoped( + Effect.gen(function* () { + yield* transientUnitResource(command); + const limits = resourceBudgetPolicy.scenarios[scenarioId].limits; + const launcher = yield* runBoundedProcess( + command.argv, + command.environment, + resourceBudgetPolicy.launcherOutputMaxBytes, + limits.outerDeadlineMs, + "run-transient-unit", + scenarioId + ); + const report = yield* readResult(command); + return { launcher, report }; + }) + ); + const [unitCollected, cgroupRemoved] = yield* Effect.all( + [unitIsCollected(command), cgroupIsRemoved(cgroupPath)] as const, + { concurrency: "unbounded" } + ); + const evidence: ResourceBudgetScenarioEvidence = { + cgroupRemoved, + launcherExitCode: completed.launcher.exitCode, + report: completed.report, + unitCollected, + }; + if (completed.launcher.exitCode !== 0) { + const diagnostic = [ + completed.launcher.stderr.trim(), + completed.launcher.stdout.trim(), + ] + .filter((value) => value.length > 0) + .join("\n") + .slice(0, 16 * 1024); + return yield* Effect.fail( + new ResourceBudgetOrchestrationError({ + cause: diagnostic, + operation: "transient-unit-exit", + scenarioId, + }) + ); + } + return evidence; + }); +} + +/** Executes all representative workloads sequentially under reviewed cgroup limits. */ +export const resourceBudgetQualification: Effect.Effect< + ResourceBudgetQualificationReport, + ResourceBudgetOrchestrationError | ResourceBudgetOrchestrationDeadlineError +> = Effect.scoped( + Effect.gen(function* () { + if (process.getuid === undefined) { + return yield* Effect.fail( + new ResourceBudgetOrchestrationError({ + operation: "read-current-user-id", + }) + ); + } + const workspace = yield* temporaryWorkspace(); + const executables: ResourceBudgetExecutables = { + bun: process.execPath, + env: requiredExecutable("env"), + systemctl: requiredExecutable("systemctl"), + systemdRun: requiredExecutable("systemd-run"), + }; + const userId = process.getuid(); + const scenarioEvidence = yield* Effect.forEach( + resourceBudgetScenarioIds, + (scenarioId) => runScenario(scenarioId, workspace, executables, userId), + { concurrency: 1 } + ); + const assessments = scenarioEvidence.map((evidence) => + assessResourceBudgetEvidence(evidence, userId) + ); + return Object.freeze({ + assessments: Object.freeze(assessments), + bunRevision: Bun.revision, + bunVersion: Bun.version, + ciInvariants: Object.freeze([ + "exact transient-unit controller limits", + "zero OOM/high/max events", + "zero workload exit status and no signal", + "memory peak below memory.high", + "no child process or transient-unit leakage", + "bounded output and Effect-owned deadlines", + ]), + hostMeasurementsAreTimingGates: false as const, + scenarioEvidence: Object.freeze(scenarioEvidence), + }); + }) +); diff --git a/qualification/budgets/resourceBudgetPolicy.test.ts b/qualification/budgets/resourceBudgetPolicy.test.ts new file mode 100644 index 000000000..a0aabf696 --- /dev/null +++ b/qualification/budgets/resourceBudgetPolicy.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildResourceBudgetLauncherCommand, + buildResourceBudgetWorkloadCommand, +} from "./resourceBudgetCommand.ts"; +import { + assessResourceBudgetEvidence, + createResourceBudgetUnitName, + expectedResourceBudgetCgroupPath, + parseResourceBudgetUnitReport, + resourceBudgetPolicy, + resourceBudgetScenarioIds, + type ResourceBudgetScenarioEvidence, +} from "./resourceBudgetPolicy.ts"; + +const userId = 1001; +const wrapperProcessId = 4242; +const unitName = createResourceBudgetUnitName( + "chat-batching", + "00000000-0000-4000-8000-000000000001" +); + +function validEvidence() { + const events = { + high: 0, + low: 0, + max: 0, + oom: 0, + oomGroupKill: 0, + oomKill: 0, + }; + return { + cgroupRemoved: true as boolean, + launcherExitCode: 0, + report: { + cgroup: { + final: { + cpuNrThrottled: 2, + cpuPressure: { fullTotalMicros: 1, someTotalMicros: 8 }, + cpuThrottledMicros: 20, + cpuUsageMicros: 80_000, + memoryCurrentBytes: 32 * 1024 * 1024, + memoryEvents: { ...events }, + memoryPressure: { fullTotalMicros: 1, someTotalMicros: 6 }, + pidsCurrent: 1, + }, + finalProcessIds: [wrapperProcessId], + initial: { + cpuNrThrottled: 0, + cpuPressure: { fullTotalMicros: 0, someTotalMicros: 3 }, + cpuThrottledMicros: 0, + cpuUsageMicros: 10_000, + memoryCurrentBytes: 24 * 1024 * 1024, + memoryEvents: { ...events }, + memoryPressure: { fullTotalMicros: 0, someTotalMicros: 2 }, + pidsCurrent: 1, + }, + memoryPeakBytes: 64 * 1024 * 1024, + path: expectedResourceBudgetCgroupPath(userId, unitName), + pidsPeak: 8, + }, + formatVersion: 1, + limits: { + cpuPeriodMicros: 100_000, + cpuQuotaMicros: 100_000, + memoryHighBytes: 128 * 1024 * 1024, + memoryMaxBytes: 192 * 1024 * 1024, + memorySwapMaxBytes: 0, + oomGroup: true, + pidsMax: 64, + }, + runtime: { + bunRevision: "0".repeat(40), + bunVersion: "1.4.0", + }, + scenarioId: "chat-batching", + unitName, + workload: { + durationMs: 1200, + exitCode: 0, + signalCode: null, + stderrBytes: 0, + stdoutBytes: 2048, + }, + wrapperProcessId, + }, + unitCollected: true as boolean, + } satisfies ResourceBudgetScenarioEvidence; +} + +describe("resource-budget policy", () => { + test("freezes one reviewed explicit limit profile for every representative workload", () => { + expect(Object.keys(resourceBudgetPolicy.scenarios).toSorted()).toEqual( + [...resourceBudgetScenarioIds].toSorted() + ); + expect(Object.isFrozen(resourceBudgetPolicy)).toBeTrue(); + expect(Object.isFrozen(resourceBudgetPolicy.scenarios)).toBeTrue(); + for (const scenario of Object.values(resourceBudgetPolicy.scenarios)) { + expect(Object.isFrozen(scenario)).toBeTrue(); + expect(Object.isFrozen(scenario.limits)).toBeTrue(); + expect(scenario.limits.memoryHighBytes).toBeLessThan( + scenario.limits.memoryMaxBytes + ); + expect(scenario.limits.workloadDeadlineMs).toBeLessThan( + scenario.limits.runtimeMaxSeconds * 1000 + ); + expect(scenario.limits.runtimeMaxSeconds * 1000).toBeLessThan( + scenario.limits.outerDeadlineMs + ); + expect(scenario.limits.memorySwapMaxBytes).toBe(0); + } + }); + + test("builds an argv-only transient unit with no inherited application secrets", () => { + const command = buildResourceBudgetLauncherCommand({ + bunExecutable: "/home/test/.bun/bin/bun", + childEntrypoint: "/repo/qualification/budgets/resourceBudgetUnit.ts", + envExecutable: "/usr/bin/env", + environment: { + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1001/bus", + HOME: "/home/test", + MIRA_SECRET: "must-not-leak", + OPENCLAW_GATEWAY_TOKEN: "must-not-leak", + PATH: "/untrusted/bin", + PUBLIC_AMBIENT: "must-not-leak", + XDG_RUNTIME_DIR: "/run/user/1001", + }, + repositoryRoot: "/repo", + resultPath: "/tmp/result.json", + scenarioId: "chat-batching", + systemctlExecutable: "/usr/bin/systemctl", + systemdRunExecutable: "/usr/bin/systemd-run", + temporaryDirectory: "/tmp/workload", + unitName, + }); + + expect(command.argv).toContain("--collect"); + expect(command.argv).toContain("--property=MemoryHigh=134217728"); + expect(command.argv).toContain("--property=MemoryMax=201326592"); + expect(command.argv).toContain("--property=MemorySwapMax=0"); + expect(command.argv).toContain("--property=TasksMax=64"); + expect(command.argv).toContain("--property=CPUQuota=100%"); + expect(command.argv).toContain("--property=OOMPolicy=kill"); + expect(command.argv).toContain("-i"); + expect(command.argv.join(" ")).not.toContain("must-not-leak"); + expect(command.environment).toEqual({ + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1001/bus", + HOME: "/home/test", + PATH: "/untrusted/bin", + XDG_RUNTIME_DIR: "/run/user/1001", + }); + }); + + test("maps every scenario to a bounded first-party qualification command", () => { + const environment = Object.freeze({ HOME: "/home/test" }); + for (const scenarioId of resourceBudgetScenarioIds) { + const command = buildResourceBudgetWorkloadCommand( + scenarioId, + "/repo", + "/home/test/.bun/bin/bun", + environment + ); + expect(command.argv[0]).toBe("/home/test/.bun/bin/bun"); + expect(command.argv.join(" ")).toContain("/repo/qualification/"); + expect(command.environment).toBe(environment); + } + }); + + test("accepts bounded evidence and returns host measurements without timing gates", () => { + const evidence = validEvidence(); + expect(parseResourceBudgetUnitReport(JSON.stringify(evidence.report))).toEqual( + evidence.report + ); + expect(assessResourceBudgetEvidence(evidence, userId)).toEqual({ + cpuPressureMicros: 5, + cpuThrottledMicros: 20, + cpuUsageMicros: 70_000, + durationMs: 1200, + memoryHeadroomBytes: 64 * 1024 * 1024, + memoryPeakBytes: 64 * 1024 * 1024, + memoryPressureMicros: 4, + pidsPeak: 8, + scenarioId: "chat-batching", + }); + }); + + test("rejects pressure events, cap crossings, failures, and leaked resources", () => { + const cases: Array< + [string, (candidate: ReturnType) => void] + > = [ + [ + "memory.events oomKill", + (candidate) => { + candidate.report.cgroup.final.memoryEvents.oomKill = 1; + }, + ], + [ + "crossed memory.high", + (candidate) => { + candidate.report.cgroup.memoryPeakBytes = 128 * 1024 * 1024; + }, + ], + [ + "workload did not exit zero", + (candidate) => { + candidate.report.workload.exitCode = 1; + }, + ], + [ + "leaked processes", + (candidate) => { + candidate.report.cgroup.finalProcessIds.push(9999); + }, + ], + [ + "unit was not collected", + (candidate) => { + candidate.cgroupRemoved = false; + }, + ], + ]; + for (const [message, mutate] of cases) { + const candidate = structuredClone(validEvidence()); + mutate(candidate); + expect(() => assessResourceBudgetEvidence(candidate, userId)).toThrow( + message + ); + } + }); + + test("rejects malformed reports and attacker-controlled unit names", () => { + expect(() => + parseResourceBudgetUnitReport( + JSON.stringify({ ...validEvidence().report, unexpected: true }) + ) + ).toThrow(); + expect(() => + createResourceBudgetUnitName("chat-batching", "../attacker") + ).toThrow("unit identifier"); + }); +}); diff --git a/qualification/budgets/resourceBudgetPolicy.ts b/qualification/budgets/resourceBudgetPolicy.ts new file mode 100644 index 000000000..8dd7093ce --- /dev/null +++ b/qualification/budgets/resourceBudgetPolicy.ts @@ -0,0 +1,404 @@ +import path from "node:path"; + +import * as v from "valibot"; + +const mebibyte = 1024 * 1024; +const resourceBudgetUnitIdentifierPattern = + /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu; +const resourceBudgetUnitNamePattern = + /^mira-dashboard-resource-(?:frontend-build|representative-tests|sqlite-outbox|chat-batching|complete-shutdown|child-cancellation)-[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu; + +export const resourceBudgetScenarioIds = [ + "frontend-build", + "representative-tests", + "sqlite-outbox", + "chat-batching", + "complete-shutdown", + "child-cancellation", +] as const; + +export type ResourceBudgetScenarioId = (typeof resourceBudgetScenarioIds)[number]; + +export interface ResourceBudgetLimits { + readonly cpuQuotaPercent: number; + readonly memoryHighBytes: number; + readonly memoryMaxBytes: number; + readonly memorySwapMaxBytes: number; + readonly outerDeadlineMs: number; + readonly runtimeMaxSeconds: number; + readonly tasksMax: number; + readonly workloadDeadlineMs: number; +} + +export interface ResourceBudgetScenarioPolicy { + readonly description: string; + readonly limits: Readonly; +} + +const sharedSmallWorkloadLimits = Object.freeze({ + cpuQuotaPercent: 100, + memorySwapMaxBytes: 0, + outerDeadlineMs: 75_000, + runtimeMaxSeconds: 60, + tasksMax: 64, + workloadDeadlineMs: 50_000, +}); + +function frozenScenario( + description: string, + limits: ResourceBudgetLimits +): Readonly { + return Object.freeze({ description, limits: Object.freeze(limits) }); +} + +const resourceBudgetScenarios = Object.freeze({ + "chat-batching": frozenScenario("OpenClaw-shaped deterministic chat batching", { + ...sharedSmallWorkloadLimits, + memoryHighBytes: 128 * mebibyte, + memoryMaxBytes: 192 * mebibyte, + }), + "child-cancellation": frozenScenario( + "Effect interruption and detached process-group cleanup", + { + ...sharedSmallWorkloadLimits, + memoryHighBytes: 192 * mebibyte, + memoryMaxBytes: 256 * mebibyte, + } + ), + "complete-shutdown": frozenScenario( + "Two-generation complete shutdown and WAL recovery", + { + ...sharedSmallWorkloadLimits, + memoryHighBytes: 256 * mebibyte, + memoryMaxBytes: 384 * mebibyte, + } + ), + "frontend-build": frozenScenario( + "Production frontend build with hashes and compression", + { + cpuQuotaPercent: 200, + memoryHighBytes: 768 * mebibyte, + memoryMaxBytes: 1024 * mebibyte, + memorySwapMaxBytes: 0, + outerDeadlineMs: 195_000, + runtimeMaxSeconds: 180, + tasksMax: 96, + workloadDeadlineMs: 165_000, + } + ), + "representative-tests": frozenScenario( + "Bounded representative Phase 0 qualification tests", + { + cpuQuotaPercent: 200, + memoryHighBytes: 768 * mebibyte, + memoryMaxBytes: 1024 * mebibyte, + memorySwapMaxBytes: 0, + outerDeadlineMs: 195_000, + runtimeMaxSeconds: 180, + tasksMax: 96, + workloadDeadlineMs: 165_000, + } + ), + "sqlite-outbox": frozenScenario( + "Multi-process SQLite outbox, crash recovery, and restore", + { + ...sharedSmallWorkloadLimits, + memoryHighBytes: 256 * mebibyte, + memoryMaxBytes: 384 * mebibyte, + } + ), +} satisfies Record); + +/** Reviewed host-measurement caps. CI validates policy mechanics without timing gates. */ +export const resourceBudgetPolicy = Object.freeze({ + childOutputMaxBytes: 512 * 1024, + formatVersion: 1 as const, + launcherOutputMaxBytes: 64 * 1024, + resultMaxBytes: 64 * 1024, + scenarios: resourceBudgetScenarios, +}); + +const nonnegativeIntegerSchema = v.pipe(v.number(), v.safeInteger(), v.minValue(0)); +const positiveIntegerSchema = v.pipe(v.number(), v.safeInteger(), v.minValue(1)); +const memoryEventsSchema = v.strictObject({ + high: nonnegativeIntegerSchema, + low: nonnegativeIntegerSchema, + max: nonnegativeIntegerSchema, + oom: nonnegativeIntegerSchema, + oomGroupKill: nonnegativeIntegerSchema, + oomKill: nonnegativeIntegerSchema, +}); +const pressureSchema = v.strictObject({ + fullTotalMicros: nonnegativeIntegerSchema, + someTotalMicros: nonnegativeIntegerSchema, +}); +const resourceSnapshotSchema = v.strictObject({ + cpuNrThrottled: nonnegativeIntegerSchema, + cpuPressure: pressureSchema, + cpuThrottledMicros: nonnegativeIntegerSchema, + cpuUsageMicros: nonnegativeIntegerSchema, + memoryCurrentBytes: nonnegativeIntegerSchema, + memoryEvents: memoryEventsSchema, + memoryPressure: pressureSchema, + pidsCurrent: nonnegativeIntegerSchema, +}); +const resourceBudgetScenarioSchema = v.picklist(resourceBudgetScenarioIds); +const cgroupPathSchema = v.pipe(v.string(), v.startsWith("/")); +const bunRevisionSchema = v.pipe(v.string(), v.regex(/^[\da-f]{40}$/u)); +const bunVersionSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(64)); +const signalCodeSchema = v.pipe(v.string(), v.maxLength(32)); +const cgroupReportSchema = v.strictObject({ + final: resourceSnapshotSchema, + finalProcessIds: v.array(positiveIntegerSchema), + initial: resourceSnapshotSchema, + memoryPeakBytes: nonnegativeIntegerSchema, + path: cgroupPathSchema, + pidsPeak: nonnegativeIntegerSchema, +}); +const observedLimitsSchema = v.strictObject({ + cpuPeriodMicros: positiveIntegerSchema, + cpuQuotaMicros: positiveIntegerSchema, + memoryHighBytes: positiveIntegerSchema, + memoryMaxBytes: positiveIntegerSchema, + memorySwapMaxBytes: nonnegativeIntegerSchema, + oomGroup: v.literal(true), + pidsMax: positiveIntegerSchema, +}); +const runtimeIdentitySchema = v.strictObject({ + bunRevision: bunRevisionSchema, + bunVersion: bunVersionSchema, +}); +const workloadResultSchema = v.strictObject({ + durationMs: nonnegativeIntegerSchema, + exitCode: v.nullable(nonnegativeIntegerSchema), + signalCode: v.nullable(signalCodeSchema), + stderrBytes: nonnegativeIntegerSchema, + stdoutBytes: nonnegativeIntegerSchema, +}); + +export const resourceBudgetUnitReportSchema = v.strictObject({ + cgroup: cgroupReportSchema, + formatVersion: v.literal(resourceBudgetPolicy.formatVersion), + limits: observedLimitsSchema, + runtime: runtimeIdentitySchema, + scenarioId: resourceBudgetScenarioSchema, + unitName: v.pipe(v.string(), v.regex(resourceBudgetUnitNamePattern)), + workload: workloadResultSchema, + wrapperProcessId: positiveIntegerSchema, +}); + +export type ResourceBudgetUnitReport = v.InferOutput< + typeof resourceBudgetUnitReportSchema +>; + +export interface ResourceBudgetScenarioEvidence { + readonly cgroupRemoved: boolean; + readonly launcherExitCode: number; + readonly report: ResourceBudgetUnitReport; + readonly unitCollected: boolean; +} + +export interface ResourceBudgetAssessment { + readonly cpuPressureMicros: number; + readonly cpuThrottledMicros: number; + readonly cpuUsageMicros: number; + readonly durationMs: number; + readonly memoryHeadroomBytes: number; + readonly memoryPeakBytes: number; + readonly memoryPressureMicros: number; + readonly pidsPeak: number; + readonly scenarioId: ResourceBudgetScenarioId; +} + +/** + * Creates a unique, non-user-controlled transient service name. + * @param scenarioId Reviewed workload identity. + * @param identifier UUID-compatible random identifier. + * @returns Valid transient service name without the `.service` suffix. + */ +export function createResourceBudgetUnitName( + scenarioId: ResourceBudgetScenarioId, + identifier: string = crypto.randomUUID() +): string { + if (!resourceBudgetUnitIdentifierPattern.test(identifier)) { + throw new TypeError("Resource-budget unit identifier is invalid"); + } + return `mira-dashboard-resource-${scenarioId}-${identifier}`; +} + +/** + * Rejects unit names outside the exact qualification grammar. + * @param unitName Candidate transient service name. + */ +export function assertResourceBudgetUnitName(unitName: string): void { + if (!resourceBudgetUnitNamePattern.test(unitName)) { + throw new TypeError("Resource-budget unit name is invalid"); + } +} + +/** + * Returns the exact app.slice cgroup created by the user systemd manager. + * @param userId POSIX user-manager identity. + * @param unitName Validated transient service name. + * @returns Absolute unified cgroup path. + */ +export function expectedResourceBudgetCgroupPath( + userId: number, + unitName: string +): string { + assertResourceBudgetUnitName(unitName); + if (!Number.isSafeInteger(userId) || userId < 0) { + throw new TypeError("Resource-budget user ID is invalid"); + } + return path.posix.join( + "/user.slice", + `user-${userId}.slice`, + `user@${userId}.service`, + "app.slice", + `${unitName}.service` + ); +} + +/** + * Parses the bounded unit report written by the capped child. + * @param value Raw JSON report. + * @returns Strict validated unit report. + */ +export function parseResourceBudgetUnitReport(value: string): ResourceBudgetUnitReport { + let candidate: unknown; + try { + candidate = JSON.parse(value); + } catch (error) { + throw new Error("Resource-budget unit report is not valid JSON", { + cause: error, + }); + } + return v.parse(resourceBudgetUnitReportSchema, candidate); +} + +function delta(label: string, finalValue: number, initialValue: number): number { + if (finalValue < initialValue) { + throw new Error(`Resource-budget ${label} counter moved backwards`); + } + return finalValue - initialValue; +} + +function assertObservedLimits( + report: ResourceBudgetUnitReport, + expected: Readonly +): void { + const observed = report.limits; + if ( + observed.memoryHighBytes !== expected.memoryHighBytes || + observed.memoryMaxBytes !== expected.memoryMaxBytes || + observed.memorySwapMaxBytes !== expected.memorySwapMaxBytes || + observed.pidsMax !== expected.tasksMax || + observed.cpuQuotaMicros * 100 !== + observed.cpuPeriodMicros * expected.cpuQuotaPercent || + !observed.oomGroup + ) { + throw new Error( + `Resource-budget ${report.scenarioId} limits do not match policy` + ); + } +} + +/** + * Applies deterministic acceptance invariants to one host-measured scenario. + * CPU and pressure totals are retained as evidence, not flaky CI thresholds. + * @param evidence Unit report plus post-run cleanup observations. + * @param userId POSIX user-manager identity used to derive the exact cgroup. + * @returns Non-gating host measurements for the accepted scenario. + */ +export function assessResourceBudgetEvidence( + evidence: Readonly, + userId: number +): Readonly { + const { report } = evidence; + const scenario = resourceBudgetPolicy.scenarios[report.scenarioId]; + assertObservedLimits(report, scenario.limits); + + const expectedCgroupPath = expectedResourceBudgetCgroupPath(userId, report.unitName); + if (report.cgroup.path !== expectedCgroupPath) { + throw new Error( + `Resource-budget ${report.scenarioId} ran in ${report.cgroup.path}; expected ${expectedCgroupPath}` + ); + } + if (evidence.launcherExitCode !== 0 || report.workload.exitCode !== 0) { + throw new Error( + `Resource-budget ${report.scenarioId} workload did not exit zero` + ); + } + if (report.workload.signalCode !== null) { + throw new Error(`Resource-budget ${report.scenarioId} workload was signalled`); + } + if (report.workload.durationMs > scenario.limits.workloadDeadlineMs) { + throw new Error(`Resource-budget ${report.scenarioId} exceeded its deadline`); + } + if ( + report.workload.stdoutBytes > resourceBudgetPolicy.childOutputMaxBytes || + report.workload.stderrBytes > resourceBudgetPolicy.childOutputMaxBytes + ) { + throw new Error(`Resource-budget ${report.scenarioId} exceeded its output bound`); + } + if (report.cgroup.memoryPeakBytes >= scenario.limits.memoryHighBytes) { + throw new Error(`Resource-budget ${report.scenarioId} crossed memory.high`); + } + if (report.cgroup.pidsPeak > scenario.limits.tasksMax) { + throw new Error(`Resource-budget ${report.scenarioId} crossed TasksMax`); + } + if ( + report.cgroup.finalProcessIds.length !== 1 || + report.cgroup.finalProcessIds[0] !== report.wrapperProcessId + ) { + throw new Error(`Resource-budget ${report.scenarioId} leaked processes`); + } + if (!evidence.unitCollected || !evidence.cgroupRemoved) { + throw new Error(`Resource-budget ${report.scenarioId} unit was not collected`); + } + + const initialEvents = report.cgroup.initial.memoryEvents; + const finalEvents = report.cgroup.final.memoryEvents; + for (const eventName of ["high", "max", "oom", "oomKill", "oomGroupKill"] as const) { + if ( + delta( + `memory.events ${eventName}`, + finalEvents[eventName], + initialEvents[eventName] + ) !== 0 + ) { + throw new Error( + `Resource-budget ${report.scenarioId} observed memory.events ${eventName}` + ); + } + } + + return Object.freeze({ + cpuPressureMicros: delta( + "cpu pressure", + report.cgroup.final.cpuPressure.someTotalMicros, + report.cgroup.initial.cpuPressure.someTotalMicros + ), + cpuThrottledMicros: delta( + "CPU throttling", + report.cgroup.final.cpuThrottledMicros, + report.cgroup.initial.cpuThrottledMicros + ), + cpuUsageMicros: delta( + "CPU usage", + report.cgroup.final.cpuUsageMicros, + report.cgroup.initial.cpuUsageMicros + ), + durationMs: report.workload.durationMs, + memoryHeadroomBytes: + scenario.limits.memoryHighBytes - report.cgroup.memoryPeakBytes, + memoryPeakBytes: report.cgroup.memoryPeakBytes, + memoryPressureMicros: delta( + "memory pressure", + report.cgroup.final.memoryPressure.someTotalMicros, + report.cgroup.initial.memoryPressure.someTotalMicros + ), + pidsPeak: report.cgroup.pidsPeak, + scenarioId: report.scenarioId, + }); +} diff --git a/qualification/budgets/resourceBudgetUnit.ts b/qualification/budgets/resourceBudgetUnit.ts new file mode 100644 index 000000000..aa1e6733b --- /dev/null +++ b/qualification/budgets/resourceBudgetUnit.ts @@ -0,0 +1,463 @@ +import { rename } from "node:fs/promises"; +import path from "node:path"; + +import { Clock, Data, Effect, Scope } from "effect"; + +import { + readCgroupV2ControlFile, + readCurrentCgroupV2Snapshot, + type CgroupV2MemoryEvents, +} from "../resources/cgroupV2.ts"; +import { + buildResourceBudgetWorkloadCommand, + type ResourceBudgetWorkloadCommand, +} from "./resourceBudgetCommand.ts"; +import { + assertResourceBudgetUnitName, + expectedResourceBudgetCgroupPath, + resourceBudgetPolicy, + resourceBudgetScenarioIds, + type ResourceBudgetScenarioId, + type ResourceBudgetUnitReport, +} from "./resourceBudgetPolicy.ts"; + +const nanosecondsPerMillisecond = 1_000_000n; +type WorkloadProcess = Bun.Subprocess<"ignore", "pipe", "pipe">; + +export class ResourceBudgetUnitError extends Data.TaggedError("ResourceBudgetUnitError")<{ + readonly cause?: unknown; + readonly operation: string; +}> {} + +export class ResourceBudgetUnitDeadlineError extends Data.TaggedError( + "ResourceBudgetUnitDeadlineError" +)<{ + readonly scenarioId: ResourceBudgetScenarioId; +}> {} + +interface ResourceBudgetUnitArguments { + readonly repositoryRoot: string; + readonly resultPath: string; + readonly scenarioId: ResourceBudgetScenarioId; + readonly unitName: string; +} + +interface PressureTotals { + readonly fullTotalMicros: number; + readonly someTotalMicros: number; +} + +interface ResourceSnapshot { + readonly cpuNrThrottled: number; + readonly cpuPressure: PressureTotals; + readonly cpuThrottledMicros: number; + readonly cpuUsageMicros: number; + readonly memoryCurrentBytes: number; + readonly memoryEvents: Readonly; + readonly memoryPressure: PressureTotals; + readonly pidsCurrent: number; +} + +function parseArguments(arguments_: readonly string[]): ResourceBudgetUnitArguments { + if (arguments_.length !== 4) { + throw new TypeError("Resource-budget unit requires exactly four arguments"); + } + const values = Object.fromEntries( + arguments_.map((argument) => { + const separator = argument.indexOf("="); + if (!argument.startsWith("--") || separator <= 2) { + throw new TypeError("Resource-budget unit argument is malformed"); + } + return [argument.slice(2, separator), argument.slice(separator + 1)]; + }) + ); + if (Object.keys(values).length !== 4) { + throw new TypeError("Resource-budget unit arguments contain duplicates"); + } + const repositoryRoot = values.repository ?? ""; + const resultPath = values.result ?? ""; + const scenarioId = values.scenario ?? ""; + const unitName = values.unit ?? ""; + if ( + !path.isAbsolute(repositoryRoot) || + !path.isAbsolute(resultPath) || + repositoryRoot.includes("\0") || + resultPath.includes("\0") || + !resourceBudgetScenarioIds.includes(scenarioId as ResourceBudgetScenarioId) + ) { + throw new TypeError("Resource-budget unit arguments are invalid"); + } + assertResourceBudgetUnitName(unitName); + return { + repositoryRoot, + resultPath, + scenarioId: scenarioId as ResourceBudgetScenarioId, + unitName, + }; +} + +function parseNamedCounters( + value: string, + requiredNames: readonly string[] +): Map { + const counters = new Map(); + for (const line of value.split(/\r?\n/u)) { + const normalized = line.trim(); + if (normalized.length === 0) continue; + const [name, rawCounter, ...extra] = normalized.split(/\s+/u); + if ( + name === undefined || + rawCounter === undefined || + extra.length > 0 || + !/^\d+$/u.test(rawCounter) || + counters.has(name) + ) { + throw new Error("Malformed cgroup counter file"); + } + const counter = Number(rawCounter); + if (!Number.isSafeInteger(counter)) { + throw new TypeError("Cgroup counter exceeds the safe integer range"); + } + counters.set(name, counter); + } + for (const name of requiredNames) { + if (!counters.has(name)) throw new Error(`Missing cgroup counter ${name}`); + } + return counters; +} + +function parsePressure(value: string): PressureTotals { + const totals = new Map(); + for (const line of value.split(/\r?\n/u)) { + const normalized = line.trim(); + if (normalized.length === 0) continue; + const [kind, ...fields] = normalized.split(/\s+/u); + if ((kind !== "some" && kind !== "full") || totals.has(kind)) { + throw new Error("Malformed cgroup pressure file"); + } + const total = fields.find((field) => field.startsWith("total=")); + const rawTotal = total?.slice("total=".length) ?? ""; + if (!/^\d+$/u.test(rawTotal)) { + throw new Error("Malformed cgroup pressure total"); + } + const parsed = Number(rawTotal); + if (!Number.isSafeInteger(parsed)) { + throw new TypeError("Cgroup pressure total exceeds the safe integer range"); + } + totals.set(kind, parsed); + } + const fullTotalMicros = totals.get("full"); + const someTotalMicros = totals.get("some"); + if (fullTotalMicros === undefined || someTotalMicros === undefined) { + throw new Error("Incomplete cgroup pressure file"); + } + return { fullTotalMicros, someTotalMicros }; +} + +function readResourceSnapshot(): Effect.Effect< + ResourceSnapshot, + ResourceBudgetUnitError +> { + return Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetUnitError({ cause, operation: "read-cgroup-snapshot" }), + try: async () => { + const cgroup = await readCurrentCgroupV2Snapshot(); + const [cpuStatText, cpuPressureText, memoryPressureText] = await Promise.all([ + readCgroupV2ControlFile(cgroup.path, "cpu.stat"), + readCgroupV2ControlFile(cgroup.path, "cpu.pressure"), + readCgroupV2ControlFile(cgroup.path, "memory.pressure"), + ]); + const cpu = parseNamedCounters(cpuStatText, [ + "usage_usec", + "nr_throttled", + "throttled_usec", + ]); + return { + cpuNrThrottled: cpu.get("nr_throttled")!, + cpuPressure: parsePressure(cpuPressureText), + cpuThrottledMicros: cpu.get("throttled_usec")!, + cpuUsageMicros: cpu.get("usage_usec")!, + memoryCurrentBytes: cgroup.memoryCurrentBytes, + memoryEvents: cgroup.memoryEvents, + memoryPressure: parsePressure(memoryPressureText), + pidsCurrent: cgroup.pidsCurrent, + }; + }, + }); +} + +function awaitProcessExit( + process_: WorkloadProcess +): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetUnitError({ cause, operation: "await-workload-exit" }), + try: () => process_.exited, + }); +} + +function stopProcess(process_: WorkloadProcess): Effect.Effect { + if (process_.exitCode !== null || process_.signalCode !== null) return Effect.void; + const graceful = Effect.sync(() => process_.kill("SIGTERM")).pipe( + Effect.andThen(awaitProcessExit(process_)), + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => + Effect.sync(() => process_.kill("SIGKILL")).pipe( + Effect.andThen(awaitProcessExit(process_)) + ), + }) + ); + return graceful.pipe(Effect.asVoid, Effect.orDie); +} + +function workloadProcessResource( + command: ResourceBudgetWorkloadCommand, + repositoryRoot: string +): Effect.Effect { + return Effect.gen(function* () { + const signal = yield* Effect.abortSignal; + return yield* Effect.acquireRelease( + Effect.try({ + catch: (cause) => + new ResourceBudgetUnitError({ + cause, + operation: "spawn-workload", + }), + try: () => + Bun.spawn([...command.argv], { + cwd: repositoryRoot, + env: command.environment, + killSignal: "SIGTERM", + maxBuffer: resourceBudgetPolicy.childOutputMaxBytes, + signal, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }), + }), + stopProcess + ); + }); +} + +function captureOutput( + stream: ReadableStream, + operation: string +): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => new ResourceBudgetUnitError({ cause, operation }), + try: async () => { + const output = await new Response(stream).arrayBuffer(); + return output.byteLength; + }, + }); +} + +function runWorkload( + arguments_: ResourceBudgetUnitArguments, + environment: Readonly> +) { + const command = buildResourceBudgetWorkloadCommand( + arguments_.scenarioId, + arguments_.repositoryRoot, + process.execPath, + environment + ); + const deadlineMs = + resourceBudgetPolicy.scenarios[arguments_.scenarioId].limits.workloadDeadlineMs; + return Effect.scoped( + Effect.gen(function* () { + const startedAt = yield* Clock.monotonicTimeNanos; + const process_ = yield* workloadProcessResource( + command, + arguments_.repositoryRoot + ); + const [exitCode, stderrBytes, stdoutBytes] = yield* Effect.all( + [ + awaitProcessExit(process_), + captureOutput(process_.stderr, "read-workload-stderr"), + captureOutput(process_.stdout, "read-workload-stdout"), + ] as const, + { concurrency: "unbounded" } + ).pipe( + Effect.timeoutOrElse({ + duration: deadlineMs, + orElse: () => + Effect.fail( + new ResourceBudgetUnitDeadlineError({ + scenarioId: arguments_.scenarioId, + }) + ), + }) + ); + const endedAt = yield* Clock.monotonicTimeNanos; + return Object.freeze({ + durationMs: Number((endedAt - startedAt) / nanosecondsPerMillisecond), + exitCode, + signalCode: process_.signalCode, + stderrBytes, + stdoutBytes, + }); + }) + ); +} + +function readFinalProcessIds(cgroupPath: string) { + return Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetUnitError({ cause, operation: "read-final-processes" }), + try: async () => { + const value = await readCgroupV2ControlFile(cgroupPath, "cgroup.procs"); + return value + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => { + if (!/^\d+$/u.test(entry)) { + throw new Error("Malformed cgroup.procs entry"); + } + return Number(entry); + }) + .toSorted((left, right) => left - right); + }, + }); +} + +function writeReport(resultPath: string, report: ResourceBudgetUnitReport) { + return Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetUnitError({ cause, operation: "write-unit-report" }), + try: async () => { + const temporaryPath = `${resultPath}.tmp`; + await Bun.write(temporaryPath, `${JSON.stringify(report, null, 2)}\n`); + await rename(temporaryPath, resultPath); + }, + }); +} + +function runUnit(arguments_: ResourceBudgetUnitArguments) { + return Effect.gen(function* () { + if (process.getuid === undefined) { + return yield* Effect.fail( + new ResourceBudgetUnitError({ operation: "read-user-id" }) + ); + } + const initialCgroup = yield* Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetUnitError({ + cause, + operation: "read-initial-cgroup", + }), + try: () => readCurrentCgroupV2Snapshot(), + }); + const expectedPath = expectedResourceBudgetCgroupPath( + process.getuid(), + arguments_.unitName + ); + if (initialCgroup.path !== expectedPath) { + return yield* Effect.fail( + new ResourceBudgetUnitError({ operation: "verify-cgroup-membership" }) + ); + } + const policy = resourceBudgetPolicy.scenarios[arguments_.scenarioId].limits; + if ( + initialCgroup.memoryHighBytes !== policy.memoryHighBytes || + initialCgroup.memoryMaxBytes !== policy.memoryMaxBytes || + initialCgroup.memorySwapMaxBytes !== policy.memorySwapMaxBytes || + initialCgroup.pidsMax !== policy.tasksMax || + initialCgroup.cpuQuotaMicros === "max" || + initialCgroup.cpuQuotaMicros * 100 !== + initialCgroup.cpuPeriodMicros * policy.cpuQuotaPercent || + !initialCgroup.oomGroup + ) { + return yield* Effect.fail( + new ResourceBudgetUnitError({ operation: "verify-cgroup-policy" }) + ); + } + + const initial = yield* readResourceSnapshot(); + const environment = Object.freeze({ + CI: process.env.CI ?? "1", + FORCE_COLOR: "0", + HOME: process.env.HOME ?? "", + LANG: "C.UTF-8", + NODE_ENV: "test", + NO_COLOR: "1", + PATH: "/usr/local/bin:/usr/bin:/bin", + TMPDIR: process.env.TMPDIR ?? "/tmp", + }); + const workload = yield* runWorkload(arguments_, environment); + const finalCgroup = yield* Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetUnitError({ + cause, + operation: "read-final-cgroup", + }), + try: () => readCurrentCgroupV2Snapshot(), + }); + const [final, finalProcessIds, pidsPeakText] = yield* Effect.all( + [ + readResourceSnapshot(), + readFinalProcessIds(finalCgroup.path), + Effect.tryPromise({ + catch: (cause) => + new ResourceBudgetUnitError({ + cause, + operation: "read-pids-peak", + }), + try: () => readCgroupV2ControlFile(finalCgroup.path, "pids.peak"), + }), + ] as const, + { concurrency: "unbounded" } + ); + const pidsPeak = Number(pidsPeakText.trim()); + if (!Number.isSafeInteger(pidsPeak) || pidsPeak < 0) { + return yield* Effect.fail( + new ResourceBudgetUnitError({ operation: "parse-pids-peak" }) + ); + } + const report: ResourceBudgetUnitReport = { + cgroup: { + final, + finalProcessIds, + initial, + memoryPeakBytes: finalCgroup.memoryPeakBytes, + path: finalCgroup.path, + pidsPeak, + }, + formatVersion: resourceBudgetPolicy.formatVersion, + limits: { + cpuPeriodMicros: finalCgroup.cpuPeriodMicros, + cpuQuotaMicros: finalCgroup.cpuQuotaMicros as number, + memoryHighBytes: finalCgroup.memoryHighBytes as number, + memoryMaxBytes: finalCgroup.memoryMaxBytes as number, + memorySwapMaxBytes: finalCgroup.memorySwapMaxBytes as number, + oomGroup: true, + pidsMax: finalCgroup.pidsMax as number, + }, + runtime: { + bunRevision: Bun.revision, + bunVersion: Bun.version, + }, + scenarioId: arguments_.scenarioId, + unitName: arguments_.unitName, + workload, + wrapperProcessId: process.pid, + }; + yield* writeReport(arguments_.resultPath, report); + }); +} + +if (import.meta.main) { + try { + const arguments_ = parseArguments(Bun.argv.slice(2)); + await Effect.runPromise(runUnit(arguments_)); + } catch (error) { + process.stderr.write( + `${Bun.inspect(error, { colors: false, depth: 6 }).slice(0, 16 * 1024)}\n` + ); + process.exitCode = 1; + } +} diff --git a/qualification/budgets/runResourceBudgetEvidence.ts b/qualification/budgets/runResourceBudgetEvidence.ts new file mode 100644 index 000000000..0a511da7d --- /dev/null +++ b/qualification/budgets/runResourceBudgetEvidence.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect"; + +import { resourceBudgetQualification } from "./resourceBudgetOrchestration.ts"; + +try { + const report = await Effect.runPromise(resourceBudgetQualification); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} catch (error) { + process.stderr.write( + `${Bun.inspect(error, { colors: false, depth: 8 }).slice(0, 32 * 1024)}\n` + ); + process.exitCode = 1; +} diff --git a/qualification/budgets/runSafeChildCancellationEvidence.ts b/qualification/budgets/runSafeChildCancellationEvidence.ts new file mode 100644 index 000000000..662cf5ebe --- /dev/null +++ b/qualification/budgets/runSafeChildCancellationEvidence.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect"; + +import { interruptedShutdownQualification } from "../shutdown/completeShutdownQualification.ts"; + +const report = await Effect.runPromise(interruptedShutdownQualification); +if ( + report.processGroupMembersAfterInterruption.length > 0 || + report.stoppedStatus.phase !== "stopped" +) { + throw new Error("Interrupted child-process scope did not cleanly terminate"); +} +process.stdout.write( + `${JSON.stringify({ + processGroupMembersAfterInterruption: report.processGroupMembersAfterInterruption, + processGroupMembersWhileReady: report.processGroupMembersWhileReady.length, + stoppedPhase: report.stoppedStatus.phase, + })}\n` +); diff --git a/qualification/build/fixtures/frontend/index.html b/qualification/build/fixtures/frontend/index.html new file mode 100644 index 000000000..98d699414 --- /dev/null +++ b/qualification/build/fixtures/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Frontend build qualification + + + +
+ + + diff --git a/qualification/build/fixtures/frontend/src/LazyPanel.tsx b/qualification/build/fixtures/frontend/src/LazyPanel.tsx new file mode 100644 index 000000000..8eaebd4cb --- /dev/null +++ b/qualification/build/fixtures/frontend/src/LazyPanel.tsx @@ -0,0 +1,7 @@ +export default function LazyPanel() { + return ( +

+ Loaded from a route-shaped lazy chunk. +

+ ); +} diff --git a/qualification/build/fixtures/frontend/src/QualificationApp.tsx b/qualification/build/fixtures/frontend/src/QualificationApp.tsx new file mode 100644 index 000000000..2e603ad77 --- /dev/null +++ b/qualification/build/fixtures/frontend/src/QualificationApp.tsx @@ -0,0 +1,22 @@ +import { lazy, Suspense, useState } from "react"; + +const LazyPanel = lazy(() => import("./LazyPanel")); + +export default function QualificationApp() { + const [count, setCount] = useState(0); + + return ( +
+ + Loading…

}> + +
+
+ ); +} diff --git a/qualification/build/fixtures/frontend/src/index.css b/qualification/build/fixtures/frontend/src/index.css new file mode 100644 index 000000000..449f81f15 --- /dev/null +++ b/qualification/build/fixtures/frontend/src/index.css @@ -0,0 +1,5 @@ +@import "tailwindcss"; + +html { + color-scheme: dark; +} diff --git a/qualification/build/fixtures/frontend/src/main.tsx b/qualification/build/fixtures/frontend/src/main.tsx new file mode 100644 index 000000000..02d16e25f --- /dev/null +++ b/qualification/build/fixtures/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import QualificationApp from "./QualificationApp"; + +createRoot(document.querySelector("#root")!).render( + + + +); diff --git a/qualification/build/frontendBuildQualification.test.ts b/qualification/build/frontendBuildQualification.test.ts new file mode 100644 index 000000000..42ffc1d1b --- /dev/null +++ b/qualification/build/frontendBuildQualification.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + assertSelfHostedFrontendHtml, + buildQualificationFrontend, + qualificationFrontendPluginOrder, +} from "./frontendBuildQualification"; + +const hashedAssetPattern = /^assets\/.+-[a-z\d]{8}\.(?:css|js)$/u; + +describe("Bun frontend build qualification", () => { + test("proves compiler-first HTML mechanics, Tailwind, lazy chunks, and delivery policy", async () => { + const developmentOutdir = await mkdtemp( + path.join(tmpdir(), "mira-build-development-") + ); + const productionOutdir = await mkdtemp( + path.join(tmpdir(), "mira-build-production-") + ); + + try { + expect(qualificationFrontendPluginOrder).toEqual([ + "react-compiler", + "@tailwindcss/bun", + ]); + + const development = await buildQualificationFrontend( + "development", + developmentOutdir + ); + expect( + development.outputPaths.some((outputPath) => outputPath.endsWith(".map")) + ).toBeTrue(); + await assertSelfHostedFrontendHtml( + path.join(developmentOutdir, "index.html") + ); + + const production = await buildQualificationFrontend( + "production", + productionOutdir + ); + const productionFiles = await listRelativeFiles(productionOutdir); + expect( + production.outputPaths.some((outputPath) => outputPath.endsWith(".map")) + ).toBeFalse(); + expect( + production.outputPaths + .filter((outputPath) => /\.(?:css|js)$/u.test(outputPath)) + .every((outputPath) => hashedAssetPattern.test(outputPath)) + ).toBeTrue(); + expect(production.compressedFileCount).toBeGreaterThan(0); + expect(productionFiles.some((file) => file.endsWith(".br"))).toBeTrue(); + expect(productionFiles.some((file) => file.endsWith(".gz"))).toBeTrue(); + expect(production.metrics?.formatVersion).toBe(1); + + const lazyJavaScript = production.outputPaths.filter( + (outputPath) => + outputPath.endsWith(".js") && + !production.initialOutputPaths.includes(outputPath) + ); + expect(lazyJavaScript.length).toBeGreaterThan(0); + expect( + Object.values(production.metafile.outputs).some((output) => + output.imports.some(({ kind }) => kind === "dynamic-import") + ) + ).toBeTrue(); + + const javascript = await readFilesWithExtension( + productionOutdir, + productionFiles, + ".js" + ); + const stylesheet = await readFilesWithExtension( + productionOutdir, + productionFiles, + ".css" + ); + expect(javascript).toContain("useMemoCache"); + expect(stylesheet).toContain(".bg-indigo-600"); + await assertSelfHostedFrontendHtml(path.join(productionOutdir, "index.html")); + } finally { + await Promise.all([ + rm(developmentOutdir, { force: true, recursive: true }), + rm(productionOutdir, { force: true, recursive: true }), + ]); + } + }, 60_000); +}); + +async function listRelativeFiles(directory: string): Promise { + const files: string[] = []; + const pending = [directory]; + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + for (const entry of await readdir(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile()) { + files.push(path.relative(directory, entryPath).replaceAll("\\", "/")); + } + } + } + return files.toSorted(); +} + +async function readFilesWithExtension( + directory: string, + files: readonly string[], + extension: string +): Promise { + const contents = await Promise.all( + files + .filter((file) => file.endsWith(extension)) + .map((file) => readFile(path.join(directory, file), "utf8")) + ); + return contents.join("\n"); +} diff --git a/qualification/build/frontendBuildQualification.ts b/qualification/build/frontendBuildQualification.ts new file mode 100644 index 000000000..f2b1c06bd --- /dev/null +++ b/qualification/build/frontendBuildQualification.ts @@ -0,0 +1,146 @@ +import { mkdir, readFile, rm } from "node:fs/promises"; +import path from "node:path"; + +import tailwindPlugin from "bun-plugin-tailwind"; + +import { + assertFrontendBundleBudgets, + initialFrontendOutputKeys, + measureFrontendBundle, + type FrontendBundleMetrics, + writeFrontendHtmlAppEntrypoint, + writePrecompressedFrontendAssets, +} from "../../scripts/frontendBuildArtifacts"; +import reactCompilerPlugin from "../../scripts/reactCompilerPlugin"; + +export type QualificationFrontendBuildMode = "development" | "production"; + +export interface QualificationFrontendBuildEvidence { + compressedFileCount: number; + initialOutputPaths: string[]; + metafile: Bun.BuildMetafile; + metrics?: FrontendBundleMetrics; + outputPaths: string[]; +} + +const qualificationFrontendEntrypoint = path.resolve( + "qualification/build/fixtures/frontend/index.html" +); + +export const qualificationFrontendPluginOrder = [ + reactCompilerPlugin.name, + tailwindPlugin.name, +] as const; + +/** + * Builds a minimal HTML-entry frontend with the target compiler-first pipeline. + * The fixture intentionally shares the production artifact policy helpers. + * @param mode Build policy to qualify. + * @param outdir Disposable build output directory. + * @returns Build metadata and delivery evidence. + */ +export async function buildQualificationFrontend( + mode: QualificationFrontendBuildMode, + outdir: string +): Promise { + const resolvedOutdir = path.resolve(outdir); + const isProduction = mode === "production"; + + await rm(resolvedOutdir, { force: true, recursive: true }); + await mkdir(resolvedOutdir, { recursive: true }); + + const result = await Bun.build({ + define: { + "process.env.NODE_ENV": JSON.stringify(mode), + }, + entrypoints: [qualificationFrontendEntrypoint], + minify: isProduction, + metafile: true, + naming: { + asset: "assets/[name]-[hash].[ext]", + chunk: "assets/[name]-[hash].[ext]", + }, + outdir: resolvedOutdir, + plugins: [reactCompilerPlugin, tailwindPlugin], + publicPath: "/", + sourcemap: isProduction ? "none" : "linked", + splitting: true, + target: "browser", + }); + + if (!result.success) { + throw new AggregateError(result.logs, "Qualification frontend build failed"); + } + if (!result.metafile) { + throw new Error("Qualification frontend build did not produce metadata"); + } + + await writeFrontendHtmlAppEntrypoint(result.metafile, resolvedOutdir); + const initialOutputPaths = [...initialFrontendOutputKeys(result.metafile)].map( + (outputPath) => normalizedOutputPath(outputPath, resolvedOutdir) + ); + const outputPaths = result.outputs.map(({ path: outputPath }) => + normalizedOutputPath(outputPath, resolvedOutdir) + ); + + if (!isProduction) { + return { + compressedFileCount: 0, + initialOutputPaths, + metafile: result.metafile, + outputPaths, + }; + } + + const metrics = await measureFrontendBundle(result.metafile, resolvedOutdir); + assertFrontendBundleBudgets(metrics.measurements); + const compressedFileCount = await writePrecompressedFrontendAssets( + result.outputs.map(({ path: outputPath }) => outputPath) + ); + + return { + compressedFileCount, + initialOutputPaths, + metafile: result.metafile, + metrics, + outputPaths, + }; +} + +/** + * Fails when generated HTML would require inline or third-party script/style CSP. + * @param indexPath Generated HTML entrypoint. + * @returns Promise that resolves when the entrypoint is self-hosted. + */ +export async function assertSelfHostedFrontendHtml(indexPath: string): Promise { + const html = await readFile(indexPath, "utf8"); + const scripts = [...html.matchAll(/]*)>([\s\S]*?)<\/script>/giu)]; + const styles = [...html.matchAll(/]*>[\s\S]*?<\/style>/giu)]; + if (scripts.length !== 1 || styles.length > 0) { + throw new Error( + "Frontend HTML must contain one external script and no inline styles" + ); + } + + const scriptAttributes = scripts[0]?.[1] ?? ""; + const scriptBody = scripts[0]?.[2] ?? ""; + const source = scriptAttributes.match(/\bsrc=(['"])([^'"]+)\1/iu)?.[2]; + if ( + !/\btype=(['"])module\1/iu.test(scriptAttributes) || + !source?.startsWith("/assets/") || + scriptBody.trim().length > 0 + ) { + throw new Error("Frontend HTML module script must be external and self-hosted"); + } + + const nonSelfHostedResource = html.match( + /\b(?:href|src)=(['"])(?:[a-z][a-z\d+.-]*:|\/\/)[^'"]*\1/iu + ); + if (nonSelfHostedResource) { + throw new Error("Frontend HTML cannot depend on a third-party CSP origin"); + } +} + +function normalizedOutputPath(outputPath: string, outdir: string): string { + return path.relative(outdir, path.resolve(outputPath)).replaceAll("\\", "/"); +} diff --git a/qualification/build/runFrontendBuildQualification.ts b/qualification/build/runFrontendBuildQualification.ts new file mode 100644 index 000000000..034002bd7 --- /dev/null +++ b/qualification/build/runFrontendBuildQualification.ts @@ -0,0 +1,93 @@ +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { buildFrontend } from "../../scripts/frontendBuild"; +import { assertSelfHostedFrontendHtml } from "./frontendBuildQualification"; + +const hashedAssetPattern = /^assets\/.+-[a-z\d]{8}\.(?:css|js)$/u; + +export interface ActualFrontendBuildEvidence { + compressedSidecarCount: number; + formatVersion: 1; + hashedAssetCount: number; + outputFileCount: number; + sourceMapsIncluded: false; +} + +/** + * Runs the selected existing production build in a separate qualification process. + * @param outdir Disposable build output directory. + * @returns Actual frontend build evidence. + */ +export async function runActualFrontendBuildQualification( + outdir: string +): Promise { + await buildFrontend({ mode: "production", outdir }); + const files = await listRelativeFiles(outdir); + const hashedAssetCount = files.filter((file) => hashedAssetPattern.test(file)).length; + const compressedSidecarCount = files.filter( + (file) => file.endsWith(".br") || file.endsWith(".gz") + ).length; + const sourceMapsIncluded = files.some((file) => file.endsWith(".map")); + const metrics = await readFile( + path.join(outdir, "frontend-bundle-metrics.json"), + "utf8" + ); + + if (hashedAssetCount < 10) { + throw new Error("Existing frontend build did not emit hashed route assets"); + } + if (compressedSidecarCount === 0) { + throw new Error("Existing frontend build did not emit compressed sidecars"); + } + if (sourceMapsIncluded) { + throw new Error("Production frontend build emitted source maps"); + } + if (!metrics.includes('"formatVersion": 1')) { + throw new Error("Existing frontend build emitted an unknown metrics format"); + } + await assertSelfHostedFrontendHtml(path.join(outdir, "index.html")); + + return { + compressedSidecarCount, + formatVersion: 1, + hashedAssetCount, + outputFileCount: files.length, + sourceMapsIncluded: false, + }; +} + +async function listRelativeFiles(directory: string): Promise { + const files: string[] = []; + const pending = [directory]; + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + for (const entry of await readdir(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile()) { + files.push(path.relative(directory, entryPath).replaceAll("\\", "/")); + } + } + } + return files.toSorted(); +} + +if (import.meta.main) { + const outdir = await mkdtemp(path.join(tmpdir(), "mira-frontend-build-evidence-")); + try { + // eslint-disable-next-line no-console -- The manual runner emits its evidence artifact. + console.log( + JSON.stringify( + await runActualFrontendBuildQualification(outdir), + undefined, + 2 + ) + ); + } finally { + await rm(outdir, { force: true, recursive: true }); + } +} diff --git a/qualification/chat/chatBatching.test.ts b/qualification/chat/chatBatching.test.ts new file mode 100644 index 000000000..6ba6a095e --- /dev/null +++ b/qualification/chat/chatBatching.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; + +import { loadReviewedOpenClawFixtures } from "../openclaw/reviewedFixtures.ts"; +import { simulateChatBatching } from "./chatBatchingModel.ts"; +import { + buildChatBatchingTrace, + chatBatchingCandidateIntervalsMs, + chatBatchingConcurrencyLevels, + qualifyChatBatching, +} from "./chatBatchingQualification.ts"; + +describe("current OpenClaw chat batching qualification", () => { + test("selects the smallest bounded interval from every reviewed candidate", async () => { + const { audit } = await loadReviewedOpenClawFixtures(); + const evidence = qualifyChatBatching(audit.chat); + + expect(evidence).toMatchObject({ + maximumAdditionalVisualDelayMs: 150, + maximumCrashWindowMs: 150, + maximumScheduledTransactionsPerSecond: 7, + selectedIntervalMs: 150, + sourceDeltaThrottleMs: 150, + }); + expect([ + ...new Set(evidence.candidates.map(({ metrics }) => metrics.intervalMs)), + ]).toEqual([...chatBatchingCandidateIntervalsMs]); + expect([ + ...new Set(evidence.candidates.map(({ concurrency }) => concurrency)), + ]).toEqual([...chatBatchingConcurrencyLevels]); + expect( + chatBatchingCandidateIntervalsMs + .filter((intervalMs) => intervalMs < 150) + .every((intervalMs) => + evidence.candidates + .filter(({ metrics }) => metrics.intervalMs === intervalMs) + .some(({ accepted }) => !accepted) + ) + ).toBeTrue(); + expect( + evidence.candidates + .filter(({ metrics }) => metrics.intervalMs === 150) + .every(({ accepted }) => accepted) + ).toBeTrue(); + expect( + evidence.candidates + .filter(({ metrics }) => metrics.intervalMs > 150) + .every(({ accepted }) => !accepted) + ).toBeTrue(); + }); + + test("flushes tool and terminal boundaries without losing ordered events", async () => { + const { audit } = await loadReviewedOpenClawFixtures(); + const trace = buildChatBatchingTrace(audit.chat, 8); + const metrics = simulateChatBatching(trace, 150); + + expect(metrics.committedEvents).toBe(trace.length); + expect(metrics.boundaryMaximumCommitDelayMs).toBe(0); + expect(metrics.terminalMaximumCommitDelayMs).toBe(0); + expect(metrics.maximumCommitDelayMs).toBeLessThanOrEqual(150); + expect(metrics.transactions).toBeLessThan(trace.length); + expect(metrics.scheduledTransactions).toBeLessThan(trace.length); + expect(metrics.maximumPendingBytes).toBeGreaterThan(0); + expect(metrics.boundaryTransactions).toBeGreaterThan(0); + }); + + test("is deterministic and rejects malformed sequence or interval input", async () => { + const { audit } = await loadReviewedOpenClawFixtures(); + const trace = buildChatBatchingTrace(audit.chat, 4); + expect(qualifyChatBatching(audit.chat)).toEqual(qualifyChatBatching(audit.chat)); + expect(() => simulateChatBatching(trace, 0)).toThrow(); + expect(() => + simulateChatBatching( + trace.map((event, index) => + index === 0 ? { ...event, sequence: 2 } : event + ), + 150 + ) + ).toThrow("sequence is not contiguous"); + }); +}); diff --git a/qualification/chat/chatBatchingModel.ts b/qualification/chat/chatBatchingModel.ts new file mode 100644 index 000000000..2e66d1880 --- /dev/null +++ b/qualification/chat/chatBatchingModel.ts @@ -0,0 +1,234 @@ +export type ChatBatchTraceEventKind = "boundary" | "delta" | "terminal"; + +export interface ChatBatchTraceEvent { + readonly arrivedAtMs: number; + readonly kind: ChatBatchTraceEventKind; + readonly payloadBytes: number; + readonly runId: string; + readonly sequence: number; + readonly stream?: "assistant" | "thinking"; +} + +export interface ChatBatchingBatch { + readonly commitAtMs: number; + readonly durableBytes: number; + readonly durableRows: number; + readonly eventCount: number; + readonly reason: "boundary" | "interval"; +} + +export interface ChatBatchingMetrics { + readonly batches: readonly ChatBatchingBatch[]; + readonly boundaryMaximumCommitDelayMs: number; + readonly boundaryTransactions: number; + readonly committedEvents: number; + readonly durableBytes: number; + readonly durableRows: number; + readonly inputBytes: number; + readonly inputEvents: number; + readonly intervalMs: number; + readonly maximumCommitDelayMs: number; + readonly maximumPendingBytes: number; + readonly p95CommitDelayMs: number; + readonly peakScheduledTransactionsPerSecond: number; + readonly scheduledTransactions: number; + readonly terminalMaximumCommitDelayMs: number; + readonly transactions: number; +} + +interface DurableRecord { + eventCount: number; + firstSequence: number; + kind: ChatBatchTraceEventKind; + lastSequence: number; + payloadBytes: number; + runId: string; + stream?: "assistant" | "thinking"; +} + +const textEncoder = new TextEncoder(); + +function compareTraceEvents( + left: ChatBatchTraceEvent, + right: ChatBatchTraceEvent +): number { + if (left.arrivedAtMs !== right.arrivedAtMs) { + return left.arrivedAtMs - right.arrivedAtMs; + } + if (left.runId !== right.runId) return left.runId < right.runId ? -1 : 1; + return left.sequence - right.sequence; +} + +function assertTrace(events: readonly ChatBatchTraceEvent[]): void { + const nextSequenceByRun = new Map(); + for (const event of events.toSorted(compareTraceEvents)) { + if ( + !Number.isSafeInteger(event.arrivedAtMs) || + event.arrivedAtMs < 0 || + !Number.isSafeInteger(event.payloadBytes) || + event.payloadBytes < 1 + ) { + throw new RangeError("Chat batching trace contains invalid bounds"); + } + const expectedSequence = nextSequenceByRun.get(event.runId) ?? 1; + if (event.sequence !== expectedSequence) { + throw new Error("Chat batching trace sequence is not contiguous"); + } + if (event.kind === "delta" && event.stream === undefined) { + throw new Error("Chat batching delta is missing its stream"); + } + if (event.kind !== "delta" && event.stream !== undefined) { + throw new Error("Chat batching boundary unexpectedly declares a stream"); + } + nextSequenceByRun.set(event.runId, expectedSequence + 1); + } +} + +function coalesceDurableRecords( + events: readonly ChatBatchTraceEvent[] +): readonly DurableRecord[] { + const records: DurableRecord[] = []; + const latestRecordByRun = new Map(); + for (const event of events) { + const latest = latestRecordByRun.get(event.runId); + if ( + event.kind === "delta" && + latest?.kind === "delta" && + latest.stream === event.stream + ) { + latest.eventCount += 1; + latest.lastSequence = event.sequence; + latest.payloadBytes += event.payloadBytes; + continue; + } + const record: DurableRecord = { + eventCount: 1, + firstSequence: event.sequence, + kind: event.kind, + lastSequence: event.sequence, + payloadBytes: event.payloadBytes, + runId: event.runId, + ...(event.stream === undefined ? {} : { stream: event.stream }), + }; + records.push(record); + latestRecordByRun.set(event.runId, record); + } + return records; +} + +function serializedBytes(value: unknown): number { + return textEncoder.encode(JSON.stringify(value)).byteLength; +} + +function percentile95(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = values.toSorted((left, right) => left - right); + return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? 0; +} + +function peakTransactionsPerSecond(commitTimes: readonly number[]): number { + let peak = 0; + let start = 0; + for (let end = 0; end < commitTimes.length; end += 1) { + while (commitTimes[end]! - commitTimes[start]! >= 1000) start += 1; + peak = Math.max(peak, end - start + 1); + } + return peak; +} + +/** + * Simulates one process-wide fixed-window journal batcher without wall-clock timing. + * Semantic boundaries and terminal states always flush immediately; only deltas wait. + * + * @param inputEvents Ordered source-shaped chat events to persist. + * @param intervalMs Fixed batching interval under evaluation. + * @returns Deterministic persistence and latency metrics for the trace. + */ +export function simulateChatBatching( + inputEvents: readonly ChatBatchTraceEvent[], + intervalMs: number +): ChatBatchingMetrics { + if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) { + throw new RangeError("Chat batching interval must be a positive safe integer"); + } + assertTrace(inputEvents); + const events = inputEvents.toSorted(compareTraceEvents); + const batches: ChatBatchingBatch[] = []; + const commitDelays: number[] = []; + const scheduledCommitTimes: number[] = []; + let maximumPendingBytes = 0; + let pending: ChatBatchTraceEvent[] = []; + let pendingBytes = 0; + let pendingDeadlineMs: number | undefined; + let boundaryMaximumCommitDelayMs = 0; + let terminalMaximumCommitDelayMs = 0; + + const flush = (commitAtMs: number, reason: ChatBatchingBatch["reason"]): void => { + if (pending.length === 0) return; + const records = coalesceDurableRecords(pending); + for (const event of pending) { + const delay = commitAtMs - event.arrivedAtMs; + if (delay < 0) throw new Error("Chat batching committed before arrival"); + commitDelays.push(delay); + if (event.kind === "terminal") { + terminalMaximumCommitDelayMs = Math.max( + terminalMaximumCommitDelayMs, + delay + ); + } + if (event.kind === "boundary") { + boundaryMaximumCommitDelayMs = Math.max( + boundaryMaximumCommitDelayMs, + delay + ); + } + } + batches.push({ + commitAtMs, + durableBytes: serializedBytes(records), + durableRows: records.length, + eventCount: pending.length, + reason, + }); + if (reason === "interval") scheduledCommitTimes.push(commitAtMs); + pending = []; + pendingBytes = 0; + pendingDeadlineMs = undefined; + }; + + for (const event of events) { + if (pendingDeadlineMs !== undefined && pendingDeadlineMs <= event.arrivedAtMs) { + flush(pendingDeadlineMs, "interval"); + } + pending.push(event); + pendingBytes += event.payloadBytes; + maximumPendingBytes = Math.max(maximumPendingBytes, pendingBytes); + if (event.kind === "delta") { + pendingDeadlineMs ??= event.arrivedAtMs + intervalMs; + } else { + flush(event.arrivedAtMs, "boundary"); + } + } + if (pendingDeadlineMs !== undefined) flush(pendingDeadlineMs, "interval"); + + return Object.freeze({ + batches: Object.freeze(batches), + boundaryMaximumCommitDelayMs, + boundaryTransactions: batches.filter(({ reason }) => reason === "boundary") + .length, + committedEvents: batches.reduce((total, batch) => total + batch.eventCount, 0), + durableBytes: batches.reduce((total, batch) => total + batch.durableBytes, 0), + durableRows: batches.reduce((total, batch) => total + batch.durableRows, 0), + inputBytes: events.reduce((total, event) => total + event.payloadBytes, 0), + inputEvents: events.length, + intervalMs, + maximumCommitDelayMs: Math.max(0, ...commitDelays), + maximumPendingBytes, + p95CommitDelayMs: percentile95(commitDelays), + peakScheduledTransactionsPerSecond: + peakTransactionsPerSecond(scheduledCommitTimes), + scheduledTransactions: scheduledCommitTimes.length, + terminalMaximumCommitDelayMs, + transactions: batches.length, + }); +} diff --git a/qualification/chat/chatBatchingQualification.ts b/qualification/chat/chatBatchingQualification.ts new file mode 100644 index 000000000..c28cad512 --- /dev/null +++ b/qualification/chat/chatBatchingQualification.ts @@ -0,0 +1,205 @@ +import type { ChatFixture } from "../openclaw/sourceAuditSchemas.ts"; +import { + simulateChatBatching, + type ChatBatchingMetrics, + type ChatBatchTraceEvent, +} from "./chatBatchingModel.ts"; + +export const chatBatchingCandidateIntervalsMs = [50, 100, 150, 200, 250, 500] as const; +export const chatBatchingConcurrencyLevels = [1, 4, 8] as const; + +export interface ChatBatchingCandidateEvidence { + readonly accepted: boolean; + readonly concurrency: number; + readonly metrics: ChatBatchingMetrics; + readonly rejectionReasons: readonly string[]; +} + +export interface ChatBatchingQualificationEvidence { + readonly candidates: readonly ChatBatchingCandidateEvidence[]; + readonly maximumAdditionalVisualDelayMs: number; + readonly maximumCrashWindowMs: number; + readonly maximumScheduledTransactionsPerSecond: number; + readonly selectedIntervalMs: number; + readonly sourceDeltaThrottleMs: number; +} + +type SyntheticChatEvent = ChatFixture["syntheticScenarios"][number]["events"][number]; + +const textEncoder = new TextEncoder(); + +function fixtureEvent( + fixture: ChatFixture, + scenarioId: string, + kind: SyntheticChatEvent["kind"] +): SyntheticChatEvent { + const scenario = fixture.syntheticScenarios.find(({ id }) => id === scenarioId); + const event = scenario?.events.find((candidate) => candidate.kind === kind); + if (event === undefined) { + throw new Error(`Reviewed chat fixture lacks ${scenarioId}/${kind}`); + } + return event; +} + +function payloadBytes( + runId: string, + sequence: number, + event: SyntheticChatEvent +): number { + return textEncoder.encode(JSON.stringify({ ...event, runId, seq: sequence })) + .byteLength; +} + +/** + * Builds a deterministic, source-shaped streaming load without host runtime data. + * + * @param fixture Reviewed, version-pinned OpenClaw chat fixture. + * @param concurrency Number of interleaved synthetic runs. + * @returns A bounded deterministic trace for the batching simulator. + */ +export function buildChatBatchingTrace( + fixture: ChatFixture, + concurrency: number +): readonly ChatBatchTraceEvent[] { + if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 32) { + throw new RangeError("Chat batching concurrency is outside qualification bounds"); + } + const throttleMs = fixture.streamingPolicy.deltaThrottleMs; + const thinking = fixtureEvent(fixture, "completed-tool-run", "agent-delta"); + const assistant = fixture.syntheticScenarios + .flatMap(({ events }) => events) + .find( + (event): event is Extract => + event.kind === "agent-delta" && event.stream === "assistant" + ); + if (thinking.kind !== "agent-delta" || assistant === undefined) { + throw new Error("Reviewed chat fixture lacks both coalesced agent streams"); + } + const toolStart = fixtureEvent(fixture, "completed-tool-run", "tool-start"); + const toolResult = fixtureEvent(fixture, "completed-tool-run", "tool-result"); + const chatDelta = fixtureEvent(fixture, "completed-tool-run", "chat-delta"); + const final = fixtureEvent(fixture, "completed-tool-run", "chat-terminal"); + const aborted = fixtureEvent(fixture, "cancelled-run", "chat-terminal"); + const events: ChatBatchTraceEvent[] = []; + + for (let runIndex = 0; runIndex < concurrency; runIndex += 1) { + const runId = `qualification-run-${runIndex + 1}`; + const offsetMs = Math.floor((throttleMs * runIndex) / concurrency); + let sequence = 0; + const append = ( + arrivedAtMs: number, + kind: ChatBatchTraceEvent["kind"], + template: SyntheticChatEvent, + stream?: "assistant" | "thinking" + ): void => { + sequence += 1; + events.push({ + arrivedAtMs, + kind, + payloadBytes: payloadBytes(runId, sequence, template), + runId, + sequence, + ...(stream === undefined ? {} : { stream }), + }); + }; + + for (let deltaIndex = 0; deltaIndex < 48; deltaIndex += 1) { + const arrivedAtMs = offsetMs + deltaIndex * throttleMs; + const stream = deltaIndex < 12 ? "thinking" : "assistant"; + append( + arrivedAtMs, + "delta", + stream === "thinking" ? thinking : assistant, + stream + ); + if (deltaIndex === 12) { + append(arrivedAtMs + Math.floor(throttleMs / 3), "boundary", toolStart); + append( + arrivedAtMs + Math.floor((throttleMs * 2) / 3), + "boundary", + toolResult + ); + } + } + const finalDeltaAtMs = offsetMs + 48 * throttleMs; + append(finalDeltaAtMs, "delta", chatDelta, "assistant"); + append( + finalDeltaAtMs + Math.floor(throttleMs / 2), + "terminal", + runIndex % 2 === 0 ? final : aborted + ); + } + return Object.freeze(events); +} + +function candidateRejectionReasons( + metrics: ChatBatchingMetrics, + fixture: ChatFixture +): readonly string[] { + const throttleMs = fixture.streamingPolicy.deltaThrottleMs; + const maximumScheduledTransactionsPerSecond = Math.ceil(1000 / throttleMs); + return Object.freeze([ + ...(metrics.maximumCommitDelayMs > throttleMs + ? ["visual-delay-exceeds-one-source-tick"] + : []), + ...(metrics.maximumCommitDelayMs > throttleMs + ? ["crash-window-exceeds-one-source-tick"] + : []), + ...(metrics.peakScheduledTransactionsPerSecond > + maximumScheduledTransactionsPerSecond + ? ["scheduled-write-rate-exceeds-source-cadence"] + : []), + ...(metrics.terminalMaximumCommitDelayMs === 0 + ? [] + : ["terminal-event-was-not-flushed-immediately"]), + ...(metrics.boundaryMaximumCommitDelayMs === 0 + ? [] + : ["semantic-boundary-was-not-flushed-immediately"]), + ...(metrics.committedEvents === metrics.inputEvents + ? [] + : ["event-count-mismatch"]), + ]); +} + +/** + * Evaluates every reviewed interval at one, four, and eight concurrent runs. + * + * @param fixture Reviewed, version-pinned OpenClaw chat fixture. + * @returns Candidate evidence and the smallest interval satisfying every bound. + */ +export function qualifyChatBatching( + fixture: ChatFixture +): ChatBatchingQualificationEvidence { + const candidates = chatBatchingCandidateIntervalsMs.flatMap((intervalMs) => + chatBatchingConcurrencyLevels.map((concurrency) => { + const metrics = simulateChatBatching( + buildChatBatchingTrace(fixture, concurrency), + intervalMs + ); + const rejectionReasons = candidateRejectionReasons(metrics, fixture); + return Object.freeze({ + accepted: rejectionReasons.length === 0, + concurrency, + metrics, + rejectionReasons, + }); + }) + ); + const selectedIntervalMs = chatBatchingCandidateIntervalsMs.find((intervalMs) => + candidates + .filter((candidate) => candidate.metrics.intervalMs === intervalMs) + .every(({ accepted }) => accepted) + ); + if (selectedIntervalMs === undefined) { + throw new Error("No chat batching candidate satisfies the reviewed policy"); + } + const sourceDeltaThrottleMs = fixture.streamingPolicy.deltaThrottleMs; + return Object.freeze({ + candidates: Object.freeze(candidates), + maximumAdditionalVisualDelayMs: sourceDeltaThrottleMs, + maximumCrashWindowMs: sourceDeltaThrottleMs, + maximumScheduledTransactionsPerSecond: Math.ceil(1000 / sourceDeltaThrottleMs), + selectedIntervalMs, + sourceDeltaThrottleMs, + }); +} diff --git a/qualification/chat/runChatBatchingQualification.ts b/qualification/chat/runChatBatchingQualification.ts new file mode 100644 index 000000000..0cd5d6543 --- /dev/null +++ b/qualification/chat/runChatBatchingQualification.ts @@ -0,0 +1,49 @@ +import { loadReviewedOpenClawFixtures } from "../openclaw/reviewedFixtures.ts"; +import { qualifyChatBatching } from "./chatBatchingQualification.ts"; + +/** Prints deterministic reviewed evidence without reading host runtime state. */ +export async function runChatBatchingQualification(): Promise { + const { audit, manifest } = await loadReviewedOpenClawFixtures(); + const evidence = qualifyChatBatching(audit.chat); + process.stdout.write( + `${JSON.stringify( + { + candidates: evidence.candidates.map( + ({ accepted, concurrency, metrics, rejectionReasons }) => ({ + accepted, + boundaryMaximumCommitDelayMs: + metrics.boundaryMaximumCommitDelayMs, + concurrency, + durableBytes: metrics.durableBytes, + durableRows: metrics.durableRows, + inputBytes: metrics.inputBytes, + inputEvents: metrics.inputEvents, + intervalMs: metrics.intervalMs, + maximumCommitDelayMs: metrics.maximumCommitDelayMs, + maximumPendingBytes: metrics.maximumPendingBytes, + p95CommitDelayMs: metrics.p95CommitDelayMs, + peakScheduledTransactionsPerSecond: + metrics.peakScheduledTransactionsPerSecond, + rejectionReasons, + scheduledTransactions: metrics.scheduledTransactions, + terminalMaximumCommitDelayMs: + metrics.terminalMaximumCommitDelayMs, + transactions: metrics.transactions, + }) + ), + maximumAdditionalVisualDelayMs: evidence.maximumAdditionalVisualDelayMs, + maximumCrashWindowMs: evidence.maximumCrashWindowMs, + maximumScheduledTransactionsPerSecond: + evidence.maximumScheduledTransactionsPerSecond, + openClawCommit: manifest.source.commit, + openClawVersion: manifest.source.version, + selectedIntervalMs: evidence.selectedIntervalMs, + sourceDeltaThrottleMs: evidence.sourceDeltaThrottleMs, + }, + null, + 2 + )}\n` + ); +} + +if (import.meta.main) await runChatBatchingQualification(); diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/agents.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/agents.json new file mode 100644 index 000000000..672c25139 --- /dev/null +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/agents.json @@ -0,0 +1,19 @@ +{ + "gatewayEvents": ["agent"], + "methods": [ + "agent", + "agent.identity.get", + "agent.wait", + "agents.create", + "agents.delete", + "agents.files.get", + "agents.files.list", + "agents.files.set", + "agents.list", + "agents.update", + "agents.workspace.get", + "agents.workspace.list" + ], + "schemaVersion": 1, + "domain": "agents" +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/chat.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/chat.json new file mode 100644 index 000000000..5ca4d9b9e --- /dev/null +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/chat.json @@ -0,0 +1,90 @@ +{ + "gatewayEvents": ["agent", "chat", "session.message", "session.tool"], + "methods": [ + "chat.abort", + "chat.history", + "chat.inject", + "chat.message.get", + "chat.metadata", + "chat.send", + "chat.startup", + "chat.toolTitles" + ], + "schemaVersion": 1, + "domain": "chat", + "streamingPolicy": { + "coalescedAgentStreams": ["assistant", "thinking"], + "deltaThrottleMs": 150, + "flushBeforeBoundaries": ["item.start", "tool.start"], + "flushBufferedDeltaBeforeTerminal": true, + "terminalStates": ["final", "aborted", "error"] + }, + "syntheticScenarios": [ + { + "events": [ + { + "delta": "Checking cancellation.", + "kind": "agent-delta", + "seq": 1, + "stream": "assistant", + "text": "Checking cancellation." + }, + { + "deltaText": "Checking cancellation.", + "kind": "chat-delta", + "seq": 2 + }, + { + "kind": "chat-terminal", + "seq": 3, + "state": "aborted", + "stopReason": "cancelled" + } + ], + "id": "cancelled-run" + }, + { + "events": [ + { + "delta": "Inspecting synthetic input.", + "kind": "agent-delta", + "seq": 1, + "stream": "thinking", + "text": "Inspecting synthetic input." + }, + { + "delta": "Running the fixture tool.", + "kind": "agent-delta", + "seq": 2, + "stream": "assistant", + "text": "Running the fixture tool." + }, + { + "kind": "tool-start", + "seq": 3, + "toolCallId": "fixture-tool-1", + "toolName": "fixture.lookup" + }, + { + "kind": "tool-result", + "outcome": "ok", + "seq": 4, + "toolCallId": "fixture-tool-1", + "toolName": "fixture.lookup" + }, + { + "deltaText": "Fixture complete.", + "kind": "chat-delta", + "seq": 5 + }, + { + "kind": "chat-terminal", + "seq": 6, + "state": "final", + "stopReason": "completed" + } + ], + "id": "completed-tool-run" + } + ] +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/cron.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/cron.json new file mode 100644 index 000000000..5d33afb95 --- /dev/null +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/cron.json @@ -0,0 +1,18 @@ +{ + "gatewayEvents": ["cron"], + "methods": [ + "cron.add", + "cron.get", + "cron.list", + "cron.remove", + "cron.run", + "cron.runs", + "cron.scratch.get", + "cron.scratch.set", + "cron.status", + "cron.update", + "wake" + ], + "schemaVersion": 1, + "domain": "cron" +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/gateway.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/gateway.json new file mode 100644 index 000000000..245db15c6 --- /dev/null +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/gateway.json @@ -0,0 +1,23 @@ +{ + "challengeEvent": "connect.challenge", + "frameTypes": ["event", "req", "res"], + "gatewayEvents": [ + "connect.challenge", + "health", + "heartbeat", + "presence", + "shutdown", + "tick" + ], + "helloType": "hello-ok", + "limits": { + "authenticatedFrameBytes": 26214400, + "preauthenticationFrameBytes": 65536 + }, + "method": "connect", + "minimumClientProtocolVersion": 4, + "minimumNodeProtocolVersion": 3, + "minimumProbeProtocolVersion": 3, + "protocolVersion": 4, + "schemaVersion": 1 +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json new file mode 100644 index 000000000..a6cfdb428 --- /dev/null +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json @@ -0,0 +1,177 @@ +{ + "components": [ + { + "file": "agents.json", + "sha256": "0f4dabfe6373bc1448bd5b3f3e6926a2e4f2cec47a18a4443a82785f1e5cfeae" + }, + { + "file": "chat.json", + "sha256": "e7a2520f9506b64b5acd6083e15aef8890be26c625bf7630341284e063a50ccc" + }, + { + "file": "cron.json", + "sha256": "1a7916667cba1e13bf830ae6e92f7a027beb28d22d1235f73fa0b77f92f826b9" + }, + { + "file": "gateway.json", + "sha256": "4b3bfa47e9bb0f8e96e519acbb6bdce18ba7c303778980216109a369a6086137" + }, + { + "file": "sessions.json", + "sha256": "a335ae2b415016e805246dc2650e43158eddb4e6f36df6996fd69dad01d06074" + }, + { + "file": "tasks.json", + "sha256": "a179bd882f1397941743841ba10cdece0616f3123f8c972bfbf5c7668c538f65" + } + ], + "contentPolicy": { + "containsHostConfiguration": false, + "containsRuntimeState": false, + "containsSecrets": false, + "sourceArtifacts": "hashes-only", + "syntheticPayloadsOnly": true + }, + "schemaVersion": 1, + "source": { + "builtAt": "2026-08-01T19:22:56.002Z", + "commit": "dabe1915362e20c25704af91612a32a8f4c96e83", + "packageName": "openclaw", + "protocolVersion": 4, + "version": "2026.7.2-beta.7" + }, + "sourceArtifacts": [ + { + "bytes": 132, + "path": "dist/build-info.json", + "role": "build-info", + "sha256": "623249d20e099eec46ac4c3c281ad8672737919b68b74d1da62f94c3432ab491" + }, + { + "bytes": 14070, + "path": "dist/chat-abort-BhN5ed23.js", + "role": "chat-run-projection", + "sha256": "a1c96ee4d16954fee214ab43fa1528a2f5650231fa64e9db9562cf9580cf6891" + }, + { + "bytes": 43792, + "path": "dist/server-chat-CdNm7sYZ.js", + "role": "chat-streaming", + "sha256": "b5c177536b7d7c89be6327f9146a8cf8223b70bb9f202f07ade55c3bad1d9ea8" + }, + { + "bytes": 527552, + "path": "dist/control-ui/assets/chat-page-6DmC-GhZ.js", + "role": "control-ui-chat", + "sha256": "5ff8d222793f85b73c7b8baa93a7e9ab30762c826793e9e59087fefbfa14be7c" + }, + { + "bytes": 14333, + "path": "dist/control-ui/assets/chat-session-rail-BrqMURJB.js", + "role": "control-ui-plan-rail", + "sha256": "af63092637ece340a6d751f15de1a1a64a03aa35ff9d4872dc7d2334915f447c" + }, + { + "bytes": 251651, + "path": "dist/control-ui/assets/chat-message-0eXitjaF.js", + "role": "control-ui-plan-renderer", + "sha256": "a42e193f44dd1066309961c94aafdbe116efa419aa8416a60166bc686dbefb2c" + }, + { + "bytes": 2735, + "path": "dist/server-methods-list-BBb6tAGx.js", + "role": "gateway-events", + "sha256": "c2574b2acf54a3b9983a53cb1305d443c8e50d132fc4879c16a2890277f2c714" + }, + { + "bytes": 663, + "path": "dist/server-constants-DKuFNbQH.js", + "role": "gateway-limits", + "sha256": "f91c3844f4ba9518e94bd84a71e72bbefe4ea36a61a90b81b16a30e60b06bb9e" + }, + { + "bytes": 32625, + "path": "dist/server-methods-qb0Zm5m_.js", + "role": "gateway-methods", + "sha256": "87e0150acf911d80b2af719c9550a5295d22b83a0eda94e4ab18ccdfdac54222" + }, + { + "bytes": 32788, + "path": "dist/server-ws-runtime-BSymIEDW.js", + "role": "gateway-websocket", + "sha256": "ef51e2b35c7d1172dda3e1049caa6de7d0e8d5fde64267e06086689c7a01e652" + }, + { + "bytes": 33960, + "path": "dist/core-descriptors-BbSqaxhR.js", + "role": "method-descriptors", + "sha256": "9f67a7220b8753996feb74a9bf76529b955b39983667f57565aa2cb977770e04" + }, + { + "bytes": 113599, + "path": "package.json", + "role": "package-metadata", + "sha256": "19153cb18fadae0b12627274e86e2bf8ccec619e3b0141af24dac4acac0823cb" + }, + { + "bytes": 871605, + "path": "dist/openclaw-tools-DsjdCfmj.js", + "role": "plan-tool", + "sha256": "f05a1a4164690da8040561c572435264d991f424bb1bf7845a685a84a09f4643" + }, + { + "bytes": 793342, + "path": "dist/index-Dfn_edHo.d.ts", + "role": "protocol-declarations", + "sha256": "5bfb08c959f6e37b0f900e065e436876f729d183b430b96f0c569b0a5bb7ed1a" + }, + { + "bytes": 380524, + "path": "dist/src-Bf6X-__K.js", + "role": "protocol-schemas", + "sha256": "8a6305a5f56b7c853ad1964c1df093d41c17d2812e8356685280ef4f84d893fb" + }, + { + "bytes": 638, + "path": "dist/version-CwNT1gaY.js", + "role": "protocol-version", + "sha256": "fb5bf01f88b38b22bb05bb91538fed58db58038359e43017b68e4c989c971f76" + }, + { + "bytes": 62049, + "path": "dist/server-runtime-subscriptions-BQ4b3zkh.js", + "role": "runtime-subscriptions", + "sha256": "171c52bd6a3d10e8555fdaf00aed3ce225fd465911ef8958ce8235751a4c6f7b" + }, + { + "bytes": 3364, + "path": "dist/session-companion-rpc-BItcEiDG.js", + "role": "session-companion-rpc", + "sha256": "30ef81d5df4c08320bcbdf6fdbb831a83ac0e1d383e06f878717080b92e72479" + }, + { + "bytes": 16104, + "path": "dist/session-companion-ask-DdsC3uMJ.js", + "role": "session-companion-runtime", + "sha256": "9a7057f9fb6e44ef642f9a6413bb716076c5633cbc68f186d3da81cfb8e5dce1" + }, + { + "bytes": 24828, + "path": "dist/subagent-control-BLCfdEdC.js", + "role": "subagent-control", + "sha256": "216871a7c7e5aa26644425f2daf9e238961f84ee0aa95c89fb21c68090e93b88" + }, + { + "bytes": 102984, + "path": "dist/task-registry-CSGqEUH0.js", + "role": "task-registry", + "sha256": "fb7ab0badc2453c326edb76099a7324340251df46e7ec0b9b9ef7123b3b7e97b" + }, + { + "bytes": 4058, + "path": "dist/tasks-Btru2I19.js", + "role": "tasks-handlers", + "sha256": "5616d32c248fb98f38e1bccdc85fdb92acba492410da085a7876e29ea4a648e3" + } + ] +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/sessions.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/sessions.json new file mode 100644 index 000000000..d4ddb27ff --- /dev/null +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/sessions.json @@ -0,0 +1,166 @@ +{ + "gatewayEvents": [ + "session.approval", + "session.message", + "session.observer", + "session.operation", + "session.sharing", + "session.suggestion", + "session.tool", + "session.typing", + "sessions.changed" + ], + "methods": [ + "session.discussion.info", + "session.discussion.open", + "session.members.add", + "session.members.list", + "session.members.remove", + "session.suggestions.add", + "session.suggestions.list", + "session.suggestions.resolve", + "session.typing", + "session.visibility.set", + "sessions.abort", + "sessions.branches.list", + "sessions.branches.switch", + "sessions.catalog.archive", + "sessions.catalog.continue", + "sessions.catalog.list", + "sessions.catalog.read", + "sessions.cleanup", + "sessions.compact", + "sessions.compaction.branch", + "sessions.compaction.get", + "sessions.compaction.list", + "sessions.compaction.restore", + "sessions.companion.ask", + "sessions.companion.reset", + "sessions.companion.state", + "sessions.create", + "sessions.delete", + "sessions.describe", + "sessions.diff", + "sessions.dispatch", + "sessions.files.get", + "sessions.files.list", + "sessions.files.reveal", + "sessions.files.set", + "sessions.fork", + "sessions.get", + "sessions.groups.delete", + "sessions.groups.list", + "sessions.groups.put", + "sessions.groups.rename", + "sessions.list", + "sessions.messages.subscribe", + "sessions.messages.unsubscribe", + "sessions.observer.visibility", + "sessions.patch", + "sessions.pluginPatch", + "sessions.preview", + "sessions.reclaim", + "sessions.reset", + "sessions.resolve", + "sessions.rewind", + "sessions.search", + "sessions.send", + "sessions.steer", + "sessions.subscribe", + "sessions.unsubscribe", + "sessions.usage", + "sessions.usage.logs", + "sessions.usage.timeseries", + "sessions.viewers.set" + ], + "schemaVersion": 1, + "companion": { + "authority": { + "askResultDelivery": "requester-only", + "dedicatedGatewayEvent": false, + "stateStorage": "process-memory" + }, + "lifecycle": { + "firstFailedAskRemovesEmptyThread": true, + "resetAbortsActiveAsk": true, + "sessionResetClearsThread": true, + "serviceDisposeAbortsAll": true + }, + "limits": { + "answerChars": 1200, + "connectionAsksPerMinute": 4, + "exchangeBytes": 49152, + "exchanges": 24, + "globalAsksPerMinute": 12, + "globalConcurrentAsks": 6, + "idleTtlMs": 7200000, + "perSeedMessageChars": 4000, + "perSessionConcurrentAsks": 1, + "questionChars": 400, + "seedBytes": 24576, + "seedTranscriptMessages": 40, + "sweepIntervalMs": 600000, + "timeoutMs": 60000 + }, + "methodPermissions": [ + { + "controlPlaneWrite": false, + "name": "sessions.companion.ask", + "scope": "operator.read" + }, + { + "controlPlaneWrite": true, + "name": "sessions.companion.reset", + "scope": "operator.write" + }, + { + "controlPlaneWrite": false, + "name": "sessions.companion.state", + "scope": "operator.read" + } + ], + "runtimePolicy": { + "askStartsUtilityModelInference": true, + "messageToolDisabled": true, + "sessionsVisibility": "self", + "toolSearchDisabled": true, + "tools": ["read", "sessions_history", "sessions_search"], + "workspaceOnly": true + }, + "uiProjection": { + "busyCode": "SESSION_COMPANION_BUSY", + "hydrationIsRevisionGuarded": true, + "localPendingPerSession": true, + "retainedExchanges": 24 + } + }, + "domain": "sessions", + "plan": { + "authority": { + "dedicatedGatewayEvent": false, + "dedicatedRpcMethod": false, + "gatewayEvent": "agent", + "phase": "update", + "producerTool": "update_plan", + "stream": "plan" + }, + "contract": { + "legacyStringStepsBecomePending": true, + "maximumInProgressSteps": 1, + "minimumSteps": 1, + "statuses": ["pending", "in_progress", "completed"] + }, + "lifecycle": { + "clearedOnOwningRunTerminal": true, + "durableAfterTerminal": false, + "historyRecovery": "in-flight-run-only", + "runOwned": true + }, + "uiProjection": { + "activeOnly": true, + "composerChecklist": true, + "messageStreamCard": true, + "sessionRailStepLimit": 3 + } + } +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/tasks.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/tasks.json new file mode 100644 index 000000000..a188433b5 --- /dev/null +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/tasks.json @@ -0,0 +1,76 @@ +{ + "authority": { + "cancelTarget": "task-id", + "ledgerScope": "global-with-optional-filters", + "sessionFilterRequired": false + }, + "cancellation": { + "canonicalCompletionCanWinRace": true, + "cascadesSubagentDescendants": true, + "notFoundIsRpcSuccess": true, + "operatorControlBypassesCallerSessionOwnership": true, + "refusalIsRpcSuccess": true, + "subagentCancellationIsProvisional": true, + "terminalTaskIsNotCancelled": true + }, + "domain": "tasks", + "event": { + "actions": ["deleted", "restored", "upserted"], + "delivery": "best-effort-drop-if-slow", + "name": "task" + }, + "gatewayEvents": ["task"], + "list": { + "cursor": "decimal-offset", + "defaultLimit": 100, + "filters": ["agentId", "sessionKey", "status"], + "maximumLimit": 500, + "ordering": "last-activity-descending" + }, + "methodPermissions": [ + { + "controlPlaneWrite": false, + "name": "tasks.cancel", + "scope": "operator.write" + }, + { + "controlPlaneWrite": false, + "name": "tasks.get", + "scope": "operator.read" + }, + { + "controlPlaneWrite": false, + "name": "tasks.list", + "scope": "operator.read" + } + ], + "methods": ["tasks.cancel", "tasks.get", "tasks.list"], + "promptVisibility": { + "getIncludesBoundedPrompt": true, + "listAndEventsOmitPrompt": true, + "promptChars": 4000 + }, + "runtimeMappings": [ + { "internal": "cancelled", "wire": "cancelled" }, + { "internal": "failed", "wire": "failed" }, + { "internal": "lost", "wire": "failed" }, + { "internal": "queued", "wire": "queued" }, + { "internal": "running", "wire": "running" }, + { "internal": "succeeded", "wire": "completed" }, + { "internal": "timed_out", "wire": "timed_out" } + ], + "schemaVersion": 1, + "statuses": ["queued", "running", "completed", "failed", "cancelled", "timed_out"], + "uiProjection": { + "activeSnapshotLimit": 200, + "cancelledAndTimedOutUseFailedGroup": true, + "detailUsesTasksGet": true, + "eventBufferDuringSnapshot": true, + "finishedSnapshotLimit": 100, + "nonSubagentOpenSessionLink": true, + "reconnectRefetch": true, + "restoredEventRefetch": true, + "stopRequiresOperatorWrite": true, + "subagentOpenSessionLink": false + } +} diff --git a/qualification/openclaw/reviewedFixtures.ts b/qualification/openclaw/reviewedFixtures.ts new file mode 100644 index 000000000..e4ad90db8 --- /dev/null +++ b/qualification/openclaw/reviewedFixtures.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + agentsFixtureSchema, + chatFixtureSchema, + cronFixtureSchema, + gatewayFixtureSchema, + parseFixtureDocument, + parseFixtureManifest, + parseSourceAuditResult, + sessionsFixtureSchema, + tasksFixtureSchema, + type FixtureManifest, + type SourceAuditResult, +} from "./sourceAuditSchemas.ts"; + +const maximumFixtureBytes = 256 * 1024; +const reviewedFixtureFileNames = [ + "agents.json", + "chat.json", + "cron.json", + "gateway.json", + "manifest.json", + "sessions.json", + "tasks.json", +] as const; + +export const defaultReviewedOpenClawFixtureRoot = new URL( + "fixtures/2026.7.2-beta.7/", + import.meta.url +); + +export interface ReviewedOpenClawFixtures { + audit: SourceAuditResult; + manifest: FixtureManifest; +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function canonicalJson(value: unknown): string { + return `${JSON.stringify(value, null, 4)}\n`; +} + +function sha256(contents: Uint8Array): string { + return createHash("sha256").update(contents).digest("hex"); +} + +async function readBoundedFixture( + fixtureRoot: string, + fileName: string +): Promise<{ bytes: Buffer; serialized: string }> { + const target = path.resolve(fixtureRoot, fileName); + if (!target.startsWith(`${fixtureRoot}${path.sep}`)) { + throw new Error("Reviewed OpenClaw fixture escaped its version directory"); + } + const fileStat = await stat(target); + if (!fileStat.isFile() || fileStat.size <= 0 || fileStat.size > maximumFixtureBytes) { + throw new Error(`Reviewed OpenClaw fixture ${fileName} has an invalid size`); + } + const bytes = await readFile(target); + let serialized: string; + try { + serialized = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`Reviewed OpenClaw fixture ${fileName} is not valid UTF-8`); + } + return { bytes, serialized }; +} + +/** + * Loads only committed, synthetic fixtures and verifies their byte hashes. + * @param selectedFixtureRoot Reviewed version directory or its file URL. + * @returns Strictly parsed manifest and component facts. + */ +export async function loadReviewedOpenClawFixtures( + selectedFixtureRoot: string | URL = defaultReviewedOpenClawFixtureRoot +): Promise { + let selectedPath: string; + if (selectedFixtureRoot instanceof URL) { + const manifestUrl = new URL("manifest.json", selectedFixtureRoot); + selectedPath = path.dirname(fileURLToPath(manifestUrl)); + } else { + selectedPath = selectedFixtureRoot; + } + const fixtureRoot = path.resolve(selectedPath); + const fixtureEntries = await readdir(fixtureRoot, { withFileTypes: true }); + const fileNames = fixtureEntries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .toSorted(compareStrings); + if (JSON.stringify(fileNames) !== JSON.stringify(reviewedFixtureFileNames)) { + throw new Error("Reviewed OpenClaw fixture directory has an unexpected file set"); + } + + const manifestFile = await readBoundedFixture(fixtureRoot, "manifest.json"); + const manifest = parseFixtureManifest(manifestFile.serialized); + if (path.basename(fixtureRoot) !== manifest.source.version) { + throw new Error( + "Reviewed OpenClaw fixture directory does not match its source version" + ); + } + + const componentFiles = new Map( + await Promise.all( + manifest.components.map(async (component) => { + const fixture = await readBoundedFixture(fixtureRoot, component.file); + if (sha256(fixture.bytes) !== component.sha256) { + throw new Error( + `Reviewed OpenClaw fixture hash mismatch for ${component.file}` + ); + } + return [component.file, fixture.serialized] as const; + }) + ) + ); + const required = ( + fileName: Exclude<(typeof reviewedFixtureFileNames)[number], "manifest.json"> + ) => { + const serialized = componentFiles.get(fileName); + if (!serialized) + throw new Error(`Reviewed OpenClaw fixture is missing ${fileName}`); + return serialized; + }; + const audit = parseSourceAuditResult({ + agents: parseFixtureDocument(agentsFixtureSchema, required("agents.json")), + chat: parseFixtureDocument(chatFixtureSchema, required("chat.json")), + cron: parseFixtureDocument(cronFixtureSchema, required("cron.json")), + gateway: parseFixtureDocument(gatewayFixtureSchema, required("gateway.json")), + sessions: parseFixtureDocument(sessionsFixtureSchema, required("sessions.json")), + tasks: parseFixtureDocument(tasksFixtureSchema, required("tasks.json")), + source: manifest.source, + sourceArtifacts: manifest.sourceArtifacts, + }); + if (audit.gateway.protocolVersion !== audit.source.protocolVersion) { + throw new Error("Reviewed OpenClaw fixture protocol versions differ"); + } + return { audit, manifest }; +} + +/** + * Fails when an explicit host audit differs from the reviewed fixture set. + * @param observed Source-derived audit candidate. + * @param reviewed Hash-verified committed fixture set. + * @returns Nothing when both canonical audit values match. + */ +export function assertOpenClawAuditMatchesReviewed( + observed: SourceAuditResult, + reviewed: SourceAuditResult +): void { + const parsedObserved = parseSourceAuditResult(observed); + const parsedReviewed = parseSourceAuditResult(reviewed); + if (canonicalJson(parsedObserved) !== canonicalJson(parsedReviewed)) { + throw new Error( + "Installed OpenClaw source differs from the reviewed protocol fixtures" + ); + } +} + +function fixtureComponents(audit: SourceAuditResult): readonly [string, unknown][] { + return [ + ["agents.json", audit.agents], + ["chat.json", audit.chat], + ["cron.json", audit.cron], + ["gateway.json", audit.gateway], + ["sessions.json", audit.sessions], + ["tasks.json", audit.tasks], + ]; +} + +/** + * Emits a candidate fixture directory for review without touching committed evidence. + * @param audit Strict source-derived audit candidate. + * @param selectedOutputDirectory New absolute candidate version directory. + * @returns Completion after an atomic directory rename. + */ +export async function writeOpenClawAuditCandidate( + audit: SourceAuditResult, + selectedOutputDirectory: string +): Promise { + const parsedAudit = parseSourceAuditResult(audit); + if ( + !path.isAbsolute(selectedOutputDirectory) || + selectedOutputDirectory.includes("\0") + ) { + throw new TypeError("OpenClaw audit output directory must be an absolute path"); + } + const outputDirectory = path.resolve(selectedOutputDirectory); + if (path.basename(outputDirectory) !== parsedAudit.source.version) { + throw new Error( + "OpenClaw audit output directory must be named after the source version" + ); + } + try { + await stat(outputDirectory); + throw new Error("OpenClaw audit output directory already exists"); + } catch (error) { + if ( + error instanceof Error && + error.message === "OpenClaw audit output directory already exists" + ) { + throw error; + } + if ( + !( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw error; + } + } + + const parentDirectory = path.dirname(outputDirectory); + await mkdir(parentDirectory, { recursive: true }); + const temporaryDirectory = await mkdtemp( + path.join(parentDirectory, `.${parsedAudit.source.version}.candidate-`) + ); + try { + const components = fixtureComponents(parsedAudit); + const manifestComponents: FixtureManifest["components"] = []; + for (const [fileName, value] of components) { + const serialized = canonicalJson(value); + await writeFile(path.join(temporaryDirectory, fileName), serialized, { + encoding: "utf8", + flag: "wx", + }); + manifestComponents.push({ + file: fileName as FixtureManifest["components"][number]["file"], + sha256: sha256(Buffer.from(serialized, "utf8")), + }); + } + const manifest: FixtureManifest = { + components: manifestComponents, + contentPolicy: { + containsHostConfiguration: false, + containsRuntimeState: false, + containsSecrets: false, + sourceArtifacts: "hashes-only", + syntheticPayloadsOnly: true, + }, + schemaVersion: 1, + source: parsedAudit.source, + sourceArtifacts: parsedAudit.sourceArtifacts, + }; + await writeFile( + path.join(temporaryDirectory, "manifest.json"), + canonicalJson(manifest), + { encoding: "utf8", flag: "wx" } + ); + await rename(temporaryDirectory, outputDirectory); + } catch (error) { + await rm(temporaryDirectory, { force: true, recursive: true }); + throw error; + } +} diff --git a/qualification/openclaw/runSourceAudit.ts b/qualification/openclaw/runSourceAudit.ts new file mode 100644 index 000000000..502e052c0 --- /dev/null +++ b/qualification/openclaw/runSourceAudit.ts @@ -0,0 +1,91 @@ +import path from "node:path"; + +import { + assertOpenClawAuditMatchesReviewed, + loadReviewedOpenClawFixtures, + writeOpenClawAuditCandidate, +} from "./reviewedFixtures.ts"; +import { auditInstalledOpenClaw } from "./sourceAudit.ts"; + +type SourceAuditCliArguments = + | { mode: "check"; sourceRoot: string } + | { mode: "write"; outputDirectory: string; sourceRoot: string }; + +const usage = + "Usage: runSourceAudit.ts --source-root=/absolute/openclaw/package (--check-reviewed | --output=/absolute/candidate/)"; + +function readAbsolutePathOption(argument: string | undefined, prefix: string): string { + const value = argument?.startsWith(prefix) ? argument.slice(prefix.length) : ""; + if (!value || value.includes("\0") || !path.isAbsolute(value)) { + throw new TypeError(usage); + } + return path.resolve(value); +} + +/** + * Parses the deliberately explicit host-audit CLI interface. + * @param arguments_ Arguments after the Bun entrypoint. + * @returns One explicit check or candidate-write operation. + */ +export function parseSourceAuditCliArguments( + arguments_: readonly string[] +): SourceAuditCliArguments { + if (arguments_.length !== 2) throw new TypeError(usage); + const sourceRootArguments = arguments_.filter((argument) => + argument.startsWith("--source-root=") + ); + if (sourceRootArguments.length !== 1) throw new TypeError(usage); + const sourceRootArgument = sourceRootArguments[0]; + const sourceRoot = readAbsolutePathOption(sourceRootArgument, "--source-root="); + const operation = arguments_.find((argument) => argument !== sourceRootArgument); + if (operation === "--check-reviewed") { + return { mode: "check", sourceRoot }; + } + if (operation?.startsWith("--output=")) { + return { + mode: "write", + outputDirectory: readAbsolutePathOption(operation, "--output="), + sourceRoot, + }; + } + throw new TypeError(usage); +} + +/** + * Runs an explicit host audit and emits only redacted protocol metadata. + * @param arguments_ Arguments after the Bun entrypoint. + * @returns Redacted status metadata safe for standard output. + */ +export async function runSourceAuditCli(arguments_: readonly string[]): Promise { + const options = parseSourceAuditCliArguments(arguments_); + const observed = await auditInstalledOpenClaw(options.sourceRoot); + if (options.mode === "check") { + const reviewed = await loadReviewedOpenClawFixtures(); + assertOpenClawAuditMatchesReviewed(observed, reviewed.audit); + return { + artifactCount: observed.sourceArtifacts.length, + protocolVersion: observed.source.protocolVersion, + status: "MATCH", + version: observed.source.version, + }; + } + await writeOpenClawAuditCandidate(observed, options.outputDirectory); + return { + artifactCount: observed.sourceArtifacts.length, + protocolVersion: observed.source.protocolVersion, + status: "CANDIDATE_WRITTEN", + version: observed.source.version, + }; +} + +if (import.meta.main) { + try { + const result = await runSourceAuditCli(Bun.argv.slice(2)); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + const message = + error instanceof Error ? error.message : "OpenClaw source audit failed"; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/qualification/openclaw/sourceAudit.test.ts b/qualification/openclaw/sourceAudit.test.ts new file mode 100644 index 000000000..62410023a --- /dev/null +++ b/qualification/openclaw/sourceAudit.test.ts @@ -0,0 +1,451 @@ +import { describe, expect, test } from "bun:test"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + assertOpenClawAuditMatchesReviewed, + defaultReviewedOpenClawFixtureRoot, + loadReviewedOpenClawFixtures, + writeOpenClawAuditCandidate, +} from "./reviewedFixtures.ts"; +import { parseSourceAuditCliArguments } from "./runSourceAudit.ts"; +import { auditInstalledOpenClaw } from "./sourceAudit.ts"; +import { chatFixtureSchema, parseFixtureDocument } from "./sourceAuditSchemas.ts"; + +const sourceVersion = "2026.7.2-beta.7"; +const sourceCommit = "dabe1915362e20c25704af91612a32a8f4c96e83"; +const sourceBuiltAt = "2026-08-01T19:22:56.002Z"; + +async function writeSyntheticOpenClawPackage(sourceRoot: string): Promise { + const dist = path.join(sourceRoot, "dist"); + const controlUiAssets = path.join(dist, "control-ui", "assets"); + await mkdir(dist, { recursive: true }); + await mkdir(controlUiAssets, { recursive: true }); + const artifacts: Record = { + "build-info.json": `${JSON.stringify({ + builtAt: sourceBuiltAt, + commit: sourceCommit, + version: sourceVersion, + })}\n`, + "index-fixture.d.ts": ` + declare const PROTOCOL_VERSION: 4; + declare const ChatEventSchema: unknown; + state: Type.TLiteral<"status">; + state: Type.TLiteral<"delta">; + state: Type.TLiteral<"final">; + state: Type.TLiteral<"aborted">; + state: Type.TLiteral<"error">; + type: Type.TLiteral<"hello-ok">; + type: Type.TLiteral<"req">; + type: Type.TLiteral<"res">; + type: Type.TLiteral<"event">; + `, + "server-chat-fixture.js": ` + function flushBufferedChatDeltaIfNeeded() {} + if (now - (run.deltaSentAt ?? 0) < 150) return; + if (now - last < 150) return; + if (evt.stream === "assistant") return "assistant"; + if (evt.stream === "thinking") return "thinking"; + if (toolPhase === "start") flushBufferedChatDeltaIfNeeded(); + if (phase === "start" && (isControlUiVisible || hasSessionMessageSubscribers)) {} + const emitChatTerminal = () => { + flushBufferedChatDeltaIfNeeded(sessionKey, opts?.agentId); + chatRunState.clearRun(clientRunId); + }; + if (evt.stream === "plan" && evt.data?.phase === "update") { + chatRunState.getOrCreate(clientRunId).planSnapshot = {}; + } + `, + "chat-abort-fixture.js": ` + const plan = run?.planSnapshot; + const withoutText = params.snapshot.plan ? { plan: params.snapshot.plan } : {}; + const droppedPlan = { plan: { steps: [] } }; + `, + "core-descriptors-fixture.js": ` + { name: "tasks.list", scope: "operator.read" }, + { name: "tasks.get", scope: "operator.read" }, + { name: "tasks.cancel", scope: "operator.write" }, + { name: "sessions.companion.ask", scope: "operator.read" }, + { name: "sessions.companion.state", scope: "operator.read" }, + { name: "sessions.companion.reset", scope: "operator.write", controlPlaneWrite: true }, + `, + "openclaw-tools-fixture.js": ` + const PLAN_STEP_STATUSES = [ + "pending", + "in_progress", + "completed" + ]; + const schema = { minItems: 1 }; + status === "in_progress"; + throw new Error("plan can contain at most one in_progress step"); + const tool = { name: "update_plan", status: "updated" }; + `, + "src-fixture.js": ` + const TaskLedgerStatusSchema = [ + Type.Literal("queued"), Type.Literal("running"), + Type.Literal("completed"), Type.Literal("failed"), + Type.Literal("cancelled"), Type.Literal("timed_out") + ]; + const SessionsCompanionAskParamsSchema = { + maxLength: 400, maxLength: 1200, maxItems: 24, maximum: 500 + }; + // Companion answer returned only to the requesting operator. + // Returned by tasks.get; omitted from list/event summaries. + `, + "server-runtime-subscriptions-fixture.js": ` + const SESSION_COMPANION_IDLE_TTL_MS = 120 * 6e4; + const SESSION_COMPANION_SWEEP_INTERVAL_MS = 10 * 6e4; + payload = { action: "restored" }; + params.broadcast("task", payload, { dropIfSlow: true }); + `, + "session-companion-rpc-fixture.js": ` + "sessions.companion.ask"; + "sessions.companion.state"; + "sessions.companion.reset"; + SESSION_COMPANION_BUSY; + const details = { retryable: true }; + `, + "session-companion-ask-fixture.js": ` + const SESSION_COMPANION_TOOLS = [ + "read", + "sessions_history", + "sessions_search" + ]; + const policy = { visibility: "self", workspaceOnly: true, enabled: false }; + const SESSION_COMPANION_MAX_EXCHANGES = 24; + const SESSION_COMPANION_MAX_EXCHANGE_BYTES = 48 * 1024; + const ASK_TIMEOUT_MS = 6e4; + const ANSWER_MAX_CHARS = 1200; + const SEED_MAX_BYTES = 24 * 1024; + const SEED_MESSAGE_MAX_CHARS = 4e3; + const MAX_CONCURRENT_ASKS = 6; + const MAX_ASKS_PER_RATE_WINDOW = 12; + const MAX_ASKS_PER_CONNECTION_RATE_WINDOW = 4; + messages.slice(-40); + const run = { disableMessageTool: true }; + throw new Error("The session companion is answering another question."); + `, + "tasks-fixture.js": ` + const DEFAULT_TASKS_LIST_LIMIT = 100; + const MAX_TASKS_LIST_LIMIT = 500; + const LEDGER_STATUS_TO_TASK_STATUSES = { failed: ["failed", "lost"] }; + function parseCursor() {} + "tasks.list"; "tasks.get"; "tasks.cancel"; + mapTaskSummary(task, { includePrompt: true }); + respond(true, {}); + `, + "task-registry-fixture.js": ` + "Task is already terminal."; + killSubagentRunAdmin(); + "Subagent completed while cancellation was in progress."; + `, + "subagent-control-fixture.js": ` + // Admin kill path for a subagent session key, bypassing caller ownership checks. + cascadeKillChildren(); + const result = { cascadeKilled: cascade.killed }; + `, + "server-constants-fixture.js": ` + const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024; + const MAX_PREAUTH_PAYLOAD_BYTES = 64 * 1024; + `, + "server-methods-fixture.js": ` + //#region src/gateway/server-methods.ts + const coreGatewayHandlers = {}; + methods: ["agent", "agent.wait"]; + methods: ["agent.identity.get", "agents.list"]; + methods: ["chat.abort", "chat.history", "chat.send"]; + methods: ["session.typing", "sessions.list", "sessions.send"]; + methods: ["tasks.cancel", "tasks.get", "tasks.list"]; + methods: [ + "wake", + "cron.list", + "cron.add" + ]; + `, + "server-methods-list-fixture.js": ` + const GATEWAY_EVENTS = [ + "connect.challenge", + "agent", + "chat", + "session.message", + "session.tool", + "session.typing", + "sessions.changed", + "task", + "cron", + "health", + "heartbeat", + "presence", + "shutdown", + "tick" + ]; + `, + "server-ws-runtime-fixture.js": ` + MAX_PREAUTH_PAYLOAD_BYTES; + send({ type: "event", event: "connect.challenge" }); + setLastFrameMeta({ method: "connect" }); + `, + "version-fixture.js": ` + //#region packages/gateway-protocol/src/version.ts + const PROTOCOL_VERSION = 4; + const MIN_CLIENT_PROTOCOL_VERSION = 4; + const MIN_NODE_PROTOCOL_VERSION = 3; + const MIN_PROBE_PROTOCOL_VERSION = 3; + `, + }; + await Promise.all( + Object.entries(artifacts).map(([fileName, contents]) => + writeFile(path.join(dist, fileName), contents, "utf8") + ) + ); + const controlUiArtifacts: Record = { + "chat-message-fixture.js": ` + if (t.stream===\`plan\` && n.phase===\`update\`) {} + const status = a===\`in_progress\`&&n?\`pending\`:a; + e.planStatus=null; + "plan-checklist__body"; "plan-checklist__count"; + `, + "chat-page-fixture.js": ` + sessions.companion.ask; tasks.list; tasks.get; tasks.cancel; + const limits = { ob=200,sb=100 }; + runtime!==\`subagent\`; + SESSION_COMPANION_BUSY; + exchanges.slice(-24); + `, + "chat-session-rail-fixture.js": ` + planStatus; planProgress; steps.slice(-3); openclaw-chat-session-rail; + `, + }; + await Promise.all( + Object.entries(controlUiArtifacts).map(([fileName, contents]) => + writeFile(path.join(controlUiAssets, fileName), contents, "utf8") + ) + ); + await writeFile( + path.join(sourceRoot, "package.json"), + `${JSON.stringify({ name: "openclaw", version: sourceVersion })}\n`, + "utf8" + ); +} + +async function withTemporaryDirectory( + prefix: string, + operation: (directory: string) => Promise +): Promise { + const directory = await mkdtemp(path.join(tmpdir(), prefix)); + try { + return await operation(directory); + } finally { + await rm(directory, { force: true, recursive: true }); + } +} + +describe("reviewed OpenClaw protocol fixtures", () => { + test("loads strict, hash-pinned fixtures without an installed OpenClaw package", async () => { + const reviewed = await loadReviewedOpenClawFixtures(); + + expect(reviewed.manifest.contentPolicy).toEqual({ + containsHostConfiguration: false, + containsRuntimeState: false, + containsSecrets: false, + sourceArtifacts: "hashes-only", + syntheticPayloadsOnly: true, + }); + expect(reviewed.audit.source).toEqual({ + builtAt: sourceBuiltAt, + commit: sourceCommit, + packageName: "openclaw", + protocolVersion: 4, + version: sourceVersion, + }); + expect(reviewed.audit.gateway).toMatchObject({ + challengeEvent: "connect.challenge", + helloType: "hello-ok", + limits: { + authenticatedFrameBytes: 25 * 1024 * 1024, + preauthenticationFrameBytes: 64 * 1024, + }, + protocolVersion: 4, + }); + expect(reviewed.audit.chat.streamingPolicy).toEqual({ + coalescedAgentStreams: ["assistant", "thinking"], + deltaThrottleMs: 150, + flushBeforeBoundaries: ["item.start", "tool.start"], + flushBufferedDeltaBeforeTerminal: true, + terminalStates: ["final", "aborted", "error"], + }); + expect(reviewed.audit.chat.syntheticScenarios).toHaveLength(2); + expect( + reviewed.audit.chat.syntheticScenarios[1]?.events.map((event) => event.kind) + ).toEqual([ + "agent-delta", + "agent-delta", + "tool-start", + "tool-result", + "chat-delta", + "chat-terminal", + ]); + expect(reviewed.audit.sourceArtifacts).toHaveLength(22); + expect(reviewed.audit.sessions.plan.authority).toMatchObject({ + dedicatedGatewayEvent: false, + gatewayEvent: "agent", + producerTool: "update_plan", + stream: "plan", + }); + expect(reviewed.audit.sessions.companion.methodPermissions).toEqual([ + { + controlPlaneWrite: false, + name: "sessions.companion.ask", + scope: "operator.read", + }, + { + controlPlaneWrite: true, + name: "sessions.companion.reset", + scope: "operator.write", + }, + { + controlPlaneWrite: false, + name: "sessions.companion.state", + scope: "operator.read", + }, + ]); + expect(reviewed.audit.tasks.uiProjection.subagentOpenSessionLink).toBeFalse(); + }); + + test("rejects unknown fixture fields before policy use", async () => { + const fixtureRoot = path.dirname( + fileURLToPath(new URL("manifest.json", defaultReviewedOpenClawFixtureRoot)) + ); + const serialized = await readFile(path.join(fixtureRoot, "chat.json"), "utf8"); + const value = JSON.parse(serialized) as Record; + + expect(() => + parseFixtureDocument( + chatFixtureSchema, + JSON.stringify({ ...value, rawHostConfiguration: {} }) + ) + ).toThrow(); + }); + + test("rejects a fixture whose bytes no longer match the reviewed manifest", async () => { + await withTemporaryDirectory("mira-openclaw-fixtures-", async (temporaryRoot) => { + const fixtureRoot = path.join(temporaryRoot, sourceVersion); + await mkdir(fixtureRoot); + const reviewedRoot = path.dirname( + fileURLToPath( + new URL("manifest.json", defaultReviewedOpenClawFixtureRoot) + ) + ); + for (const fileName of [ + "agents.json", + "chat.json", + "cron.json", + "gateway.json", + "manifest.json", + "sessions.json", + "tasks.json", + ]) { + await copyFile( + path.join(reviewedRoot, fileName), + path.join(fixtureRoot, fileName) + ); + } + await writeFile( + path.join(fixtureRoot, "chat.json"), + `${await readFile(path.join(fixtureRoot, "chat.json"), "utf8")} `, + "utf8" + ); + + try { + await loadReviewedOpenClawFixtures(fixtureRoot); + throw new Error("Expected fixture hash validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("hash mismatch for chat.json"); + } + }); + }); +}); + +describe("explicit OpenClaw source audit", () => { + test("extracts only reviewed facts from a synthetic package distribution", async () => { + await withTemporaryDirectory("mira-openclaw-source-", async (sourceRoot) => { + await writeSyntheticOpenClawPackage(sourceRoot); + + const audit = await auditInstalledOpenClaw(sourceRoot); + + expect(audit.source).toMatchObject({ + commit: sourceCommit, + protocolVersion: 4, + version: sourceVersion, + }); + expect(audit.chat.methods).toEqual([ + "chat.abort", + "chat.history", + "chat.send", + ]); + expect(audit.agents.methods).toEqual([ + "agent", + "agent.identity.get", + "agent.wait", + "agents.list", + ]); + expect(audit.sessions.gatewayEvents).toEqual([ + "session.message", + "session.tool", + "session.typing", + "sessions.changed", + ]); + expect(audit.tasks.methods).toEqual([ + "tasks.cancel", + "tasks.get", + "tasks.list", + ]); + expect(audit.sourceArtifacts).toHaveLength(22); + }); + }); + + test("round-trips a source audit through a separately generated candidate", async () => { + await withTemporaryDirectory( + "mira-openclaw-candidate-", + async (temporaryRoot) => { + const sourceRoot = path.join(temporaryRoot, "source"); + await writeSyntheticOpenClawPackage(sourceRoot); + const audit = await auditInstalledOpenClaw(sourceRoot); + const outputDirectory = path.join(temporaryRoot, sourceVersion); + + await writeOpenClawAuditCandidate(audit, outputDirectory); + const loaded = await loadReviewedOpenClawFixtures(outputDirectory); + + expect(() => + assertOpenClawAuditMatchesReviewed(audit, loaded.audit) + ).not.toThrow(); + } + ); + }); + + test("requires explicit absolute host paths and one operation", () => { + expect( + parseSourceAuditCliArguments([ + "--check-reviewed", + "--source-root=/opt/openclaw", + ]) + ).toEqual({ mode: "check", sourceRoot: "/opt/openclaw" }); + expect(() => parseSourceAuditCliArguments(["--check-reviewed"])).toThrow(); + expect(() => + parseSourceAuditCliArguments([ + "--source-root=relative/openclaw", + "--check-reviewed", + ]) + ).toThrow(); + expect(() => + parseSourceAuditCliArguments([ + "--source-root=/opt/openclaw", + "--source-root=/opt/openclaw", + "--check-reviewed", + ]) + ).toThrow(); + }); +}); diff --git a/qualification/openclaw/sourceAudit.ts b/qualification/openclaw/sourceAudit.ts new file mode 100644 index 000000000..7ff92f6b7 --- /dev/null +++ b/qualification/openclaw/sourceAudit.ts @@ -0,0 +1,1053 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +import * as v from "valibot"; + +import { + parseSourceAuditResult, + type SourceArtifact, + type SourceAuditResult, +} from "./sourceAuditSchemas.ts"; + +const maximumPackageMetadataBytes = 512 * 1024; +const maximumBuildInfoBytes = 4 * 1024; +const maximumDistributionArtifactBytes = 2 * 1024 * 1024; + +interface LoadedSourceArtifact extends SourceArtifact { + contents: string; +} + +interface DistributionArtifactSpec { + directory?: "dist" | "dist/control-ui/assets"; + fileNamePattern: RegExp; + markers: readonly string[]; + role: SourceArtifact["role"]; +} + +const distributionArtifactSpecs: readonly DistributionArtifactSpec[] = [ + { + fileNamePattern: /^chat-abort-[A-Za-z0-9_-]+\.js$/u, + markers: ["const plan = run?.planSnapshot", "const withoutText"], + role: "chat-run-projection", + }, + { + fileNamePattern: /^server-chat-[A-Za-z0-9_-]+\.js$/u, + markers: ["flushBufferedChatDeltaIfNeeded", "run.deltaSentAt"], + role: "chat-streaming", + }, + { + directory: "dist/control-ui/assets", + fileNamePattern: /^chat-page-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "sessions.companion.ask", + "tasks.list", + "tasks.get", + "tasks.cancel", + "runtime!==`subagent`", + "ob=200,sb=100", + ], + role: "control-ui-chat", + }, + { + directory: "dist/control-ui/assets", + fileNamePattern: /^chat-message-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "stream===`plan`", + "phase===`update`", + "plan-checklist__body", + "plan-checklist__count", + ], + role: "control-ui-plan-renderer", + }, + { + directory: "dist/control-ui/assets", + fileNamePattern: /^chat-session-rail-[A-Za-z0-9_-]+\.js$/u, + markers: ["planStatus", "steps.slice(-3)", "openclaw-chat-session-rail"], + role: "control-ui-plan-rail", + }, + { + fileNamePattern: /^server-methods-list-[A-Za-z0-9_-]+\.js$/u, + markers: ["const GATEWAY_EVENTS", "connect.challenge"], + role: "gateway-events", + }, + { + fileNamePattern: /^server-constants-[A-Za-z0-9_-]+\.js$/u, + markers: ["MAX_PAYLOAD_BYTES", "MAX_PREAUTH_PAYLOAD_BYTES"], + role: "gateway-limits", + }, + { + fileNamePattern: /^server-methods-[A-Za-z0-9_-]+\.js$/u, + markers: ["src/gateway/server-methods.ts", "const coreGatewayHandlers"], + role: "gateway-methods", + }, + { + fileNamePattern: /^server-ws-runtime-[A-Za-z0-9_-]+\.js$/u, + markers: ["connect.challenge", "MAX_PREAUTH_PAYLOAD_BYTES"], + role: "gateway-websocket", + }, + { + fileNamePattern: /^core-descriptors-[A-Za-z0-9_-]+\.js$/u, + markers: [ + 'name: "tasks.list"', + 'name: "sessions.companion.ask"', + "controlPlaneWrite: true", + ], + role: "method-descriptors", + }, + { + fileNamePattern: /^openclaw-tools-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "const PLAN_STEP_STATUSES", + "plan can contain at most one in_progress step", + 'name: "update_plan"', + ], + role: "plan-tool", + }, + { + fileNamePattern: /^index-[A-Za-z0-9_-]+\.d\.ts$/u, + markers: ["declare const PROTOCOL_VERSION: 4", "ChatEventSchema"], + role: "protocol-declarations", + }, + { + fileNamePattern: /^src-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "const TaskLedgerStatusSchema", + "const SessionsCompanionAskParamsSchema", + ], + role: "protocol-schemas", + }, + { + fileNamePattern: /^version-[A-Za-z0-9_-]+\.js$/u, + markers: ["packages/gateway-protocol/src/version.ts", "PROTOCOL_VERSION"], + role: "protocol-version", + }, + { + fileNamePattern: /^server-runtime-subscriptions-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "SESSION_COMPANION_IDLE_TTL_MS", + 'params.broadcast("task"', + 'action: "restored"', + ], + role: "runtime-subscriptions", + }, + { + fileNamePattern: /^session-companion-rpc-[A-Za-z0-9_-]+\.js$/u, + markers: [ + '"sessions.companion.ask"', + "SESSION_COMPANION_BUSY", + '"sessions.companion.reset"', + ], + role: "session-companion-rpc", + }, + { + fileNamePattern: /^session-companion-ask-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "SESSION_COMPANION_TOOLS", + "MAX_CONCURRENT_ASKS", + "The session companion is answering another question.", + ], + role: "session-companion-runtime", + }, + { + fileNamePattern: /^subagent-control-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "Admin kill path for a subagent session key, bypassing caller ownership checks.", + "cascadeKillChildren", + "cascadeKilled", + ], + role: "subagent-control", + }, + { + fileNamePattern: /^task-registry-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "Task is already terminal.", + "Subagent completed while cancellation was in progress.", + "killSubagentRunAdmin", + ], + role: "task-registry", + }, + { + fileNamePattern: /^tasks-[A-Za-z0-9_-]+\.js$/u, + markers: ["LEDGER_STATUS_TO_TASK_STATUSES", '"tasks.list"', '"tasks.cancel"'], + role: "tasks-handlers", + }, +]; + +const packageMetadataSchema = v.object({ + name: v.literal("openclaw"), + version: v.string(), +}); +const buildInfoSchema = v.strictObject({ + builtAt: v.string(), + commit: v.string(), + version: v.string(), +}); + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function sortedUnique(values: readonly string[]): string[] { + return [...new Set(values)].toSorted(compareStrings); +} + +function sha256(contents: Uint8Array): string { + return createHash("sha256").update(contents).digest("hex"); +} + +function assertContainedPath(root: string, target: string): void { + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error("OpenClaw source artifact escaped the selected package root"); + } +} + +async function loadSourceArtifact( + sourceRoot: string, + relativePath: string, + role: SourceArtifact["role"], + maximumBytes: number +): Promise { + const requestedPath = path.resolve(sourceRoot, relativePath); + assertContainedPath(sourceRoot, requestedPath); + const absolutePath = await realpath(requestedPath); + assertContainedPath(sourceRoot, absolutePath); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile() || fileStat.size <= 0 || fileStat.size > maximumBytes) { + throw new Error(`OpenClaw ${role} artifact has an invalid size`); + } + const bytes = await readFile(absolutePath); + let contents: string; + try { + contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`OpenClaw ${role} artifact is not valid UTF-8`); + } + return { + bytes: bytes.byteLength, + contents, + path: relativePath, + role, + sha256: sha256(bytes), + }; +} + +async function locateDistributionArtifact( + sourceRoot: string, + spec: DistributionArtifactSpec +): Promise { + const directory = spec.directory ?? "dist"; + const entries = await readdir(path.join(sourceRoot, directory), { + withFileTypes: true, + }); + const fileNames = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .toSorted(compareStrings); + const matches: LoadedSourceArtifact[] = []; + for (const fileName of fileNames) { + if (!spec.fileNamePattern.test(fileName)) continue; + const candidate = await loadSourceArtifact( + sourceRoot, + `${directory}/${fileName}`, + spec.role, + maximumDistributionArtifactBytes + ); + if (spec.markers.every((marker) => candidate.contents.includes(marker))) { + matches.push(candidate); + } + } + if (matches.length !== 1) { + throw new Error( + `Expected one OpenClaw ${spec.role} artifact, found ${matches.length}` + ); + } + return matches[0]!; +} + +function artifactByRole( + artifacts: readonly LoadedSourceArtifact[], + role: SourceArtifact["role"] +): LoadedSourceArtifact { + const artifact = artifacts.find((candidate) => candidate.role === role); + if (!artifact) throw new Error(`Missing OpenClaw ${role} artifact`); + return artifact; +} + +function parseIntegerConstant(source: string, name: string): number { + const match = source.match(new RegExp(`const ${name} = ([^;]+);`, "u")); + if (!match?.[1]) throw new Error(`OpenClaw source is missing ${name}`); + const factors = match[1] + .trim() + .split("*") + .map((factor) => factor.trim()); + if (factors.length === 0 || factors.some((factor) => !/^\d+$/u.test(factor))) { + throw new Error(`OpenClaw ${name} is not a reviewed integer product`); + } + const result = factors.reduce((product, factor) => product * Number(factor), 1); + if (!Number.isSafeInteger(result) || result <= 0) { + throw new Error(`OpenClaw ${name} is outside the reviewed integer range`); + } + return result; +} + +function extractMethodNames(source: string): { + agents: string[]; + chat: string[]; + cron: string[]; + sessions: string[]; + tasks: string[]; +} { + const dottedNames = [ + ...source.matchAll(/"([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9_-]+)+)"/gu), + ].map((match) => match[1]!); + if (!source.includes('methods: ["agent", "agent.wait"]')) { + throw new Error("OpenClaw source is missing the reviewed agent method group"); + } + if (!/methods: \[\s*"wake",\s*"cron\.list"/u.test(source)) { + throw new Error("OpenClaw source is missing the reviewed cron wake method group"); + } + return { + agents: sortedUnique([ + "agent", + ...dottedNames.filter( + (name) => name.startsWith("agent.") || name.startsWith("agents.") + ), + ]), + chat: sortedUnique(dottedNames.filter((name) => name.startsWith("chat."))), + cron: sortedUnique([ + "wake", + ...dottedNames.filter((name) => name.startsWith("cron.")), + ]), + sessions: sortedUnique( + dottedNames.filter( + (name) => name.startsWith("session.") || name.startsWith("sessions.") + ) + ), + tasks: sortedUnique(dottedNames.filter((name) => name.startsWith("tasks."))), + }; +} + +function assertRequiredMarkers( + source: string, + surface: string, + markers: readonly string[] +): void { + for (const marker of markers) { + if (!source.includes(marker)) { + throw new Error( + `OpenClaw ${surface} changed outside the reviewed source-backed shape` + ); + } + } +} + +function assertMethodPermission( + source: string, + method: string, + scope: "operator.read" | "operator.write", + controlPlaneWrite: boolean +): void { + const start = source.indexOf(`name: "${method}"`); + if (start === -1) + throw new Error(`OpenClaw method descriptors are missing ${method}`); + const end = source.indexOf("},", start); + if (end === -1 || end - start > 240) { + throw new Error(`OpenClaw method descriptor is unbounded for ${method}`); + } + const descriptor = source.slice(start, end); + const hasExpectedScope = + scope === "operator.read" + ? /scope: "operator\.read"/u.test(descriptor) + : /scope: "operator\.write"/u.test(descriptor); + const isControlPlaneWrite = /controlPlaneWrite: true/u.test(descriptor); + if (!hasExpectedScope || isControlPlaneWrite !== controlPlaneWrite) { + throw new Error(`OpenClaw permission descriptor changed for ${method}`); + } +} + +function assertPlanCompanionAndTasks(artifacts: readonly LoadedSourceArtifact[]): void { + const planTool = artifactByRole(artifacts, "plan-tool").contents; + assertRequiredMarkers(planTool, "plan producer", [ + '"pending"', + '"in_progress"', + '"completed"', + "minItems: 1", + 'status === "in_progress"', + "plan can contain at most one in_progress step", + 'name: "update_plan"', + 'status: "updated"', + ]); + assertRequiredMarkers( + artifactByRole(artifacts, "chat-streaming").contents, + "plan Gateway projection", + ['evt.stream === "plan" && evt.data?.phase === "update"', "planSnapshot ="] + ); + assertRequiredMarkers( + artifactByRole(artifacts, "chat-run-projection").contents, + "plan history recovery", + ["const plan = run?.planSnapshot", "params.snapshot.plan", "steps: []"] + ); + assertRequiredMarkers( + artifactByRole(artifacts, "control-ui-plan-renderer").contents, + "plan UI projection", + [ + "stream===`plan`", + "phase===`update`", + "a===`in_progress`&&n?`pending`:a", + "plan-checklist__body", + "plan-checklist__count", + "e.planStatus=null", + ] + ); + assertRequiredMarkers( + artifactByRole(artifacts, "control-ui-plan-rail").contents, + "plan session rail", + ["steps.slice(-3)", "planStatus", "planProgress"] + ); + + const protocolSchemas = artifactByRole(artifacts, "protocol-schemas").contents; + assertRequiredMarkers(protocolSchemas, "companion protocol", [ + "const SessionsCompanionAskParamsSchema", + "maxLength: 400", + "maxLength: 1200", + "maxItems: 24", + "Companion answer returned only to the requesting operator.", + ]); + assertRequiredMarkers(protocolSchemas, "task protocol", [ + "const TaskLedgerStatusSchema", + 'Type.Literal("queued")', + 'Type.Literal("running")', + 'Type.Literal("completed")', + 'Type.Literal("failed")', + 'Type.Literal("cancelled")', + 'Type.Literal("timed_out")', + "maxItems: 24", + "maximum: 500", + "Returned by tasks.get; omitted from list/event summaries.", + ]); + + const companionRuntime = artifactByRole( + artifacts, + "session-companion-runtime" + ).contents; + assertRequiredMarkers(companionRuntime, "companion runtime", [ + '"read"', + '"sessions_history"', + '"sessions_search"', + 'visibility: "self"', + "workspaceOnly: true", + "enabled: false", + "SESSION_COMPANION_MAX_EXCHANGES = 24", + "SESSION_COMPANION_MAX_EXCHANGE_BYTES = 48 * 1024", + "ASK_TIMEOUT_MS = 6e4", + "ANSWER_MAX_CHARS = 1200", + "SEED_MAX_BYTES = 24 * 1024", + "SEED_MESSAGE_MAX_CHARS = 4e3", + "MAX_CONCURRENT_ASKS = 6", + "MAX_ASKS_PER_RATE_WINDOW = 12", + "MAX_ASKS_PER_CONNECTION_RATE_WINDOW = 4", + ".slice(-40)", + "disableMessageTool: true", + ]); + assertRequiredMarkers( + artifactByRole(artifacts, "runtime-subscriptions").contents, + "companion and task lifecycle", + [ + "SESSION_COMPANION_IDLE_TTL_MS = 120 * 6e4", + "SESSION_COMPANION_SWEEP_INTERVAL_MS = 10 * 6e4", + 'payload = { action: "restored" }', + 'params.broadcast("task", payload, { dropIfSlow: true })', + ] + ); + assertRequiredMarkers( + artifactByRole(artifacts, "session-companion-rpc").contents, + "companion RPC", + [ + '"sessions.companion.ask"', + '"sessions.companion.state"', + '"sessions.companion.reset"', + "SESSION_COMPANION_BUSY", + "retryable: true", + ] + ); + + const descriptors = artifactByRole(artifacts, "method-descriptors").contents; + assertMethodPermission(descriptors, "sessions.companion.ask", "operator.read", false); + assertMethodPermission( + descriptors, + "sessions.companion.state", + "operator.read", + false + ); + assertMethodPermission( + descriptors, + "sessions.companion.reset", + "operator.write", + true + ); + assertMethodPermission(descriptors, "tasks.list", "operator.read", false); + assertMethodPermission(descriptors, "tasks.get", "operator.read", false); + assertMethodPermission(descriptors, "tasks.cancel", "operator.write", false); + + assertRequiredMarkers( + artifactByRole(artifacts, "tasks-handlers").contents, + "task handlers", + [ + "DEFAULT_TASKS_LIST_LIMIT = 100", + "MAX_TASKS_LIST_LIMIT = 500", + 'failed: ["failed", "lost"]', + "parseCursor", + "mapTaskSummary(task, { includePrompt: true })", + "respond(true, {", + ] + ); + assertRequiredMarkers( + artifactByRole(artifacts, "task-registry").contents, + "task cancellation", + [ + "Task is already terminal.", + "killSubagentRunAdmin", + "Subagent completed while cancellation was in progress.", + ] + ); + assertRequiredMarkers( + artifactByRole(artifacts, "subagent-control").contents, + "subagent task cancellation", + [ + "Admin kill path for a subagent session key, bypassing caller ownership checks.", + "cascadeKillChildren", + "cascadeKilled: cascade.killed", + ] + ); + assertRequiredMarkers( + artifactByRole(artifacts, "control-ui-chat").contents, + "task and companion UI projection", + [ + "ob=200,sb=100", + "runtime!==`subagent`", + "SESSION_COMPANION_BUSY", + "slice(-24)", + "tasks.cancel", + ] + ); +} + +function extractGatewayEvents(source: string): string[] { + const block = source.match(/const GATEWAY_EVENTS = \[([\s\S]*?)\];/u)?.[1]; + if (!block) throw new Error("OpenClaw source is missing the gateway event catalog"); + return sortedUnique( + [...block.matchAll(/"([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9_-]+)*)"/gu)].map( + (match) => match[1]! + ) + ); +} + +function selectRequiredEvents( + gatewayEvents: readonly string[], + selected: readonly string[] +): string[] { + const available = new Set(gatewayEvents); + for (const event of selected) { + if (!available.has(event)) { + throw new Error(`OpenClaw gateway event catalog is missing ${event}`); + } + } + return sortedUnique(selected); +} + +function assertChatStreamingPolicy( + chatSource: string, + declarationSource: string +): number { + const chatThrottle = chatSource.match( + /now - \(run\.deltaSentAt \?\? 0\) < (\d+)/u + )?.[1]; + const agentThrottle = chatSource.match(/now - last < (\d+)/u)?.[1]; + if (!chatThrottle || chatThrottle !== agentThrottle) { + throw new Error("OpenClaw chat and agent delta throttles do not match"); + } + const throttleMs = Number(chatThrottle); + if (!Number.isSafeInteger(throttleMs) || throttleMs <= 0) { + throw new Error("OpenClaw chat delta throttle is invalid"); + } + const requiredSourceMarkers = [ + 'if (evt.stream === "assistant") return "assistant"', + 'if (evt.stream === "thinking") return "thinking"', + 'if (toolPhase === "start"', + '=== "start" && (isControlUiVisible || hasSessionMessageSubscribers)', + "flushBufferedChatDeltaIfNeeded(sessionKey, opts?.agentId", + "chatRunState.clearRun(clientRunId)", + ]; + for (const marker of requiredSourceMarkers) { + if (!chatSource.includes(marker)) { + throw new Error( + "OpenClaw chat streaming policy changed outside the reviewed shape" + ); + } + } + const terminalStart = chatSource.indexOf("const emitChatTerminal ="); + const terminalFlush = chatSource.indexOf( + "flushBufferedChatDeltaIfNeeded(sessionKey, opts?.agentId", + terminalStart + ); + const terminalClear = chatSource.indexOf( + "chatRunState.clearRun(clientRunId);", + terminalStart + ); + if ( + terminalStart === -1 || + terminalFlush < terminalStart || + terminalClear < terminalFlush || + terminalClear - terminalStart > 4096 + ) { + throw new Error( + "OpenClaw chat terminal handling no longer flushes before clearing state" + ); + } + for (const state of ["status", "delta", "final", "aborted", "error"]) { + if (!declarationSource.includes(`state: Type.TLiteral<"${state}">`)) { + throw new Error( + `OpenClaw protocol declarations are missing chat state ${state}` + ); + } + } + return throttleMs; +} + +function assertGatewayHandshake( + websocketSource: string, + declarationSource: string +): void { + const requiredWebsocketMarkers = [ + 'type: "event"', + 'event: "connect.challenge"', + 'method: "connect"', + ]; + const requiredDeclarationMarkers = [ + 'type: Type.TLiteral<"hello-ok">', + 'type: Type.TLiteral<"req">', + 'type: Type.TLiteral<"res">', + 'type: Type.TLiteral<"event">', + ]; + if ( + !requiredWebsocketMarkers.every((marker) => websocketSource.includes(marker)) || + !requiredDeclarationMarkers.every((marker) => declarationSource.includes(marker)) + ) { + throw new Error("OpenClaw gateway handshake changed outside the reviewed shape"); + } +} + +function publicArtifacts(artifacts: readonly LoadedSourceArtifact[]): SourceArtifact[] { + return artifacts + .map(({ bytes, path: artifactPath, role, sha256: digest }) => ({ + bytes, + path: artifactPath, + role, + sha256: digest, + })) + .toSorted((left, right) => compareStrings(left.role, right.role)); +} + +/** + * Audits only the installed package metadata and reviewed distribution artifacts. + * It never reads OpenClaw state, configuration, credentials, or session data. + * @param selectedSourceRoot Absolute path to an explicitly selected package root. + * @returns Strict, redacted protocol facts and hashes for reviewed public artifacts. + */ +export async function auditInstalledOpenClaw( + selectedSourceRoot: string +): Promise { + if (!path.isAbsolute(selectedSourceRoot) || selectedSourceRoot.includes("\0")) { + throw new TypeError("OpenClaw source root must be an absolute path"); + } + const sourceRoot = await realpath(selectedSourceRoot); + const sourceRootStat = await stat(sourceRoot); + if (!sourceRootStat.isDirectory()) { + throw new Error("OpenClaw source root is not a directory"); + } + const packageArtifact = await loadSourceArtifact( + sourceRoot, + "package.json", + "package-metadata", + maximumPackageMetadataBytes + ); + const buildInfoArtifact = await loadSourceArtifact( + sourceRoot, + "dist/build-info.json", + "build-info", + maximumBuildInfoBytes + ); + const packageMetadata = v.parse( + packageMetadataSchema, + JSON.parse(packageArtifact.contents) as unknown + ); + const buildInfo = v.parse( + buildInfoSchema, + JSON.parse(buildInfoArtifact.contents) as unknown + ); + if (packageMetadata.version !== buildInfo.version) { + throw new Error("OpenClaw package and build-info versions differ"); + } + + const distributionArtifacts = await Promise.all( + distributionArtifactSpecs.map((spec) => + locateDistributionArtifact(sourceRoot, spec) + ) + ); + const artifacts = [packageArtifact, buildInfoArtifact, ...distributionArtifacts]; + const versionSource = artifactByRole(artifacts, "protocol-version").contents; + const protocolVersion = parseIntegerConstant(versionSource, "PROTOCOL_VERSION"); + const minimumClientProtocolVersion = parseIntegerConstant( + versionSource, + "MIN_CLIENT_PROTOCOL_VERSION" + ); + const minimumNodeProtocolVersion = parseIntegerConstant( + versionSource, + "MIN_NODE_PROTOCOL_VERSION" + ); + const minimumProbeProtocolVersion = parseIntegerConstant( + versionSource, + "MIN_PROBE_PROTOCOL_VERSION" + ); + const declarations = artifactByRole(artifacts, "protocol-declarations").contents; + if (!declarations.includes(`declare const PROTOCOL_VERSION: ${protocolVersion};`)) { + throw new Error("OpenClaw runtime and declaration protocol versions differ"); + } + + const limitsSource = artifactByRole(artifacts, "gateway-limits").contents; + const methods = extractMethodNames( + artifactByRole(artifacts, "gateway-methods").contents + ); + const gatewayEvents = extractGatewayEvents( + artifactByRole(artifacts, "gateway-events").contents + ); + const chatThrottleMs = assertChatStreamingPolicy( + artifactByRole(artifacts, "chat-streaming").contents, + declarations + ); + assertGatewayHandshake( + artifactByRole(artifacts, "gateway-websocket").contents, + declarations + ); + assertPlanCompanionAndTasks(artifacts); + + return parseSourceAuditResult({ + agents: { + domain: "agents", + gatewayEvents: selectRequiredEvents(gatewayEvents, ["agent"]), + methods: methods.agents, + schemaVersion: 1, + }, + chat: { + domain: "chat", + gatewayEvents: selectRequiredEvents(gatewayEvents, [ + "agent", + "chat", + "session.message", + "session.tool", + ]), + methods: methods.chat, + schemaVersion: 1, + streamingPolicy: { + coalescedAgentStreams: ["assistant", "thinking"], + deltaThrottleMs: chatThrottleMs, + flushBeforeBoundaries: ["item.start", "tool.start"], + flushBufferedDeltaBeforeTerminal: true, + terminalStates: ["final", "aborted", "error"], + }, + syntheticScenarios: [ + { + events: [ + { + delta: "Checking cancellation.", + kind: "agent-delta", + seq: 1, + stream: "assistant", + text: "Checking cancellation.", + }, + { + deltaText: "Checking cancellation.", + kind: "chat-delta", + seq: 2, + }, + { + kind: "chat-terminal", + seq: 3, + state: "aborted", + stopReason: "cancelled", + }, + ], + id: "cancelled-run", + }, + { + events: [ + { + delta: "Inspecting synthetic input.", + kind: "agent-delta", + seq: 1, + stream: "thinking", + text: "Inspecting synthetic input.", + }, + { + delta: "Running the fixture tool.", + kind: "agent-delta", + seq: 2, + stream: "assistant", + text: "Running the fixture tool.", + }, + { + kind: "tool-start", + seq: 3, + toolCallId: "fixture-tool-1", + toolName: "fixture.lookup", + }, + { + kind: "tool-result", + outcome: "ok", + seq: 4, + toolCallId: "fixture-tool-1", + toolName: "fixture.lookup", + }, + { + deltaText: "Fixture complete.", + kind: "chat-delta", + seq: 5, + }, + { + kind: "chat-terminal", + seq: 6, + state: "final", + stopReason: "completed", + }, + ], + id: "completed-tool-run", + }, + ], + }, + cron: { + domain: "cron", + gatewayEvents: selectRequiredEvents(gatewayEvents, ["cron"]), + methods: methods.cron, + schemaVersion: 1, + }, + gateway: { + challengeEvent: "connect.challenge", + frameTypes: ["event", "req", "res"], + gatewayEvents: selectRequiredEvents(gatewayEvents, [ + "connect.challenge", + "health", + "heartbeat", + "presence", + "shutdown", + "tick", + ]), + helloType: "hello-ok", + limits: { + authenticatedFrameBytes: parseIntegerConstant( + limitsSource, + "MAX_PAYLOAD_BYTES" + ), + preauthenticationFrameBytes: parseIntegerConstant( + limitsSource, + "MAX_PREAUTH_PAYLOAD_BYTES" + ), + }, + method: "connect", + minimumClientProtocolVersion, + minimumNodeProtocolVersion, + minimumProbeProtocolVersion, + protocolVersion, + schemaVersion: 1, + }, + sessions: { + companion: { + authority: { + askResultDelivery: "requester-only", + dedicatedGatewayEvent: false, + stateStorage: "process-memory", + }, + lifecycle: { + firstFailedAskRemovesEmptyThread: true, + resetAbortsActiveAsk: true, + sessionResetClearsThread: true, + serviceDisposeAbortsAll: true, + }, + limits: { + answerChars: 1200, + connectionAsksPerMinute: 4, + exchangeBytes: 48 * 1024, + exchanges: 24, + globalAsksPerMinute: 12, + globalConcurrentAsks: 6, + idleTtlMs: 120 * 60_000, + perSeedMessageChars: 4000, + perSessionConcurrentAsks: 1, + questionChars: 400, + seedBytes: 24 * 1024, + seedTranscriptMessages: 40, + sweepIntervalMs: 10 * 60_000, + timeoutMs: 60_000, + }, + methodPermissions: [ + { + controlPlaneWrite: false, + name: "sessions.companion.ask", + scope: "operator.read", + }, + { + controlPlaneWrite: true, + name: "sessions.companion.reset", + scope: "operator.write", + }, + { + controlPlaneWrite: false, + name: "sessions.companion.state", + scope: "operator.read", + }, + ], + runtimePolicy: { + askStartsUtilityModelInference: true, + messageToolDisabled: true, + sessionsVisibility: "self", + toolSearchDisabled: true, + tools: ["read", "sessions_history", "sessions_search"], + workspaceOnly: true, + }, + uiProjection: { + busyCode: "SESSION_COMPANION_BUSY", + hydrationIsRevisionGuarded: true, + localPendingPerSession: true, + retainedExchanges: 24, + }, + }, + domain: "sessions", + gatewayEvents: gatewayEvents.filter( + (event) => event.startsWith("session.") || event.startsWith("sessions.") + ), + methods: methods.sessions, + plan: { + authority: { + dedicatedGatewayEvent: false, + dedicatedRpcMethod: false, + gatewayEvent: "agent", + phase: "update", + producerTool: "update_plan", + stream: "plan", + }, + contract: { + legacyStringStepsBecomePending: true, + maximumInProgressSteps: 1, + minimumSteps: 1, + statuses: ["pending", "in_progress", "completed"], + }, + lifecycle: { + clearedOnOwningRunTerminal: true, + durableAfterTerminal: false, + historyRecovery: "in-flight-run-only", + runOwned: true, + }, + uiProjection: { + activeOnly: true, + composerChecklist: true, + messageStreamCard: true, + sessionRailStepLimit: 3, + }, + }, + schemaVersion: 1, + }, + source: { + builtAt: buildInfo.builtAt, + commit: buildInfo.commit, + packageName: packageMetadata.name, + protocolVersion, + version: packageMetadata.version, + }, + sourceArtifacts: publicArtifacts(artifacts), + tasks: { + authority: { + cancelTarget: "task-id", + ledgerScope: "global-with-optional-filters", + sessionFilterRequired: false, + }, + cancellation: { + canonicalCompletionCanWinRace: true, + cascadesSubagentDescendants: true, + notFoundIsRpcSuccess: true, + operatorControlBypassesCallerSessionOwnership: true, + refusalIsRpcSuccess: true, + subagentCancellationIsProvisional: true, + terminalTaskIsNotCancelled: true, + }, + domain: "tasks", + event: { + actions: ["deleted", "restored", "upserted"], + delivery: "best-effort-drop-if-slow", + name: "task", + }, + gatewayEvents: selectRequiredEvents(gatewayEvents, ["task"]), + list: { + cursor: "decimal-offset", + defaultLimit: 100, + filters: ["agentId", "sessionKey", "status"], + maximumLimit: 500, + ordering: "last-activity-descending", + }, + methodPermissions: [ + { + controlPlaneWrite: false, + name: "tasks.cancel", + scope: "operator.write", + }, + { + controlPlaneWrite: false, + name: "tasks.get", + scope: "operator.read", + }, + { + controlPlaneWrite: false, + name: "tasks.list", + scope: "operator.read", + }, + ], + methods: methods.tasks, + promptVisibility: { + getIncludesBoundedPrompt: true, + listAndEventsOmitPrompt: true, + promptChars: 4000, + }, + runtimeMappings: [ + { internal: "cancelled", wire: "cancelled" }, + { internal: "failed", wire: "failed" }, + { internal: "lost", wire: "failed" }, + { internal: "queued", wire: "queued" }, + { internal: "running", wire: "running" }, + { internal: "succeeded", wire: "completed" }, + { internal: "timed_out", wire: "timed_out" }, + ], + schemaVersion: 1, + statuses: [ + "queued", + "running", + "completed", + "failed", + "cancelled", + "timed_out", + ], + uiProjection: { + activeSnapshotLimit: 200, + cancelledAndTimedOutUseFailedGroup: true, + detailUsesTasksGet: true, + eventBufferDuringSnapshot: true, + finishedSnapshotLimit: 100, + nonSubagentOpenSessionLink: true, + reconnectRefetch: true, + restoredEventRefetch: true, + stopRequiresOperatorWrite: true, + subagentOpenSessionLink: false, + }, + }, + }); +} diff --git a/qualification/openclaw/sourceAuditSchemas.ts b/qualification/openclaw/sourceAuditSchemas.ts new file mode 100644 index 000000000..2b7616fb8 --- /dev/null +++ b/qualification/openclaw/sourceAuditSchemas.ts @@ -0,0 +1,553 @@ +import * as v from "valibot"; + +const fixtureSchemaVersion = v.literal(1); +const positiveSafeIntegerSchema = v.pipe( + v.number(), + v.integer(), + v.safeInteger(), + v.minValue(1) +); +const boundedStringSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(256)); +const methodOrEventNameSchema = v.pipe( + v.string(), + v.regex(/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9_-]+)*$/u) +); +const sourcePathSchema = v.pipe( + v.string(), + v.regex(/^(?:package\.json|dist\/[A-Za-z0-9._/-]+)$/u), + v.check((value) => !value.includes(".."), "Source paths cannot traverse directories") +); +const sha256Schema = v.pipe(v.string(), v.regex(/^[a-f\d]{64}$/u)); +const versionSchema = v.pipe( + v.string(), + v.regex(/^\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u) +); +const commitSchema = v.pipe(v.string(), v.regex(/^[a-f\d]{40}$/u)); +const timestampSchema = v.pipe( + v.string(), + v.check((value) => { + const parsed = new Date(value); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value; + }, "Expected a canonical UTC timestamp") +); + +function isSortedAndUnique(values: string[]): boolean { + return values.every((value, index) => index === 0 || values[index - 1]! < value); +} + +const sortedUniqueNamesSchema = v.pipe( + v.array(methodOrEventNameSchema), + v.minLength(1), + v.maxLength(128), + v.check(isSortedAndUnique, "Names must be sorted and unique") +); + +const agentDeltaEventSchema = v.strictObject({ + delta: boundedStringSchema, + kind: v.literal("agent-delta"), + seq: positiveSafeIntegerSchema, + stream: v.union([v.literal("assistant"), v.literal("thinking")]), + text: boundedStringSchema, +}); +const toolStartEventSchema = v.strictObject({ + kind: v.literal("tool-start"), + seq: positiveSafeIntegerSchema, + toolCallId: boundedStringSchema, + toolName: boundedStringSchema, +}); +const toolResultEventSchema = v.strictObject({ + kind: v.literal("tool-result"), + outcome: v.union([v.literal("ok"), v.literal("error")]), + seq: positiveSafeIntegerSchema, + toolCallId: boundedStringSchema, + toolName: boundedStringSchema, +}); +const chatDeltaEventSchema = v.strictObject({ + deltaText: boundedStringSchema, + kind: v.literal("chat-delta"), + seq: positiveSafeIntegerSchema, +}); +const chatTerminalStateSchema = v.union([ + v.literal("final"), + v.literal("aborted"), + v.literal("error"), +]); +const chatTerminalEventSchema = v.strictObject({ + kind: v.literal("chat-terminal"), + seq: positiveSafeIntegerSchema, + state: chatTerminalStateSchema, + stopReason: boundedStringSchema, +}); +const syntheticChatEventSchema = v.variant("kind", [ + agentDeltaEventSchema, + toolStartEventSchema, + toolResultEventSchema, + chatDeltaEventSchema, + chatTerminalEventSchema, +]); +const syntheticScenarioEventsSchema = v.pipe( + v.array(syntheticChatEventSchema), + v.minLength(2), + v.maxLength(32) +); +const syntheticScenarioIdSchema = v.pipe(v.string(), v.regex(/^[a-z][a-z0-9-]{2,63}$/u)); +const syntheticScenarioObjectSchema = v.strictObject({ + events: syntheticScenarioEventsSchema, + id: syntheticScenarioIdSchema, +}); +const syntheticScenarioSchema = v.pipe( + syntheticScenarioObjectSchema, + v.check( + (scenario) => scenario.events.every((event, index) => event.seq === index + 1), + "Synthetic event sequences must use contiguous sequence numbers" + ) +); + +const gatewayFrameTypesSchema = v.tuple([ + v.literal("event"), + v.literal("req"), + v.literal("res"), +]); +const coalescedAgentStreamsSchema = v.tuple([ + v.literal("assistant"), + v.literal("thinking"), +]); +const flushBeforeBoundariesSchema = v.tuple([ + v.literal("item.start"), + v.literal("tool.start"), +]); +const terminalStatesSchema = v.tuple([ + v.literal("final"), + v.literal("aborted"), + v.literal("error"), +]); + +const syntheticScenariosSchema = v.pipe( + v.array(syntheticScenarioSchema), + v.length(2), + v.check( + (scenarios) => isSortedAndUnique(scenarios.map((scenario) => scenario.id)), + "Synthetic scenario ids must be sorted and unique" + ) +); + +const domainFixtureEntries = { + gatewayEvents: sortedUniqueNamesSchema, + methods: sortedUniqueNamesSchema, + schemaVersion: fixtureSchemaVersion, +}; + +const operatorScopeSchema = v.union([ + v.literal("operator.read"), + v.literal("operator.write"), +]); +const methodPermissionSchema = v.strictObject({ + controlPlaneWrite: v.boolean(), + name: methodOrEventNameSchema, + scope: operatorScopeSchema, +}); +const methodPermissionsSchema = v.pipe( + v.array(methodPermissionSchema), + v.minLength(1), + v.maxLength(16), + v.check( + (entries) => isSortedAndUnique(entries.map((entry) => entry.name)), + "Method permissions must be sorted and unique" + ) +); +const taskStatusSchema = v.picklist([ + "queued", + "running", + "completed", + "failed", + "cancelled", + "timed_out", +]); +export const gatewayFixtureSchema = v.strictObject({ + challengeEvent: v.literal("connect.challenge"), + frameTypes: gatewayFrameTypesSchema, + gatewayEvents: sortedUniqueNamesSchema, + helloType: v.literal("hello-ok"), + limits: v.strictObject({ + authenticatedFrameBytes: positiveSafeIntegerSchema, + preauthenticationFrameBytes: positiveSafeIntegerSchema, + }), + method: v.literal("connect"), + minimumClientProtocolVersion: positiveSafeIntegerSchema, + minimumNodeProtocolVersion: positiveSafeIntegerSchema, + minimumProbeProtocolVersion: positiveSafeIntegerSchema, + protocolVersion: positiveSafeIntegerSchema, + schemaVersion: fixtureSchemaVersion, +}); + +export const chatFixtureSchema = v.strictObject({ + ...domainFixtureEntries, + domain: v.literal("chat"), + streamingPolicy: v.strictObject({ + coalescedAgentStreams: coalescedAgentStreamsSchema, + deltaThrottleMs: positiveSafeIntegerSchema, + flushBeforeBoundaries: flushBeforeBoundariesSchema, + flushBufferedDeltaBeforeTerminal: v.literal(true), + terminalStates: terminalStatesSchema, + }), + syntheticScenarios: syntheticScenariosSchema, +}); + +/* + * The declarations below intentionally remain domain-specific so a fixture + * cannot acquire fields from another OpenClaw surface by accident. + */ +const companionAuthoritySchema = v.strictObject({ + askResultDelivery: v.literal("requester-only"), + dedicatedGatewayEvent: v.literal(false), + stateStorage: v.literal("process-memory"), +}); +const companionLifecycleSchema = v.strictObject({ + firstFailedAskRemovesEmptyThread: v.literal(true), + resetAbortsActiveAsk: v.literal(true), + sessionResetClearsThread: v.literal(true), + serviceDisposeAbortsAll: v.literal(true), +}); +const companionLimitsSchema = v.strictObject({ + answerChars: positiveSafeIntegerSchema, + connectionAsksPerMinute: positiveSafeIntegerSchema, + exchangeBytes: positiveSafeIntegerSchema, + exchanges: positiveSafeIntegerSchema, + globalAsksPerMinute: positiveSafeIntegerSchema, + globalConcurrentAsks: positiveSafeIntegerSchema, + idleTtlMs: positiveSafeIntegerSchema, + perSeedMessageChars: positiveSafeIntegerSchema, + perSessionConcurrentAsks: positiveSafeIntegerSchema, + questionChars: positiveSafeIntegerSchema, + seedBytes: positiveSafeIntegerSchema, + seedTranscriptMessages: positiveSafeIntegerSchema, + sweepIntervalMs: positiveSafeIntegerSchema, + timeoutMs: positiveSafeIntegerSchema, +}); +const companionToolsSchema = v.tuple([ + v.literal("read"), + v.literal("sessions_history"), + v.literal("sessions_search"), +]); +const companionRuntimePolicySchema = v.strictObject({ + askStartsUtilityModelInference: v.literal(true), + messageToolDisabled: v.literal(true), + sessionsVisibility: v.literal("self"), + toolSearchDisabled: v.literal(true), + tools: companionToolsSchema, + workspaceOnly: v.literal(true), +}); +const companionUiProjectionSchema = v.strictObject({ + busyCode: v.literal("SESSION_COMPANION_BUSY"), + hydrationIsRevisionGuarded: v.literal(true), + localPendingPerSession: v.literal(true), + retainedExchanges: positiveSafeIntegerSchema, +}); +const companionSchema = v.strictObject({ + authority: companionAuthoritySchema, + lifecycle: companionLifecycleSchema, + limits: companionLimitsSchema, + methodPermissions: methodPermissionsSchema, + runtimePolicy: companionRuntimePolicySchema, + uiProjection: companionUiProjectionSchema, +}); +const planAuthoritySchema = v.strictObject({ + dedicatedGatewayEvent: v.literal(false), + dedicatedRpcMethod: v.literal(false), + gatewayEvent: v.literal("agent"), + phase: v.literal("update"), + producerTool: v.literal("update_plan"), + stream: v.literal("plan"), +}); +const planStatusesSchema = v.tuple([ + v.literal("pending"), + v.literal("in_progress"), + v.literal("completed"), +]); +const planContractSchema = v.strictObject({ + legacyStringStepsBecomePending: v.literal(true), + maximumInProgressSteps: v.literal(1), + minimumSteps: v.literal(1), + statuses: planStatusesSchema, +}); +const planLifecycleSchema = v.strictObject({ + clearedOnOwningRunTerminal: v.literal(true), + durableAfterTerminal: v.literal(false), + historyRecovery: v.literal("in-flight-run-only"), + runOwned: v.literal(true), +}); +const planUiProjectionSchema = v.strictObject({ + activeOnly: v.literal(true), + composerChecklist: v.literal(true), + messageStreamCard: v.literal(true), + sessionRailStepLimit: positiveSafeIntegerSchema, +}); +const planSchema = v.strictObject({ + authority: planAuthoritySchema, + contract: planContractSchema, + lifecycle: planLifecycleSchema, + uiProjection: planUiProjectionSchema, +}); + +export const sessionsFixtureSchema = v.strictObject({ + ...domainFixtureEntries, + companion: companionSchema, + domain: v.literal("sessions"), + plan: planSchema, +}); + +export const agentsFixtureSchema = v.strictObject({ + ...domainFixtureEntries, + domain: v.literal("agents"), +}); + +export const cronFixtureSchema = v.strictObject({ + ...domainFixtureEntries, + domain: v.literal("cron"), +}); + +const taskRuntimeMappingSchema = v.strictObject({ + internal: v.picklist([ + "cancelled", + "failed", + "lost", + "queued", + "running", + "succeeded", + "timed_out", + ]), + wire: taskStatusSchema, +}); +const taskCancellationSchema = v.strictObject({ + canonicalCompletionCanWinRace: v.literal(true), + cascadesSubagentDescendants: v.literal(true), + notFoundIsRpcSuccess: v.literal(true), + operatorControlBypassesCallerSessionOwnership: v.literal(true), + refusalIsRpcSuccess: v.literal(true), + subagentCancellationIsProvisional: v.literal(true), + terminalTaskIsNotCancelled: v.literal(true), +}); +const taskAuthoritySchema = v.strictObject({ + cancelTarget: v.literal("task-id"), + ledgerScope: v.literal("global-with-optional-filters"), + sessionFilterRequired: v.literal(false), +}); +const taskEventActionsSchema = v.tuple([ + v.literal("deleted"), + v.literal("restored"), + v.literal("upserted"), +]); +const taskEventSchema = v.strictObject({ + actions: taskEventActionsSchema, + delivery: v.literal("best-effort-drop-if-slow"), + name: v.literal("task"), +}); +const taskFiltersSchema = v.tuple([ + v.literal("agentId"), + v.literal("sessionKey"), + v.literal("status"), +]); +const taskListSchema = v.strictObject({ + cursor: v.literal("decimal-offset"), + defaultLimit: positiveSafeIntegerSchema, + filters: taskFiltersSchema, + maximumLimit: positiveSafeIntegerSchema, + ordering: v.literal("last-activity-descending"), +}); +const taskMethodsSchema = v.tuple([ + v.literal("tasks.cancel"), + v.literal("tasks.get"), + v.literal("tasks.list"), +]); +const taskPromptVisibilitySchema = v.strictObject({ + getIncludesBoundedPrompt: v.literal(true), + listAndEventsOmitPrompt: v.literal(true), + promptChars: positiveSafeIntegerSchema, +}); +const taskRuntimeMappingsSchema = v.pipe( + v.array(taskRuntimeMappingSchema), + v.length(7), + v.check( + (mappings) => isSortedAndUnique(mappings.map((mapping) => mapping.internal)), + "Task runtime mappings must be sorted and unique" + ) +); +const taskStatusesSchema = v.tuple([ + v.literal("queued"), + v.literal("running"), + v.literal("completed"), + v.literal("failed"), + v.literal("cancelled"), + v.literal("timed_out"), +]); +const taskUiProjectionSchema = v.strictObject({ + activeSnapshotLimit: positiveSafeIntegerSchema, + cancelledAndTimedOutUseFailedGroup: v.literal(true), + detailUsesTasksGet: v.literal(true), + eventBufferDuringSnapshot: v.literal(true), + finishedSnapshotLimit: positiveSafeIntegerSchema, + nonSubagentOpenSessionLink: v.literal(true), + reconnectRefetch: v.literal(true), + restoredEventRefetch: v.literal(true), + stopRequiresOperatorWrite: v.literal(true), + subagentOpenSessionLink: v.literal(false), +}); + +export const tasksFixtureSchema = v.strictObject({ + authority: taskAuthoritySchema, + cancellation: taskCancellationSchema, + domain: v.literal("tasks"), + event: taskEventSchema, + gatewayEvents: v.tuple([v.literal("task")]), + list: taskListSchema, + methodPermissions: methodPermissionsSchema, + methods: taskMethodsSchema, + promptVisibility: taskPromptVisibilitySchema, + runtimeMappings: taskRuntimeMappingsSchema, + schemaVersion: fixtureSchemaVersion, + statuses: taskStatusesSchema, + uiProjection: taskUiProjectionSchema, +}); + +export const sourceIdentitySchema = v.strictObject({ + builtAt: timestampSchema, + commit: commitSchema, + packageName: v.literal("openclaw"), + protocolVersion: positiveSafeIntegerSchema, + version: versionSchema, +}); + +export const sourceArtifactSchema = v.strictObject({ + bytes: positiveSafeIntegerSchema, + path: sourcePathSchema, + role: v.picklist([ + "build-info", + "chat-run-projection", + "chat-streaming", + "control-ui-chat", + "control-ui-plan-renderer", + "control-ui-plan-rail", + "gateway-events", + "gateway-limits", + "gateway-methods", + "gateway-websocket", + "method-descriptors", + "package-metadata", + "plan-tool", + "protocol-declarations", + "protocol-schemas", + "protocol-version", + "runtime-subscriptions", + "session-companion-rpc", + "session-companion-runtime", + "subagent-control", + "task-registry", + "tasks-handlers", + ]), + sha256: sha256Schema, +}); + +const sourceArtifactsSchema = v.pipe( + v.array(sourceArtifactSchema), + v.length(22), + v.check( + (artifacts) => isSortedAndUnique(artifacts.map((artifact) => artifact.role)), + "Source artifact roles must be sorted and unique" + ), + v.check( + (artifacts) => + new Set(artifacts.map((artifact) => artifact.path)).size === artifacts.length, + "Source artifact paths must be unique" + ) +); + +const fixtureManifestEntrySchema = v.strictObject({ + file: v.picklist([ + "agents.json", + "chat.json", + "cron.json", + "gateway.json", + "sessions.json", + "tasks.json", + ]), + sha256: sha256Schema, +}); + +export const fixtureManifestSchema = v.strictObject({ + components: v.pipe( + v.array(fixtureManifestEntrySchema), + v.length(6), + v.check( + (components) => + isSortedAndUnique(components.map((component) => component.file)), + "Manifest component files must be sorted and unique" + ) + ), + contentPolicy: v.strictObject({ + containsHostConfiguration: v.literal(false), + containsRuntimeState: v.literal(false), + containsSecrets: v.literal(false), + sourceArtifacts: v.literal("hashes-only"), + syntheticPayloadsOnly: v.literal(true), + }), + schemaVersion: fixtureSchemaVersion, + source: sourceIdentitySchema, + sourceArtifacts: sourceArtifactsSchema, +}); + +export const sourceAuditResultSchema = v.pipe( + v.strictObject({ + agents: agentsFixtureSchema, + chat: chatFixtureSchema, + cron: cronFixtureSchema, + gateway: gatewayFixtureSchema, + sessions: sessionsFixtureSchema, + tasks: tasksFixtureSchema, + source: sourceIdentitySchema, + sourceArtifacts: sourceArtifactsSchema, + }), + v.check( + (audit) => audit.gateway.protocolVersion === audit.source.protocolVersion, + "Source and Gateway protocol versions must match" + ) +); + +export type AgentsFixture = v.InferOutput; +export type ChatFixture = v.InferOutput; +export type CronFixture = v.InferOutput; +export type FixtureManifest = v.InferOutput; +export type GatewayFixture = v.InferOutput; +export type SessionsFixture = v.InferOutput; +export type TasksFixture = v.InferOutput; +export type SourceArtifact = v.InferOutput; +export type SourceAuditResult = v.InferOutput; + +/** + * Parses one strict fixture document without accepting unknown fields. + * @param schema Strict component schema. + * @param serialized Serialized fixture bytes decoded as UTF-8. + * @returns Parsed component data. + */ +export function parseFixtureDocument< + TSchema extends v.BaseSchema>, +>(schema: TSchema, serialized: string): v.InferOutput { + return v.parse(schema, JSON.parse(serialized) as unknown); +} + +/** + * Applies every strict schema to a source-derived audit result. + * @param value Unknown source-derived candidate. + * @returns Strict audit facts safe to compare or serialize. + */ +export function parseSourceAuditResult(value: unknown): SourceAuditResult { + return v.parse(sourceAuditResultSchema, value); +} + +/** + * Applies the strict reviewed-fixture manifest schema. + * @param serialized Serialized manifest bytes decoded as UTF-8. + * @returns Strict reviewed fixture manifest. + */ +export function parseFixtureManifest(serialized: string): FixtureManifest { + return parseFixtureDocument(fixtureManifestSchema, serialized); +} diff --git a/qualification/outbox/runSqliteOutboxEvidence.ts b/qualification/outbox/runSqliteOutboxEvidence.ts new file mode 100644 index 000000000..6c3a4d1e8 --- /dev/null +++ b/qualification/outbox/runSqliteOutboxEvidence.ts @@ -0,0 +1,48 @@ +import { Clock, Effect } from "effect"; + +import { + sqliteOutboxQualification, + summarizeOutboxLatencies, +} from "./sqliteOutboxQualification.ts"; + +const defaultSampleCount = 5; +const maximumSampleCount = 20; +const nanosecondsPerMillisecond = 1_000_000; + +function parseSampleCount(value: string | undefined): number { + if (value === undefined) return defaultSampleCount; + if (!/^[1-9][0-9]*$/u.test(value)) { + throw new Error("Outbox evidence sample count must be a positive integer"); + } + const sampleCount = Number(value); + if (!Number.isSafeInteger(sampleCount) || sampleCount > maximumSampleCount) { + throw new Error( + `Outbox evidence sample count must not exceed ${maximumSampleCount}` + ); + } + return sampleCount; +} + +const sampleCount = parseSampleCount(process.argv[2]); +const evidence = Effect.gen(function* () { + const convergenceSamplesMs = yield* Effect.forEach( + Array.from({ length: sampleCount }), + () => + Effect.gen(function* () { + const startedAt = yield* Clock.monotonicTimeNanos; + yield* sqliteOutboxQualification; + const endedAt = yield* Clock.monotonicTimeNanos; + return Number(endedAt - startedAt) / nanosecondsPerMillisecond; + }), + { concurrency: 1 } + ); + return Object.freeze({ + bunVersion: Bun.version, + metric: "producer-to-drained-and-restored-scenario-ms", + samples: summarizeOutboxLatencies(convergenceSamplesMs), + thresholdsEnforced: false, + }); +}); + +const result = await Effect.runPromise(evidence); +process.stdout.write(`${JSON.stringify(result, undefined, 2)}\n`); diff --git a/qualification/outbox/sqliteOutboxChild.ts b/qualification/outbox/sqliteOutboxChild.ts new file mode 100644 index 000000000..ba06c1c9e --- /dev/null +++ b/qualification/outbox/sqliteOutboxChild.ts @@ -0,0 +1,236 @@ +import { Data, Effect } from "effect"; +import * as v from "valibot"; + +import type { SqliteOutboxChildStatus } from "./sqliteOutboxProtocol.ts"; +import { + appendQualificationOutboxBatch, + claimQualificationOutboxBatch, + deliverQualificationOutboxClaims, + openQualificationOutboxDatabase, + retryQualificationSqliteOperation, +} from "./sqliteOutboxStore.ts"; + +const pathSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(4096)); +const identifierSchema = v.pipe(v.string(), v.regex(/^[a-z][a-z0-9-]{0,63}$/u)); +const positiveIntegerSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); +const timestampSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); +const boundedBatchSchema = v.pipe( + v.number(), + v.integer(), + v.minValue(1), + v.maxValue(1000) +); + +const produceCommandSchema = v.strictObject({ + count: boundedBatchSchema, + createdAt: timestampSchema, + databasePath: pathSchema, + kind: v.literal("produce"), + producerId: identifierSchema, + statusPath: pathSchema, +}); +const claimAndHoldCommandSchema = v.strictObject({ + databasePath: pathSchema, + kind: v.literal("claim-and-hold"), + leaseUntil: timestampSchema, + limit: boundedBatchSchema, + now: timestampSchema, + statusPath: pathSchema, + workerId: identifierSchema, +}); +const drainCommandSchema = v.strictObject({ + batchSize: boundedBatchSchema, + databasePath: pathSchema, + kind: v.literal("drain"), + leaseDuration: positiveIntegerSchema, + now: timestampSchema, + statusPath: pathSchema, + workerId: identifierSchema, +}); +const childCommandSchema = v.variant("kind", [ + produceCommandSchema, + claimAndHoldCommandSchema, + drainCommandSchema, +]); + +type ChildCommand = v.InferOutput; + +class QualificationChildArgumentError extends Data.TaggedError( + "QualificationChildArgumentError" +)<{ + readonly message: string; +}> {} + +class QualificationChildStatusWriteError extends Data.TaggedError( + "QualificationChildStatusWriteError" +)<{ + readonly cause: unknown; +}> {} + +class QualificationOutboxPollingExhaustedError extends Data.TaggedError( + "QualificationOutboxPollingExhaustedError" +)<{ + readonly maximumPolls: number; +}> {} + +function parseInteger(value: string | undefined): number { + if (value === undefined || !/^(?:0|[1-9][0-9]*)$/u.test(value)) return Number.NaN; + return Number(value); +} + +function parseCommand(arguments_: readonly string[]): ChildCommand { + const [kind, databasePath, statusPath, identifier, first, second, third] = arguments_; + switch (kind) { + case "produce": { + return v.parse(produceCommandSchema, { + count: parseInteger(first), + createdAt: parseInteger(second), + databasePath, + kind, + producerId: identifier, + statusPath, + }); + } + case "claim-and-hold": { + const command = v.parse(claimAndHoldCommandSchema, { + databasePath, + kind, + leaseUntil: parseInteger(second), + limit: parseInteger(third), + now: parseInteger(first), + statusPath, + workerId: identifier, + }); + if (command.leaseUntil <= command.now) { + throw new QualificationChildArgumentError({ + message: "Claim lease must expire after its logical claim time", + }); + } + return command; + } + case "drain": { + return v.parse(drainCommandSchema, { + batchSize: parseInteger(third), + databasePath, + kind, + leaseDuration: parseInteger(second), + now: parseInteger(first), + statusPath, + workerId: identifier, + }); + } + default: { + throw new QualificationChildArgumentError({ + message: "Unrecognized SQLite outbox qualification child command", + }); + } + } +} + +function writeStatus( + statusPath: string, + status: SqliteOutboxChildStatus +): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => new QualificationChildStatusWriteError({ cause }), + try: () => Bun.write(statusPath, `${JSON.stringify(status)}\n`), + }).pipe(Effect.asVoid); +} + +function runDrainCommand( + database: ReturnType, + command: Extract +) { + const maximumPolls = 1001; + return Effect.gen(function* () { + let claimedCount = 0; + let deliveredCount = 0; + for (let poll = 0; poll < maximumPolls; poll += 1) { + const claimedEventIds = yield* retryQualificationSqliteOperation(() => + claimQualificationOutboxBatch( + database, + command.workerId, + command.now, + command.now + command.leaseDuration, + command.batchSize + ) + ); + claimedCount += claimedEventIds.length; + if (claimedEventIds.length === 0) { + return { claimedCount, deliveredCount }; + } + const deliveredEventIds = yield* retryQualificationSqliteOperation(() => + deliverQualificationOutboxClaims(database, command.workerId, command.now) + ); + deliveredCount += deliveredEventIds.length; + yield* Effect.sleep("1 millis"); + } + return yield* Effect.fail( + new QualificationOutboxPollingExhaustedError({ maximumPolls }) + ); + }); +} + +function childProgram(command: ChildCommand) { + return Effect.scoped( + Effect.gen(function* () { + const database = yield* Effect.acquireRelease( + Effect.sync(() => openQualificationOutboxDatabase(command.databasePath)), + (acquiredDatabase) => Effect.sync(() => acquiredDatabase.close(true)) + ); + + switch (command.kind) { + case "produce": { + const batch = yield* retryQualificationSqliteOperation(() => + appendQualificationOutboxBatch( + database, + command.producerId, + command.count, + command.createdAt + ) + ); + yield* writeStatus(command.statusPath, { + count: batch.eventIds.length, + eventIds: [...batch.eventIds], + kind: "produced", + producerId: command.producerId, + }); + return; + } + case "claim-and-hold": { + const eventIds = yield* retryQualificationSqliteOperation(() => + claimQualificationOutboxBatch( + database, + command.workerId, + command.now, + command.leaseUntil, + command.limit + ) + ); + yield* writeStatus(command.statusPath, { + eventIds: [...eventIds], + kind: "claimed", + workerId: command.workerId, + }); + return yield* Effect.never; + } + case "drain": { + const result = yield* runDrainCommand(database, command); + yield* writeStatus(command.statusPath, { + ...result, + kind: "drained", + workerId: command.workerId, + }); + } + } + }) + ); +} + +try { + const command = parseCommand(process.argv.slice(2)); + await Effect.runPromise(childProgram(command)); +} catch { + process.stderr.write("SQLite outbox qualification child failed\n"); + process.exitCode = 1; +} diff --git a/qualification/outbox/sqliteOutboxProtocol.ts b/qualification/outbox/sqliteOutboxProtocol.ts new file mode 100644 index 000000000..9494567cb --- /dev/null +++ b/qualification/outbox/sqliteOutboxProtocol.ts @@ -0,0 +1,36 @@ +import * as v from "valibot"; + +const identifierSchema = v.pipe(v.string(), v.regex(/^[a-z][a-z0-9-]{0,63}$/u)); +const countSchema = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(10_000)); +const eventIdSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); + +export const sqliteOutboxChildStatusSchema = v.variant("kind", [ + v.strictObject({ + count: countSchema, + eventIds: v.array(eventIdSchema), + kind: v.literal("produced"), + producerId: identifierSchema, + }), + v.strictObject({ + eventIds: v.array(eventIdSchema), + kind: v.literal("claimed"), + workerId: identifierSchema, + }), + v.strictObject({ + claimedCount: countSchema, + deliveredCount: countSchema, + kind: v.literal("drained"), + workerId: identifierSchema, + }), +]); + +export type SqliteOutboxChildStatus = v.InferOutput; + +/** + * Parses one bounded status file emitted by a qualification child. + * @param value Parsed JSON value. + * @returns Strictly validated child status. + */ +export function parseSqliteOutboxChildStatus(value: unknown): SqliteOutboxChildStatus { + return v.parse(sqliteOutboxChildStatusSchema, value); +} diff --git a/qualification/outbox/sqliteOutboxQualification.test.ts b/qualification/outbox/sqliteOutboxQualification.test.ts new file mode 100644 index 000000000..8f45a5b27 --- /dev/null +++ b/qualification/outbox/sqliteOutboxQualification.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { Effect } from "effect"; + +import { + sqliteOutboxQualification, + summarizeOutboxLatencies, +} from "./sqliteOutboxQualification.ts"; +import { + appendQualificationOutboxBatch, + classifyQualificationSqliteError, + countQualificationRows, + initializeQualificationOutboxDatabase, + openQualificationOutboxDatabase, + QualificationSqliteContentionError, + readQualificationJournalMode, +} from "./sqliteOutboxStore.ts"; + +function temporaryDirectoryResource() { + return Effect.acquireRelease( + Effect.promise(() => mkdtemp(path.join(tmpdir(), "mira-dashboard-sqlite-test-"))), + (directoryPath) => + Effect.promise(() => + rm(directoryPath, { force: true, recursive: true }) + ).pipe(Effect.orDie) + ); +} + +function databaseResource(databasePath: string, readonly = false) { + return Effect.acquireRelease( + Effect.sync(() => openQualificationOutboxDatabase(databasePath, { readonly })), + (database) => Effect.sync(() => database.close(true)) + ); +} + +describe("file-backed Bun SQLite qualification", () => { + test("qualifies WAL reader/writer snapshots and writer contention", async () => { + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const directoryPath = yield* temporaryDirectoryResource(); + const databasePath = path.join(directoryPath, "wal.sqlite"); + const writer = yield* databaseResource(databasePath); + yield* Effect.sync(() => + initializeQualificationOutboxDatabase(writer) + ); + const reader = yield* databaseResource(databasePath, true); + const competingWriter = yield* databaseResource(databasePath); + + yield* Effect.sync(() => { + appendQualificationOutboxBatch(writer, "reader-before", 1, 1000); + reader.run("BEGIN"); + }); + const snapshotBeforeWrite = yield* Effect.sync(() => + countQualificationRows(reader, "qualification_outbox_events") + ); + yield* Effect.sync(() => + appendQualificationOutboxBatch(writer, "reader-after", 1, 2000) + ); + const stableReaderSnapshot = yield* Effect.sync(() => + countQualificationRows(reader, "qualification_outbox_events") + ); + const refreshedReaderSnapshot = yield* Effect.sync(() => { + reader.run("COMMIT"); + return countQualificationRows( + reader, + "qualification_outbox_events" + ); + }); + + const contention = yield* Effect.sync(() => { + writer.run("BEGIN IMMEDIATE"); + try { + competingWriter.run("BEGIN IMMEDIATE"); + throw new Error( + "Competing writer unexpectedly acquired WAL lock" + ); + } catch (error) { + return classifyQualificationSqliteError(error); + } finally { + writer.run("ROLLBACK"); + } + }); + + return { + contention, + journalMode: readQualificationJournalMode(writer), + refreshedReaderSnapshot, + snapshotBeforeWrite, + stableReaderSnapshot, + }; + }) + ) + ); + + expect(result.contention).toBeInstanceOf(QualificationSqliteContentionError); + expect(result.journalMode).toBe("wal"); + expect(result.refreshedReaderSnapshot).toBe(2); + expect(result.snapshotBeforeWrite).toBe(1); + expect(result.stableReaderSnapshot).toBe(1); + expect(result.contention?.code).toBe("SQLITE_BUSY"); + const lockedError = Object.assign(new Error("shared cache locked"), { + code: "SQLITE_LOCKED_SHAREDCACHE", + }); + const classifiedLockedError = classifyQualificationSqliteError(lockedError); + expect(classifiedLockedError).toBeInstanceOf(QualificationSqliteContentionError); + }); + + test("qualifies nested savepoints and deterministic native disposal", async () => { + let releasedDatabase: + | ReturnType + | undefined; + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const directoryPath = yield* temporaryDirectoryResource(); + const databasePath = path.join(directoryPath, "savepoints.sqlite"); + const database = yield* databaseResource(databasePath); + releasedDatabase = database; + yield* Effect.sync(() => { + initializeQualificationOutboxDatabase(database); + database.run( + "CREATE TABLE qualification_savepoints (id INTEGER PRIMARY KEY NOT NULL) STRICT" + ); + }); + + const nested = database.transaction(() => { + database.run("INSERT INTO qualification_savepoints VALUES (2)"); + throw new Error("rollback nested savepoint"); + }); + yield* Effect.sync(() => + database + .transaction(() => { + database.run( + "INSERT INTO qualification_savepoints VALUES (1)" + ); + try { + nested(); + } catch (error) { + if (!(error instanceof Error)) throw error; + } + database.run( + "INSERT INTO qualification_savepoints VALUES (3)" + ); + }) + .immediate() + ); + + const statement = database.prepare<{ id: number }, []>( + "SELECT id FROM qualification_savepoints ORDER BY id" + ); + const rows = statement.all(); + statement.finalize(); + const finalizedStatementThrows = yield* Effect.sync(() => { + try { + statement.all(); + return false; + } catch { + return true; + } + }); + return { finalizedStatementThrows, rows }; + }) + ) + ); + + expect(result).toEqual({ + finalizedStatementThrows: true, + rows: [{ id: 1 }, { id: 3 }], + }); + expect(releasedDatabase).toBeDefined(); + expect(() => releasedDatabase?.query("SELECT 1").get()).toThrow(); + }); +}); + +describe("multi-process SQLite outbox qualification", () => { + test("recovers terminated claims with no event gaps or duplicate deliveries", async () => { + const report = await Effect.runPromise(sqliteOutboxQualification); + const expectedEventIds = Array.from({ length: 42 }, (_, index) => index + 1); + const expectedProducerSequences = [ + ...Array.from({ length: 19 }, (_, index) => `web-a:${index + 1}`), + ...Array.from({ length: 23 }, (_, index) => `web-b:${index + 1}`), + ]; + + expect(report.journalMode).toBe("wal"); + expect(report.producerCounts.toSorted((left, right) => left - right)).toEqual([ + 19, 23, + ]); + expect(report.crashedClaimEventIds).toHaveLength(7); + expect(report.crashedWorkerSignal).toBe("SIGKILL"); + expect(report.workerClaimCounts.reduce((sum, count) => sum + count, 0)).toBe(42); + expect(report.workerDeliveryCounts.reduce((sum, count) => sum + count, 0)).toBe( + 42 + ); + expect(report.finalSnapshot).toEqual({ + claimedCount: 0, + deliveredCount: 42, + deliveredEventIds: expectedEventIds, + eventCount: 42, + eventIds: expectedEventIds, + pendingCount: 0, + producerSequences: expectedProducerSequences, + }); + expect( + report.crashedClaimEventIds.every((eventId) => + report.finalSnapshot.deliveredEventIds.includes(eventId) + ) + ).toBeTrue(); + expect(new Set(report.finalSnapshot.deliveredEventIds).size).toBe(42); + expect(report.integrityCheck).toBe("ok"); + expect(report.restoredIntegrityCheck).toBe("ok"); + expect(report.restoredSnapshot).toEqual(report.finalSnapshot); + expect(report.latency).toEqual({ + maximumMs: 29_000, + medianMs: 28_000, + p95Ms: 29_000, + sampleCount: 42, + }); + }, 15_000); + + test("summarizes latency evidence without a flaky wall-clock threshold", () => { + expect(summarizeOutboxLatencies([])).toEqual({ + maximumMs: 0, + medianMs: 0, + p95Ms: 0, + sampleCount: 0, + }); + expect(summarizeOutboxLatencies([5, 1, 9, 3, 7])).toEqual({ + maximumMs: 9, + medianMs: 5, + p95Ms: 9, + sampleCount: 5, + }); + }); +}); diff --git a/qualification/outbox/sqliteOutboxQualification.ts b/qualification/outbox/sqliteOutboxQualification.ts new file mode 100644 index 000000000..6ce083425 --- /dev/null +++ b/qualification/outbox/sqliteOutboxQualification.ts @@ -0,0 +1,383 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Data, Effect, Schedule, Scope } from "effect"; + +import { + parseSqliteOutboxChildStatus, + type SqliteOutboxChildStatus, +} from "./sqliteOutboxProtocol.ts"; +import { + createQualificationOutboxBackup, + initializeQualificationOutboxDatabase, + openQualificationOutboxDatabase, + readQualificationDeliveryLatencies, + readQualificationIntegrityCheck, + readQualificationJournalMode, + readQualificationOutboxSnapshot, + type QualificationOutboxSnapshot, +} from "./sqliteOutboxStore.ts"; + +const childModulePath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "sqliteOutboxChild.ts" +); +const childStatusMaximumBytes = 16 * 1024; +const childDeadline = "5 seconds"; +const statusPollingSchedule = Schedule.spaced("5 millis").pipe( + Schedule.upTo({ times: 1000 }) +); + +type QualificationChildProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; + +export class QualificationChildProcessError extends Data.TaggedError( + "QualificationChildProcessError" +)<{ + readonly exitCode?: number; + readonly operation: string; +}> {} + +export class QualificationDeadlineError extends Data.TaggedError( + "QualificationDeadlineError" +)<{ + readonly operation: string; +}> {} + +class QualificationStatusPendingError extends Data.TaggedError( + "QualificationStatusPendingError" +)<{ + readonly cause?: unknown; +}> {} + +export interface OutboxLatencySummary { + readonly maximumMs: number; + readonly medianMs: number; + readonly p95Ms: number; + readonly sampleCount: number; +} + +export interface SqliteOutboxQualificationReport { + readonly crashedClaimEventIds: readonly number[]; + readonly crashedWorkerSignal: NodeJS.Signals | null; + readonly finalSnapshot: QualificationOutboxSnapshot; + readonly integrityCheck: string; + readonly journalMode: string; + readonly latency: OutboxLatencySummary; + readonly producerCounts: readonly number[]; + readonly restoredIntegrityCheck: string; + readonly restoredSnapshot: QualificationOutboxSnapshot; + readonly workerClaimCounts: readonly number[]; + readonly workerDeliveryCounts: readonly number[]; +} + +function percentile(sortedValues: readonly number[], fraction: number): number { + if (sortedValues.length === 0) return 0; + const index = Math.ceil(sortedValues.length * fraction) - 1; + return sortedValues[Math.max(0, Math.min(index, sortedValues.length - 1))] ?? 0; +} + +/** + * Summarizes logical delivery latency without imposing wall-clock CI thresholds. + * Performance qualification can publish the same shape from capped CLI runs. + * @param values Logical delivery-latency samples. + * @returns Deterministic percentile summary with no wall-clock pass threshold. + */ +export function summarizeOutboxLatencies( + values: readonly number[] +): OutboxLatencySummary { + const sorted = values.toSorted((left, right) => left - right); + return Object.freeze({ + maximumMs: sorted.at(-1) ?? 0, + medianMs: percentile(sorted, 0.5), + p95Ms: percentile(sorted, 0.95), + sampleCount: sorted.length, + }); +} + +function childDeadlineFailure(operation: string): QualificationDeadlineError { + return new QualificationDeadlineError({ operation }); +} + +function awaitChildExit( + child: QualificationChildProcess, + operation: string +): Effect.Effect { + return Effect.tryPromise({ + catch: () => new QualificationChildProcessError({ operation }), + try: () => child.exited, + }).pipe( + Effect.timeoutOrElse({ + duration: childDeadline, + orElse: () => Effect.fail(childDeadlineFailure(operation)), + }) + ); +} + +function stopChild( + child: QualificationChildProcess, + operation: string +): Effect.Effect { + if (child.exitCode !== null || child.signalCode !== null) return Effect.void; + const graceful = Effect.sync(() => child.kill("SIGTERM")).pipe( + Effect.andThen(awaitChildExit(child, `${operation}:sigterm`)) + ); + return graceful.pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => + Effect.sync(() => child.kill("SIGKILL")).pipe( + Effect.andThen(awaitChildExit(child, `${operation}:sigkill`)) + ), + }), + Effect.asVoid, + Effect.orDie + ); +} + +function spawnChild( + operation: string, + arguments_: readonly string[] +): Effect.Effect { + return Effect.gen(function* () { + const signal = yield* Effect.abortSignal; + return yield* Effect.acquireRelease( + Effect.try({ + catch: () => new QualificationChildProcessError({ operation }), + try: () => + Bun.spawn([process.execPath, childModulePath, ...arguments_], { + killSignal: "SIGTERM", + signal, + stderr: "ignore", + stdin: "ignore", + stdout: "ignore", + }), + }), + (child) => stopChild(child, operation) + ); + }); +} + +function readStatus( + statusPath: string, + operation: string +): Effect.Effect { + const attempt = Effect.tryPromise({ + catch: (cause) => new QualificationStatusPendingError({ cause }), + try: async () => { + const statusFile = Bun.file(statusPath); + if (!(await statusFile.exists())) throw new Error("status pending"); + if (statusFile.size > childStatusMaximumBytes) { + throw new Error("status exceeds qualification bound"); + } + const statusText = await statusFile.text(); + const statusValue: unknown = JSON.parse(statusText); + return parseSqliteOutboxChildStatus(statusValue); + }, + }); + return attempt.pipe( + Effect.retry({ schedule: statusPollingSchedule }), + Effect.catchTag("QualificationStatusPendingError", () => + Effect.fail(childDeadlineFailure(operation)) + ), + Effect.timeoutOrElse({ + duration: childDeadline, + orElse: () => Effect.fail(childDeadlineFailure(operation)), + }) + ); +} + +function runOneShotChild( + operation: string, + statusPath: string, + arguments_: readonly string[] +): Effect.Effect< + SqliteOutboxChildStatus, + QualificationChildProcessError | QualificationDeadlineError +> { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnChild(operation, arguments_); + const exitCode = yield* awaitChildExit(child, operation); + if (exitCode !== 0) { + return yield* Effect.fail( + new QualificationChildProcessError({ exitCode, operation }) + ); + } + return yield* readStatus(statusPath, operation); + }) + ); +} + +function claimAndTerminateChild( + databasePath: string, + statusPath: string +): Effect.Effect< + { + readonly child: QualificationChildProcess; + readonly status: SqliteOutboxChildStatus; + }, + QualificationChildProcessError | QualificationDeadlineError +> { + return Effect.scoped( + Effect.gen(function* () { + const operation = "claim-before-termination"; + const child = yield* spawnChild(operation, [ + "claim-and-hold", + databasePath, + statusPath, + "crash-worker", + "10000", + "20000", + "7", + ]); + const status = yield* readStatus(statusPath, operation); + yield* Effect.sync(() => child.kill("SIGKILL")); + yield* awaitChildExit(child, `${operation}:crash`); + return { child, status }; + }) + ); +} + +function databaseResource(databasePath: string, readonly = false) { + return Effect.acquireRelease( + Effect.sync(() => openQualificationOutboxDatabase(databasePath, { readonly })), + (database) => Effect.sync(() => database.close(true)) + ); +} + +function temporaryWorkspace() { + return Effect.acquireRelease( + Effect.tryPromise({ + catch: () => + new QualificationChildProcessError({ operation: "temp-directory" }), + try: () => mkdtemp(path.join(tmpdir(), "mira-dashboard-outbox-")), + }), + (workspacePath) => + Effect.tryPromise(() => + rm(workspacePath, { force: true, recursive: true }) + ).pipe(Effect.orDie) + ); +} + +/** + * Runs the file-backed multi-process outbox qualification in one Effect scope. + * Logical lease timestamps keep recovery assertions deterministic in CI. + */ +export const sqliteOutboxQualification = Effect.scoped( + Effect.gen(function* () { + const workspacePath = yield* temporaryWorkspace(); + const databasePath = path.join(workspacePath, "qualification.sqlite"); + const backupPath = path.join(workspacePath, "qualification.backup.sqlite"); + const database = yield* databaseResource(databasePath); + yield* Effect.sync(() => initializeQualificationOutboxDatabase(database)); + + const producerSpecifications = [ + { count: 19, createdAt: 1000, id: "web-a" }, + { count: 23, createdAt: 2000, id: "web-b" }, + ] as const; + const producerStatuses = yield* Effect.all( + producerSpecifications.map((producer) => { + const statusPath = path.join( + workspacePath, + `producer-${producer.id}.json` + ); + return runOneShotChild("web-producer", statusPath, [ + "produce", + databasePath, + statusPath, + producer.id, + String(producer.count), + String(producer.createdAt), + ]); + }), + { concurrency: 2 } + ); + const produced = producerStatuses.map((status) => { + if (status.kind !== "produced") { + throw new Error("Web child returned an unexpected status kind"); + } + return status.count; + }); + + const terminated = yield* claimAndTerminateChild( + databasePath, + path.join(workspacePath, "terminated-claim.json") + ); + if (terminated.status.kind !== "claimed") { + return yield* Effect.die("Claim child returned an unexpected status kind"); + } + const afterTermination = yield* Effect.sync(() => + readQualificationOutboxSnapshot(database) + ); + if (afterTermination.claimedCount !== terminated.status.eventIds.length) { + return yield* Effect.die( + "Terminated worker claims were not durable before recovery" + ); + } + + const workerSpecifications = ["worker-a", "worker-b"] as const; + const workerStatuses = yield* Effect.all( + workerSpecifications.map((workerId) => { + const statusPath = path.join(workspacePath, `${workerId}.json`); + return runOneShotChild("worker-drain", statusPath, [ + "drain", + databasePath, + statusPath, + workerId, + "30000", + "5000", + "5", + ]); + }), + { concurrency: 2 } + ); + const drained = workerStatuses.map((status) => { + if (status.kind !== "drained") { + throw new Error("Worker child returned an unexpected status kind"); + } + return status; + }); + + const finalSnapshot = yield* Effect.sync(() => + readQualificationOutboxSnapshot(database) + ); + const journalMode = yield* Effect.sync(() => + readQualificationJournalMode(database) + ); + const integrityCheck = yield* Effect.sync(() => + readQualificationIntegrityCheck(database) + ); + const latency = yield* Effect.sync(() => + summarizeOutboxLatencies(readQualificationDeliveryLatencies(database)) + ); + yield* Effect.sync(() => createQualificationOutboxBackup(database, backupPath)); + + const restoredDatabase = yield* databaseResource(backupPath, true); + const restoredSnapshot = yield* Effect.sync(() => + readQualificationOutboxSnapshot(restoredDatabase) + ); + const restoredIntegrityCheck = yield* Effect.sync(() => + readQualificationIntegrityCheck(restoredDatabase) + ); + + return Object.freeze({ + crashedClaimEventIds: Object.freeze([...terminated.status.eventIds]), + crashedWorkerSignal: terminated.child.signalCode, + finalSnapshot, + integrityCheck, + journalMode, + latency, + producerCounts: Object.freeze(produced), + restoredIntegrityCheck, + restoredSnapshot, + workerClaimCounts: Object.freeze( + drained.map((status) => status.claimedCount) + ), + workerDeliveryCounts: Object.freeze( + drained.map((status) => status.deliveredCount) + ), + } satisfies SqliteOutboxQualificationReport); + }) +); diff --git a/qualification/outbox/sqliteOutboxStore.ts b/qualification/outbox/sqliteOutboxStore.ts new file mode 100644 index 000000000..516f86197 --- /dev/null +++ b/qualification/outbox/sqliteOutboxStore.ts @@ -0,0 +1,488 @@ +import { Database } from "bun:sqlite"; + +import { Data, Duration, Effect, Predicate, Schedule } from "effect"; +import * as v from "valibot"; + +const outboxSchemaStatements = [ + `CREATE TABLE qualification_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + payload TEXT NOT NULL, + producer_id TEXT NOT NULL, + producer_sequence INTEGER NOT NULL, + UNIQUE (producer_id, producer_sequence) + ) STRICT`, + `CREATE TABLE qualification_outbox_events ( + claim_owner TEXT, + created_at INTEGER NOT NULL, + delivered_at INTEGER, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + lease_until INTEGER, + record_id INTEGER NOT NULL UNIQUE + REFERENCES qualification_records(id) ON DELETE CASCADE, + state TEXT NOT NULL DEFAULT 'pending', + CONSTRAINT qualification_outbox_state_check CHECK ( + (state = 'pending' AND claim_owner IS NULL AND lease_until IS NULL AND delivered_at IS NULL) + OR (state = 'claimed' AND claim_owner IS NOT NULL AND lease_until IS NOT NULL AND delivered_at IS NULL) + OR (state = 'delivered' AND claim_owner IS NULL AND lease_until IS NULL AND delivered_at IS NOT NULL) + ) + ) STRICT`, + `CREATE INDEX qualification_outbox_claim_idx + ON qualification_outbox_events (state, lease_until, id)`, + `CREATE TABLE qualification_outbox_deliveries ( + delivered_at INTEGER NOT NULL, + event_id INTEGER NOT NULL UNIQUE + REFERENCES qualification_outbox_events(id) ON DELETE RESTRICT, + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + worker_id TEXT NOT NULL + ) STRICT`, +] as const; + +const sqliteErrorSchema = v.object({ + code: v.pipe(v.string(), v.startsWith("SQLITE_")), +}); + +const qualificationCountStatements = Object.freeze({ + qualification_outbox_deliveries: + "SELECT count(*) AS count FROM qualification_outbox_deliveries", + qualification_outbox_events: + "SELECT count(*) AS count FROM qualification_outbox_events", + qualification_records: "SELECT count(*) AS count FROM qualification_records", +}); + +export type QualificationTableName = keyof typeof qualificationCountStatements; + +export class QualificationSqliteContentionError extends Data.TaggedError( + "QualificationSqliteContentionError" +)<{ + readonly cause: unknown; + readonly code: string; +}> {} + +export class QualificationSqliteUnavailableError extends Data.TaggedError( + "QualificationSqliteUnavailableError" +)<{ + readonly cause: unknown; + readonly code: string; +}> {} + +export type QualificationSqliteOperationError = + | QualificationSqliteContentionError + | QualificationSqliteUnavailableError; + +export interface AppendedOutboxBatch { + readonly eventIds: readonly number[]; + readonly producerId: string; +} + +export interface QualificationOutboxSnapshot { + readonly claimedCount: number; + readonly deliveredCount: number; + readonly deliveredEventIds: readonly number[]; + readonly eventCount: number; + readonly eventIds: readonly number[]; + readonly pendingCount: number; + readonly producerSequences: readonly string[]; +} + +interface CountRow { + count: number; +} + +interface IdRow { + id: number; +} + +interface JournalModeRow { + journal_mode: string; +} + +interface OutboxStateCountRow { + count: number; + state: "claimed" | "delivered" | "pending"; +} + +interface ProducerSequenceRow { + producerId: string; + producerSequence: number; +} + +interface DeliveryLatencyRow { + latencyMs: number; +} + +function sqliteErrorCode(error: unknown): string | undefined { + const result = v.safeParse(sqliteErrorSchema, error); + return result.success ? result.output.code : undefined; +} + +function isContentionCode(code: string): boolean { + return ( + code === "SQLITE_BUSY" || + code.startsWith("SQLITE_BUSY_") || + code === "SQLITE_LOCKED" || + code.startsWith("SQLITE_LOCKED_") + ); +} + +/** + * Converts Bun SQLite failures into stable qualification failure tags. + * @param error Unknown thrown value. + * @returns A classified SQLite failure, or undefined for non-SQLite defects. + */ +export function classifyQualificationSqliteError( + error: unknown +): QualificationSqliteOperationError | undefined { + const code = sqliteErrorCode(error); + if (code === undefined) return undefined; + return isContentionCode(code) + ? new QualificationSqliteContentionError({ cause: error, code }) + : new QualificationSqliteUnavailableError({ cause: error, code }); +} + +const isContentionError = Predicate.isTagged("QualificationSqliteContentionError"); + +const contentionRetrySchedule = Schedule.exponential(Duration.millis(1)).pipe( + Schedule.modifyDelay(({ duration }) => { + const boundedDelayMs = Math.min(Duration.toMillis(duration), 10); + return Effect.succeed(Duration.millis(boundedDelayMs)); + }), + Schedule.upTo({ times: 40 }), + Schedule.while(({ input }) => isContentionError(input)) +); + +/** + * Runs one synchronous SQLite operation with bounded Effect-owned contention retries. + * The transaction callback supplied by callers remains synchronous. + * @param operation Synchronous SQLite operation. + * @returns An Effect with bounded contention retries and tagged expected failures. + */ +export function retryQualificationSqliteOperation( + operation: () => A +): Effect.Effect { + const attempt = Effect.suspend(() => { + try { + return Effect.succeed(operation()); + } catch (error) { + const failure = classifyQualificationSqliteError(error); + return failure === undefined ? Effect.die(error) : Effect.fail(failure); + } + }); + return attempt.pipe(Effect.retry({ schedule: contentionRetrySchedule })); +} + +/** + * Opens one strict file-backed qualification connection. + * @param databasePath Absolute temporary database path. + * @param options Connection access mode. + * @returns The opened Bun SQLite connection. + */ +export function openQualificationOutboxDatabase( + databasePath: string, + options: { readonly?: boolean } = {} +): Database { + const database = new Database(databasePath, { + create: options.readonly !== true, + readonly: options.readonly === true, + readwrite: options.readonly !== true, + strict: true, + }); + database.run("PRAGMA foreign_keys = ON"); + database.run("PRAGMA busy_timeout = 0"); + return database; +} + +/** + * Initializes the deterministic WAL-backed outbox fixture. + * @param database Writable qualification database. + */ +export function initializeQualificationOutboxDatabase(database: Database): void { + const journalMode = database + .query("PRAGMA journal_mode = WAL") + .get()?.journal_mode; + if (journalMode?.toLowerCase() !== "wal") { + throw new Error("Qualification database did not enter WAL mode"); + } + database.run("PRAGMA synchronous = NORMAL"); + for (const statement of outboxSchemaStatements) database.run(statement); +} + +/** + * Returns the connection-visible journal mode. + * @param database Open qualification database. + * @returns Lowercase SQLite journal mode. + */ +export function readQualificationJournalMode(database: Database): string { + const row = database.query("PRAGMA journal_mode").get(); + if (row === null) throw new Error("SQLite returned no journal mode"); + return row.journal_mode.toLowerCase(); +} + +/** + * Appends domain rows and their outbox events in one synchronous immediate transaction. + * @param database Writable qualification database. + * @param producerId Stable child producer identifier. + * @param count Number of records and events to append. + * @param createdAt Deterministic logical creation timestamp. + * @returns Inserted event identifiers. + */ +export function appendQualificationOutboxBatch( + database: Database, + producerId: string, + count: number, + createdAt: number +): AppendedOutboxBatch { + const insertRecord = database.prepare( + `INSERT INTO qualification_records (producer_id, producer_sequence, payload) + VALUES (?, ?, ?)` + ); + const insertEvent = database.prepare( + `INSERT INTO qualification_outbox_events (record_id, created_at) + VALUES (?, ?)` + ); + try { + const eventIds = database + .transaction(() => { + const insertedEventIds: number[] = []; + for (let sequence = 1; sequence <= count; sequence += 1) { + const record = insertRecord.run( + producerId, + sequence, + JSON.stringify({ producerId, sequence }) + ); + const outbox = insertEvent.run( + Number(record.lastInsertRowid), + createdAt + ); + insertedEventIds.push(Number(outbox.lastInsertRowid)); + } + return insertedEventIds; + }) + .immediate(); + return Object.freeze({ eventIds: Object.freeze(eventIds), producerId }); + } finally { + insertEvent.finalize(); + insertRecord.finalize(); + } +} + +/** + * Claims one ordered batch, including claims whose logical lease expired. + * @param database Writable qualification database. + * @param workerId Stable worker identifier. + * @param now Deterministic logical claim timestamp. + * @param leaseUntil Deterministic logical lease expiry. + * @param limit Maximum events to claim. + * @returns Ordered claimed event identifiers. + */ +export function claimQualificationOutboxBatch( + database: Database, + workerId: string, + now: number, + leaseUntil: number, + limit: number +): readonly number[] { + const select = database.prepare( + `SELECT id + FROM qualification_outbox_events + WHERE state = 'pending' + OR (state = 'claimed' AND lease_until <= ?) + ORDER BY id + LIMIT ?` + ); + const update = database.prepare( + `UPDATE qualification_outbox_events + SET state = 'claimed', claim_owner = ?, lease_until = ? + WHERE id = ? + AND (state = 'pending' OR (state = 'claimed' AND lease_until <= ?))` + ); + try { + const claimed = database + .transaction(() => { + const ids = select.all(now, limit).map((row) => row.id); + for (const id of ids) { + const result = update.run(workerId, leaseUntil, id, now); + if (result.changes !== 1) { + throw new Error( + "Outbox claim changed unexpectedly inside its transaction" + ); + } + } + return ids; + }) + .immediate(); + return Object.freeze(claimed); + } finally { + update.finalize(); + select.finalize(); + } +} + +/** + * Persists exactly-once delivery evidence and terminal event state atomically. + * @param database Writable qualification database. + * @param workerId Claim owner and delivery worker. + * @param deliveredAt Deterministic logical delivery timestamp. + * @returns Ordered delivered event identifiers. + */ +export function deliverQualificationOutboxClaims( + database: Database, + workerId: string, + deliveredAt: number +): readonly number[] { + const select = database.prepare( + `SELECT id + FROM qualification_outbox_events + WHERE state = 'claimed' AND claim_owner = ? + ORDER BY id` + ); + const insertDelivery = database.prepare( + `INSERT INTO qualification_outbox_deliveries (event_id, worker_id, delivered_at) + VALUES (?, ?, ?)` + ); + const markDelivered = database.prepare( + `UPDATE qualification_outbox_events + SET state = 'delivered', claim_owner = NULL, lease_until = NULL, delivered_at = ? + WHERE id = ? AND state = 'claimed' AND claim_owner = ?` + ); + try { + const delivered = database + .transaction(() => { + const ids = select.all(workerId).map((row) => row.id); + for (const id of ids) { + insertDelivery.run(id, workerId, deliveredAt); + const result = markDelivered.run(deliveredAt, id, workerId); + if (result.changes !== 1) { + throw new Error( + "Outbox delivery changed unexpectedly inside its transaction" + ); + } + } + return ids; + }) + .immediate(); + return Object.freeze(delivered); + } finally { + markDelivered.finalize(); + insertDelivery.finalize(); + select.finalize(); + } +} + +/** + * Captures all deterministic event and delivery invariants for assertions/evidence. + * @param database Open qualification database. + * @returns Immutable state snapshot. + */ +export function readQualificationOutboxSnapshot( + database: Database +): QualificationOutboxSnapshot { + const eventIds = database + .query("SELECT id FROM qualification_outbox_events ORDER BY id") + .all() + .map((row) => row.id); + const deliveredEventIds = database + .query( + "SELECT event_id AS id FROM qualification_outbox_deliveries ORDER BY event_id" + ) + .all() + .map((row) => row.id); + const stateCounts = new Map( + database + .query( + `SELECT state, count(*) AS count + FROM qualification_outbox_events + GROUP BY state` + ) + .all() + .map((row) => [row.state, row.count] as const) + ); + const producerSequences = database + .query( + `SELECT producer_id AS producerId, producer_sequence AS producerSequence + FROM qualification_records + ORDER BY producer_id, producer_sequence` + ) + .all() + .map((row) => `${row.producerId}:${row.producerSequence}`); + + return Object.freeze({ + claimedCount: stateCounts.get("claimed") ?? 0, + deliveredCount: stateCounts.get("delivered") ?? 0, + deliveredEventIds: Object.freeze(deliveredEventIds), + eventCount: eventIds.length, + eventIds: Object.freeze(eventIds), + pendingCount: stateCounts.get("pending") ?? 0, + producerSequences: Object.freeze(producerSequences), + }); +} + +/** + * Reads deterministic logical delivery latencies for later percentile evidence. + * @param database Open qualification database. + * @returns Logical event delivery latencies in event order. + */ +export function readQualificationDeliveryLatencies( + database: Database +): readonly number[] { + return Object.freeze( + database + .query( + `SELECT events.delivered_at - events.created_at AS latencyMs + FROM qualification_outbox_events AS events + WHERE events.state = 'delivered' + ORDER BY events.id` + ) + .all() + .map((row) => row.latencyMs) + ); +} + +/** + * Returns SQLite's full integrity result. + * @param database Open qualification database. + * @returns SQLite integrity-check response. + */ +export function readQualificationIntegrityCheck(database: Database): string { + const row = database + .query<{ integrityCheck: string }, []>( + "SELECT integrity_check AS integrityCheck FROM pragma_integrity_check" + ) + .get(); + if (row === null) throw new Error("SQLite returned no integrity result"); + return row.integrityCheck; +} + +/** + * Creates a consistent standalone backup after explicitly checkpointing WAL. + * @param database Writable qualification database. + * @param backupPath New standalone backup path. + */ +export function createQualificationOutboxBackup( + database: Database, + backupPath: string +): void { + database.run("PRAGMA wal_checkpoint(TRUNCATE)"); + const backup = database.prepare("VACUUM INTO ?"); + try { + backup.run(backupPath); + } finally { + backup.finalize(); + } +} + +/** + * Counts one allowlisted table without exposing a cached prepared statement. + * @param database Open qualification database. + * @param tableName Allowlisted qualification table. + * @returns Table row count. + */ +export function countQualificationRows( + database: Database, + tableName: QualificationTableName +): number { + const row = database + .query(qualificationCountStatements[tableName]) + .get(); + if (row === null) throw new Error("SQLite returned no count"); + return row.count; +} diff --git a/qualification/parity/fixtures/frontend-routes.json b/qualification/parity/fixtures/frontend-routes.json new file mode 100644 index 000000000..e2cb2fdde --- /dev/null +++ b/qualification/parity/fixtures/frontend-routes.json @@ -0,0 +1,272 @@ +{ + "contentPolicy": { + "containsHostConfiguration": false, + "containsRuntimeState": false, + "containsSecrets": false, + "sourceBacked": true + }, + "routes": [ + { + "access": "session", + "featureOwner": "overview", + "moduleKey": "dashboard", + "navigationLabel": "Dashboard", + "navigationPosition": 0, + "pageModule": "../pages/Dashboard", + "path": "/", + "searchNormalizer": null, + "sourceRouteName": "index", + "target": { + "delivery": "planned", + "path": "/", + "phase": "phase-3" + } + }, + { + "access": "session", + "featureOwner": "agents", + "moduleKey": "agents", + "navigationLabel": "Agents", + "navigationPosition": 2, + "pageModule": "../pages/Agents", + "path": "/agents", + "searchNormalizer": null, + "sourceRouteName": "agents", + "target": { + "delivery": "planned", + "path": "/agents", + "phase": "phase-3" + } + }, + { + "access": "session", + "featureOwner": "chat", + "moduleKey": "chat", + "navigationLabel": "Chat", + "navigationPosition": 4, + "pageModule": "../pages/Chat", + "path": "/chat", + "searchNormalizer": "normalizeChatSearch", + "sourceRouteName": "chat", + "target": { + "delivery": "planned", + "path": "/chat", + "phase": "phase-4" + } + }, + { + "access": "session", + "featureOwner": "database", + "moduleKey": "database", + "navigationLabel": "Database", + "navigationPosition": 11, + "pageModule": "../pages/Database", + "path": "/database", + "searchNormalizer": null, + "sourceRouteName": "database", + "target": { + "delivery": "planned", + "path": "/database", + "phase": "phase-5" + } + }, + { + "access": "session", + "featureOwner": "delivery", + "moduleKey": "delivery", + "navigationLabel": "Delivery", + "navigationPosition": 8, + "pageModule": "../pages/Delivery", + "path": "/delivery", + "searchNormalizer": null, + "sourceRouteName": "delivery", + "target": { + "delivery": "planned", + "path": "/delivery", + "phase": "phase-5" + } + }, + { + "access": "session", + "featureOwner": "docker", + "moduleKey": "docker", + "navigationLabel": "Docker", + "navigationPosition": 10, + "pageModule": "../pages/Docker", + "path": "/docker", + "searchNormalizer": null, + "sourceRouteName": "docker", + "target": { + "delivery": "planned", + "path": "/docker", + "phase": "phase-5" + } + }, + { + "access": "session", + "featureOwner": "files", + "moduleKey": "files", + "navigationLabel": "Files", + "navigationPosition": 9, + "pageModule": "../pages/Files", + "path": "/files", + "searchNormalizer": null, + "sourceRouteName": "files", + "target": { + "delivery": "planned", + "path": "/files", + "phase": "phase-5" + } + }, + { + "access": "session", + "featureOwner": "schedules-jobs", + "moduleKey": "jobs", + "navigationLabel": "Jobs", + "navigationPosition": 6, + "pageModule": "../pages/Jobs", + "path": "/jobs", + "searchNormalizer": null, + "sourceRouteName": "jobs", + "target": { + "delivery": "planned", + "path": "/jobs", + "phase": "phase-3" + } + }, + { + "access": "public", + "featureOwner": "security", + "moduleKey": "login", + "navigationLabel": null, + "navigationPosition": null, + "pageModule": "../pages/Login", + "path": "/login", + "searchNormalizer": null, + "sourceRouteName": "login", + "target": { + "delivery": "planned", + "path": "/login", + "phase": "phase-2" + } + }, + { + "access": "session", + "featureOwner": "logs", + "moduleKey": "logs", + "navigationLabel": "Logs", + "navigationPosition": 5, + "pageModule": "../pages/Logs", + "path": "/logs", + "searchNormalizer": null, + "sourceRouteName": "logs", + "target": { + "delivery": "planned", + "path": "/logs", + "phase": "phase-5" + } + }, + { + "access": "session", + "featureOwner": "moltbook", + "moduleKey": "moltbook", + "navigationLabel": "Moltbook", + "navigationPosition": 12, + "pageModule": "../pages/Moltbook", + "path": "/moltbook", + "searchNormalizer": null, + "sourceRouteName": "moltbook", + "target": { + "delivery": "planned", + "path": "/moltbook", + "phase": "phase-5" + } + }, + { + "access": "session", + "featureOwner": "monitoring", + "moduleKey": "reports", + "navigationLabel": "Reports", + "navigationPosition": 7, + "pageModule": "../pages/Reports", + "path": "/reports", + "searchNormalizer": null, + "sourceRouteName": "reports", + "target": { + "delivery": "planned", + "path": "/reports", + "phase": "phase-3" + } + }, + { + "access": "session", + "featureOwner": "gateway-sessions", + "moduleKey": "sessions", + "navigationLabel": "Sessions", + "navigationPosition": 3, + "pageModule": "../pages/Sessions", + "path": "/sessions", + "searchNormalizer": null, + "sourceRouteName": "sessions", + "target": { + "delivery": "planned", + "path": "/sessions", + "phase": "phase-4" + } + }, + { + "access": "session", + "featureOwner": "settings", + "moduleKey": "settings", + "navigationLabel": "Settings", + "navigationPosition": 14, + "pageModule": "../pages/Settings", + "path": "/settings", + "searchNormalizer": "normalizeSettingsSearch", + "sourceRouteName": "settings", + "target": { + "delivery": "planned", + "path": "/settings", + "phase": "phase-5" + } + }, + { + "access": "session", + "featureOwner": "tasks", + "moduleKey": "tasks", + "navigationLabel": "Tasks", + "navigationPosition": 1, + "pageModule": "../pages/Tasks", + "path": "/tasks", + "searchNormalizer": null, + "sourceRouteName": "tasks", + "target": { + "delivery": "planned", + "path": "/tasks", + "phase": "phase-3" + } + }, + { + "access": "session", + "featureOwner": "terminal", + "moduleKey": "terminal", + "navigationLabel": "Terminal", + "navigationPosition": 13, + "pageModule": "../pages/Terminal", + "path": "/terminal", + "searchNormalizer": null, + "sourceRouteName": "terminal", + "target": { + "delivery": "planned", + "path": "/terminal", + "phase": "phase-5" + } + } + ], + "schemaVersion": 1, + "sources": { + "navigation": "frontend/src/components/layout/Layout.tsx", + "routeModules": "frontend/src/lib/routeModules.ts", + "router": "frontend/src/router.tsx" + } +} diff --git a/qualification/parity/fixtures/greenfield-contracts.json b/qualification/parity/fixtures/greenfield-contracts.json new file mode 100644 index 000000000..dda86a82f --- /dev/null +++ b/qualification/parity/fixtures/greenfield-contracts.json @@ -0,0 +1,178 @@ +{ + "contentPolicy": { + "containsHostConfiguration": false, + "containsRuntimeState": false, + "containsSecrets": false, + "sourceBacked": true + }, + "procedures": [ + { + "kind": "mutation", + "name": "accountSecurity.beginTotpEnrollment" + }, + { + "kind": "mutation", + "name": "accountSecurity.beginWebAuthnEnrollment" + }, + { + "kind": "mutation", + "name": "accountSecurity.beginWebAuthnStepUp" + }, + { + "kind": "mutation", + "name": "accountSecurity.confirmTotpEnrollment" + }, + { + "kind": "mutation", + "name": "accountSecurity.confirmWebAuthnEnrollment" + }, + { + "kind": "mutation", + "name": "accountSecurity.disableMfa" + }, + { + "kind": "mutation", + "name": "accountSecurity.reauthenticatePassword" + }, + { + "kind": "mutation", + "name": "accountSecurity.removeTotpFactor" + }, + { + "kind": "mutation", + "name": "accountSecurity.removeWebAuthnCredential" + }, + { + "kind": "mutation", + "name": "accountSecurity.rotateRecoveryCodes" + }, + { + "kind": "mutation", + "name": "accountSecurity.stepUpRecovery" + }, + { + "kind": "mutation", + "name": "accountSecurity.stepUpTotp" + }, + { + "kind": "mutation", + "name": "accountSecurity.stepUpWebAuthn" + }, + { + "kind": "query", + "name": "accountSecurity.summary" + }, + { + "kind": "mutation", + "name": "auth.beginWebAuthnLogin" + }, + { + "kind": "mutation", + "name": "auth.bootstrap" + }, + { + "kind": "mutation", + "name": "auth.changePassword" + }, + { + "kind": "mutation", + "name": "auth.login" + }, + { + "kind": "mutation", + "name": "auth.loginRecovery" + }, + { + "kind": "mutation", + "name": "auth.loginTotp" + }, + { + "kind": "mutation", + "name": "auth.loginWebAuthn" + }, + { + "kind": "mutation", + "name": "auth.logout" + }, + { + "kind": "mutation", + "name": "auth.revokeSession" + }, + { + "kind": "query", + "name": "auth.sessions" + }, + { + "kind": "query", + "name": "auth.status" + }, + { + "kind": "mutation", + "name": "auth.touch" + }, + { + "kind": "mutation", + "name": "automationSecurity.createCredential" + }, + { + "kind": "mutation", + "name": "automationSecurity.createPrincipal" + }, + { + "kind": "mutation", + "name": "automationSecurity.disablePrincipal" + }, + { + "kind": "query", + "name": "automationSecurity.listCredentials" + }, + { + "kind": "query", + "name": "automationSecurity.listPrincipals" + }, + { + "kind": "mutation", + "name": "automationSecurity.replaceCapabilities" + }, + { + "kind": "mutation", + "name": "automationSecurity.revokeCredential" + }, + { + "kind": "mutation", + "name": "automationSecurity.rotateCredential" + }, + { + "kind": "subscription", + "name": "events.stream" + }, + { + "kind": "query", + "name": "system.runtimeIdentity" + } + ], + "rawHttp": [ + { + "id": "GET /api/health/live", + "method": "GET", + "path": "/api/health/live" + }, + { + "id": "GET /api/health/ready", + "method": "GET", + "path": "/api/health/ready" + }, + { + "id": "HEAD /api/health/live", + "method": "HEAD", + "path": "/api/health/live" + }, + { + "id": "HEAD /api/health/ready", + "method": "HEAD", + "path": "/api/health/ready" + } + ], + "schemaVersion": 1, + "source": "src/contracts/contractRegistry.ts" +} diff --git a/qualification/parity/fixtures/legacy-endpoints.json b/qualification/parity/fixtures/legacy-endpoints.json new file mode 100644 index 000000000..2cdf82df6 --- /dev/null +++ b/qualification/parity/fixtures/legacy-endpoints.json @@ -0,0 +1,2069 @@ +{ + "contentPolicy": { + "containsHostConfiguration": false, + "containsRuntimeState": false, + "containsSecrets": false, + "sourceBacked": true + }, + "endpoints": [ + { + "id": "DELETE /api/account/security/sessions/:sessionId", + "method": "DELETE", + "path": "/api/account/security/sessions/:sessionId", + "purpose": "Revokes one browser session.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.revokeSession"], + "phase": "phase-2" + } + }, + { + "id": "DELETE /api/account/security/totp/:factorId", + "method": "DELETE", + "path": "/api/account/security/totp/:factorId", + "purpose": "Removes a TOTP factor, but never the final factor.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.removeTotpFactor"], + "phase": "phase-2" + } + }, + { + "id": "DELETE /api/account/security/webauthn/:credentialId", + "method": "DELETE", + "path": "/api/account/security/webauthn/:credentialId", + "purpose": "Removes a security key, but never the final factor.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.removeWebAuthnCredential"], + "phase": "phase-2" + } + }, + { + "id": "DELETE /api/docker/images/:imageId", + "method": "DELETE", + "path": "/api/docker/images/:imageId", + "purpose": "Queues image deletion.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.deleteImage"], + "phase": "phase-5" + } + }, + { + "id": "DELETE /api/docker/volumes/:volumeName", + "method": "DELETE", + "path": "/api/docker/volumes/:volumeName", + "purpose": "Queues volume deletion.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.deleteVolume"], + "phase": "phase-5" + } + }, + { + "id": "DELETE /api/notifications/:id", + "method": "DELETE", + "path": "/api/notifications/:id", + "purpose": "Deletes one notification.", + "section": "Notifications", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["notifications.delete"], + "phase": "phase-3" + } + }, + { + "id": "DELETE /api/reports/:id", + "method": "DELETE", + "path": "/api/reports/:id", + "purpose": "Deletes a report and linked notifications.", + "section": "Reports", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["reports.delete"], + "phase": "phase-3" + } + }, + { + "id": "DELETE /api/sessions/:id", + "method": "DELETE", + "path": "/api/sessions/:id", + "purpose": "Deletes/removes a session.", + "section": "Sessions And Chat", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["gatewaySessions.delete"], + "phase": "phase-4" + } + }, + { + "id": "DELETE /api/tasks/:id", + "method": "DELETE", + "path": "/api/tasks/:id", + "purpose": "Deletes a task and its updates/events.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.delete"], + "phase": "phase-3" + } + }, + { + "id": "DELETE /api/tasks/:id/updates/:updateId", + "method": "DELETE", + "path": "/api/tasks/:id/updates/:updateId", + "purpose": "Deletes a task update.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.deleteProgress"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/account/security", + "method": "GET", + "path": "/api/account/security", + "purpose": "Lists MFA state, factors, configuration, and browser sessions.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.summary", "auth.sessions"], + "phase": "phase-2" + } + }, + { + "id": "GET /api/agents/:id/status", + "method": "GET", + "path": "/api/agents/:id/status", + "purpose": "Reads one agent status.", + "section": "Agents", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["agents.getStatus"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/agents/config", + "method": "GET", + "path": "/api/agents/config", + "purpose": "Reads agent config.", + "section": "Agents", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["agents.getConfiguration"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/agents/status", + "method": "GET", + "path": "/api/agents/status", + "purpose": "Reads all agent statuses.", + "section": "Agents", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["agents.listStatuses"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/agents/tasks/history", + "method": "GET", + "path": "/api/agents/tasks/history", + "purpose": "Reads agent task history.", + "section": "Agents", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["agents.listTaskHistory"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/audit-events", + "method": "GET", + "path": "/api/audit-events", + "purpose": "Pages append-only redacted events newest-first (`limit`, `before` cursor).", + "section": "Audit", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["securityAudit.listEvents"], + "phase": "phase-2" + } + }, + { + "id": "GET /api/auth/bootstrap", + "method": "GET", + "path": "/api/auth/bootstrap", + "purpose": "Returns first-user/bootstrap state.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.status"], + "phase": "phase-2" + } + }, + { + "id": "GET /api/auth/session", + "method": "GET", + "path": "/api/auth/session", + "purpose": "Returns current auth/session state.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.status"], + "phase": "phase-2" + } + }, + { + "id": "GET /api/backups/kopia", + "method": "GET", + "path": "/api/backups/kopia", + "purpose": "Reads Kopia backup state.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["backups.getKopiaStatus"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/backups/walg", + "method": "GET", + "path": "/api/backups/walg", + "purpose": "Reads WAL-G backup state.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["backups.getWalgStatus"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/cache/:key", + "method": "GET", + "path": "/api/cache/:key", + "purpose": "Reads one cache entry.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["cache.getEntry"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/cache/heartbeat", + "method": "GET", + "path": "/api/cache/heartbeat", + "purpose": "Reads schema v3 cache envelopes plus compact task, OpenClaw cron, and Dashboard-job projections.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["cache.getHeartbeat"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/cache/status", + "method": "GET", + "path": "/api/cache/status", + "purpose": "Reads cache envelopes without payload data for lightweight UI polling.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["cache.getStatus"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/chat/media/outgoing/*", + "method": "GET", + "path": "/api/chat/media/outgoing/*", + "purpose": "Proxies an exact managed Gateway media path with backend-held auth.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "GET", + "path": "/api/chat/media/*", + "phase": "phase-4" + } + }, + { + "id": "GET /api/config", + "method": "GET", + "path": "/api/config", + "purpose": "Reads recursively masked OpenClaw config plus hash.", + "section": "OpenClaw Config", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.getConfiguration"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/config-files", + "method": "GET", + "path": "/api/config-files", + "purpose": "Lists OpenClaw config files.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.listConfigFiles"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/config-files/*", + "method": "GET", + "path": "/api/config-files/*", + "purpose": "Reads a config file under OpenClaw root.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.getConfigFile"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/cron/jobs", + "method": "GET", + "path": "/api/cron/jobs", + "purpose": "Lists OpenClaw cron jobs and open linked tasks.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawCron.list"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/database/overview", + "method": "GET", + "path": "/api/database/overview", + "purpose": "Reads Postgres/PgBouncer plus Dashboard SQLite lifecycle overview.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["database.overview"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/containers", + "method": "GET", + "path": "/api/docker/containers", + "purpose": "Lists containers.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.listContainers"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/containers/:containerId", + "method": "GET", + "path": "/api/docker/containers/:containerId", + "purpose": "Reads container details.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.getContainer"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/containers/:containerId/logs", + "method": "GET", + "path": "/api/docker/containers/:containerId/logs", + "purpose": "Reads container logs.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.listContainerLogs"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/containers/stats", + "method": "GET", + "path": "/api/docker/containers/stats", + "purpose": "Reads the current container stats snapshot.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.getContainerStats"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/exec/:jobId", + "method": "GET", + "path": "/api/docker/exec/:jobId", + "purpose": "Reads persisted exec output/state.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.getExec"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/images", + "method": "GET", + "path": "/api/docker/images", + "purpose": "Lists images.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.listImages"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/updater/events", + "method": "GET", + "path": "/api/docker/updater/events", + "purpose": "Lists update events.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.listUpdateEvents"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/updater/services", + "method": "GET", + "path": "/api/docker/updater/services", + "purpose": "Lists managed update services.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.listManagedServices"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/docker/volumes", + "method": "GET", + "path": "/api/docker/volumes", + "purpose": "Lists volumes.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.listVolumes"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/exec/:jobId", + "method": "GET", + "path": "/api/exec/:jobId", + "purpose": "Reads persisted exec output/state.", + "section": "Exec And Terminal", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["terminal.getExecution"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/files", + "method": "GET", + "path": "/api/files", + "purpose": "Lists workspace files.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["files.list"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/files/*", + "method": "GET", + "path": "/api/files/*", + "purpose": "Reads workspace file/media metadata/content.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "GET", + "path": "/api/files/content/*", + "phase": "phase-5" + } + }, + { + "id": "GET /api/health/diagnostics", + "method": "GET", + "path": "/api/health/diagnostics", + "purpose": "Authenticated readiness details and dependency status.", + "section": "Health", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["system.healthDiagnostics"], + "phase": "phase-1" + } + }, + { + "id": "GET /api/health/live", + "method": "GET", + "path": "/api/health/live", + "purpose": "Public web-process liveness.", + "section": "Health", + "target": { + "delivery": "implemented", + "kind": "raw-http", + "method": "GET", + "path": "/api/health/live", + "phase": "phase-1" + } + }, + { + "id": "GET /api/health/ready", + "method": "GET", + "path": "/api/health/ready", + "purpose": "Public activation readiness; `503` when not ready.", + "section": "Health", + "target": { + "delivery": "implemented", + "kind": "raw-http", + "method": "GET", + "path": "/api/health/ready", + "phase": "phase-1" + } + }, + { + "id": "GET /api/job-executions", + "method": "GET", + "path": "/api/job-executions", + "purpose": "Lists recent executions plus queue/worker summary; `?include=claims` adds pause state.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["jobs.listRuns"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/job-executions/:id", + "method": "GET", + "path": "/api/job-executions/:id", + "purpose": "Reads one execution, including its persisted progress/result output snapshot.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["jobs.getRun"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/jobs", + "method": "GET", + "path": "/api/jobs", + "purpose": "Lists Dashboard scheduled jobs.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["schedules.list"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/jobs/:id", + "method": "GET", + "path": "/api/jobs/:id", + "purpose": "Reads a scheduled job.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["schedules.get"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/jobs/:id/runs", + "method": "GET", + "path": "/api/jobs/:id/runs", + "purpose": "Lists job run history.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["schedules.listRuns"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/logs/dashboard", + "method": "GET", + "path": "/api/logs/dashboard", + "purpose": "Reads the bounded Dashboard service log tail.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "GET", + "path": "/api/logs/dashboard", + "phase": "phase-5" + } + }, + { + "id": "GET /api/logs/openclaw/content", + "method": "GET", + "path": "/api/logs/openclaw/content", + "purpose": "Reads a bounded tail from one allowlisted OpenClaw log file.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "GET", + "path": "/api/logs/openclaw/content/*", + "phase": "phase-5" + } + }, + { + "id": "GET /api/logs/openclaw/files", + "method": "GET", + "path": "/api/logs/openclaw/files", + "purpose": "Lists readable OpenClaw log files and metadata.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["logs.listOpenClawFiles"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/media", + "method": "GET", + "path": "/api/media", + "purpose": "Serves or safely previews media bytes from OpenClaw media roots.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "GET", + "path": "/api/media/*", + "phase": "phase-5" + } + }, + { + "id": "GET /api/metrics", + "method": "GET", + "path": "/api/metrics", + "purpose": "Reads host metrics.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["system.metrics"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/moltbook/feed", + "method": "GET", + "path": "/api/moltbook/feed", + "purpose": "Reads feed with query params.", + "section": "Moltbook And Voice", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["moltbook.feed"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/moltbook/home", + "method": "GET", + "path": "/api/moltbook/home", + "purpose": "Reads Moltbook home cache/API.", + "section": "Moltbook And Voice", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["moltbook.home"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/moltbook/my-posts", + "method": "GET", + "path": "/api/moltbook/my-posts", + "purpose": "Reads own content.", + "section": "Moltbook And Voice", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["moltbook.listMyPosts"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/moltbook/profile", + "method": "GET", + "path": "/api/moltbook/profile", + "purpose": "Reads profile.", + "section": "Moltbook And Voice", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["moltbook.profile"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/notifications", + "method": "GET", + "path": "/api/notifications", + "purpose": "Lists notifications with filters/limit.", + "section": "Notifications", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["notifications.list"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/ops/log-rotation/status", + "method": "GET", + "path": "/api/ops/log-rotation/status", + "purpose": "Reads log rotation status.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["logMaintenance.status"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/pull-requests", + "method": "GET", + "path": "/api/pull-requests", + "purpose": "Lists Dashboard PRs.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.listPullRequests"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/pull-requests/deployments", + "method": "GET", + "path": "/api/pull-requests/deployments", + "purpose": "Lists deploy and rollback jobs.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.listDeployments"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/pull-requests/preview", + "method": "GET", + "path": "/api/pull-requests/preview", + "purpose": "Reads the single managed PR-dev slot.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.getPreview"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/pull-requests/production-checkout", + "method": "GET", + "path": "/api/pull-requests/production-checkout", + "purpose": "Reads production checkout status.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.getProductionCheckout"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/pull-requests/releases", + "method": "GET", + "path": "/api/pull-requests/releases", + "purpose": "Reads immutable `current`/`previous` release status.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.getReleases"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/reports", + "method": "GET", + "path": "/api/reports", + "purpose": "Lists reports, default limit `100`, max `200`. Supports `type` and `status`.", + "section": "Reports", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["reports.list"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/reports/:id", + "method": "GET", + "path": "/api/reports/:id", + "purpose": "Reads one report with full Markdown body.", + "section": "Reports", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["reports.get"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/sessions", + "method": "GET", + "path": "/api/sessions", + "purpose": "Normalized session snapshot from Gateway.", + "section": "Health", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["gatewaySessions.list"], + "phase": "phase-4" + } + }, + { + "id": "GET /api/sessions/list", + "method": "GET", + "path": "/api/sessions/list", + "purpose": "Lists Gateway sessions with optional filters.", + "section": "Sessions And Chat", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["gatewaySessions.list"], + "phase": "phase-4" + } + }, + { + "id": "GET /api/sessions/stats", + "method": "GET", + "path": "/api/sessions/stats", + "purpose": "Returns session stats.", + "section": "Sessions And Chat", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["gatewaySessions.stats"], + "phase": "phase-4" + } + }, + { + "id": "GET /api/settings", + "method": "GET", + "path": "/api/settings", + "purpose": "Reads Dashboard preferences plus current Gateway connection.", + "section": "Dashboard Settings", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["settings.get"], + "phase": "phase-1" + } + }, + { + "id": "GET /api/skills", + "method": "GET", + "path": "/api/skills", + "purpose": "Lists OpenClaw skills.", + "section": "OpenClaw Config", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.listSkills"], + "phase": "phase-5" + } + }, + { + "id": "GET /api/tasks", + "method": "GET", + "path": "/api/tasks", + "purpose": "Lists local tasks.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.list"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/tasks/:id", + "method": "GET", + "path": "/api/tasks/:id", + "purpose": "Reads one task.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.get"], + "phase": "phase-3" + } + }, + { + "id": "GET /api/tasks/:id/updates", + "method": "GET", + "path": "/api/tasks/:id/updates", + "purpose": "Lists task progress updates.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.listUpdates"], + "phase": "phase-3" + } + }, + { + "id": "HEAD /api/health/live", + "method": "HEAD", + "path": "/api/health/live", + "purpose": "Bodyless public web-process liveness probe.", + "section": "Health", + "target": { + "delivery": "implemented", + "kind": "raw-http", + "method": "HEAD", + "path": "/api/health/live", + "phase": "phase-1" + } + }, + { + "id": "HEAD /api/health/ready", + "method": "HEAD", + "path": "/api/health/ready", + "purpose": "Bodyless readiness probe with the same status as GET.", + "section": "Health", + "target": { + "delivery": "implemented", + "kind": "raw-http", + "method": "HEAD", + "path": "/api/health/ready", + "phase": "phase-1" + } + }, + { + "id": "PATCH /api/job-executions/claims", + "method": "PATCH", + "path": "/api/job-executions/claims", + "purpose": "Pauses/resumes new worker claims; running work is not cancelled.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["jobs.setClaimingPaused"], + "phase": "phase-3" + } + }, + { + "id": "PATCH /api/jobs/:id", + "method": "PATCH", + "path": "/api/jobs/:id", + "purpose": "Updates scheduled job settings and intentional-disable metadata.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["schedules.update"], + "phase": "phase-3" + } + }, + { + "id": "PATCH /api/tasks/:id", + "method": "PATCH", + "path": "/api/tasks/:id", + "purpose": "Updates title/body/labels/automation.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.update"], + "phase": "phase-3" + } + }, + { + "id": "PATCH /api/tasks/:id/updates/:updateId", + "method": "PATCH", + "path": "/api/tasks/:id/updates/:updateId", + "purpose": "Edits a task update.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.updateProgress"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/account/security/mfa/disable", + "method": "POST", + "path": "/api/account/security/mfa/disable", + "purpose": "Disables MFA after password plus recent second factor.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.disableMfa"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/password/change", + "method": "POST", + "path": "/api/account/security/password/change", + "purpose": "Changes password, rotates current session, revokes the rest.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.changePassword"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/reauth/password", + "method": "POST", + "path": "/api/account/security/reauth/password", + "purpose": "Refreshes recent password verification.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.reauthenticatePassword"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/recovery-codes/rotate", + "method": "POST", + "path": "/api/account/security/recovery-codes/rotate", + "purpose": "Invalidates and replaces all recovery codes.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.rotateRecoveryCodes"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/sessions/revoke-all", + "method": "POST", + "path": "/api/account/security/sessions/revoke-all", + "purpose": "Revokes every session, including the current one.", + "section": "Account Security", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["auth.revokeAllSessions"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/sessions/revoke-others", + "method": "POST", + "path": "/api/account/security/sessions/revoke-others", + "purpose": "Revokes every other browser session.", + "section": "Account Security", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["auth.revokeOtherSessions"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/step-up/recovery", + "method": "POST", + "path": "/api/account/security/step-up/recovery", + "purpose": "Refreshes recent MFA and consumes a recovery code.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.stepUpRecovery"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/step-up/totp", + "method": "POST", + "path": "/api/account/security/step-up/totp", + "purpose": "Refreshes recent MFA with TOTP.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.stepUpTotp"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/step-up/webauthn/options", + "method": "POST", + "path": "/api/account/security/step-up/webauthn/options", + "purpose": "Creates security-key step-up options.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.beginWebAuthnStepUp"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/step-up/webauthn/verify", + "method": "POST", + "path": "/api/account/security/step-up/webauthn/verify", + "purpose": "Verifies security-key step-up.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.stepUpWebAuthn"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/totp/confirm", + "method": "POST", + "path": "/api/account/security/totp/confirm", + "purpose": "Confirms and activates a TOTP factor.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.confirmTotpEnrollment"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/totp/setup", + "method": "POST", + "path": "/api/account/security/totp/setup", + "purpose": "Starts encrypted TOTP enrollment.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.beginTotpEnrollment"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/webauthn/register/options", + "method": "POST", + "path": "/api/account/security/webauthn/register/options", + "purpose": "Starts registration for a named security key.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.beginWebAuthnEnrollment"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/account/security/webauthn/register/verify", + "method": "POST", + "path": "/api/account/security/webauthn/register/verify", + "purpose": "Verifies and stores a security-key public credential.", + "section": "Account Security", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["accountSecurity.confirmWebAuthnEnrollment"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/auth/login", + "method": "POST", + "path": "/api/auth/login", + "purpose": "Verifies password; returns a session or a pending MFA login with `202`.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.login"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/auth/login/recovery", + "method": "POST", + "path": "/api/auth/login/recovery", + "purpose": "Completes pending login and consumes a one-time recovery code.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.loginRecovery"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/auth/login/totp", + "method": "POST", + "path": "/api/auth/login/totp", + "purpose": "Completes pending login with an authenticator-app code.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.loginTotp"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/auth/login/webauthn/options", + "method": "POST", + "path": "/api/auth/login/webauthn/options", + "purpose": "Creates options for pending security-key login.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.beginWebAuthnLogin"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/auth/login/webauthn/verify", + "method": "POST", + "path": "/api/auth/login/webauthn/verify", + "purpose": "Verifies a pending security-key assertion and creates the session.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.loginWebAuthn"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/auth/logout", + "method": "POST", + "path": "/api/auth/logout", + "purpose": "Deletes the current auth session and cookies.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.logout"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/auth/register-first-user", + "method": "POST", + "path": "/api/auth/register-first-user", + "purpose": "Validates Gateway token and creates the first user/session.", + "section": "Auth", + "target": { + "delivery": "implemented", + "kind": "procedure", + "names": ["auth.bootstrap"], + "phase": "phase-2" + } + }, + { + "id": "POST /api/backup", + "method": "POST", + "path": "/api/backup", + "purpose": "Creates config backup.", + "section": "OpenClaw Config", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.createConfigurationBackup"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/backups/kopia/clear-needs-attention", + "method": "POST", + "path": "/api/backups/kopia/clear-needs-attention", + "purpose": "Queues clearing Kopia attention state.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["backups.clearKopiaAttention"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/backups/kopia/run", + "method": "POST", + "path": "/api/backups/kopia/run", + "purpose": "Queues Kopia backup.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["backups.runKopia"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/backups/walg/clear-needs-attention", + "method": "POST", + "path": "/api/backups/walg/clear-needs-attention", + "purpose": "Queues clearing WAL-G attention state.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["backups.clearWalgAttention"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/backups/walg/run", + "method": "POST", + "path": "/api/backups/walg/run", + "purpose": "Queues WAL-G backup.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["backups.runWalg"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/cache/:key/refresh", + "method": "POST", + "path": "/api/cache/:key/refresh", + "purpose": "Queues and observes one cache refresh.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["cache.refreshEntry"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/cron/jobs/:id/delete", + "method": "POST", + "path": "/api/cron/jobs/:id/delete", + "purpose": "Deletes an OpenClaw cron job.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawCron.delete"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/cron/jobs/:id/run", + "method": "POST", + "path": "/api/cron/jobs/:id/run", + "purpose": "Runs an OpenClaw cron job.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawCron.run"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/cron/jobs/:id/toggle", + "method": "POST", + "path": "/api/cron/jobs/:id/toggle", + "purpose": "Enables/disables an OpenClaw cron job and updates its Dashboard-owned disable intent.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawCron.setEnabled"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/cron/jobs/:id/update", + "method": "POST", + "path": "/api/cron/jobs/:id/update", + "purpose": "Updates an OpenClaw cron job patch.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawCron.update"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/docker/containers/:containerId/action", + "method": "POST", + "path": "/api/docker/containers/:containerId/action", + "purpose": "Queues a container start/stop/restart.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.performContainerAction"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/docker/exec/:jobId/stop", + "method": "POST", + "path": "/api/docker/exec/:jobId/stop", + "purpose": "Requests exec cancellation.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.stopExec"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/docker/exec/start", + "method": "POST", + "path": "/api/docker/exec/start", + "purpose": "Queues a worker-owned container exec job.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.startExec"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/docker/prune", + "method": "POST", + "path": "/api/docker/prune", + "purpose": "Queues a Docker prune target.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.prune"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/docker/stack/action", + "method": "POST", + "path": "/api/docker/stack/action", + "purpose": "Queues a Compose stack action.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.performStackAction"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/docker/updater/run", + "method": "POST", + "path": "/api/docker/updater/run", + "purpose": "Queues an updater scan.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.runUpdater"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/docker/updater/services/:serviceId/update", + "method": "POST", + "path": "/api/docker/updater/services/:serviceId/update", + "purpose": "Queues one managed service update.", + "section": "Docker", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["docker.updateService"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/exec", + "method": "POST", + "path": "/api/exec", + "purpose": "Queues one command and observes its persisted result.", + "section": "Exec And Terminal", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["terminal.startExecution"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/exec/:jobId/stop", + "method": "POST", + "path": "/api/exec/:jobId/stop", + "purpose": "Requests exec cancellation.", + "section": "Exec And Terminal", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["terminal.stopExecution"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/exec/start", + "method": "POST", + "path": "/api/exec/start", + "purpose": "Queues a worker-owned long-running exec job.", + "section": "Exec And Terminal", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["terminal.startExecution"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/job-executions/:id/cancel", + "method": "POST", + "path": "/api/job-executions/:id/cancel", + "purpose": "Cancels queued work or requests cooperative cancellation of a running execution.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["jobs.cancelRun"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/jobs/:id/run", + "method": "POST", + "path": "/api/jobs/:id/run", + "purpose": "Queues a scheduled job and returns `202`.", + "section": "Jobs And Cron", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["schedules.run"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/notifications", + "method": "POST", + "path": "/api/notifications", + "purpose": "Creates/upserts a notification.", + "section": "Notifications", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["notifications.upsert"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/notifications/:id/read", + "method": "POST", + "path": "/api/notifications/:id/read", + "purpose": "Marks one notification read.", + "section": "Notifications", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["notifications.markRead"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/notifications/clear-read", + "method": "POST", + "path": "/api/notifications/clear-read", + "purpose": "Deletes read notifications, optionally by source.", + "section": "Notifications", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["notifications.clearRead"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/notifications/mark-all-read", + "method": "POST", + "path": "/api/notifications/mark-all-read", + "purpose": "Marks notifications read.", + "section": "Notifications", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["notifications.markAllRead"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/ops/log-rotation/dry-run", + "method": "POST", + "path": "/api/ops/log-rotation/dry-run", + "purpose": "Queues and observes log rotation dry-run.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["logMaintenance.dryRun"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/ops/log-rotation/run", + "method": "POST", + "path": "/api/ops/log-rotation/run", + "purpose": "Queues and observes log rotation.", + "section": "Backups, Cache, Metrics, Ops", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["logMaintenance.run"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/:number/approve", + "method": "POST", + "path": "/api/pull-requests/:number/approve", + "purpose": "Queues merge, optionally followed by deploy.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.approvePullRequest"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/:number/preview/start", + "method": "POST", + "path": "/api/pull-requests/:number/preview/start", + "purpose": "Validates and queues trusted PR dev (`202 Starting`).", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.startPreview"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/:number/preview/stop", + "method": "POST", + "path": "/api/pull-requests/:number/preview/stop", + "purpose": "Stops PR dev while retaining isolated state.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.stopPreview"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/:number/reject", + "method": "POST", + "path": "/api/pull-requests/:number/reject", + "purpose": "Queues reject/close.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.rejectPullRequest"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/:number/review-approval", + "method": "POST", + "path": "/api/pull-requests/:number/review-approval", + "purpose": "Queues review approval.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.approveReview"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/:number/update-branch", + "method": "POST", + "path": "/api/pull-requests/:number/update-branch", + "purpose": "Queues branch update.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.updateBranch"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/deploy", + "method": "POST", + "path": "/api/pull-requests/deploy", + "purpose": "Queues an atomic deploy of latest `main`.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.deploy"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/releases/rollback", + "method": "POST", + "path": "/api/pull-requests/releases/rollback", + "purpose": "Queues atomic rollback to the confirmed previous full SHA.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.rollbackRelease"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/pull-requests/stacks", + "method": "POST", + "path": "/api/pull-requests/stacks", + "purpose": "Creates one reviewed native GitHub PR stack.", + "section": "Pull Requests And Deployments", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["delivery.createPullRequestStack"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/reports", + "method": "POST", + "path": "/api/reports", + "purpose": "Creates or upserts a report.", + "section": "Reports", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["reports.upsert"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/restart", + "method": "POST", + "path": "/api/restart", + "purpose": "Queues an OpenClaw Gateway restart and waits for its persisted result.", + "section": "OpenClaw Config", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.restartGateway"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/sessions/:id/action", + "method": "POST", + "path": "/api/sessions/:id/action", + "purpose": "Sends a session action.", + "section": "Sessions And Chat", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["gatewaySessions.performAction"], + "phase": "phase-4" + } + }, + { + "id": "POST /api/skills/:name", + "method": "POST", + "path": "/api/skills/:name", + "purpose": "Toggles a skill.", + "section": "OpenClaw Config", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.setSkillEnabled"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/stt/transcribe", + "method": "POST", + "path": "/api/stt/transcribe", + "purpose": "Transcribes audio through ElevenLabs.", + "section": "Moltbook And Voice", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "POST", + "path": "/api/stt/transcriptions", + "phase": "phase-5" + } + }, + { + "id": "POST /api/tasks", + "method": "POST", + "path": "/api/tasks", + "purpose": "Creates a task.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.create"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/tasks/:id/assign", + "method": "POST", + "path": "/api/tasks/:id/assign", + "purpose": "Assigns or unassigns a task.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.assign"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/tasks/:id/move", + "method": "POST", + "path": "/api/tasks/:id/move", + "purpose": "Moves a task between status columns.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.move"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/tasks/:id/updates", + "method": "POST", + "path": "/api/tasks/:id/updates", + "purpose": "Adds a Markdown progress update.", + "section": "Tasks", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["tasks.addUpdate"], + "phase": "phase-3" + } + }, + { + "id": "POST /api/terminal/cd", + "method": "POST", + "path": "/api/terminal/cd", + "purpose": "Resolves validated directory changes.", + "section": "Exec And Terminal", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["terminal.resolveDirectory"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/terminal/complete", + "method": "POST", + "path": "/api/terminal/complete", + "purpose": "Returns shell/path completions.", + "section": "Exec And Terminal", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["terminal.complete"], + "phase": "phase-5" + } + }, + { + "id": "POST /api/tts/speak", + "method": "POST", + "path": "/api/tts/speak", + "purpose": "Streams ElevenLabs TTS audio.", + "section": "Moltbook And Voice", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "POST", + "path": "/api/tts/speech", + "phase": "phase-5" + } + }, + { + "id": "PUT /api/agents/:id/metadata", + "method": "PUT", + "path": "/api/agents/:id/metadata", + "purpose": "Updates agent metadata, including current task.", + "section": "Agents", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["agents.updateMetadata"], + "phase": "phase-3" + } + }, + { + "id": "PUT /api/config", + "method": "PUT", + "path": "/api/config", + "purpose": "Writes config with hash check and preserves valid masked placeholders.", + "section": "OpenClaw Config", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.updateConfiguration"], + "phase": "phase-5" + } + }, + { + "id": "PUT /api/config-files/*", + "method": "PUT", + "path": "/api/config-files/*", + "purpose": "Writes a config file under OpenClaw root.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["openClawSettings.updateConfigFile"], + "phase": "phase-5" + } + }, + { + "id": "PUT /api/files/*", + "method": "PUT", + "path": "/api/files/*", + "purpose": "Writes workspace file content.", + "section": "Files, Config Files, Logs, Media", + "target": { + "delivery": "planned", + "kind": "raw-http", + "method": "PUT", + "path": "/api/files/content/*", + "phase": "phase-5" + } + }, + { + "id": "PUT /api/settings", + "method": "PUT", + "path": "/api/settings", + "purpose": "Updates the validated Dashboard preference subset atomically.", + "section": "Dashboard Settings", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["settings.update"], + "phase": "phase-1" + } + }, + { + "id": "WebSocket /ws", + "method": "WebSocket", + "path": "/ws", + "purpose": "Browser Dashboard socket for Gateway-backed live updates.", + "section": "Sessions And Chat", + "target": { + "delivery": "planned", + "kind": "procedure", + "names": ["events.stream"], + "phase": "phase-4" + } + } + ], + "schemaVersion": 1, + "sources": { + "documentation": "docs/api/endpoints.md", + "httpRegistry": "backend/src/routes/registry.ts", + "websocket": "backend/src/server/app.ts" + } +} diff --git a/qualification/parity/legacyBackendRouteInventory.ts b/qualification/parity/legacyBackendRouteInventory.ts new file mode 100644 index 000000000..d20dbed34 --- /dev/null +++ b/qualification/parity/legacyBackendRouteInventory.ts @@ -0,0 +1,159 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import * as v from "valibot"; + +const maximumServerSourceBytes = 256 * 1024; +const maximumProbeOutputBytes = 64 * 1024; +const importedRepositoryRoot = path.resolve(import.meta.dir, "../.."); +const probeEntrypoint = path.join( + importedRepositoryRoot, + "scripts/qualification/legacyBackendRouteProbe.ts" +); +const httpMethodSchema = v.picklist(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]); +const httpRouteIdentitySchema = v.strictObject({ + id: v.pipe(v.string(), v.minLength(6), v.maxLength(256)), + method: httpMethodSchema, + path: v.pipe(v.string(), v.startsWith("/api/"), v.maxLength(192)), +}); +const httpRouteIdentitiesSchema = v.pipe( + v.array(httpRouteIdentitySchema), + v.minLength(1), + v.maxLength(256) +); + +export interface LegacyBackendRouteIdentity { + readonly id: string; + readonly method: "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "WebSocket"; + readonly path: string; +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function assertSingleSourceMatch( + source: string, + pattern: RegExp, + description: string +): void { + const matches = [...source.matchAll(pattern)]; + if (matches.length !== 1) { + throw new Error( + `Legacy server must contain exactly one reviewed ${description}; found ${matches.length}` + ); + } +} + +async function assertWebSocketRouteSource(repositoryRoot: string): Promise { + const sourcePath = path.join(repositoryRoot, "backend/src/server/app.ts"); + const sourceStat = await stat(sourcePath); + if ( + !sourceStat.isFile() || + sourceStat.size <= 0 || + sourceStat.size > maximumServerSourceBytes + ) { + throw new Error("Legacy WebSocket server source has an invalid size"); + } + const source = await readFile(sourcePath, "utf8"); + assertSingleSourceMatch( + source, + /if \(url\.pathname === "\/ws"\) \{/gu, + "WebSocket route branch" + ); + assertSingleSourceMatch( + source, + /server\.upgrade\(request, \{/gu, + "WebSocket upgrade call" + ); +} + +async function httpRouteIdentities(): Promise { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "mira-route-probe-")); + try { + const environment = { + CI: "1", + HOME: temporaryDirectory, + LANG: "C.UTF-8", + MIRA_DASHBOARD_DB_PATH: path.join(temporaryDirectory, "route-probe.sqlite"), + MIRA_DASHBOARD_PROJECT_ROOT: path.join( + temporaryDirectory, + "dashboard-project" + ), + NODE_ENV: "test", + OPENCLAW_HOME: path.join(temporaryDirectory, "openclaw"), + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", + TMPDIR: temporaryDirectory, + XDG_CACHE_HOME: path.join(temporaryDirectory, "cache"), + XDG_CONFIG_HOME: path.join(temporaryDirectory, "config"), + XDG_DATA_HOME: path.join(temporaryDirectory, "data"), + XDG_STATE_HOME: path.join(temporaryDirectory, "state"), + }; + const result = Bun.spawnSync({ + cmd: [process.execPath, probeEntrypoint], + cwd: temporaryDirectory, + env: environment, + killSignal: "SIGKILL", + maxBuffer: maximumProbeOutputBytes, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + timeout: 5000, + }); + if (!result.success) { + throw new Error("Legacy route registry probe failed"); + } + let candidate: unknown; + try { + candidate = JSON.parse(new TextDecoder().decode(result.stdout)) as unknown; + } catch { + throw new Error("Legacy route registry probe returned invalid JSON"); + } + const identities = v.parse(httpRouteIdentitiesSchema, candidate); + for (const identity of identities) { + if (identity.id !== `${identity.method} ${identity.path}`) { + throw new Error( + `Legacy route probe returned an invalid id ${identity.id}` + ); + } + } + return identities; + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } +} + +/** + * Reads the executable legacy HTTP registry and verifies the source-owned WebSocket route. + * Documentation supplies descriptions, but these identities are the parity authority. + * @param repositoryRoot Absolute repository root containing the imported registry. + * @returns Sorted, unique current-production route identities. + */ +export async function loadLegacyBackendRouteIdentities( + repositoryRoot: string +): Promise { + const resolvedRepositoryRoot = path.resolve(repositoryRoot); + if (resolvedRepositoryRoot !== importedRepositoryRoot) { + throw new Error( + "Legacy route inventory must inspect its imported repository root" + ); + } + await assertWebSocketRouteSource(resolvedRepositoryRoot); + const identities = [ + ...(await httpRouteIdentities()), + { + id: "WebSocket /ws", + method: "WebSocket" as const, + path: "/ws", + }, + ].toSorted((left, right) => compareStrings(left.id, right.id)); + for (const [index, identity] of identities.entries()) { + if (index > 0 && identities[index - 1]!.id === identity.id) { + throw new Error(`Duplicate legacy route identity ${identity.id}`); + } + } + return identities; +} diff --git a/qualification/parity/parityFixtureCandidate.ts b/qualification/parity/parityFixtureCandidate.ts new file mode 100644 index 000000000..a35bd43a4 --- /dev/null +++ b/qualification/parity/parityFixtureCandidate.ts @@ -0,0 +1,112 @@ +import { + parseFrontendParityFixture, + parseGreenfieldContractParityFixture, + parseLegacyEndpointParityFixture, + type FrontendParityFixture, + type GreenfieldContractParityFixture, + type LegacyEndpointParityFixture, +} from "./parityInventorySchemas.ts"; +import type { ReviewedParityInventory } from "./reviewedParityInventory.ts"; +import type { SourceParityInventory } from "./sourceParityInventory.ts"; + +export interface ParityFixtureCandidate { + frontend: FrontendParityFixture; + legacyEndpoints: LegacyEndpointParityFixture; +} + +interface ProcedureContractCandidate { + kind: "mutation" | "query" | "subscription"; + name: string; +} + +interface RawHttpContractCandidate { + method: "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; + path: string; +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +/** + * Builds a deterministic greenfield registry identity candidate. + * @param procedureContracts Live procedure registry entries. + * @param rawHttpContracts Live raw HTTP registry entries. + * @returns Strict registry identity fixture candidate. + */ +export function buildGreenfieldContractFixtureCandidate( + procedureContracts: readonly ProcedureContractCandidate[], + rawHttpContracts: readonly RawHttpContractCandidate[] +): GreenfieldContractParityFixture { + return parseGreenfieldContractParityFixture({ + contentPolicy: { + containsHostConfiguration: false, + containsRuntimeState: false, + containsSecrets: false, + sourceBacked: true, + }, + procedures: procedureContracts + .map(({ kind, name }) => ({ kind, name })) + .toSorted((left, right) => compareStrings(left.name, right.name)), + rawHttp: rawHttpContracts + .map(({ method, path }) => ({ id: `${method} ${path}`, method, path })) + .toSorted((left, right) => compareStrings(left.id, right.id)), + schemaVersion: 1, + source: "src/contracts/contractRegistry.ts", + }); +} + +/** + * Builds a source-refreshed candidate while preserving only explicitly reviewed target mappings. + * New route paths or endpoint ids fail instead of receiving an inferred target. + * @param observed Current semantic source inventory. + * @param reviewed Committed reviewed inventory and target mappings. + * @returns Source-refreshed fixture candidate. + */ +export function buildParityFixtureCandidate( + observed: SourceParityInventory, + reviewed: ReviewedParityInventory +): ParityFixtureCandidate { + const reviewedRoutes = new Map( + reviewed.frontend.routes.map((route) => [route.path, route] as const) + ); + const reviewedEndpoints = new Map( + reviewed.legacyEndpoints.endpoints.map( + (endpoint) => [endpoint.id, endpoint] as const + ) + ); + const frontend = parseFrontendParityFixture({ + ...reviewed.frontend, + routes: observed.routes.map((route) => { + const target = reviewedRoutes.get(route.path); + if (!target) { + throw new Error( + `Frontend route ${route.path} needs an explicit parity target review` + ); + } + return { + ...route, + featureOwner: target.featureOwner, + target: target.target, + }; + }), + }); + const legacyEndpoints = parseLegacyEndpointParityFixture({ + ...reviewed.legacyEndpoints, + endpoints: observed.endpoints.map((endpoint) => { + const target = reviewedEndpoints.get(endpoint.id)?.target; + if (!target) { + throw new Error( + `Legacy endpoint ${endpoint.id} needs an explicit parity target review` + ); + } + return { ...endpoint, target }; + }), + }); + return { + frontend, + legacyEndpoints, + }; +} diff --git a/qualification/parity/parityInventory.test.ts b/qualification/parity/parityInventory.test.ts new file mode 100644 index 000000000..7d7dde4c7 --- /dev/null +++ b/qualification/parity/parityInventory.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "bun:test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + procedureContracts, + rawHttpContracts, +} from "../../src/contracts/contractRegistry.ts"; +import { loadLegacyBackendRouteIdentities } from "./legacyBackendRouteInventory.ts"; +import { + buildGreenfieldContractFixtureCandidate, + buildParityFixtureCandidate, +} from "./parityFixtureCandidate.ts"; +import { parseFrontendParityFixture } from "./parityInventorySchemas.ts"; +import { + assertGreenfieldRegistryMatchesReviewed, + assertGreenfieldTargetAccounting, + assertSourcesMatchReviewedParity, + loadReviewedParityInventory, +} from "./reviewedParityInventory.ts"; +import { + loadSourceParityInventory, + type SourceParityInventory, +} from "./sourceParityInventory.ts"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../.." +); + +function countByPhase( + values: readonly { target: { kind?: string; phase?: string } }[] +): Record { + const counts: Record = {}; + for (const value of values) { + if (value.target.kind === "reviewed-removal" || !value.target.phase) continue; + counts[value.target.phase] = (counts[value.target.phase] ?? 0) + 1; + } + return counts; +} + +describe("reviewed frontend parity inventory", () => { + test("matches current route, navigation, lazy-module, and search sources exactly", async () => { + const [reviewed, observed] = await Promise.all([ + loadReviewedParityInventory(), + loadSourceParityInventory(repositoryRoot), + ]); + + expect(() => assertSourcesMatchReviewedParity(observed, reviewed)).not.toThrow(); + expect(reviewed.frontend.routes).toHaveLength(16); + expect( + reviewed.frontend.routes.filter((route) => route.navigationPosition !== null) + ).toHaveLength(15); + expect( + reviewed.frontend.routes + .filter((route) => route.navigationPosition !== null) + .toSorted( + (left, right) => left.navigationPosition! - right.navigationPosition! + ) + .map((route) => route.navigationPosition) + ).toEqual(Array.from({ length: 15 }, (_, index) => index)); + expect( + reviewed.frontend.routes.every((route) => route.target.delivery === "planned") + ).toBeTrue(); + expect(countByPhase(reviewed.frontend.routes)).toEqual({ + "phase-2": 1, + "phase-3": 5, + "phase-4": 2, + "phase-5": 8, + }); + }); + + test("generates the same candidate while requiring explicit review for new routes", async () => { + const [reviewed, observed] = await Promise.all([ + loadReviewedParityInventory(), + loadSourceParityInventory(repositoryRoot), + ]); + expect(buildParityFixtureCandidate(observed, reviewed).frontend).toEqual( + reviewed.frontend + ); + + const changedRoute: SourceParityInventory = structuredClone(observed); + changedRoute.routes[0] = { ...changedRoute.routes[0]!, path: "/new-route" }; + expect(() => buildParityFixtureCandidate(changedRoute, reviewed)).toThrow( + "Frontend route /new-route needs an explicit parity target review" + ); + }); + + test("uses strict fixture objects", async () => { + const { frontend } = await loadReviewedParityInventory(); + expect(() => + parseFrontendParityFixture({ ...frontend, unreviewedField: true }) + ).toThrow(); + }); +}); + +describe("reviewed legacy endpoint parity inventory", () => { + test("accounts for every executable backend route and documented row exactly once", async () => { + const [reviewed, observed, backendRoutes] = await Promise.all([ + loadReviewedParityInventory(), + loadSourceParityInventory(repositoryRoot), + loadLegacyBackendRouteIdentities(repositoryRoot), + ]); + + expect(() => assertSourcesMatchReviewedParity(observed, reviewed)).not.toThrow(); + expect( + reviewed.legacyEndpoints.endpoints.map(({ id, method, path: routePath }) => ({ + id, + method, + path: routePath, + })) + ).toEqual(backendRoutes); + expect(backendRoutes.filter(({ method }) => method !== "WebSocket")).toHaveLength( + 156 + ); + expect(backendRoutes.filter(({ method }) => method === "WebSocket")).toEqual([ + { id: "WebSocket /ws", method: "WebSocket", path: "/ws" }, + ]); + expect(reviewed.legacyEndpoints.endpoints).toHaveLength(157); + expect(new Set(reviewed.legacyEndpoints.endpoints.map(({ id }) => id)).size).toBe( + 157 + ); + expect(countByPhase(reviewed.legacyEndpoints.endpoints)).toEqual({ + "phase-1": 7, + "phase-2": 28, + "phase-3": 45, + "phase-4": 7, + "phase-5": 70, + }); + expect( + reviewed.legacyEndpoints.endpoints.filter( + ({ target }) => + target.kind !== "reviewed-removal" && + target.delivery === "implemented" + ) + ).toHaveLength(29); + expect( + reviewed.legacyEndpoints.endpoints.filter( + ({ target }) => target.kind === "reviewed-removal" + ) + ).toHaveLength(0); + }); + + test("checks implemented mappings against the greenfield registries", async () => { + const reviewed = await loadReviewedParityInventory(); + expect(() => + assertGreenfieldRegistryMatchesReviewed( + reviewed, + procedureContracts, + rawHttpContracts + ) + ).not.toThrow(); + expect( + buildGreenfieldContractFixtureCandidate(procedureContracts, rawHttpContracts) + ).toEqual(reviewed.greenfieldContracts); + expect(reviewed.greenfieldContracts.procedures).toHaveLength(36); + expect(reviewed.greenfieldContracts.rawHttp).toHaveLength(4); + expect(() => + assertGreenfieldTargetAccounting( + reviewed, + procedureContracts, + rawHttpContracts + ) + ).not.toThrow(); + + const missingContract = structuredClone(reviewed); + const implementedProcedure = missingContract.legacyEndpoints.endpoints.find( + ({ target }) => + target.kind === "procedure" && target.delivery === "implemented" + ); + expect(implementedProcedure?.target.kind).toBe("procedure"); + if (implementedProcedure?.target.kind !== "procedure") { + throw new Error("Test fixture has no implemented procedure target"); + } + implementedProcedure.target.names = ["missing.procedure"]; + expect(() => + assertGreenfieldTargetAccounting( + missingContract, + procedureContracts, + rawHttpContracts + ) + ).toThrow("is not registered"); + }); + + test("keeps unresolved Phase 2 browser behavior explicit instead of overclaiming", async () => { + const reviewed = await loadReviewedParityInventory(); + expect( + reviewed.legacyEndpoints.endpoints + .filter( + ({ target }) => + target.kind !== "reviewed-removal" && + target.phase === "phase-2" && + target.delivery === "planned" + ) + .map(({ id }) => id) + ).toEqual([ + "GET /api/audit-events", + "POST /api/account/security/sessions/revoke-all", + "POST /api/account/security/sessions/revoke-others", + ]); + }); + + test("requires an explicit target before generating a candidate for a new endpoint", async () => { + const [reviewed, observed] = await Promise.all([ + loadReviewedParityInventory(), + loadSourceParityInventory(repositoryRoot), + ]); + expect(buildParityFixtureCandidate(observed, reviewed).legacyEndpoints).toEqual( + reviewed.legacyEndpoints + ); + + const changedEndpoint: SourceParityInventory = structuredClone(observed); + changedEndpoint.endpoints.push({ + id: "GET /api/unreviewed", + method: "GET", + path: "/api/unreviewed", + purpose: "Unreviewed source drift.", + section: "Unreviewed", + }); + expect(() => buildParityFixtureCandidate(changedEndpoint, reviewed)).toThrow( + "Legacy endpoint GET /api/unreviewed needs an explicit parity target review" + ); + }); +}); diff --git a/qualification/parity/parityInventorySchemas.ts b/qualification/parity/parityInventorySchemas.ts new file mode 100644 index 000000000..77ddf57ea --- /dev/null +++ b/qualification/parity/parityInventorySchemas.ts @@ -0,0 +1,281 @@ +import * as v from "valibot"; + +/* oxlint-disable unicorn/max-nested-calls -- Strict Valibot schemas are intentionally declarative. */ + +const schemaVersionSchema = v.literal(1); +const boundedTextSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(512)); +const procedureNameSchema = v.pipe( + v.string(), + v.regex(/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/u) +); +const routePathSchema = v.pipe( + v.string(), + v.regex( + /^\/(?:[A-Za-z0-9._~!$&'()*+,;=:@%-]+(?:\/[A-Za-z0-9._~!$&'()*+,;=:@%-]+)*)?$/u + ) +); +const rawHttpPathSchema = v.pipe( + v.string(), + v.regex(/^\/api\/[A-Za-z0-9._~!$&'()*+,;=:@%*/-]+$/u) +); +const phaseSchema = v.picklist([ + "phase-1", + "phase-2", + "phase-3", + "phase-4", + "phase-5", + "phase-6", +]); +const deliverySchema = v.picklist(["implemented", "planned"]); +const sourceMethodSchema = v.picklist([ + "DELETE", + "GET", + "HEAD", + "PATCH", + "POST", + "PUT", + "WebSocket", +]); +const rawHttpMethodSchema = v.picklist(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]); + +function valuesAreSortedAndUnique(values: string[]): boolean { + return values.every((value, index) => index === 0 || values[index - 1]! < value); +} + +const procedureTargetSchema = v.strictObject({ + delivery: deliverySchema, + kind: v.literal("procedure"), + names: v.pipe( + v.array(procedureNameSchema), + v.minLength(1), + v.maxLength(4), + v.check(valuesAreSortedAndUnique, "Procedure names must be sorted and unique") + ), + phase: phaseSchema, +}); + +const rawHttpTargetSchema = v.strictObject({ + delivery: deliverySchema, + kind: v.literal("raw-http"), + method: rawHttpMethodSchema, + path: rawHttpPathSchema, + phase: phaseSchema, +}); + +const reviewedRemovalTargetSchema = v.strictObject({ + consumerEvidence: v.literal("no-current-consumers"), + kind: v.literal("reviewed-removal"), + reason: boundedTextSchema, +}); + +export const endpointTargetSchema = v.variant("kind", [ + procedureTargetSchema, + rawHttpTargetSchema, + reviewedRemovalTargetSchema, +]); + +const legacyEndpointSchema = v.strictObject({ + id: v.pipe(v.string(), v.minLength(6), v.maxLength(256)), + method: sourceMethodSchema, + path: v.pipe(v.string(), v.startsWith("/"), v.maxLength(192)), + purpose: boundedTextSchema, + section: v.pipe(v.string(), v.minLength(1), v.maxLength(64)), + target: endpointTargetSchema, +}); + +const frontendRouteSchema = v.strictObject({ + access: v.picklist(["public", "session"]), + featureOwner: v.pipe(v.string(), v.regex(/^[a-z][a-z0-9-]*$/u)), + moduleKey: v.pipe(v.string(), v.regex(/^[a-z][a-z0-9]*$/u)), + navigationLabel: v.nullable(v.pipe(v.string(), v.minLength(1), v.maxLength(32))), + navigationPosition: v.nullable( + v.pipe(v.number(), v.integer(), v.safeInteger(), v.minValue(0), v.maxValue(64)) + ), + pageModule: v.pipe(v.string(), v.regex(/^\.\.\/pages\/[A-Z][A-Za-z0-9]*$/u)), + path: routePathSchema, + searchNormalizer: v.nullable( + v.picklist(["normalizeChatSearch", "normalizeSettingsSearch"]) + ), + sourceRouteName: v.pipe(v.string(), v.regex(/^[a-z][A-Za-z0-9]*$/u)), + target: v.strictObject({ + delivery: v.literal("planned"), + path: routePathSchema, + phase: phaseSchema, + }), +}); + +const contentPolicySchema = v.strictObject({ + containsHostConfiguration: v.literal(false), + containsRuntimeState: v.literal(false), + containsSecrets: v.literal(false), + sourceBacked: v.literal(true), +}); + +export const frontendParityFixtureSchema = v.pipe( + v.strictObject({ + contentPolicy: contentPolicySchema, + routes: v.pipe( + v.array(frontendRouteSchema), + v.minLength(1), + v.maxLength(64), + v.check( + (routes) => valuesAreSortedAndUnique(routes.map((route) => route.path)), + "Frontend routes must be sorted and unique" + ), + v.check( + (routes) => + valuesAreSortedAndUnique( + routes + .filter((route) => route.navigationPosition !== null) + .toSorted( + (left, right) => + left.navigationPosition! - right.navigationPosition! + ) + .map((route) => + String(route.navigationPosition).padStart(3, "0") + ) + ), + "Navigation positions must be unique" + ), + v.check( + (routes) => + routes.every( + (route) => + (route.navigationLabel === null) === + (route.navigationPosition === null) + ), + "Navigation labels and positions must both be present or absent" + ) + ), + schemaVersion: schemaVersionSchema, + sources: v.strictObject({ + navigation: v.literal("frontend/src/components/layout/Layout.tsx"), + routeModules: v.literal("frontend/src/lib/routeModules.ts"), + router: v.literal("frontend/src/router.tsx"), + }), + }), + v.check( + (fixture) => fixture.routes.every((route) => route.path === route.target.path), + "Current public route paths must remain stable in the target inventory" + ) +); + +export const legacyEndpointParityFixtureSchema = v.pipe( + v.strictObject({ + contentPolicy: contentPolicySchema, + endpoints: v.pipe( + v.array(legacyEndpointSchema), + v.minLength(1), + v.maxLength(256), + v.check( + (endpoints) => + valuesAreSortedAndUnique(endpoints.map((endpoint) => endpoint.id)), + "Legacy endpoint ids must be sorted and unique" + ), + v.check( + (endpoints) => + endpoints.every( + (endpoint) => + endpoint.id === `${endpoint.method} ${endpoint.path}` + ), + "Legacy endpoint ids must be derived from method and path" + ) + ), + schemaVersion: schemaVersionSchema, + sources: v.strictObject({ + documentation: v.literal("docs/api/endpoints.md"), + httpRegistry: v.literal("backend/src/routes/registry.ts"), + websocket: v.literal("backend/src/server/app.ts"), + }), + }), + v.check( + (fixture) => fixture.endpoints.length === 157, + "The reviewed legacy endpoint inventory must contain exactly 157 rows" + ) +); + +const greenfieldProcedureIdentitySchema = v.strictObject({ + kind: v.picklist(["mutation", "query", "subscription"]), + name: procedureNameSchema, +}); + +const greenfieldRawHttpIdentitySchema = v.strictObject({ + id: v.pipe(v.string(), v.minLength(6), v.maxLength(256)), + method: rawHttpMethodSchema, + path: rawHttpPathSchema, +}); + +export const greenfieldContractParityFixtureSchema = v.strictObject({ + contentPolicy: contentPolicySchema, + procedures: v.pipe( + v.array(greenfieldProcedureIdentitySchema), + v.minLength(1), + v.maxLength(256), + v.check( + (procedures) => + valuesAreSortedAndUnique(procedures.map((procedure) => procedure.name)), + "Greenfield procedure identities must be sorted and unique" + ) + ), + rawHttp: v.pipe( + v.array(greenfieldRawHttpIdentitySchema), + v.minLength(1), + v.maxLength(64), + v.check( + (contracts) => + valuesAreSortedAndUnique(contracts.map((contract) => contract.id)), + "Greenfield raw HTTP identities must be sorted and unique" + ), + v.check( + (contracts) => + contracts.every( + (contract) => contract.id === `${contract.method} ${contract.path}` + ), + "Greenfield raw HTTP ids must be derived from method and path" + ) + ), + schemaVersion: schemaVersionSchema, + source: v.literal("src/contracts/contractRegistry.ts"), +}); + +export type EndpointTarget = v.InferOutput; +export type FrontendParityFixture = v.InferOutput; +export type FrontendRouteInventory = FrontendParityFixture["routes"][number]; +export type LegacyEndpointParityFixture = v.InferOutput< + typeof legacyEndpointParityFixtureSchema +>; +export type LegacyEndpointInventory = LegacyEndpointParityFixture["endpoints"][number]; +export type GreenfieldContractParityFixture = v.InferOutput< + typeof greenfieldContractParityFixtureSchema +>; + +/** + * Parses one strict frontend parity fixture. + * @param value Candidate fixture value. + * @returns Validated frontend parity fixture. + */ +export function parseFrontendParityFixture(value: unknown): FrontendParityFixture { + return v.parse(frontendParityFixtureSchema, value); +} + +/** + * Parses one strict legacy endpoint parity fixture. + * @param value Candidate fixture value. + * @returns Validated legacy endpoint parity fixture. + */ +export function parseLegacyEndpointParityFixture( + value: unknown +): LegacyEndpointParityFixture { + return v.parse(legacyEndpointParityFixtureSchema, value); +} + +/** + * Parses one strict greenfield contract identity fixture. + * @param value Candidate fixture value. + * @returns Validated greenfield contract identity fixture. + */ +export function parseGreenfieldContractParityFixture( + value: unknown +): GreenfieldContractParityFixture { + return v.parse(greenfieldContractParityFixtureSchema, value); +} diff --git a/qualification/parity/reviewedParityInventory.ts b/qualification/parity/reviewedParityInventory.ts new file mode 100644 index 000000000..420367989 --- /dev/null +++ b/qualification/parity/reviewedParityInventory.ts @@ -0,0 +1,206 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + parseFrontendParityFixture, + parseGreenfieldContractParityFixture, + parseLegacyEndpointParityFixture, + type FrontendParityFixture, + type GreenfieldContractParityFixture, + type LegacyEndpointParityFixture, +} from "./parityInventorySchemas.ts"; +import type { SourceParityInventory } from "./sourceParityInventory.ts"; + +const fixtureDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures" +); +const maximumFixtureBytes = 256 * 1024; + +export interface ReviewedParityInventory { + frontend: FrontendParityFixture; + greenfieldContracts: GreenfieldContractParityFixture; + legacyEndpoints: LegacyEndpointParityFixture; +} + +interface ProcedureContractIdentity { + kind: "mutation" | "query" | "subscription"; + name: string; +} + +interface RawHttpContractIdentity { + method: string; + path: string; +} + +function canonicalJson(value: unknown): string { + return `${JSON.stringify(value, undefined, 2)}\n`; +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +async function loadJsonFixture(fileName: string): Promise { + const fixturePath = path.join(fixtureDirectory, fileName); + const fixtureStat = await stat(fixturePath); + if ( + !fixtureStat.isFile() || + fixtureStat.size <= 0 || + fixtureStat.size > maximumFixtureBytes + ) { + throw new Error(`Parity fixture ${fileName} has an invalid size`); + } + const serialized = await readFile(fixturePath, "utf8"); + try { + return JSON.parse(serialized) as unknown; + } catch { + throw new Error(`Parity fixture ${fileName} is not valid JSON`); + } +} + +/** + * Loads and strictly validates the committed parity inventory fixtures. + * @returns Validated reviewed parity inventory. + */ +export async function loadReviewedParityInventory(): Promise { + const [frontend, greenfieldContracts, legacyEndpoints] = await Promise.all([ + loadJsonFixture("frontend-routes.json"), + loadJsonFixture("greenfield-contracts.json"), + loadJsonFixture("legacy-endpoints.json"), + ]); + return { + frontend: parseFrontendParityFixture(frontend), + greenfieldContracts: parseGreenfieldContractParityFixture(greenfieldContracts), + legacyEndpoints: parseLegacyEndpointParityFixture(legacyEndpoints), + }; +} + +function reviewedSourceProjection( + reviewed: ReviewedParityInventory +): SourceParityInventory { + return { + endpoints: reviewed.legacyEndpoints.endpoints.map( + ({ id, method, path: endpointPath, purpose, section }) => ({ + id, + method, + path: endpointPath, + purpose, + section, + }) + ), + routes: reviewed.frontend.routes.map( + ({ + access, + moduleKey, + navigationLabel, + navigationPosition, + pageModule, + path: routePath, + searchNormalizer, + sourceRouteName, + }) => ({ + access, + moduleKey, + navigationLabel, + navigationPosition, + pageModule, + path: routePath, + searchNormalizer, + sourceRouteName, + }) + ), + }; +} + +/** Fails when current route, navigation, module, or endpoint sources drift from review. */ +export function assertSourcesMatchReviewedParity( + observed: SourceParityInventory, + reviewed: ReviewedParityInventory +): void { + if (canonicalJson(observed) !== canonicalJson(reviewedSourceProjection(reviewed))) { + throw new Error( + "Current-production parity sources differ from reviewed fixtures" + ); + } +} + +function contractKey(method: string, routePath: string): string { + return `${method} ${routePath}`; +} + +/** Fails when the live greenfield contract registry differs from the reviewed identity set. */ +export function assertGreenfieldRegistryMatchesReviewed( + reviewed: ReviewedParityInventory, + procedureContracts: readonly ProcedureContractIdentity[], + rawHttpContracts: readonly RawHttpContractIdentity[] +): void { + const observed = { + procedures: procedureContracts + .map(({ kind, name }) => ({ kind, name })) + .toSorted((left, right) => compareStrings(left.name, right.name)), + rawHttp: rawHttpContracts + .map(({ method, path: routePath }) => ({ + id: contractKey(method, routePath), + method, + path: routePath, + })) + .toSorted((left, right) => compareStrings(left.id, right.id)), + }; + const expected = { + procedures: reviewed.greenfieldContracts.procedures, + rawHttp: reviewed.greenfieldContracts.rawHttp, + }; + if (canonicalJson(observed) !== canonicalJson(expected)) { + throw new Error("Greenfield contract registry differs from reviewed fixtures"); + } +} + +/** + * Verifies that implemented targets exist and that later-phase work is never marked implemented. + */ +export function assertGreenfieldTargetAccounting( + reviewed: ReviewedParityInventory, + procedureContracts: readonly ProcedureContractIdentity[], + rawHttpContracts: readonly RawHttpContractIdentity[] +): void { + const procedureNames = new Set(procedureContracts.map((contract) => contract.name)); + const rawHttpNames = new Set( + rawHttpContracts.map((contract) => contractKey(contract.method, contract.path)) + ); + for (const endpoint of reviewed.legacyEndpoints.endpoints) { + const { target } = endpoint; + if (target.kind === "reviewed-removal") continue; + if ( + target.delivery === "implemented" && + (target.phase === "phase-3" || + target.phase === "phase-4" || + target.phase === "phase-5" || + target.phase === "phase-6") + ) { + throw new Error( + `Later-phase target for ${endpoint.id} cannot be marked implemented` + ); + } + if (target.kind === "procedure" && target.delivery === "implemented") { + for (const name of target.names) { + if (!procedureNames.has(name)) { + throw new Error( + `Implemented target ${name} for ${endpoint.id} is not registered` + ); + } + } + } + if (target.kind === "raw-http" && target.delivery === "implemented") { + const targetKey = contractKey(target.method, target.path); + if (!rawHttpNames.has(targetKey)) { + throw new Error( + `Implemented target ${targetKey} for ${endpoint.id} is not registered` + ); + } + } + } +} diff --git a/qualification/parity/sourceParityInventory.test.ts b/qualification/parity/sourceParityInventory.test.ts new file mode 100644 index 000000000..837bdae77 --- /dev/null +++ b/qualification/parity/sourceParityInventory.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "bun:test"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadSourceParityInventory, paritySourcePaths } from "./sourceParityInventory.ts"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../.." +); + +function replaceExactly(source: string, target: string, replacement: string): string { + const parts = source.split(target); + if (parts.length !== 2) { + throw new Error(`Expected one source occurrence of ${JSON.stringify(target)}`); + } + return `${parts[0]}${replacement}${parts[1]}`; +} + +async function withModifiedRouter( + modifyRouter: (source: string) => string, + verify: (temporaryRepositoryRoot: string) => Promise +): Promise { + const temporaryRepositoryRoot = await mkdtemp( + path.join(tmpdir(), "mira-parity-router-") + ); + try { + await Promise.all( + Object.values(paritySourcePaths).map(async (relativePath) => { + const destination = path.join(temporaryRepositoryRoot, relativePath); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(path.join(repositoryRoot, relativePath), destination); + }) + ); + const routerPath = path.join(temporaryRepositoryRoot, paritySourcePaths.router); + const routerSource = await readFile(routerPath, "utf8"); + await writeFile(routerPath, modifyRouter(routerSource), "utf8"); + await verify(temporaryRepositoryRoot); + } finally { + await rm(temporaryRepositoryRoot, { force: true, recursive: true }); + } +} + +async function expectInventoryLoadFailure( + temporaryRepositoryRoot: string, + expectedMessage: string +): Promise { + try { + await loadSourceParityInventory(temporaryRepositoryRoot); + } catch (error) { + if (!(error instanceof Error)) throw error; + expect(error.message).toContain(expectedMessage); + return; + } + throw new Error(`Expected parity inventory loading to fail with ${expectedMessage}`); +} + +test("allows the explicitly reviewed authenticated pathless layout", async () => { + const inventory = await loadSourceParityInventory(repositoryRoot); + expect(inventory.routes).toHaveLength(16); + expect( + inventory.routes.some( + ({ sourceRouteName }) => sourceRouteName === "authenticated" + ) + ).toBeFalse(); +}); + +test("rejects a pathless layout whose authentication guard is weakened", async () => { + await withModifiedRouter( + (source) => + replaceExactly( + source, + " if (!authStore.state.isAuthenticated) {", + " if (false) {" + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Pathless route authenticatedRoute changed outside its explicit review" + ) + ); +}); + +test("rejects an unreviewed pathless createRoute declaration", async () => { + await withModifiedRouter( + (source) => + replaceExactly( + source, + "const routeTree = rootRoute.addChildren([", + `const hiddenRoute = createRoute({ + getParentRoute: () => rootRoute, + id: "hidden", + component: Login, +}); + +const routeTree = rootRoute.addChildren([` + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Pathless route hiddenRoute is not explicitly reviewed" + ) + ); +}); + +test("rejects a createRoute path that is no longer a reviewed literal", async () => { + await withModifiedRouter( + (source) => replaceExactly(source, ' path: "/login",', " path: loginPath,"), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Route loginRoute path changed outside the reviewed literal shape" + ) + ); +}); + +test("rejects a createRoute declaration outside the reviewed block shape", async () => { + await withModifiedRouter( + (source) => + replaceExactly( + source, + "const loginRoute = createRoute({", + "const loginRoute = createRoute( {" + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Reviewed router createRoute declarations changed outside the reviewed literal shape" + ) + ); +}); + +test("rejects routeTree identifiers that do not exactly match declarations", async () => { + await withModifiedRouter( + (source) => + replaceExactly(source, " settingsRoute,", " loginRoute,"), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Reviewed route tree identifiers differ" + ) + ); +}); diff --git a/qualification/parity/sourceParityInventory.ts b/qualification/parity/sourceParityInventory.ts new file mode 100644 index 000000000..8c520fffd --- /dev/null +++ b/qualification/parity/sourceParityInventory.ts @@ -0,0 +1,469 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import type { + FrontendRouteInventory, + LegacyEndpointInventory, +} from "./parityInventorySchemas.ts"; + +const maximumSourceBytes = 2 * 1024 * 1024; + +export const paritySourcePaths = { + endpoints: "docs/api/endpoints.md", + navigation: "frontend/src/components/layout/Layout.tsx", + routeModules: "frontend/src/lib/routeModules.ts", + router: "frontend/src/router.tsx", +} as const; + +type SourceFrontendRoute = Omit; +type SourceLegacyEndpoint = Omit; + +export interface SourceParityInventory { + endpoints: SourceLegacyEndpoint[]; + routes: SourceFrontendRoute[]; +} + +interface NavigationEntry { + label: string; + path: string; + position: number; +} + +interface RouteModuleEntry { + key: string; + pageModule: string; +} + +interface LazyComponentEntry { + component: string; + moduleKey: string; +} + +interface PreloadEntry { + moduleKey: string; + path: string; +} + +interface RouteEntry { + access: "public" | "session"; + component: string; + path: string; + searchNormalizer: SourceFrontendRoute["searchNormalizer"]; + sourceRouteName: string; +} + +interface RouteDeclaration { + block: string; + identifier: string; + sourceRouteName: string; +} + +const reviewedPathlessRoutes = { + authenticatedRoute: { + id: "authenticated", + parent: "rootRoute", + }, +} as const; +const routeIdentifierSuffix = "Route"; + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function requiredBlock(source: string, pattern: RegExp, context: string): string { + const block = source.match(pattern)?.[1]; + if (!block) throw new Error(`Cannot locate reviewed ${context}`); + return block; +} + +function extractNavigationEntries(source: string): NavigationEntry[] { + const block = requiredBlock( + source, + /^const navItems = \[\n([\s\S]*?)^\];$/mu, + "navigation array" + ); + const entries = [ + ...block.matchAll( + /^\s{4}\{ to: "([^"]+)", icon: [A-Za-z][A-Za-z0-9]*, label: "([^"]+)" \},$/gmu + ), + ].map((match, position) => ({ + label: match[2]!, + path: match[1]!, + position, + })); + if (entries.length === 0) throw new Error("Reviewed navigation has no literal items"); + const remaining = block.replaceAll( + /^\s{4}\{ to: "([^"]+)", icon: [A-Za-z][A-Za-z0-9]*, label: "([^"]+)" \},\n?/gmu, + "" + ); + if (remaining.trim()) { + throw new Error("Reviewed navigation contains an unrecognized item shape"); + } + return entries; +} + +function extractRouteModules(source: string): RouteModuleEntry[] { + const block = requiredBlock( + source, + /^export const routeModules = \{\n([\s\S]*?)^\};$/mu, + "route module registry" + ); + const entries = [ + ...block.matchAll(/^\s{4}([a-z][A-Za-z0-9]*): \(\) => import\("([^"]+)"\),$/gmu), + ].map((match) => ({ key: match[1]!, pageModule: match[2]! })); + const remaining = block.replaceAll( + /^\s{4}([a-z][A-Za-z0-9]*): \(\) => import\("([^"]+)"\),\n?/gmu, + "" + ); + if (entries.length === 0 || remaining.trim()) { + throw new Error( + "Reviewed route module registry changed outside the literal shape" + ); + } + return entries; +} + +function extractPreloadEntries(source: string): PreloadEntry[] { + const block = requiredBlock( + source, + /^const routeModulesByPath: Readonly> = \{\n([\s\S]*?)^\};$/mu, + "route preload registry" + ); + const entries = [ + ...block.matchAll(/^\s{4}"([^"]+)": routeModules\.([a-z][A-Za-z0-9]*),$/gmu), + ].map((match) => ({ moduleKey: match[2]!, path: match[1]! })); + const remaining = block.replaceAll( + /^\s{4}"([^"]+)": routeModules\.([a-z][A-Za-z0-9]*),\n?/gmu, + "" + ); + if (entries.length === 0 || remaining.trim()) { + throw new Error( + "Reviewed route preload registry changed outside the literal shape" + ); + } + return entries; +} + +function extractLazyComponents(source: string): LazyComponentEntry[] { + return [ + ...source.matchAll( + /^const ([A-Z][A-Za-z0-9]*) = lazyRouteComponent\(\n\s*\(\) => loadLazyModule\("route-[a-z-]+", routeModules\.([a-z][A-Za-z0-9]*)\),\n\s*"\1"\n\);$/gmu + ), + ].map((match) => ({ component: match[1]!, moduleKey: match[2]! })); +} + +function extractRouteDeclarations(source: string): RouteDeclaration[] { + const createRouteCallCount = [...source.matchAll(/\bcreateRoute\s*\(/gu)].length; + const declaredIdentifiers = [ + ...source.matchAll(/^const ([a-z][A-Za-z0-9]*Route) = createRoute\s*\(/gmu), + ].map((match) => match[1]!); + if (createRouteCallCount !== declaredIdentifiers.length) { + throw new Error( + "Reviewed router contains a createRoute call outside the reviewed declaration shape" + ); + } + + const declarations = [ + ...source.matchAll( + /^const ([a-z][A-Za-z0-9]*Route) = createRoute\(\{\n([\s\S]*?)^\}\);$/gmu + ), + ].map((match): RouteDeclaration => ({ + block: match[2]!, + identifier: match[1]!, + sourceRouteName: match[1]!.slice(0, -routeIdentifierSuffix.length), + })); + if ( + declarations.length !== declaredIdentifiers.length || + declarations.some( + (declaration, index) => declaration.identifier !== declaredIdentifiers[index] + ) + ) { + throw new Error( + "Reviewed router createRoute declarations changed outside the reviewed literal shape" + ); + } + if (new Set(declaredIdentifiers).size !== declaredIdentifiers.length) { + throw new Error("Reviewed router contains duplicate createRoute declarations"); + } + if (declarations.length === 0) { + throw new Error("Reviewed router has no literal createRoute declarations"); + } + return declarations; +} + +function assertReviewedPathlessRoute(declaration: RouteDeclaration): void { + const review = + reviewedPathlessRoutes[ + declaration.identifier as keyof typeof reviewedPathlessRoutes + ]; + if (!review) { + throw new Error( + `Pathless route ${declaration.identifier} is not explicitly reviewed` + ); + } + const parent = declaration.block.match( + /^\s{4}getParentRoute: \(\) => ([a-z][A-Za-z0-9]*Route),$/mu + )?.[1]; + const routeId = declaration.block.match(/^\s{4}id: "([^"]+)",$/mu)?.[1]; + const rendersAuthenticatedLayout = + /^\s{4}component: \(\) => \(\n\s{8}\n\s{12}\n\s{8}<\/Layout>\n\s{4}\),$/mu.test( + declaration.block + ); + const enforcesAuthenticatedSession = + /^\s{4}beforeLoad: async \(\) => \{\n\s{8}await authActions\.initialize\(\);\n\s{8}if \(!authStore\.state\.isAuthenticated\) \{\n\s{12}redirect\(\{ throw: true, to: "\/login" \}\);\n\s{8}\}\n\s{4}\},$/mu.test( + declaration.block + ); + if ( + parent !== review.parent || + routeId !== review.id || + !enforcesAuthenticatedSession || + !rendersAuthenticatedLayout + ) { + throw new Error( + `Pathless route ${declaration.identifier} changed outside its explicit review` + ); + } +} + +function assertExactRouteTreeIdentifiers( + source: string, + declarations: readonly RouteDeclaration[] +): void { + const routeTree = requiredBlock( + source, + /^const routeTree = ([\s\S]*?)^\/\*\* Defines router\. \*\/$/mu, + "route tree" + ); + const observedIdentifiers = [...routeTree.matchAll(/\b([a-z][A-Za-z0-9]*Route)\b/gu)] + .map((match) => match[1]!) + .toSorted(compareStrings); + const expectedIdentifiers = [ + "rootRoute", + ...declarations.map((declaration) => declaration.identifier), + ].toSorted(compareStrings); + if ( + observedIdentifiers.length !== expectedIdentifiers.length || + observedIdentifiers.some( + (identifier, index) => identifier !== expectedIdentifiers[index] + ) + ) { + throw new Error( + `Reviewed route tree identifiers differ: expected ${expectedIdentifiers.join( + ", " + )}; observed ${observedIdentifiers.join(", ")}` + ); + } +} + +function extractRoutes(source: string): RouteEntry[] { + const declarations = extractRouteDeclarations(source); + const routes: RouteEntry[] = []; + for (const declaration of declarations) { + const literalPaths = [ + ...declaration.block.matchAll(/^\s{4}path: "([^"]+)",$/gmu), + ]; + if (literalPaths.length === 0) { + if (/^\s{4}path\s*:/mu.test(declaration.block)) { + throw new Error( + `Route ${declaration.identifier} path changed outside the reviewed literal shape` + ); + } + assertReviewedPathlessRoute(declaration); + continue; + } + if (literalPaths.length !== 1) { + throw new Error(`Route ${declaration.identifier} has multiple literal paths`); + } + if (declaration.identifier in reviewedPathlessRoutes) { + throw new Error( + `Explicitly reviewed pathless route ${declaration.identifier} now has a path` + ); + } + const routePath = literalPaths[0]![1]!; + const parent = declaration.block.match( + /^\s{4}getParentRoute: \(\) => (rootRoute|authenticatedRoute),$/mu + )?.[1]; + const component = declaration.block.match( + /^\s{4}component: ([A-Z][A-Za-z0-9]*),$/mu + )?.[1]; + if (!parent || !component) { + throw new Error( + `Route ${declaration.identifier} changed outside the reviewed shape` + ); + } + const normalizer = declaration.block.match( + /^\s{4}validateSearch: (normalizeChatSearch|normalizeSettingsSearch),$/mu + )?.[1]; + routes.push({ + access: parent === "rootRoute" ? "public" : "session", + component, + path: routePath, + searchNormalizer: + normalizer === "normalizeChatSearch" || + normalizer === "normalizeSettingsSearch" + ? normalizer + : null, + sourceRouteName: declaration.sourceRouteName, + }); + } + if (routes.length === 0) throw new Error("Reviewed router has no literal routes"); + assertExactRouteTreeIdentifiers(source, declarations); + return routes; +} + +function buildFrontendSourceInventory( + routerSource: string, + navigationSource: string, + routeModulesSource: string +): SourceFrontendRoute[] { + const routes = extractRoutes(routerSource); + const lazyComponents = extractLazyComponents(routerSource); + const routeModules = extractRouteModules(routeModulesSource); + const preloadEntries = extractPreloadEntries(routeModulesSource); + const navigation = extractNavigationEntries(navigationSource); + const navigationPaths = new Set(); + for (const item of navigation) { + if (navigationPaths.has(item.path)) { + throw new Error(`Duplicate reviewed navigation path ${item.path}`); + } + navigationPaths.add(item.path); + } + const routePaths = new Set(routes.map((route) => route.path)); + for (const item of navigation) { + if (!routePaths.has(item.path)) { + throw new Error(`Navigation path ${item.path} has no reviewed route`); + } + } + const usedModuleKeys = new Set(); + const inventory = routes.map((route): SourceFrontendRoute => { + const lazyComponent = lazyComponents.find( + (candidate) => candidate.component === route.component + ); + if (!lazyComponent) { + throw new Error(`Route ${route.path} has no reviewed lazy component`); + } + const routeModule = routeModules.find( + (candidate) => candidate.key === lazyComponent.moduleKey + ); + if (!routeModule) { + throw new Error(`Route ${route.path} has no reviewed route module`); + } + usedModuleKeys.add(routeModule.key); + const navigationItem = navigation.find((item) => item.path === route.path); + const preloadEntry = preloadEntries.find((entry) => entry.path === route.path); + if ( + navigationItem && + (!preloadEntry || preloadEntry.moduleKey !== routeModule.key) + ) { + throw new Error( + `Navigation path ${route.path} has no matching route preload` + ); + } + if (!navigationItem && preloadEntry) { + throw new Error( + `Non-navigation route ${route.path} has an unreviewed preload` + ); + } + return { + access: route.access, + moduleKey: routeModule.key, + navigationLabel: navigationItem?.label ?? null, + navigationPosition: navigationItem?.position ?? null, + pageModule: routeModule.pageModule, + path: route.path, + searchNormalizer: route.searchNormalizer, + sourceRouteName: route.sourceRouteName, + }; + }); + if (usedModuleKeys.size !== routeModules.length) { + const unused = routeModules + .filter((routeModule) => !usedModuleKeys.has(routeModule.key)) + .map((routeModule) => routeModule.key) + .join(", "); + throw new Error(`Reviewed route modules are not routed: ${unused}`); + } + if (preloadEntries.length !== navigation.length) { + throw new Error("Reviewed route preload and navigation counts differ"); + } + return inventory.toSorted((left, right) => compareStrings(left.path, right.path)); +} + +function parseLegacyEndpointRows(markdown: string): SourceLegacyEndpoint[] { + let section = ""; + const endpoints: SourceLegacyEndpoint[] = []; + for (const line of markdown.split("\n")) { + const sectionMatch = line.match(/^## (.+)$/u); + if (sectionMatch?.[1]) { + section = sectionMatch[1]; + continue; + } + const row = line.match( + /^\|\s*`?(DELETE|GET|HEAD|PATCH|POST|PUT|WebSocket)`?\s*\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|$/u + ); + if (!row) continue; + if (!section) throw new Error("Legacy endpoint row has no section"); + const method = row[1]! as SourceLegacyEndpoint["method"]; + const endpointPath = row[2]!; + endpoints.push({ + id: `${method} ${endpointPath}`, + method, + path: endpointPath, + purpose: row[3]!, + section, + }); + } + const sorted = endpoints.toSorted((left, right) => compareStrings(left.id, right.id)); + for (const [index, endpoint] of sorted.entries()) { + if (index > 0 && sorted[index - 1]!.id === endpoint.id) { + throw new Error(`Duplicate legacy endpoint row ${endpoint.id}`); + } + } + return sorted; +} + +async function readBoundedUtf8( + repositoryRoot: string, + relativePath: string +): Promise { + const absolutePath = path.resolve(repositoryRoot, relativePath); + const relative = path.relative(repositoryRoot, absolutePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Parity source path escaped the repository root"); + } + const sourceStat = await stat(absolutePath); + if ( + !sourceStat.isFile() || + sourceStat.size <= 0 || + sourceStat.size > maximumSourceBytes + ) { + throw new Error(`Parity source ${relativePath} has an invalid size`); + } + return readFile(absolutePath, "utf8"); +} + +/** + * Loads the semantic current-production parity inventory from reviewed repository sources. + * @param repositoryRoot Absolute repository root. + * @returns Current route, navigation, module, and endpoint source inventory. + */ +export async function loadSourceParityInventory( + repositoryRoot: string +): Promise { + const [endpointMarkdown, navigationSource, routeModulesSource, routerSource] = + await Promise.all([ + readBoundedUtf8(repositoryRoot, paritySourcePaths.endpoints), + readBoundedUtf8(repositoryRoot, paritySourcePaths.navigation), + readBoundedUtf8(repositoryRoot, paritySourcePaths.routeModules), + readBoundedUtf8(repositoryRoot, paritySourcePaths.router), + ]); + return { + endpoints: parseLegacyEndpointRows(endpointMarkdown), + routes: buildFrontendSourceInventory( + routerSource, + navigationSource, + routeModulesSource + ), + }; +} diff --git a/qualification/resources/pausedTlsSseClient.test.ts b/qualification/resources/pausedTlsSseClient.test.ts index 3ddfbb5d2..4d0fcdcd2 100644 --- a/qualification/resources/pausedTlsSseClient.test.ts +++ b/qualification/resources/pausedTlsSseClient.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { Effect, Exit, Fiber, Result, Scope } from "effect"; +import { TestClock } from "effect/testing"; + import { QualificationEventFeed, qualificationEventLimits, @@ -9,7 +12,14 @@ import { waitFor } from "../test/waitFor.ts"; import { startHttpsReverseProxy } from "../topology/httpsReverseProxy.ts"; import { createTestTlsIdentity } from "../topology/testTlsIdentity.ts"; import { startQualificationServer } from "../trpc/server.ts"; -import { openPausedTlsSseClient, type PausedTlsSseClient } from "./pausedTlsSseClient.ts"; +import { + openPausedTlsSseClient, + PausedTlsSseClientArgumentError, + PausedTlsSseClientDeadlineError, + PausedTlsSseClientOperationError, + pausedTlsSseClientResource, + withPausedTlsSseClientDeadline, +} from "./pausedTlsSseClient.ts"; import { sseMemoryQualificationPolicy } from "./resourcePolicy.ts"; const qualificationCookie = "mira_qualification=paused-native-client"; @@ -65,13 +75,77 @@ describe("paused native TLS SSE client", () => { if (!(failure instanceof Error)) { throw new Error("Expected the invalid cookie to be rejected"); } + expect(failure).toBeInstanceOf(PausedTlsSseClientArgumentError); expect(failure.message).toContain("cookie must not contain CR or LF"); } }); + test("tags a native connection failure", async () => { + const tlsIdentity = await createTestTlsIdentity(); + const listener = Bun.listen({ + data: undefined, + hostname: "127.0.0.1", + port: 0, + socket: { + data() {}, + }, + }); + const closedPort = listener.port; + listener.stop(true); + + try { + const failure = await openPausedTlsSseClient( + new URL(`https://127.0.0.1:${closedPort}`), + tlsIdentity.certificate, + qualificationCookie, + 1000 + ).then( + () => null, + (error: unknown) => error + ); + + expect(failure).toBeInstanceOf(PausedTlsSseClientOperationError); + expect(failure).toMatchObject({ operation: "connect" }); + } finally { + await tlsIdentity.dispose(); + } + }); + + test("interrupts pending Effect work when its deadline expires", async () => { + let interrupted = false; + const pending = Effect.callback(() => + Effect.sync(() => { + interrupted = true; + }) + ); + const program = Effect.gen(function* () { + const fiber = yield* withPausedTlsSseClientDeadline( + pending, + "connect", + 25 + ).pipe(Effect.result, Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust(25); + yield* Effect.yieldNow; + return yield* Fiber.join(fiber); + }); + const outcome = await Effect.runPromise( + Effect.provide(program, TestClock.layer()) + ); + + expect(interrupted).toBe(true); + expect(Result.isFailure(outcome)).toBe(true); + if (Result.isSuccess(outcome)) return; + expect(outcome.failure).toBeInstanceOf(PausedTlsSseClientDeadlineError); + expect(outcome.failure).toMatchObject({ + message: "Paused SSE client did not connect within 25 ms", + operation: "connect", + timeoutMs: 25, + }); + }); + test("propagates a paused TLS receive window to the bounded event queue", async () => { const cleanup = new AsyncCleanupStack(); - let client: PausedTlsSseClient | undefined; try { const tlsIdentity = await createTestTlsIdentity(); @@ -96,18 +170,25 @@ describe("paused native TLS SSE client", () => { }); cleanup.defer("paused client proxy", () => proxy.stop(true)); cleanup.defer("paused client read boundary", () => readBoundary.release()); - const pendingClient = openPausedTlsSseClient( - proxy.url, - tlsIdentity.certificate, - qualificationCookie, - 2000 + const clientScope = await Effect.runPromise(Scope.make()); + cleanup.defer("paused native client scope", () => + Effect.runPromise(Scope.close(clientScope, Exit.void)) + ); + const pendingClient = Effect.runPromise( + Scope.provide(clientScope)( + pausedTlsSseClientResource( + proxy.url, + tlsIdentity.certificate, + qualificationCookie, + 2000 + ) + ) ); void pendingClient.then( () => clientPaused.resolve(), (error: unknown) => clientPaused.reject(error) ); - client = await pendingClient; - cleanup.defer("paused native client", () => client?.close()); + await pendingClient; await readBoundary.boundaryHeld; await waitFor(() => eventFeed.activeSubscriberCount === 1); @@ -147,8 +228,7 @@ describe("paused native TLS SSE client", () => { qualificationEventLimits.maximumSubscriberQueuedPayloadBytes, }); - await client.close(); - client = undefined; + await Effect.runPromise(Scope.close(clientScope, Exit.void)); readBoundary.release(); await waitFor( () => diff --git a/qualification/resources/pausedTlsSseClient.ts b/qualification/resources/pausedTlsSseClient.ts index a6d8d0cc4..12e926193 100644 --- a/qualification/resources/pausedTlsSseClient.ts +++ b/qualification/resources/pausedTlsSseClient.ts @@ -1,3 +1,5 @@ +import { Data, Deferred, Effect, Scope } from "effect"; + import { hasConnectedSseFrame, maximumPausedTlsSseHandshakeBytes, @@ -6,26 +8,87 @@ import { export { hasConnectedSseFrame } from "./pausedTlsSseHandshake.ts"; interface PausedClientState { - close: ReturnType>; + abandoned: boolean; + close: Deferred.Deferred; closeSettled: boolean; handshakeBytes: Buffer; - ready: ReturnType>; + ready: Deferred.Deferred< + Bun.Socket, + PausedTlsSseClientOperationError + >; readySettled: boolean; request: Buffer; requestOffset: number; socket?: Bun.Socket; } +export class PausedTlsSseClientDeadlineError extends Data.TaggedError( + "PausedTlsSseClientDeadlineError" +)<{ + readonly message: string; + readonly operation: string; + readonly timeoutMs: number; +}> {} + +export class PausedTlsSseClientArgumentError extends Data.TaggedError( + "PausedTlsSseClientArgumentError" +)<{ + readonly argument: "cookie" | "publicUrl"; + readonly message: string; +}> {} + +export class PausedTlsSseClientOperationError extends Data.TaggedError( + "PausedTlsSseClientOperationError" +)<{ + readonly cause: unknown; + readonly message: string; + readonly operation: string; +}> {} + +export type PausedTlsSseClientError = + | PausedTlsSseClientArgumentError + | PausedTlsSseClientDeadlineError + | PausedTlsSseClientOperationError; + /** Controlled native TLS client that stops reading after the first SSE frame. */ export interface PausedTlsSseClient { close(): Promise; } -function failClient(socket: Bun.Socket, error: Error): void { +const closeEffect = Symbol("PausedTlsSseClient.closeEffect"); + +interface ManagedPausedTlsSseClient extends PausedTlsSseClient { + readonly [closeEffect]: Effect.Effect< + void, + PausedTlsSseClientDeadlineError | PausedTlsSseClientOperationError + >; +} + +function operationFailure( + operation: string, + cause: unknown, + fallbackMessage: string +): PausedTlsSseClientOperationError { + if (cause instanceof PausedTlsSseClientOperationError) return cause; + return new PausedTlsSseClientOperationError({ + cause, + message: cause instanceof Error ? cause.message : fallbackMessage, + operation, + }); +} + +function failClient( + socket: Bun.Socket, + operation: string, + error: Error +): void { socket.data.socket = socket; if (!socket.data.readySettled) { socket.data.readySettled = true; - socket.data.ready.reject(error); + Deferred.doneUnsafe( + socket.data.ready, + Effect.fail(operationFailure(operation, error, error.message)) + ); } socket.terminate(); } @@ -36,42 +99,339 @@ function requestTarget(url: URL): string { function writePendingRequest(socket: Bun.Socket): void { const state = socket.data; - while (state.requestOffset < state.request.byteLength) { - const bytesWritten = socket.write( - state.request, - state.requestOffset, - state.request.byteLength - state.requestOffset - ); - if (bytesWritten < 0) { - failClient(socket, new Error("Paused SSE client request socket closed")); - return; + try { + while (state.requestOffset < state.request.byteLength) { + const bytesWritten = socket.write( + state.request, + state.requestOffset, + state.request.byteLength - state.requestOffset + ); + if (bytesWritten < 0) { + throw new Error("Paused SSE client request socket closed"); + } + if (bytesWritten === 0) return; + state.requestOffset += bytesWritten; } - if (bytesWritten === 0) return; - state.requestOffset += bytesWritten; + socket.flush(); + } catch (error) { + failClient( + socket, + "write-request", + error instanceof Error + ? error + : new Error("Paused SSE client request write failed", { + cause: error, + }) + ); } - socket.flush(); } -async function closeSocketBeforeDeadline( +/** + * Applies the shared typed deadline policy for paused-client operations. + * @param effect Operation governed by the deadline. + * @param operation Redacted operation label. + * @param timeoutMs Deadline in milliseconds. + * @returns Original result or a tagged deadline failure. + */ +export function withPausedTlsSseClientDeadline( + effect: Effect.Effect, + operation: string, + timeoutMs: number +): Effect.Effect { + return effect.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new PausedTlsSseClientDeadlineError({ + message: `Paused SSE client did not ${operation} within ${timeoutMs} ms`, + operation, + timeoutMs, + }) + ), + }) + ); +} + +function closeSocketBeforeDeadline( socket: Bun.Socket, timeoutMs: number -): Promise { - if (!socket.data.closeSettled) socket.terminate(); - let timeout: ReturnType | undefined; - const deadline = new Promise((_resolve, reject) => { - timeout = setTimeout( - () => - reject( - new Error(`Paused SSE client did not close within ${timeoutMs} ms`) +): Effect.Effect< + void, + PausedTlsSseClientDeadlineError | PausedTlsSseClientOperationError +> { + const terminate = Effect.try({ + catch: (cause) => + operationFailure( + "close", + cause, + "Paused SSE client could not request socket closure" + ), + try: () => { + if (!socket.data.closeSettled) socket.terminate(); + }, + }); + const awaitClose = Deferred.await(socket.data.close); + const close = terminate.pipe(Effect.andThen(awaitClose)); + return withPausedTlsSseClientDeadline(close, "close", timeoutMs); +} + +function abandonClient(state: PausedClientState): void { + state.abandoned = true; + state.socket?.terminate(); +} + +function openPausedTlsSseClientEffect( + publicUrl: URL, + certificateAuthority: string, + cookie: string, + timeoutMs: number +): Effect.Effect { + return Effect.gen(function* () { + const request = yield* Effect.try({ + catch: (cause) => + cause instanceof PausedTlsSseClientArgumentError + ? cause + : operationFailure( + "build-request", + cause, + "Paused SSE client could not build its request" + ), + try: () => { + if (publicUrl.protocol !== "https:" || publicUrl.port.length === 0) { + throw new PausedTlsSseClientArgumentError({ + argument: "publicUrl", + message: "Paused SSE client requires an explicit HTTPS port", + }); + } + if (/[\r\n]/u.test(cookie)) { + throw new PausedTlsSseClientArgumentError({ + argument: "cookie", + message: "Paused SSE client cookie must not contain CR or LF", + }); + } + const endpoint = new URL("/trpc/events.stream", publicUrl); + endpoint.searchParams.set("input", JSON.stringify({})); + return Buffer.from( + [ + `GET ${requestTarget(endpoint)} HTTP/1.1`, + `Host: ${publicUrl.host}`, + "Accept: text/event-stream", + "Accept-Encoding: identity", + `Cookie: ${cookie}`, + "Connection: keep-alive", + "", + "", + ].join("\r\n"), + "utf8" + ); + }, + }); + const close = yield* Deferred.make(); + const ready = yield* Deferred.make< + Bun.Socket, + PausedTlsSseClientOperationError + >(); + const state: PausedClientState = { + abandoned: false, + close, + closeSettled: false, + handshakeBytes: Buffer.alloc(0), + ready, + readySettled: false, + request, + requestOffset: 0, + }; + const connectPromise = yield* Effect.try({ + catch: (cause) => + operationFailure( + "connect", + cause, + "Paused SSE client could not start its TLS connection" ), - timeoutMs + try: () => + Bun.connect({ + data: state, + hostname: publicUrl.hostname, + port: Number(publicUrl.port), + socket: { + binaryType: "buffer", + close(closedSocket, error) { + closedSocket.data.socket = closedSocket; + if (!closedSocket.data.closeSettled) { + closedSocket.data.closeSettled = true; + Deferred.doneUnsafe(closedSocket.data.close, Effect.void); + } + if (!closedSocket.data.readySettled) { + const cause = + error ?? + new Error( + "Paused SSE client closed before its connected frame" + ); + closedSocket.data.readySettled = true; + Deferred.doneUnsafe( + closedSocket.data.ready, + Effect.fail( + operationFailure( + "await-connected-frame", + cause, + "Paused SSE client closed before its connected frame" + ) + ) + ); + } + }, + connectError(connectedSocket, error) { + failClient(connectedSocket, "connect", error); + }, + data(connectedSocket, data) { + connectedSocket.data.socket = connectedSocket; + if (connectedSocket.data.readySettled) return; + const handshakeBytes = connectedSocket.data.handshakeBytes; + const remainingBytes = + maximumPausedTlsSseHandshakeBytes - + handshakeBytes.byteLength; + const boundedData = data.subarray( + 0, + Math.min(data.byteLength, remainingBytes) + ); + connectedSocket.data.handshakeBytes = Buffer.concat( + [handshakeBytes, boundedData], + handshakeBytes.byteLength + boundedData.byteLength + ); + try { + if ( + hasConnectedSseFrame( + connectedSocket.data.handshakeBytes + ) + ) { + connectedSocket.pause(); + connectedSocket.data.readySettled = true; + Deferred.doneUnsafe( + connectedSocket.data.ready, + Effect.succeed(connectedSocket) + ); + return; + } + if (data.byteLength > boundedData.byteLength) { + throw new Error( + "Paused SSE client handshake exceeded its byte budget" + ); + } + } catch (error) { + failClient( + connectedSocket, + "parse-handshake", + error instanceof Error + ? error + : new Error("Paused SSE client parse failed", { + cause: error, + }) + ); + } + }, + error(connectedSocket, error) { + failClient(connectedSocket, "socket", error); + }, + drain(connectedSocket) { + connectedSocket.data.socket = connectedSocket; + writePendingRequest(connectedSocket); + }, + open(connectedSocket) { + connectedSocket.data.socket = connectedSocket; + if ( + connectedSocket.data.readySettled || + connectedSocket.data.abandoned + ) { + connectedSocket.terminate(); + return; + } + if (!connectedSocket.authorized) { + failClient( + connectedSocket, + "authorize-tls", + connectedSocket.getAuthorizationError() ?? + new Error( + "Paused SSE client TLS authorization failed" + ) + ); + return; + } + writePendingRequest(connectedSocket); + }, + timeout(connectedSocket) { + failClient( + connectedSocket, + "socket-timeout", + new Error( + `Paused SSE client timed out after ${timeoutMs} ms` + ) + ); + }, + }, + tls: { + ca: certificateAuthority, + rejectUnauthorized: true, + serverName: publicUrl.hostname, + }, + }), + }); + void connectPromise.then( + (connectedSocket) => { + if (state.abandoned) connectedSocket.terminate(); + return null; + }, + () => null ); + const awaitConnectedSocket = Effect.gen(function* () { + const connect = Effect.tryPromise({ + catch: (cause) => + operationFailure( + "connect", + cause, + "Paused SSE client TLS connection failed" + ), + try: () => connectPromise, + }); + yield* Effect.raceFirst(connect, Deferred.await(ready)); + return yield* Deferred.await(ready); + }).pipe(Effect.onInterrupt(() => Effect.sync(() => abandonClient(state)))); + const connectedSocket = yield* withPausedTlsSseClientDeadline( + awaitConnectedSocket, + "connect", + timeoutMs + ).pipe(Effect.onError(() => Effect.sync(() => abandonClient(state)))); + const closeClient = closeSocketBeforeDeadline(connectedSocket, timeoutMs); + let closePromise: Promise | undefined; + return Object.freeze({ + [closeEffect]: closeClient, + close(): Promise { + closePromise ??= Effect.runPromise(closeClient); + return closePromise; + }, + }); }); - try { - await Promise.race([socket.data.close.promise, deadline]); - } finally { - clearTimeout(timeout); - } +} + +/** + * Effect-scoped paused client whose finalizer bounds and confirms native closure. + * @param publicUrl Stable HTTPS proxy URL. + * @param certificateAuthority PEM certificate trusted only for this client. + * @param cookie Qualification cookie required by the upstream server. + * @param timeoutMs Maximum handshake, connected-frame, and close wait. + * @returns Scoped paused native socket. + */ +export function pausedTlsSseClientResource( + publicUrl: URL, + certificateAuthority: string, + cookie: string, + timeoutMs: number +): Effect.Effect { + return Effect.acquireRelease( + openPausedTlsSseClientEffect(publicUrl, certificateAuthority, cookie, timeoutMs), + (client) => client[closeEffect].pipe(Effect.orDie), + { interruptible: true } + ); } /** @@ -82,185 +442,13 @@ async function closeSocketBeforeDeadline( * @param timeoutMs Maximum handshake and connected-frame wait. * @returns Paused native socket controlled by the caller. */ -export async function openPausedTlsSseClient( +export function openPausedTlsSseClient( publicUrl: URL, certificateAuthority: string, cookie: string, timeoutMs: number ): Promise { - if (publicUrl.protocol !== "https:" || publicUrl.port.length === 0) { - throw new TypeError("Paused SSE client requires an explicit HTTPS port"); - } - if (/[\r\n]/u.test(cookie)) { - throw new TypeError("Paused SSE client cookie must not contain CR or LF"); - } - const endpoint = new URL("/trpc/events.stream", publicUrl); - endpoint.searchParams.set("input", JSON.stringify({})); - const request = Buffer.from( - [ - `GET ${requestTarget(endpoint)} HTTP/1.1`, - `Host: ${publicUrl.host}`, - "Accept: text/event-stream", - "Accept-Encoding: identity", - `Cookie: ${cookie}`, - "Connection: keep-alive", - "", - "", - ].join("\r\n"), - "utf8" + return Effect.runPromise( + openPausedTlsSseClientEffect(publicUrl, certificateAuthority, cookie, timeoutMs) ); - const ready = Promise.withResolvers(); - const state: PausedClientState = { - close: Promise.withResolvers(), - closeSettled: false, - handshakeBytes: Buffer.alloc(0), - ready, - readySettled: false, - request, - requestOffset: 0, - }; - let socket: Bun.Socket | undefined; - let timeout: ReturnType | undefined; - let abandoned = false; - - try { - timeout = setTimeout(() => { - if (state.readySettled) return; - state.readySettled = true; - state.ready.reject( - new Error(`Paused SSE client did not connect within ${timeoutMs} ms`) - ); - state.socket?.terminate(); - }, timeoutMs); - const connectPromise = Bun.connect({ - data: state, - hostname: publicUrl.hostname, - port: Number(publicUrl.port), - socket: { - binaryType: "buffer", - close(closedSocket, error) { - closedSocket.data.socket = closedSocket; - if (!closedSocket.data.closeSettled) { - closedSocket.data.closeSettled = true; - closedSocket.data.close.resolve(); - } - if (!closedSocket.data.readySettled) { - closedSocket.data.readySettled = true; - closedSocket.data.ready.reject( - error ?? - new Error( - "Paused SSE client closed before its connected frame" - ) - ); - } - }, - connectError(connectedSocket, error) { - failClient(connectedSocket, error); - }, - data(connectedSocket, data) { - connectedSocket.data.socket = connectedSocket; - if (connectedSocket.data.readySettled) return; - const handshakeBytes = connectedSocket.data.handshakeBytes; - const remainingBytes = - maximumPausedTlsSseHandshakeBytes - handshakeBytes.byteLength; - const boundedData = data.subarray( - 0, - Math.min(data.byteLength, remainingBytes) - ); - connectedSocket.data.handshakeBytes = Buffer.concat( - [handshakeBytes, boundedData], - handshakeBytes.byteLength + boundedData.byteLength - ); - try { - if (hasConnectedSseFrame(connectedSocket.data.handshakeBytes)) { - connectedSocket.pause(); - connectedSocket.data.readySettled = true; - connectedSocket.data.ready.resolve(); - return; - } - if (data.byteLength > boundedData.byteLength) { - throw new Error( - "Paused SSE client handshake exceeded its byte budget" - ); - } - } catch (error) { - failClient( - connectedSocket, - error instanceof Error - ? error - : new Error("Paused SSE client parse failed", { - cause: error, - }) - ); - } - }, - error(connectedSocket, error) { - failClient(connectedSocket, error); - }, - drain(connectedSocket) { - connectedSocket.data.socket = connectedSocket; - writePendingRequest(connectedSocket); - }, - open(connectedSocket) { - connectedSocket.data.socket = connectedSocket; - if (connectedSocket.data.readySettled || abandoned) { - connectedSocket.terminate(); - return; - } - if (!connectedSocket.authorized) { - failClient( - connectedSocket, - connectedSocket.getAuthorizationError() ?? - new Error("Paused SSE client TLS authorization failed") - ); - return; - } - writePendingRequest(connectedSocket); - }, - timeout(connectedSocket) { - failClient( - connectedSocket, - new Error(`Paused SSE client timed out after ${timeoutMs} ms`) - ); - }, - }, - tls: { - ca: certificateAuthority, - rejectUnauthorized: true, - serverName: publicUrl.hostname, - }, - }); - void connectPromise.then( - (connectedSocket) => { - if (abandoned) connectedSocket.terminate(); - return connectedSocket; - }, - () => null - ); - socket = await Promise.race([ - connectPromise, - ready.promise.then(() => { - if (state.socket === undefined) { - throw new Error("Paused SSE client connected without a socket"); - } - return state.socket; - }), - ]); - await ready.promise; - const connectedSocket = socket; - let closed = false; - return { - async close(): Promise { - if (closed) return; - closed = true; - await closeSocketBeforeDeadline(connectedSocket, timeoutMs); - }, - }; - } catch (error) { - abandoned = true; - socket?.terminate(); - throw error; - } finally { - clearTimeout(timeout); - } } diff --git a/qualification/resources/sseMemoryScenario.ts b/qualification/resources/sseMemoryScenario.ts index 91efc700d..bd4e6e2f2 100644 --- a/qualification/resources/sseMemoryScenario.ts +++ b/qualification/resources/sseMemoryScenario.ts @@ -1,3 +1,5 @@ +import { Effect, Exit, Scope } from "effect"; + import { QualificationEventFeed } from "../realtime/eventFeed.ts"; import { readRuntimeIdentity } from "../runtimeCandidate.ts"; import { AsyncCleanupStack } from "../test/asyncCleanupStack.ts"; @@ -10,7 +12,10 @@ import { type CgroupV2AncestorSnapshot, readCgroupV2AncestorSnapshots, } from "./cgroupV2Hierarchy.ts"; -import { openPausedTlsSseClient, type PausedTlsSseClient } from "./pausedTlsSseClient.ts"; +import { + pausedTlsSseClientResource, + type PausedTlsSseClient, +} from "./pausedTlsSseClient.ts"; import { maximumProcessMemory, readProcessMemorySnapshot, @@ -134,7 +139,6 @@ export async function runSseMemoryScenario( assertCgroupResourcePolicy(initialCgroup, expectedCgroupPath); const startedAt = performance.now(); const cleanup = new AsyncCleanupStack(); - const allConsumers: PausedTlsSseClient[] = []; const eventFeed = new QualificationEventFeed(); const roundEvidence: SseMemoryRoundEvidence[] = []; let sampledPeak: ProcessMemorySnapshot | undefined; @@ -167,7 +171,10 @@ export async function runSseMemoryScenario( target: new URL(`http://127.0.0.1:${release.port}`), }); cleanup.defer("SSE memory qualification proxy", () => proxy.stop(true)); - cleanup.defer("SSE memory slow consumers", () => closeConsumers(allConsumers)); + const consumerScope = await Effect.runPromise(Scope.make("parallel")); + cleanup.defer("SSE memory slow-consumer scope", () => + Effect.runPromise(Scope.close(consumerScope, Exit.void)) + ); await settleMemory(); baselineCgroup = await readCurrentCgroupV2Snapshot(); @@ -206,13 +213,16 @@ export async function runSseMemoryScenario( consumerIndex < sseMemoryQualificationPolicy.scenario.consumerCount; consumerIndex += 1 ) { - const consumer = await openPausedTlsSseClient( + const timeoutMs = remainingRoundTime(roundDeadline); + const consumerResource = pausedTlsSseClientResource( proxy.url, tlsIdentity.certificate, qualificationCookie, - remainingRoundTime(roundDeadline) + timeoutMs + ); + const consumer = await Effect.runPromise( + Scope.provide(consumerScope)(consumerResource) ); - allConsumers.push(consumer); roundConsumers.push(consumer); } traceScenario(`round-${roundIndex + 1}-clients-paused`, startedAt); diff --git a/qualification/shutdown/completeShutdownQualification.test.ts b/qualification/shutdown/completeShutdownQualification.test.ts new file mode 100644 index 000000000..55c45a3fb --- /dev/null +++ b/qualification/shutdown/completeShutdownQualification.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; + +import { Effect } from "effect"; + +import { + collectLinuxProcessGroupMembers, + completeShutdownQualification, + interruptedShutdownQualification, + linuxProcessStatReadConcurrency, + parseLinuxProcessStat, +} from "./completeShutdownQualification.ts"; + +describe("complete process shutdown qualification", () => { + test("parses Linux stat records whose command contains spaces and parentheses", () => { + expect( + parseLinuxProcessStat( + "123 (bun worker (test)) S 100 123 123 0 -1 4194304 0 0 0 0" + ) + ).toEqual({ processGroupId: 123, processId: 123 }); + expect(() => parseLinuxProcessStat("invalid")).toThrow( + "Malformed Linux process stat record" + ); + }); + + test("bounds concurrent process-stat reads and tolerates exited candidates", async () => { + const candidateProcessIds = Array.from({ length: 64 }, (_, index) => index + 100); + const processGroupId = 4242; + let activeReads = 0; + let peakReads = 0; + const members = await Effect.runPromise( + collectLinuxProcessGroupMembers( + candidateProcessIds, + processGroupId, + (processId) => + Effect.sync(() => { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + }).pipe( + Effect.andThen(Effect.sleep("5 millis")), + Effect.as( + processId === candidateProcessIds.at(-1) + ? null + : `${processId} (bounded reader) S 1 ${processGroupId} 1 0` + ), + Effect.ensuring( + Effect.sync(() => { + activeReads -= 1; + }) + ) + ) + ) + ); + + expect(peakReads).toBeGreaterThan(1); + expect(peakReads).toBeLessThanOrEqual(linuxProcessStatReadConcurrency); + expect(activeReads).toBe(0); + expect(members).toEqual(candidateProcessIds.slice(0, -1)); + }); + + test("drains readiness before resources and restarts with WAL recovery", async () => { + const report = await Effect.runPromise(completeShutdownQualification); + + expect(report.database).toEqual({ + activeLeaseCount: 0, + cleanGenerationCount: 2, + generations: [1, 2], + integrityCheck: "ok", + journalMode: "wal", + releasedLeaseCount: 2, + }); + expect(report.generations[0].readyStatus.recoveredGenerationCount).toBe(0); + expect(report.generations[1].readyStatus.recoveredGenerationCount).toBe(1); + + for (const generation of report.generations) { + expect(generation.startingReadinessStatus).toBe(503); + expect(generation.stoppingReadinessStatus).toBe(503); + expect(generation.readyState).toEqual({ + gatewaySocketOpen: true, + leaseActive: true, + readiness: true, + sseConnectionCount: 1, + }); + expect(generation.sseConnectionCountWhileDraining).toBe(1); + expect(generation.sseClosedCleanly).toBe(true); + expect(generation.exitCode).toBe(0); + expect(generation.readyStatus.grandchildPid).toBeNumber(); + expect(generation.processGroupMembersWhileReady).toContain( + generation.readyStatus.pid + ); + expect(generation.processGroupMembersWhileReady).toContain( + generation.readyStatus.grandchildPid! + ); + expect(generation.processGroupMembersAfterExit).toEqual([]); + expect(generation.stoppedStatus.gatewaySocketOpen).toBe(false); + expect(generation.stoppedStatus.leaseActive).toBe(false); + expect(generation.stoppedStatus.readiness).toBe(false); + expect(generation.stoppedStatus.sseConnectionCount).toBe(0); + + const events = generation.stoppedStatus.events; + const readinessDownIndex = events.indexOf("readiness-down"); + expect(readinessDownIndex).toBeGreaterThan( + events.indexOf("shutdown-requested") + ); + const listenerStopEvents = events.filter( + (event) => + event === "listener-drained" || event === "listener-force-stopped" + ); + expect(listenerStopEvents).toHaveLength(1); + const listenerStopIndex = events.indexOf(listenerStopEvents[0]!); + expect(listenerStopIndex).toBeGreaterThan(readinessDownIndex); + expect(events.indexOf("sse-server-closed")).toBeGreaterThan( + listenerStopIndex + ); + for (const cleanupEvent of [ + "sse-server-closed", + "gateway-socket-closed", + "gateway-fixture-closed", + "child-process-reaped", + "statement-finalized", + "worker-lease-released", + "database-checkpointed", + "database-closed", + "stopped", + ] as const) { + expect(events.indexOf(cleanupEvent)).toBeGreaterThan(readinessDownIndex); + } + } + }); + + test("interrupts the owner scope without leaking its detached process group", async () => { + const report = await Effect.runPromise(interruptedShutdownQualification); + + expect(report.stoppedStatus.grandchildPid).toBeNumber(); + expect(report.processGroupMembersWhileReady).toContain(report.stoppedStatus.pid); + expect(report.processGroupMembersWhileReady).toContain( + report.stoppedStatus.grandchildPid! + ); + expect(report.processGroupMembersAfterInterruption).toEqual([]); + expect(report.stoppedStatus.phase).toBe("stopped"); + expect(report.stoppedStatus.events).toContain("readiness-down"); + expect(report.stoppedStatus.events.at(-1)).toBe("stopped"); + }); +}); diff --git a/qualification/shutdown/completeShutdownQualification.ts b/qualification/shutdown/completeShutdownQualification.ts new file mode 100644 index 000000000..f7028e035 --- /dev/null +++ b/qualification/shutdown/completeShutdownQualification.ts @@ -0,0 +1,689 @@ +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Data, Deferred, Effect, Fiber, Schedule, Scope } from "effect"; +import * as v from "valibot"; + +import { + openShutdownQualificationDatabase, + readShutdownDatabaseSnapshot, + type ShutdownDatabaseSnapshot, +} from "./shutdownDatabase.ts"; +import { idleHttpConnectionResource } from "./shutdownIdleHttpConnection.ts"; +import { + parseShutdownServiceStatus, + type ShutdownServiceStatus, +} from "./shutdownProtocol.ts"; + +const serviceModulePath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "shutdownService.ts" +); +const statusMaximumBytes = 64 * 1024; +const operationDeadline = "10 seconds"; +export const linuxProcessStatReadConcurrency = 16; +const statusPollingSchedule = Schedule.spaced("5 millis").pipe( + Schedule.upTo({ times: 2000 }) +); + +type QualificationServiceProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; + +const applicationStateSchema = v.strictObject({ + gatewaySocketOpen: v.boolean(), + leaseActive: v.boolean(), + readiness: v.boolean(), + sseConnectionCount: v.pipe(v.number(), v.integer(), v.minValue(0)), +}); + +type ApplicationState = v.InferOutput; + +export class CompleteShutdownQualificationError extends Data.TaggedError( + "CompleteShutdownQualificationError" +)<{ + readonly cause?: unknown; + readonly operation: string; +}> {} + +export class CompleteShutdownDeadlineError extends Data.TaggedError( + "CompleteShutdownDeadlineError" +)<{ + readonly operation: string; +}> {} + +class ShutdownStatusPendingError extends Data.TaggedError("ShutdownStatusPendingError")<{ + readonly cause?: unknown; +}> {} + +export interface ShutdownGenerationEvidence { + readonly drainingStatus: ShutdownServiceStatus; + readonly exitCode: number; + readonly generation: number; + readonly processGroupMembersAfterExit: readonly number[]; + readonly processGroupMembersWhileReady: readonly number[]; + readonly readyState: ApplicationState; + readonly readyStatus: ShutdownServiceStatus; + readonly sseClosedCleanly: boolean; + readonly sseConnectionCountWhileDraining: number; + readonly startingReadinessStatus: number; + readonly stoppedStatus: ShutdownServiceStatus; + readonly stoppingReadinessStatus: number; +} + +export interface CompleteShutdownQualificationReport { + readonly database: ShutdownDatabaseSnapshot; + readonly generations: readonly [ + ShutdownGenerationEvidence, + ShutdownGenerationEvidence, + ]; +} + +export interface InterruptedShutdownQualificationReport { + readonly processGroupMembersAfterInterruption: readonly number[]; + readonly processGroupMembersWhileReady: readonly number[]; + readonly stoppedStatus: ShutdownServiceStatus; +} + +function deadlineFailure(operation: string) { + return new CompleteShutdownDeadlineError({ operation }); +} + +function withDeadline( + effect: Effect.Effect, + operation: string +): Effect.Effect { + return effect.pipe( + Effect.timeoutOrElse({ + duration: operationDeadline, + orElse: () => Effect.fail(deadlineFailure(operation)), + }) + ); +} + +function temporaryWorkspace() { + return Effect.acquireRelease( + Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "create-temporary-workspace", + }), + try: () => mkdtemp(path.join(tmpdir(), "mira-shutdown-qualification-")), + }), + (workspacePath) => + Effect.tryPromise(() => + rm(workspacePath, { force: true, recursive: true }) + ).pipe(Effect.orDie) + ); +} + +function writeMarker( + markerPath: string +): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "write-control-marker", + }), + try: () => Bun.write(markerPath, "ready\n"), + }).pipe(Effect.asVoid); +} + +function awaitServiceExit( + child: QualificationServiceProcess, + operation: string +): Effect.Effect< + number, + CompleteShutdownDeadlineError | CompleteShutdownQualificationError +> { + return withDeadline( + Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ cause, operation }), + try: () => child.exited, + }), + operation + ); +} + +function killProcessGroup(processGroupId: number): void { + try { + process.kill(-processGroupId, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +function stopServiceProcess( + child: QualificationServiceProcess, + acknowledgePath: string +): Effect.Effect { + if (child.exitCode !== null || child.signalCode !== null) return Effect.void; + const graceful = writeMarker(acknowledgePath).pipe( + Effect.andThen(Effect.sync(() => child.kill("SIGTERM"))), + Effect.andThen(awaitServiceExit(child, "release-service-process")) + ); + return graceful.pipe( + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => + Effect.sync(() => killProcessGroup(child.pid)).pipe( + Effect.andThen( + awaitServiceExit(child, "force-release-service-process") + ) + ), + }), + Effect.asVoid, + Effect.orDie + ); +} + +function serviceProcessResource(options: { + readonly acknowledgePath: string; + readonly activatePath: string; + readonly databasePath: string; + readonly generation: number; + readonly statusPath: string; +}): Effect.Effect< + QualificationServiceProcess, + CompleteShutdownQualificationError, + Scope.Scope +> { + return Effect.acquireRelease( + Effect.try({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "spawn-shutdown-service", + }), + try: () => + Bun.spawn( + [ + process.execPath, + serviceModulePath, + options.databasePath, + options.statusPath, + options.activatePath, + options.acknowledgePath, + String(options.generation), + ], + { + detached: true, + stderr: "ignore", + stdin: "ignore", + stdout: "ignore", + } + ), + }), + (child) => stopServiceProcess(child, options.acknowledgePath) + ); +} + +function readStatus( + statusPath: string, + predicate: (status: ShutdownServiceStatus) => boolean, + operation: string +): Effect.Effect { + const attempt = Effect.tryPromise({ + catch: (cause) => new ShutdownStatusPendingError({ cause }), + try: async () => { + const statusFile = Bun.file(statusPath); + if (!(await statusFile.exists())) throw new Error("status pending"); + if (statusFile.size > statusMaximumBytes) { + throw new Error("status exceeds qualification bound"); + } + const value: unknown = JSON.parse(await statusFile.text()); + const status = parseShutdownServiceStatus(value); + if (!predicate(status)) throw new Error("status phase pending"); + return status; + }, + }); + return withDeadline( + attempt.pipe( + Effect.retry({ schedule: statusPollingSchedule }), + Effect.catchTag("ShutdownStatusPendingError", () => + Effect.fail(deadlineFailure(operation)) + ) + ), + operation + ); +} + +function fetchResponse( + url: string, + operation: string +): Effect.Effect< + Response, + CompleteShutdownDeadlineError | CompleteShutdownQualificationError, + Scope.Scope +> { + return Effect.gen(function* () { + const signal = yield* Effect.abortSignal; + return yield* withDeadline( + Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ cause, operation }), + try: () => fetch(url, { signal }), + }), + operation + ); + }); +} + +function readApplicationState( + baseUrl: string +): Effect.Effect< + ApplicationState, + CompleteShutdownDeadlineError | CompleteShutdownQualificationError, + Scope.Scope +> { + return Effect.gen(function* () { + const response = yield* fetchResponse( + `${baseUrl}/api/shutdown/state`, + "read-application-state" + ); + if (!response.ok) { + return yield* Effect.fail( + new CompleteShutdownQualificationError({ + operation: "application-state-status", + }) + ); + } + const value = yield* Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "parse-application-state", + }), + try: () => response.json() as Promise, + }); + return v.parse(applicationStateSchema, value); + }); +} + +interface SseConnection { + readonly reader: ReadableStreamDefaultReader; +} + +function sseConnectionResource( + baseUrl: string +): Effect.Effect< + SseConnection, + CompleteShutdownDeadlineError | CompleteShutdownQualificationError, + Scope.Scope +> { + return Effect.acquireRelease( + Effect.gen(function* () { + const response = yield* fetchResponse( + `${baseUrl}/api/events`, + "open-sse-connection" + ); + if ( + !response.ok || + !response.headers.get("content-type")?.startsWith("text/event-stream") || + response.body === null + ) { + return yield* Effect.fail( + new CompleteShutdownQualificationError({ + operation: "validate-sse-connection", + }) + ); + } + const reader = response.body.getReader(); + const first = yield* withDeadline( + Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "read-sse-opening-event", + }), + try: () => reader.read(), + }), + "read-sse-opening-event" + ); + if ( + first.done || + first.value === undefined || + !new TextDecoder().decode(first.value).includes("event: ready") + ) { + return yield* Effect.fail( + new CompleteShutdownQualificationError({ + operation: "validate-sse-opening-event", + }) + ); + } + return Object.freeze({ reader }); + }), + ({ reader }) => + Effect.tryPromise({ + catch: () => null, + try: () => reader.cancel(), + }).pipe(Effect.ignore, Effect.asVoid) + ); +} + +function awaitSseClosed( + connection: SseConnection +): Effect.Effect< + boolean, + CompleteShutdownDeadlineError | CompleteShutdownQualificationError +> { + return withDeadline( + Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "await-sse-close", + }), + try: () => connection.reader.read(), + }).pipe(Effect.map(({ done }) => done)), + "await-sse-close" + ); +} + +/** + * Parses Linux `/proc//stat` into process identifiers. + * @param text Raw stat record. + * @returns Process and process-group IDs. + */ +export function parseLinuxProcessStat(text: string): { + readonly processGroupId: number; + readonly processId: number; +} { + const commandEnd = text.lastIndexOf(")"); + const commandStart = text.indexOf("("); + if (commandStart <= 0 || commandEnd <= commandStart) { + throw new Error("Malformed Linux process stat record"); + } + const processId = Number(text.slice(0, commandStart).trim()); + const fields = text + .slice(commandEnd + 1) + .trim() + .split(/\s+/u); + const processGroupId = Number(fields[2]); + if (!Number.isSafeInteger(processId) || !Number.isSafeInteger(processGroupId)) { + throw new TypeError("Linux process stat identifiers are invalid"); + } + return Object.freeze({ processGroupId, processId }); +} + +/** + * Collects one process group's members with bounded `/proc` stat fanout. + * @param candidateProcessIds Candidate process identifiers read from `/proc`. + * @param processGroupId Process group to retain. + * @param readProcessStat Effectful, injectable stat-record reader. + * @returns Sorted process identifiers belonging to the requested group. + */ +export function collectLinuxProcessGroupMembers( + candidateProcessIds: readonly number[], + processGroupId: number, + readProcessStat: (processId: number) => Effect.Effect +): Effect.Effect { + return Effect.forEach( + candidateProcessIds, + (processId) => + readProcessStat(processId).pipe( + Effect.flatMap((text) => + text === null + ? Effect.succeed(null) + : Effect.try({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "parse-linux-process-stat", + }), + try: () => parseLinuxProcessStat(text), + }) + ) + ), + { concurrency: linuxProcessStatReadConcurrency } + ).pipe( + Effect.map((records) => + Object.freeze( + records + .filter( + (record): record is NonNullable<(typeof records)[number]> => + record !== null && record.processGroupId === processGroupId + ) + .map((record) => record.processId) + .toSorted((left, right) => left - right) + ) + ) + ); +} + +export function readLinuxProcessGroupMembers( + processGroupId: number +): Effect.Effect { + return Effect.gen(function* () { + const entries = yield* Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "inspect-linux-process-group", + }), + try: () => readdir("/proc", { withFileTypes: true }), + }); + const candidateProcessIds = entries + .filter((entry) => entry.isDirectory() && /^[1-9][0-9]*$/u.test(entry.name)) + .map((entry) => Number(entry.name)); + return yield* collectLinuxProcessGroupMembers( + candidateProcessIds, + processGroupId, + (processId) => + Effect.tryPromise({ + catch: (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "read-linux-process-stat", + }), + try: () => readFile(`/proc/${processId}/stat`, "utf8"), + }).pipe( + Effect.catchIf( + (error) => + (error.cause as NodeJS.ErrnoException | undefined)?.code === + "ENOENT", + () => Effect.succeed(null) + ) + ) + ); + }); +} + +function runGeneration( + workspacePath: string, + databasePath: string, + generation: number +): Effect.Effect< + ShutdownGenerationEvidence, + CompleteShutdownDeadlineError | CompleteShutdownQualificationError +> { + const prefix = path.join(workspacePath, `generation-${generation}`); + const statusPath = `${prefix}.status.json`; + const activatePath = `${prefix}.activate`; + const acknowledgePath = `${prefix}.acknowledge`; + + return Effect.scoped( + Effect.gen(function* () { + const child = yield* serviceProcessResource({ + acknowledgePath, + activatePath, + databasePath, + generation, + statusPath, + }); + const startingStatus = yield* readStatus( + statusPath, + (status) => status.phase === "starting", + "await-starting-status" + ); + const baseUrl = `http://127.0.0.1:${startingStatus.port}`; + const startingReadiness = yield* fetchResponse( + `${baseUrl}/api/health/ready`, + "read-starting-readiness" + ); + + yield* writeMarker(activatePath); + const readyStatus = yield* readStatus( + statusPath, + (status) => status.phase === "ready", + "await-ready-status" + ); + const readyReadiness = yield* fetchResponse( + `${baseUrl}/api/health/ready`, + "read-ready-readiness" + ); + if (readyReadiness.status !== 200) { + return yield* Effect.fail( + new CompleteShutdownQualificationError({ + operation: "ready-readiness-status", + }) + ); + } + const connection = yield* sseConnectionResource(baseUrl); + const readyState = yield* readApplicationState(baseUrl); + yield* idleHttpConnectionResource(baseUrl).pipe( + Effect.mapError( + (cause) => + new CompleteShutdownQualificationError({ + cause, + operation: "hold-idle-http-connection", + }) + ) + ); + const processGroupMembersWhileReady = yield* readLinuxProcessGroupMembers( + child.pid + ); + + yield* Effect.sync(() => child.kill("SIGTERM")); + const drainingStatus = yield* readStatus( + statusPath, + (status) => status.phase === "draining", + "await-draining-status" + ); + const stoppingReadiness = yield* fetchResponse( + `${baseUrl}/api/health/ready`, + "read-stopping-readiness" + ); + const drainingState = yield* readApplicationState(baseUrl); + yield* writeMarker(acknowledgePath); + + const exitCode = yield* awaitServiceExit(child, "await-service-exit"); + const sseClosedCleanly = yield* awaitSseClosed(connection); + const stoppedStatus = yield* readStatus( + statusPath, + (status) => status.phase === "stopped", + "await-stopped-status" + ); + const processGroupMembersAfterExit = yield* readLinuxProcessGroupMembers( + child.pid + ); + + return Object.freeze({ + drainingStatus, + exitCode, + generation, + processGroupMembersAfterExit, + processGroupMembersWhileReady, + readyState, + readyStatus, + sseClosedCleanly, + sseConnectionCountWhileDraining: drainingState.sseConnectionCount, + startingReadinessStatus: startingReadiness.status, + stoppedStatus, + stoppingReadinessStatus: stoppingReadiness.status, + }); + }) + ); +} + +function databaseSnapshotResource(databasePath: string) { + return Effect.acquireRelease( + Effect.sync(() => openShutdownQualificationDatabase(databasePath)), + (database) => Effect.sync(() => database.close(true)) + ); +} + +/** Runs two production-shaped process generations against one WAL database. */ +export const completeShutdownQualification = Effect.scoped( + Effect.gen(function* () { + const workspacePath = yield* temporaryWorkspace(); + const databasePath = path.join(workspacePath, "shutdown.sqlite"); + const first = yield* runGeneration(workspacePath, databasePath, 1); + const second = yield* runGeneration(workspacePath, databasePath, 2); + const database = yield* databaseSnapshotResource(databasePath); + const snapshot = yield* Effect.sync(() => readShutdownDatabaseSnapshot(database)); + return Object.freeze({ + database: snapshot, + generations: Object.freeze([first, second] as const), + }); + }) +); + +/** Proves that interrupting the owning Effect scope releases the full process tree. */ +export const interruptedShutdownQualification = Effect.scoped( + Effect.gen(function* () { + const workspacePath = yield* temporaryWorkspace(); + const databasePath = path.join(workspacePath, "interrupted.sqlite"); + const statusPath = path.join(workspacePath, "interrupted.status.json"); + const activatePath = path.join(workspacePath, "interrupted.activate"); + const acknowledgePath = path.join(workspacePath, "interrupted.acknowledge"); + const ready = yield* Deferred.make<{ + readonly processGroupId: number; + readonly status: ShutdownServiceStatus; + }>(); + + const ownedProcess = Effect.scoped( + Effect.gen(function* () { + const child = yield* serviceProcessResource({ + acknowledgePath, + activatePath, + databasePath, + generation: 1, + statusPath, + }); + yield* readStatus( + statusPath, + (status) => status.phase === "starting", + "await-interrupted-starting-status" + ); + yield* writeMarker(activatePath); + const status = yield* readStatus( + statusPath, + (candidate) => candidate.phase === "ready", + "await-interrupted-ready-status" + ); + yield* Deferred.succeed(ready, { + processGroupId: child.pid, + status, + }); + return yield* Effect.never; + }) + ); + + const fiber = yield* Effect.forkChild(ownedProcess); + const readyState = yield* withDeadline( + Deferred.await(ready), + "await-interrupted-process" + ); + const processGroupMembersWhileReady = yield* readLinuxProcessGroupMembers( + readyState.processGroupId + ); + yield* Fiber.interrupt(fiber); + const stoppedStatus = yield* readStatus( + statusPath, + (status) => status.phase === "stopped", + "await-interrupted-stopped-status" + ); + const processGroupMembersAfterInterruption = yield* readLinuxProcessGroupMembers( + readyState.processGroupId + ); + return Object.freeze({ + processGroupMembersAfterInterruption, + processGroupMembersWhileReady, + stoppedStatus, + }); + }) +); diff --git a/qualification/shutdown/runCompleteShutdownEvidence.ts b/qualification/shutdown/runCompleteShutdownEvidence.ts new file mode 100644 index 000000000..8457072df --- /dev/null +++ b/qualification/shutdown/runCompleteShutdownEvidence.ts @@ -0,0 +1,6 @@ +import { Effect } from "effect"; + +import { completeShutdownQualification } from "./completeShutdownQualification.ts"; + +const report = await Effect.runPromise(completeShutdownQualification); +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/qualification/shutdown/shutdownDatabase.ts b/qualification/shutdown/shutdownDatabase.ts new file mode 100644 index 000000000..384baffa5 --- /dev/null +++ b/qualification/shutdown/shutdownDatabase.ts @@ -0,0 +1,220 @@ +import { Database } from "bun:sqlite"; + +export interface ShutdownDatabaseSnapshot { + readonly activeLeaseCount: number; + readonly cleanGenerationCount: number; + readonly generations: readonly number[]; + readonly integrityCheck: string; + readonly journalMode: string; + readonly releasedLeaseCount: number; +} + +interface CountRow { + count: number; +} + +interface GenerationRow { + generation: number; +} + +interface IntegrityRow { + integrity_check: string; +} + +interface JournalModeRow { + journal_mode: string; +} + +interface WalCheckpointRow { + busy: number; + checkpointed: number; + log: number; +} + +const schemaStatements = [ + `CREATE TABLE IF NOT EXISTS shutdown_generations ( + generation INTEGER PRIMARY KEY NOT NULL, + process_id INTEGER NOT NULL, + started_at INTEGER NOT NULL, + stopped_at INTEGER, + state TEXT NOT NULL, + CONSTRAINT shutdown_generation_state_check CHECK ( + (state = 'running' AND stopped_at IS NULL) + OR (state = 'stopped' AND stopped_at IS NOT NULL) + ) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS shutdown_worker_leases ( + generation INTEGER PRIMARY KEY NOT NULL + REFERENCES shutdown_generations(generation) ON DELETE RESTRICT, + owner_process_id INTEGER NOT NULL, + released_at INTEGER + ) STRICT`, +] as const; + +export function openShutdownQualificationDatabase(databasePath: string): Database { + const database = new Database(databasePath, { + create: true, + readwrite: true, + strict: true, + }); + database.run("PRAGMA foreign_keys = ON"); + database.run("PRAGMA busy_timeout = 1000"); + const journalMode = database + .query("PRAGMA journal_mode = WAL") + .get()?.journal_mode; + if (journalMode?.toLowerCase() !== "wal") { + database.close(true); + throw new Error("Shutdown qualification database did not enter WAL mode"); + } + database.run("PRAGMA synchronous = NORMAL"); + database.run("PRAGMA wal_autocheckpoint = 0"); + for (const statement of schemaStatements) database.run(statement); + return database; +} + +export function startShutdownGeneration( + database: Database, + generation: number, + processId: number, + timestamp: number +): number { + return database + .transaction(() => { + const activeLeaseCount = database + .query( + "SELECT count(*) AS count FROM shutdown_worker_leases WHERE released_at IS NULL" + ) + .get()?.count; + if (activeLeaseCount !== 0) { + throw new Error("A prior shutdown qualification lease remains active"); + } + const recoveredGenerationCount = database + .query( + "SELECT count(*) AS count FROM shutdown_generations WHERE state = 'stopped'" + ) + .get()?.count; + if (recoveredGenerationCount === undefined) { + throw new Error("Shutdown qualification generation count is unavailable"); + } + database + .query( + `INSERT INTO shutdown_generations ( + generation, process_id, started_at, state + ) VALUES (?, ?, ?, 'running')` + ) + .run(generation, processId, timestamp); + return recoveredGenerationCount; + }) + .immediate(); +} + +export function acquireShutdownWorkerLease( + database: Database, + generation: number, + processId: number +): void { + database + .query( + `INSERT INTO shutdown_worker_leases ( + generation, owner_process_id, released_at + ) VALUES (?, ?, NULL)` + ) + .run(generation, processId); +} + +export function releaseShutdownWorkerLease( + database: Database, + generation: number, + timestamp: number +): void { + const result = database + .query( + `UPDATE shutdown_worker_leases + SET released_at = ? + WHERE generation = ? AND released_at IS NULL` + ) + .run(timestamp, generation); + if (result.changes !== 1) { + throw new Error("Shutdown qualification worker lease was not released once"); + } +} + +export function completeShutdownGeneration( + database: Database, + generation: number, + timestamp: number +): void { + database + .transaction(() => { + const activeLeaseCount = database + .query( + `SELECT count(*) AS count + FROM shutdown_worker_leases + WHERE generation = ? AND released_at IS NULL` + ) + .get(generation)?.count; + if (activeLeaseCount !== 0) { + throw new Error("Shutdown generation completed with an active lease"); + } + const result = database + .query( + `UPDATE shutdown_generations + SET state = 'stopped', stopped_at = ? + WHERE generation = ? AND state = 'running'` + ) + .run(timestamp, generation); + if (result.changes !== 1) { + throw new Error("Shutdown generation was not completed once"); + } + }) + .immediate(); + const checkpoint = database + .query("PRAGMA wal_checkpoint(RESTART)") + .get(); + if ( + checkpoint === null || + checkpoint.busy !== 0 || + checkpoint.checkpointed !== checkpoint.log + ) { + throw new Error("Shutdown qualification WAL checkpoint did not complete"); + } +} + +export function readShutdownDatabaseSnapshot( + database: Database +): ShutdownDatabaseSnapshot { + const count = (sql: string) => { + const value = database.query(sql).get()?.count; + if (value === undefined) throw new Error("Shutdown database count is missing"); + return value; + }; + const integrityCheck = database + .query("PRAGMA integrity_check") + .get()?.integrity_check; + const journalMode = database + .query("PRAGMA journal_mode") + .get()?.journal_mode; + if (integrityCheck === undefined || journalMode === undefined) { + throw new Error("Shutdown database metadata is missing"); + } + const generations = database + .query( + "SELECT generation FROM shutdown_generations ORDER BY generation" + ) + .all() + .map(({ generation }) => generation); + return Object.freeze({ + activeLeaseCount: count( + "SELECT count(*) AS count FROM shutdown_worker_leases WHERE released_at IS NULL" + ), + cleanGenerationCount: count( + "SELECT count(*) AS count FROM shutdown_generations WHERE state = 'stopped'" + ), + generations: Object.freeze(generations), + integrityCheck, + journalMode: journalMode.toLowerCase(), + releasedLeaseCount: count( + "SELECT count(*) AS count FROM shutdown_worker_leases WHERE released_at IS NOT NULL" + ), + }); +} diff --git a/qualification/shutdown/shutdownGrandchild.ts b/qualification/shutdown/shutdownGrandchild.ts new file mode 100644 index 000000000..7f7c90ec8 --- /dev/null +++ b/qualification/shutdown/shutdownGrandchild.ts @@ -0,0 +1,3 @@ +import { Effect } from "effect"; + +await Effect.runPromise(Effect.never); diff --git a/qualification/shutdown/shutdownIdleHttpConnection.ts b/qualification/shutdown/shutdownIdleHttpConnection.ts new file mode 100644 index 000000000..944b8584d --- /dev/null +++ b/qualification/shutdown/shutdownIdleHttpConnection.ts @@ -0,0 +1,152 @@ +import { Data, Effect, Scope } from "effect"; + +const responseMaximumBytes = 16 * 1024; + +export class ShutdownIdleHttpConnectionError extends Data.TaggedError( + "ShutdownIdleHttpConnectionError" +)<{ + readonly cause?: unknown; + readonly operation: string; +}> {} + +interface IdleHttpConnectionState { + abandoned: boolean; + response: Buffer; + request: Buffer; + requestOffset: number; + settled: boolean; + socket?: Bun.Socket; +} + +function connectionFailure(operation: string, cause?: unknown) { + return new ShutdownIdleHttpConnectionError({ cause, operation }); +} + +function writePendingRequest(socket: Bun.Socket): void { + const { request, requestOffset } = socket.data; + if (requestOffset >= request.byteLength) return; + const written = socket.write(request.subarray(requestOffset)); + socket.data.requestOffset += written; +} + +function acquireIdleHttpConnection( + baseUrl: string +): Effect.Effect, ShutdownIdleHttpConnectionError> { + return Effect.callback< + Bun.Socket, + ShutdownIdleHttpConnectionError + >((resume) => { + const url = new URL(baseUrl); + const request = Buffer.from( + [ + "GET /api/health/ready HTTP/1.1", + `Host: ${url.host}`, + "Accept: application/json", + "Connection: keep-alive", + "", + "", + ].join("\r\n"), + "utf8" + ); + const state: IdleHttpConnectionState = { + abandoned: false, + request, + requestOffset: 0, + response: Buffer.alloc(0), + settled: false, + }; + const fail = (operation: string, cause?: unknown) => { + if (state.settled) return; + state.settled = true; + state.socket?.terminate(); + resume(Effect.fail(connectionFailure(operation, cause))); + }; + const connectPromise = Bun.connect({ + data: state, + hostname: url.hostname, + port: Number(url.port), + socket: { + binaryType: "buffer", + close(_socket, error) { + if (!state.settled) { + fail("idle-http-connection-closed-before-response", error); + } + }, + connectError(_socket, error) { + fail("connect-idle-http-connection", error); + }, + data(socket, chunk) { + state.socket = socket; + if (state.settled) return; + const remainingBytes = responseMaximumBytes - state.response.length; + if (chunk.length > remainingBytes) { + fail("idle-http-response-exceeded-bound"); + return; + } + state.response = Buffer.concat( + [state.response, chunk], + state.response.length + chunk.length + ); + const headerEnd = state.response.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const statusLine = state.response + .subarray(0, headerEnd) + .toString("utf8") + .split("\r\n", 1)[0]; + if (statusLine !== "HTTP/1.1 200 OK") { + fail( + "validate-idle-http-response", + new Error("Idle HTTP response was not successful") + ); + return; + } + state.settled = true; + socket.pause(); + resume(Effect.succeed(socket)); + }, + drain(socket) { + state.socket = socket; + writePendingRequest(socket); + }, + error(_socket, error) { + fail("idle-http-connection-error", error); + }, + open(socket) { + state.socket = socket; + if (state.abandoned) { + socket.terminate(); + return; + } + writePendingRequest(socket); + }, + }, + }); + void connectPromise.catch((error: unknown) => + fail("start-idle-http-connection", error) + ); + + return Effect.sync(() => { + state.abandoned = true; + state.socket?.terminate(); + void connectPromise.then((socket) => socket.terminate()).catch(() => {}); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => Effect.fail(connectionFailure("await-idle-http-response")), + }) + ); +} + +/** + * Holds one completed HTTP/1.1 keep-alive connection across listener shutdown. + * @param baseUrl Running qualification listener URL. + * @returns A scoped connection that remains open until release or remote shutdown. + */ +export function idleHttpConnectionResource( + baseUrl: string +): Effect.Effect { + return Effect.acquireRelease(acquireIdleHttpConnection(baseUrl), (socket) => + Effect.sync(() => socket.terminate()) + ).pipe(Effect.asVoid); +} diff --git a/qualification/shutdown/shutdownProtocol.ts b/qualification/shutdown/shutdownProtocol.ts new file mode 100644 index 000000000..546108ed6 --- /dev/null +++ b/qualification/shutdown/shutdownProtocol.ts @@ -0,0 +1,182 @@ +import * as v from "valibot"; + +export const shutdownLifecycleEventSchema = v.picklist([ + "signal-handler-installed", + "listener-open", + "database-open", + "worker-lease-acquired", + "statement-prepared", + "child-process-started", + "gateway-fixture-open", + "gateway-socket-open", + "readiness-up", + "shutdown-requested", + "readiness-down", + "listener-drained", + "listener-force-stopped", + "sse-server-closed", + "gateway-socket-closed", + "gateway-fixture-closed", + "child-process-reaped", + "statement-finalized", + "worker-lease-released", + "database-checkpointed", + "database-closed", + "stopped", +]); + +export type ShutdownLifecycleEvent = v.InferOutput; + +const positiveIntegerSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); +const nonnegativeIntegerSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); +const portSchema = v.pipe(positiveIntegerSchema, v.maxValue(65_535)); +const gatewayNonceSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(128)); +const gatewayOperatorReadSchema = v.literal("operator.read"); +const gatewayOperatorReadScopesSchema = v.tuple([gatewayOperatorReadSchema]); + +export const shutdownServiceStatusSchema = v.strictObject({ + events: v.array(shutdownLifecycleEventSchema), + gatewaySocketOpen: v.boolean(), + generation: positiveIntegerSchema, + grandchildPid: v.optional(positiveIntegerSchema), + leaseActive: v.boolean(), + phase: v.picklist(["starting", "ready", "draining", "stopped"]), + pid: positiveIntegerSchema, + port: portSchema, + processGroupId: positiveIntegerSchema, + readiness: v.boolean(), + recoveredGenerationCount: nonnegativeIntegerSchema, + schemaVersion: v.literal(1), + sseConnectionCount: nonnegativeIntegerSchema, +}); + +export type ShutdownServiceStatus = v.InferOutput; + +const gatewayChallengePayloadSchema = v.strictObject({ + nonce: gatewayNonceSchema, + ts: nonnegativeIntegerSchema, +}); +const gatewayChallengeSchema = v.strictObject({ + event: v.literal("connect.challenge"), + payload: gatewayChallengePayloadSchema, + type: v.literal("event"), +}); + +const gatewayConnectAuthSchema = v.strictObject({ + token: v.literal("shutdown-fixture-token"), +}); +const gatewayConnectClientSchema = v.strictObject({ + displayName: v.literal("Mira shutdown qualification"), + id: v.literal("gateway-client"), + mode: v.literal("cli"), + platform: v.literal("linux"), + version: v.literal("qualification"), +}); +const gatewayConnectParametersSchema = v.strictObject({ + auth: gatewayConnectAuthSchema, + client: gatewayConnectClientSchema, + maxProtocol: v.literal(4), + minProtocol: v.literal(4), + role: v.literal("operator"), + scopes: gatewayOperatorReadScopesSchema, +}); +const gatewayConnectRequestSchema = v.strictObject({ + id: v.literal("shutdown-qualification-connect"), + method: v.literal("connect"), + params: gatewayConnectParametersSchema, + type: v.literal("req"), +}); + +const gatewayHelloAuthSchema = v.strictObject({ + role: v.literal("operator"), + scopes: gatewayOperatorReadScopesSchema, +}); +const gatewayHelloServerSchema = v.strictObject({ + connId: v.literal("shutdown-qualification"), + version: v.literal("2026.7.2-beta.7"), +}); +const gatewayHelloSnapshotSchema = v.strictObject({ + authMode: v.literal("token"), +}); +const gatewayHelloPayloadSchema = v.strictObject({ + auth: gatewayHelloAuthSchema, + protocol: v.literal(4), + server: gatewayHelloServerSchema, + snapshot: gatewayHelloSnapshotSchema, + type: v.literal("hello-ok"), +}); +const gatewayHelloResponseSchema = v.strictObject({ + id: v.literal("shutdown-qualification-connect"), + ok: v.literal(true), + payload: gatewayHelloPayloadSchema, + type: v.literal("res"), +}); + +function parseBoundedJson(text: string, maximumBytes: number): unknown { + if (Buffer.byteLength(text, "utf8") > maximumBytes) { + throw new Error("Shutdown qualification Gateway frame exceeded its bound"); + } + return JSON.parse(text) as unknown; +} + +export function parseGatewayChallenge(text: string) { + return v.parse(gatewayChallengeSchema, parseBoundedJson(text, 16 * 1024)); +} + +export function parseGatewayConnectRequest(text: string) { + return v.parse(gatewayConnectRequestSchema, parseBoundedJson(text, 16 * 1024)); +} + +export function parseGatewayHelloResponse(text: string) { + return v.parse(gatewayHelloResponseSchema, parseBoundedJson(text, 16 * 1024)); +} + +export function createGatewayConnectRequest(nonce: string) { + if (nonce.length === 0 || nonce.length > 128) { + throw new Error("Shutdown qualification Gateway nonce is invalid"); + } + return { + id: "shutdown-qualification-connect" as const, + method: "connect" as const, + params: { + auth: { token: "shutdown-fixture-token" as const }, + client: { + displayName: "Mira shutdown qualification" as const, + id: "gateway-client" as const, + mode: "cli" as const, + platform: "linux" as const, + version: "qualification" as const, + }, + maxProtocol: 4 as const, + minProtocol: 4 as const, + role: "operator" as const, + scopes: ["operator.read" as const] as const, + }, + type: "req" as const, + }; +} + +export function createGatewayHelloResponse() { + return { + id: "shutdown-qualification-connect" as const, + ok: true as const, + payload: { + auth: { + role: "operator" as const, + scopes: ["operator.read" as const] as const, + }, + protocol: 4 as const, + server: { + connId: "shutdown-qualification" as const, + version: "2026.7.2-beta.7" as const, + }, + snapshot: { authMode: "token" as const }, + type: "hello-ok" as const, + }, + type: "res" as const, + }; +} + +export function parseShutdownServiceStatus(value: unknown): ShutdownServiceStatus { + return v.parse(shutdownServiceStatusSchema, value); +} diff --git a/qualification/shutdown/shutdownService.ts b/qualification/shutdown/shutdownService.ts new file mode 100644 index 000000000..b461e8bc6 --- /dev/null +++ b/qualification/shutdown/shutdownService.ts @@ -0,0 +1,311 @@ +import { Database, type SQLQueryBindings, type Statement } from "bun:sqlite"; + +import { Data, Effect, Scope } from "effect"; +import * as v from "valibot"; + +import { + acquireShutdownWorkerLease, + completeShutdownGeneration, + openShutdownQualificationDatabase, + releaseShutdownWorkerLease, + startShutdownGeneration, +} from "./shutdownDatabase.ts"; +import { + type ShutdownLifecycleEvent, + type ShutdownServiceStatus, +} from "./shutdownProtocol.ts"; +import { + applicationServerResource, + awaitMarkerFile, + gatewayFixtureResource, + gatewaySocketResource, + grandchildProcessResource, + shutdownSignalResource, + ShutdownQualificationResourceError, + writeShutdownStatus, +} from "./shutdownServiceResources.ts"; + +const serviceCommandSchema = v.strictObject({ + acknowledgePath: v.pipe(v.string(), v.minLength(1), v.maxLength(4096)), + activatePath: v.pipe(v.string(), v.minLength(1), v.maxLength(4096)), + databasePath: v.pipe(v.string(), v.minLength(1), v.maxLength(4096)), + generation: v.pipe(v.number(), v.integer(), v.minValue(1)), + statusPath: v.pipe(v.string(), v.minLength(1), v.maxLength(4096)), +}); + +type ServiceCommand = v.InferOutput; + +class ShutdownQualificationArgumentError extends Data.TaggedError( + "ShutdownQualificationArgumentError" +)<{ + readonly message: string; +}> {} + +function parsePositiveInteger(value: string | undefined): number { + if (value === undefined || !/^[1-9][0-9]*$/u.test(value)) return Number.NaN; + return Number(value); +} + +function parseCommand(arguments_: readonly string[]): ServiceCommand { + const [databasePath, statusPath, activatePath, acknowledgePath, generation] = + arguments_; + try { + return v.parse(serviceCommandSchema, { + acknowledgePath, + activatePath, + databasePath, + generation: parsePositiveInteger(generation), + statusPath, + }); + } catch { + throw new ShutdownQualificationArgumentError({ + message: "Invalid shutdown qualification service arguments", + }); + } +} + +function databaseResource(databasePath: string) { + return Effect.acquireRelease( + Effect.try({ + catch: (cause) => + new ShutdownQualificationResourceError({ + cause, + operation: "open-database", + }), + try: () => openShutdownQualificationDatabase(databasePath), + }), + (database) => Effect.sync(() => database.close(true)) + ); +} + +function generationResource( + database: Database, + generation: number +): Effect.Effect { + return Effect.acquireRelease( + Effect.sync(() => + startShutdownGeneration(database, generation, process.pid, Date.now()) + ), + () => + Effect.sync(() => + completeShutdownGeneration(database, generation, Date.now()) + ) + ); +} + +function workerLeaseResource( + database: Database, + generation: number +): Effect.Effect { + return Effect.acquireRelease( + Effect.sync(() => { + acquireShutdownWorkerLease(database, generation, process.pid); + }), + () => + Effect.sync(() => + releaseShutdownWorkerLease(database, generation, Date.now()) + ) + ); +} + +function preparedStatementResource( + database: Database, + generation: number +): Effect.Effect< + Statement<{ generation: number }, SQLQueryBindings[]>, + never, + Scope.Scope +> { + return Effect.acquireRelease( + Effect.sync(() => { + const statement = database.prepare< + { generation: number }, + SQLQueryBindings[] + >("SELECT generation FROM shutdown_generations WHERE generation = ?"); + const row = statement.get(generation); + if (row?.generation !== generation) { + statement.finalize(); + throw new Error("Shutdown qualification prepared statement failed"); + } + return statement; + }), + (statement) => Effect.sync(() => statement.finalize()) + ); +} + +function appendEvent( + events: ShutdownLifecycleEvent[], + event: ShutdownLifecycleEvent +): void { + events.push(event); +} + +function statusSnapshot(options: { + readonly application: { + readonly port: number; + readonly sseConnectionCount: number; + }; + readonly events: readonly ShutdownLifecycleEvent[]; + readonly gatewaySocketOpen: boolean; + readonly generation: number; + readonly grandchildPid?: number; + readonly leaseActive: boolean; + readonly phase: ShutdownServiceStatus["phase"]; + readonly readiness: boolean; + readonly recoveredGenerationCount: number; +}): ShutdownServiceStatus { + return { + events: [...options.events], + gatewaySocketOpen: options.gatewaySocketOpen, + generation: options.generation, + ...(options.grandchildPid === undefined + ? {} + : { grandchildPid: options.grandchildPid }), + leaseActive: options.leaseActive, + phase: options.phase, + pid: process.pid, + port: options.application.port, + processGroupId: process.pid, + readiness: options.readiness, + recoveredGenerationCount: options.recoveredGenerationCount, + schemaVersion: 1, + sseConnectionCount: options.application.sseConnectionCount, + }; +} + +function runService(command: ServiceCommand) { + const events: ShutdownLifecycleEvent[] = []; + const state = { + gatewaySocketOpen: false, + leaseActive: false, + readiness: false, + }; + let application: + | { + readonly port: number; + readonly sseConnectionCount: number; + } + | undefined; + let grandchildPid: number | undefined; + let recoveredGenerationCount = 0; + + const snapshot = (phase: ShutdownServiceStatus["phase"]): ShutdownServiceStatus => { + if (application === undefined) { + throw new Error("Shutdown qualification application is unavailable"); + } + return statusSnapshot({ + application, + events, + gatewaySocketOpen: state.gatewaySocketOpen, + generation: command.generation, + grandchildPid, + leaseActive: state.leaseActive, + phase, + readiness: state.readiness, + recoveredGenerationCount, + }); + }; + + const lifecycle = Effect.scoped( + Effect.gen(function* () { + const signal = yield* shutdownSignalResource(); + appendEvent(events, "signal-handler-installed"); + + const applicationServer = yield* applicationServerResource(state); + application = applicationServer; + appendEvent(events, "listener-open"); + yield* writeShutdownStatus(command.statusPath, snapshot("starting")); + yield* awaitMarkerFile(command.activatePath, "await-activation"); + + yield* Effect.addFinalizer(() => + Effect.sync(() => appendEvent(events, "database-closed")) + ); + const database = yield* databaseResource(command.databasePath); + appendEvent(events, "database-open"); + + yield* Effect.addFinalizer(() => + Effect.sync(() => appendEvent(events, "database-checkpointed")) + ); + recoveredGenerationCount = yield* generationResource( + database, + command.generation + ); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + state.leaseActive = false; + appendEvent(events, "worker-lease-released"); + }) + ); + yield* workerLeaseResource(database, command.generation); + state.leaseActive = true; + appendEvent(events, "worker-lease-acquired"); + + yield* Effect.addFinalizer(() => + Effect.sync(() => appendEvent(events, "statement-finalized")) + ); + yield* preparedStatementResource(database, command.generation); + appendEvent(events, "statement-prepared"); + + yield* Effect.addFinalizer(() => + Effect.sync(() => appendEvent(events, "child-process-reaped")) + ); + const grandchild = yield* grandchildProcessResource(); + grandchildPid = grandchild.pid; + appendEvent(events, "child-process-started"); + + yield* Effect.addFinalizer(() => + Effect.sync(() => appendEvent(events, "gateway-fixture-closed")) + ); + const gatewayFixture = yield* gatewayFixtureResource(); + appendEvent(events, "gateway-fixture-open"); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + state.gatewaySocketOpen = false; + appendEvent(events, "gateway-socket-closed"); + }) + ); + yield* gatewaySocketResource(gatewayFixture.url); + state.gatewaySocketOpen = true; + appendEvent(events, "gateway-socket-open"); + + state.readiness = true; + appendEvent(events, "readiness-up"); + yield* writeShutdownStatus(command.statusPath, snapshot("ready")); + + yield* signal.awaitSignal; + appendEvent(events, "shutdown-requested"); + state.readiness = false; + appendEvent(events, "readiness-down"); + yield* writeShutdownStatus(command.statusPath, snapshot("draining")); + yield* awaitMarkerFile( + command.acknowledgePath, + "await-drain-acknowledgement" + ); + + const listenerStopMode = yield* applicationServer.close(); + appendEvent( + events, + listenerStopMode === "graceful" + ? "listener-drained" + : "listener-force-stopped" + ); + appendEvent(events, "sse-server-closed"); + }) + ); + + return Effect.gen(function* () { + yield* lifecycle; + appendEvent(events, "stopped"); + yield* writeShutdownStatus(command.statusPath, snapshot("stopped")); + }); +} + +try { + const command = parseCommand(process.argv.slice(2)); + await Effect.runPromise(runService(command)); +} catch { + process.stderr.write("Complete-shutdown qualification service failed\n"); + process.exitCode = 1; +} diff --git a/qualification/shutdown/shutdownServiceResources.test.ts b/qualification/shutdown/shutdownServiceResources.test.ts new file mode 100644 index 000000000..c9e37e4e2 --- /dev/null +++ b/qualification/shutdown/shutdownServiceResources.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, spyOn, test } from "bun:test"; + +import { Effect } from "effect"; + +import { + applicationServerResource, + ShutdownQualificationDeadlineError, + ShutdownQualificationResourceError, + stopApplicationListener, +} from "./shutdownServiceResources.ts"; + +describe("shutdown application listener policy", () => { + test("reports a graceful stop without escalation", async () => { + const stopCalls: boolean[] = []; + + const mode = await Effect.runPromise( + stopApplicationListener({ + stop(force = false) { + stopCalls.push(force); + return Promise.resolve(); + }, + }) + ); + + expect(mode).toBe("graceful"); + expect(stopCalls).toEqual([false]); + }); + + test("forces one pending graceful stop and joins its settlement", async () => { + const gracefulStop = Promise.withResolvers(); + const stopCalls: boolean[] = []; + + const mode = await Effect.runPromise( + stopApplicationListener({ + stop(force = false) { + stopCalls.push(force); + if (force) gracefulStop.resolve(); + return gracefulStop.promise; + }, + }) + ); + + expect(mode).toBe("forced"); + expect(stopCalls).toEqual([false, true]); + }); + + test("bounds a force stop that does not settle", async () => { + const pending = new Promise(() => {}); + const stopCalls: boolean[] = []; + + const failure = await Effect.runPromise( + stopApplicationListener( + { + stop(force = false) { + stopCalls.push(force); + return pending; + }, + }, + { forcedStopDeadline: 1, gracefulStopDeadline: 1 } + ) + ).then( + () => null, + (error: unknown) => error + ); + + expect(failure).toBeInstanceOf(ShutdownQualificationDeadlineError); + expect(stopCalls).toEqual([false, true]); + }); + + test("best-effort forces after graceful rejection and preserves that failure", async () => { + const gracefulFailure = new Error("simulated graceful stop failure"); + const stopCalls: boolean[] = []; + + const failure = await Effect.runPromise( + stopApplicationListener({ + stop(force = false) { + stopCalls.push(force); + return force ? Promise.resolve() : Promise.reject(gracefulFailure); + }, + }) + ).then( + () => null, + (error: unknown) => error + ); + + expect(failure).toBeInstanceOf(ShutdownQualificationResourceError); + expect((failure as ShutdownQualificationResourceError).cause).toBe( + gracefulFailure + ); + expect(stopCalls).toEqual([false, true]); + }); + + test("memoizes concurrent and finalizer-driven close calls", async () => { + const stopCalls: boolean[] = []; + const fakeServer = { + port: 31_001, + stop(force = false) { + stopCalls.push(force); + return Promise.resolve(); + }, + } as unknown as ReturnType; + const serveSpy = spyOn(Bun, "serve").mockReturnValue(fakeServer); + + try { + const modes = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* applicationServerResource({ + gatewaySocketOpen: false, + leaseActive: false, + readiness: false, + }); + return yield* Effect.all( + [server.close(), server.close()] as const, + { concurrency: "unbounded" } + ); + }) + ) + ); + + expect(modes).toEqual(["graceful", "graceful"]); + expect(stopCalls).toEqual([false]); + } finally { + serveSpy.mockRestore(); + } + }); +}); diff --git a/qualification/shutdown/shutdownServiceResources.ts b/qualification/shutdown/shutdownServiceResources.ts new file mode 100644 index 000000000..28334e438 --- /dev/null +++ b/qualification/shutdown/shutdownServiceResources.ts @@ -0,0 +1,575 @@ +import { rename } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + Data, + Deferred, + Duration, + Effect, + Exit, + Fiber, + Option, + Schedule, + Scope, +} from "effect"; + +import { + createGatewayConnectRequest, + createGatewayHelloResponse, + parseGatewayChallenge, + parseGatewayConnectRequest, + parseGatewayHelloResponse, + type ShutdownServiceStatus, +} from "./shutdownProtocol.ts"; + +const markerPollingSchedule = Schedule.spaced("5 millis").pipe( + Schedule.upTo({ times: 2000 }) +); +const operationDeadline = "10 seconds"; +const applicationListenerGracefulStopDeadline = "250 millis"; +const applicationListenerForcedStopDeadline = "2 seconds"; +const grandchildModulePath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "shutdownGrandchild.ts" +); + +type QualificationChildProcess = Bun.Subprocess<"ignore", "ignore", "ignore">; + +export class ShutdownQualificationResourceError extends Data.TaggedError( + "ShutdownQualificationResourceError" +)<{ + readonly cause?: unknown; + readonly operation: string; +}> {} + +export class ShutdownQualificationDeadlineError extends Data.TaggedError( + "ShutdownQualificationDeadlineError" +)<{ + readonly operation: string; +}> {} + +class ShutdownMarkerPendingError extends Data.TaggedError("ShutdownMarkerPendingError")<{ + readonly operation: string; +}> {} + +function deadlineFailure(operation: string) { + return new ShutdownQualificationDeadlineError({ operation }); +} + +function withDeadline( + effect: Effect.Effect, + operation: string +): Effect.Effect { + return effect.pipe( + Effect.timeoutOrElse({ + duration: operationDeadline, + orElse: () => Effect.fail(deadlineFailure(operation)), + }) + ); +} + +export function awaitMarkerFile( + markerPath: string, + operation: string +): Effect.Effect { + const attempt = Effect.tryPromise({ + catch: () => new ShutdownMarkerPendingError({ operation }), + try: async () => { + if (!(await Bun.file(markerPath).exists())) { + throw new Error("marker pending"); + } + }, + }); + return withDeadline( + attempt.pipe( + Effect.retry({ schedule: markerPollingSchedule }), + Effect.catchTag("ShutdownMarkerPendingError", () => + Effect.fail(deadlineFailure(operation)) + ) + ), + operation + ); +} + +export function writeShutdownStatus( + statusPath: string, + status: ShutdownServiceStatus +): Effect.Effect { + const temporaryPath = `${statusPath}.${process.pid}.tmp`; + return Effect.tryPromise({ + catch: (cause) => + new ShutdownQualificationResourceError({ cause, operation: "write-status" }), + try: async () => { + await Bun.write(temporaryPath, `${JSON.stringify(status)}\n`); + await rename(temporaryPath, statusPath); + }, + }); +} + +export interface ShutdownSignalResource { + readonly awaitSignal: Effect.Effect; +} + +export function shutdownSignalResource(): Effect.Effect< + ShutdownSignalResource, + never, + Scope.Scope +> { + return Effect.gen(function* () { + const requested = yield* Deferred.make(); + const onSignal = () => { + Deferred.doneUnsafe(requested, Effect.void); + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + }), + () => + Effect.sync(() => { + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + }) + ); + return Object.freeze({ awaitSignal: Deferred.await(requested) }); + }); +} + +interface ShutdownApplicationState { + gatewaySocketOpen: boolean; + leaseActive: boolean; + readiness: boolean; +} + +interface StoppableApplicationListener { + stop(force?: boolean): Promise; +} + +interface ApplicationListenerStopPolicy { + readonly forcedStopDeadline: Duration.Input; + readonly gracefulStopDeadline: Duration.Input; +} + +const defaultApplicationListenerStopPolicy: ApplicationListenerStopPolicy = { + forcedStopDeadline: applicationListenerForcedStopDeadline, + gracefulStopDeadline: applicationListenerGracefulStopDeadline, +}; + +/** + * Applies the production-shaped graceful-to-forced listener stop policy. + * @param server Listener stop boundary. + * @param policy Bounded graceful and forced stop durations. + * @returns The observed successful shutdown mode. + */ +export function stopApplicationListener( + server: StoppableApplicationListener, + policy: ApplicationListenerStopPolicy = defaultApplicationListenerStopPolicy +): Effect.Effect< + "forced" | "graceful", + ShutdownQualificationDeadlineError | ShutdownQualificationResourceError +> { + const stopServer = (force: boolean) => + Effect.tryPromise({ + catch: (cause) => + new ShutdownQualificationResourceError({ + cause, + operation: force + ? "force-stop-application-listener" + : "gracefully-stop-application-listener", + }), + try: () => server.stop(force), + }); + const boundedForceStop = stopServer(true).pipe( + Effect.timeoutOrElse({ + duration: policy.forcedStopDeadline, + orElse: () => Effect.fail(deadlineFailure("force-stop-application-listener")), + }) + ); + + return Effect.scoped( + Effect.gen(function* () { + const gracefulFiber = yield* Effect.forkScoped(stopServer(false)); + const gracefulExit = yield* Fiber.await(gracefulFiber).pipe( + Effect.timeoutOption(policy.gracefulStopDeadline) + ); + if (Option.isSome(gracefulExit)) { + if (Exit.isSuccess(gracefulExit.value)) return "graceful"; + yield* boundedForceStop.pipe(Effect.ignore); + return yield* Effect.failCause(gracefulExit.value.cause); + } + + yield* boundedForceStop; + yield* Fiber.join(gracefulFiber).pipe( + Effect.timeoutOrElse({ + duration: policy.forcedStopDeadline, + orElse: () => + Effect.fail( + deadlineFailure("join-forced-application-listener-stop") + ), + }) + ); + return "forced"; + }) + ); +} + +export interface ShutdownApplicationServer { + readonly port: number; + readonly sseConnectionCount: number; + close(): Effect.Effect< + "forced" | "graceful", + ShutdownQualificationDeadlineError | ShutdownQualificationResourceError + >; +} + +export function applicationServerResource( + state: ShutdownApplicationState +): Effect.Effect< + ShutdownApplicationServer, + ShutdownQualificationResourceError, + Scope.Scope +> { + return Effect.acquireRelease( + Effect.gen(function* () { + const listener = yield* Effect.try({ + catch: (cause) => + new ShutdownQualificationResourceError({ + cause, + operation: "start-application-listener", + }), + try: () => { + const encoder = new TextEncoder(); + const controllers = new Set< + ReadableStreamDefaultController + >(); + const server = Bun.serve({ + fetch(request) { + const pathname = new URL(request.url).pathname; + if (pathname === "/api/health/ready") { + return Response.json( + { status: state.readiness ? "ready" : "not-ready" }, + { status: state.readiness ? 200 : 503 } + ); + } + if (pathname === "/api/shutdown/state") { + return Response.json({ + gatewaySocketOpen: state.gatewaySocketOpen, + leaseActive: state.leaseActive, + readiness: state.readiness, + sseConnectionCount: controllers.size, + }); + } + if (pathname === "/api/events") { + let ownedController: + | ReadableStreamDefaultController + | undefined; + return new Response( + new ReadableStream({ + cancel() { + if (ownedController !== undefined) { + controllers.delete(ownedController); + } + }, + start(controller) { + ownedController = controller; + controllers.add(controller); + controller.enqueue( + encoder.encode( + 'event: ready\ndata: {"status":"connected"}\n\n' + ) + ); + }, + }), + { + headers: { + "cache-control": "no-store", + "content-type": "text/event-stream", + }, + } + ); + } + return new Response("Not found", { status: 404 }); + }, + hostname: "127.0.0.1", + port: 0, + }); + if (server.port === undefined) { + void server.stop(true); + throw new Error( + "Shutdown qualification listener has no bound port" + ); + } + return { controllers, port: server.port, server }; + }, + }); + const close = yield* Effect.cached( + Effect.gen(function* () { + yield* Effect.sync(() => { + for (const controller of listener.controllers) { + controller.close(); + } + listener.controllers.clear(); + }); + return yield* stopApplicationListener(listener.server); + }) + ); + + return Object.freeze({ + close: () => close, + port: listener.port, + get sseConnectionCount() { + return listener.controllers.size; + }, + }); + }), + (server) => server.close().pipe(Effect.asVoid, Effect.orDie) + ); +} + +interface GatewayFixtureSocketData { + readonly qualification: true; +} + +export interface GatewayFixtureServer { + readonly url: string; + close(): Effect.Effect; +} + +export function gatewayFixtureResource(): Effect.Effect< + GatewayFixtureServer, + ShutdownQualificationResourceError, + Scope.Scope +> { + return Effect.acquireRelease( + Effect.try({ + catch: (cause) => + new ShutdownQualificationResourceError({ + cause, + operation: "start-gateway-fixture", + }), + try: () => { + let closePromise: Promise | undefined; + const server = Bun.serve({ + fetch(request, bunServer) { + return bunServer.upgrade(request, { + data: { qualification: true }, + }) + ? undefined + : new Response("WebSocket upgrade required", { + status: 426, + }); + }, + hostname: "127.0.0.1", + port: 0, + websocket: { + message(socket, message) { + try { + parseGatewayConnectRequest( + typeof message === "string" + ? message + : message.toString("utf8") + ); + socket.send(JSON.stringify(createGatewayHelloResponse())); + } catch { + socket.close(1008, "invalid connect request"); + } + }, + open(socket) { + socket.send( + JSON.stringify({ + event: "connect.challenge", + payload: { + nonce: "shutdown-qualification-nonce", + ts: 1_786_000_000_000, + }, + type: "event", + }) + ); + }, + }, + }); + const url = new URL(server.url); + url.protocol = "ws:"; + return Object.freeze({ + close() { + closePromise ??= server.stop(true); + return Effect.tryPromise({ + catch: (cause) => + new ShutdownQualificationResourceError({ + cause, + operation: "stop-gateway-fixture", + }), + try: () => closePromise!, + }); + }, + url: url.href, + }); + }, + }), + (fixture) => fixture.close().pipe(Effect.orDie) + ); +} + +function openGatewaySocket( + url: string +): Effect.Effect< + WebSocket, + ShutdownQualificationDeadlineError | ShutdownQualificationResourceError +> { + const connection = Effect.callback( + (resume) => { + const socket = new WebSocket(url); + let connectSent = false; + let settled = false; + const removeListeners = () => { + socket.removeEventListener("close", onClose); + socket.removeEventListener("error", onError); + socket.removeEventListener("message", onMessage); + }; + const fail = (operation: string, cause?: unknown) => { + if (settled) return; + settled = true; + removeListeners(); + resume( + Effect.fail( + new ShutdownQualificationResourceError({ cause, operation }) + ) + ); + }; + const onClose = () => fail("gateway-closed-before-hello"); + const onError = (event: Event) => fail("gateway-transport-error", event); + const onMessage = (event: MessageEvent) => { + if (typeof event.data !== "string") { + fail("gateway-non-text-frame"); + return; + } + try { + if (!connectSent) { + const challenge = parseGatewayChallenge(event.data); + connectSent = true; + socket.send( + JSON.stringify( + createGatewayConnectRequest(challenge.payload.nonce) + ) + ); + return; + } + parseGatewayHelloResponse(event.data); + settled = true; + removeListeners(); + resume(Effect.succeed(socket)); + } catch (error) { + fail("gateway-protocol-error", error); + } + }; + socket.addEventListener("close", onClose); + socket.addEventListener("error", onError); + socket.addEventListener("message", onMessage); + return Effect.sync(() => { + removeListeners(); + if ( + socket.readyState === WebSocket.CONNECTING || + socket.readyState === WebSocket.OPEN + ) { + socket.close(1000, "qualification interrupted"); + } + }); + } + ); + return withDeadline(connection, "gateway-handshake"); +} + +function closeGatewaySocket(socket: WebSocket): Effect.Effect { + if (socket.readyState === WebSocket.CLOSED) return Effect.void; + const close = Effect.callback((resume) => { + const onClose = () => { + socket.removeEventListener("close", onClose); + resume(Effect.void); + }; + socket.addEventListener("close", onClose, { once: true }); + if ( + socket.readyState === WebSocket.CONNECTING || + socket.readyState === WebSocket.OPEN + ) { + socket.close(1000, "qualification shutdown"); + } + return Effect.sync(() => socket.removeEventListener("close", onClose)); + }); + return close.pipe( + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => + Effect.die( + new Error("Shutdown qualification Gateway socket did not close") + ), + }) + ); +} + +export function gatewaySocketResource( + url: string +): Effect.Effect< + WebSocket, + ShutdownQualificationDeadlineError | ShutdownQualificationResourceError, + Scope.Scope +> { + return Effect.acquireRelease(openGatewaySocket(url), closeGatewaySocket); +} + +function awaitChildExit( + child: QualificationChildProcess, + operation: string +): Effect.Effect< + number, + ShutdownQualificationDeadlineError | ShutdownQualificationResourceError +> { + return withDeadline( + Effect.tryPromise({ + catch: (cause) => + new ShutdownQualificationResourceError({ cause, operation }), + try: () => child.exited, + }), + operation + ); +} + +function stopGrandchild(child: QualificationChildProcess): Effect.Effect { + if (child.exitCode !== null || child.signalCode !== null) return Effect.void; + return Effect.sync(() => child.kill("SIGTERM")).pipe( + Effect.andThen(awaitChildExit(child, "stop-grandchild")), + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => + Effect.sync(() => child.kill("SIGKILL")).pipe( + Effect.andThen(awaitChildExit(child, "kill-grandchild")) + ), + }), + Effect.asVoid, + Effect.orDie + ); +} + +export function grandchildProcessResource(): Effect.Effect< + QualificationChildProcess, + ShutdownQualificationResourceError, + Scope.Scope +> { + return Effect.acquireRelease( + Effect.try({ + catch: (cause) => + new ShutdownQualificationResourceError({ + cause, + operation: "start-grandchild", + }), + try: () => + Bun.spawn([process.execPath, grandchildModulePath], { + detached: false, + stderr: "ignore", + stdin: "ignore", + stdout: "ignore", + }), + }), + stopGrandchild + ); +} diff --git a/qualification/test/asyncCleanupStack.test.ts b/qualification/test/asyncCleanupStack.test.ts new file mode 100644 index 000000000..3fb2ba9ea --- /dev/null +++ b/qualification/test/asyncCleanupStack.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; + +import { Effect, Fiber, Result } from "effect"; +import { TestClock } from "effect/testing"; + +import { + AsyncCleanupDeadlineError, + AsyncCleanupOperationError, + AsyncCleanupStack, +} from "./asyncCleanupStack.ts"; + +describe("asynchronous qualification cleanup stack", () => { + test("runs every cleanup in LIFO order and tags operational failures", async () => { + const cleanup = new AsyncCleanupStack(); + const order: string[] = []; + const operationFailure = new Error("fixture cleanup failed"); + + cleanup.defer("first", () => { + order.push("first"); + }); + cleanup.defer("failing", () => { + order.push("failing"); + throw operationFailure; + }); + cleanup.defer("last", async () => { + await Promise.resolve(); + order.push("last"); + }); + + const outcome = await Effect.runPromise( + cleanup.disposeEffect().pipe(Effect.result) + ); + expect(order).toEqual(["last", "failing", "first"]); + expect(Result.isFailure(outcome)).toBe(true); + if (Result.isSuccess(outcome)) return; + expect(outcome.failure).toBeInstanceOf(AggregateError); + const failures = outcome.failure.errors as unknown[]; + expect(failures).toHaveLength(1); + expect(failures[0]).toBeInstanceOf(AsyncCleanupOperationError); + expect(failures[0]).toMatchObject({ + cause: operationFailure, + label: "failing", + message: "failing cleanup failed", + }); + + await cleanup.dispose(); + }); + + test("interrupts a timed-out cleanup and continues with older resources", async () => { + const cleanup = new AsyncCleanupStack(); + const order: string[] = []; + let cleanupSignal: AbortSignal | undefined; + + cleanup.defer("after deadline", () => { + order.push("after deadline"); + }); + cleanup.defer("pending", (signal) => { + cleanupSignal = signal; + order.push("pending"); + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + + const program = Effect.gen(function* () { + const fiber = yield* cleanup + .disposeEffect(25) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust(25); + yield* Effect.yieldNow; + return yield* Fiber.join(fiber); + }); + const outcome = await Effect.runPromise( + Effect.provide(program, TestClock.layer()) + ); + + expect(cleanupSignal?.aborted).toBe(true); + expect(order).toEqual(["pending", "after deadline"]); + expect(Result.isFailure(outcome)).toBe(true); + if (Result.isSuccess(outcome)) return; + expect(outcome.failure).toBeInstanceOf(AggregateError); + const failures = outcome.failure.errors as unknown[]; + expect(failures).toHaveLength(1); + expect(failures[0]).toBeInstanceOf(AsyncCleanupDeadlineError); + expect(failures[0]).toMatchObject({ + label: "pending", + message: "pending did not stop within 25 ms", + timeoutMs: 25, + }); + }); + + test("drains older resources before honoring external interruption", async () => { + const cleanup = new AsyncCleanupStack(); + const order: string[] = []; + + cleanup.defer("older", () => { + order.push("older"); + }); + cleanup.defer("pending", (signal) => { + order.push("pending"); + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + + const program = Effect.gen(function* () { + const cleanupFiber = yield* cleanup.disposeEffect(25).pipe(Effect.forkChild); + yield* Effect.yieldNow; + const interruptionFiber = yield* Fiber.interrupt(cleanupFiber).pipe( + Effect.forkChild + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(25); + yield* Fiber.join(interruptionFiber); + }); + await Effect.runPromise(Effect.provide(program, TestClock.layer())); + + expect(order).toEqual(["pending", "older"]); + }); +}); diff --git a/qualification/test/asyncCleanupStack.ts b/qualification/test/asyncCleanupStack.ts index 2e4e6f99f..930050f03 100644 --- a/qualification/test/asyncCleanupStack.ts +++ b/qualification/test/asyncCleanupStack.ts @@ -1,27 +1,82 @@ +import { Data, Effect } from "effect"; + /** One asynchronous cleanup registered by a qualification test. */ interface AsyncCleanupOperation { label: string; - operation: () => Promise | void; + operation: (signal: AbortSignal) => Promise | void; } -async function completeBeforeDeadline( +export class AsyncCleanupDeadlineError extends Data.TaggedError( + "AsyncCleanupDeadlineError" +)<{ + readonly label: string; + readonly message: string; + readonly timeoutMs: number; +}> {} + +export class AsyncCleanupOperationError extends Data.TaggedError( + "AsyncCleanupOperationError" +)<{ + readonly cause: unknown; + readonly label: string; + readonly message: string; +}> {} + +function completeBeforeDeadline( cleanup: AsyncCleanupOperation, timeoutMs: number -): Promise { - let timeout: ReturnType | undefined; - const deadline = new Promise((_resolve, reject) => { - timeout = setTimeout( - () => - reject(new Error(`${cleanup.label} did not stop within ${timeoutMs} ms`)), - timeoutMs - ); - }); +): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => + new AsyncCleanupOperationError({ + cause, + label: cleanup.label, + message: `${cleanup.label} cleanup failed`, + }), + try: async (signal) => { + await cleanup.operation(signal); + }, + }).pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new AsyncCleanupDeadlineError({ + label: cleanup.label, + message: `${cleanup.label} did not stop within ${timeoutMs} ms`, + timeoutMs, + }) + ), + }) + ); +} - try { - await Promise.race([Promise.resolve().then(() => cleanup.operation()), deadline]); - } finally { - clearTimeout(timeout); - } +function drainCleanupOperations( + operations: readonly AsyncCleanupOperation[], + timeoutMs: number +): Effect.Effect { + return Effect.uninterruptible( + Effect.gen(function* () { + const failures: (AsyncCleanupDeadlineError | AsyncCleanupOperationError)[] = + []; + + for (const cleanup of operations) { + yield* completeBeforeDeadline(cleanup, timeoutMs).pipe( + Effect.catch((error) => + Effect.sync(() => { + failures.push(error); + }) + ) + ); + } + + if (failures.length > 0) { + return yield* Effect.fail( + new AggregateError(failures, "Qualification resource cleanup failed") + ); + } + }) + ); } /** Failure-safe last-in-first-out cleanup for qualification resources. */ @@ -33,24 +88,26 @@ export class AsyncCleanupStack { * @param label Diagnostic resource label. * @param operation Cleanup callback. */ - defer(label: string, operation: () => Promise | void): void { + defer(label: string, operation: (signal: AbortSignal) => Promise | void): void { this.#operations.push({ label, operation }); } - /** Runs every registered cleanup even when an earlier cleanup fails. */ - async dispose(): Promise { - const failures: unknown[] = []; - - for (const cleanup of this.#operations.splice(0).toReversed()) { - try { - await completeBeforeDeadline(cleanup, 2000); - } catch (error) { - failures.push(error); - } - } + /** + * Creates an Effect-native disposal for scoped qualification orchestration. + * @param timeoutMs Per-resource cleanup deadline in milliseconds. + * @returns Uninterruptible LIFO drain with individually bounded operations. + */ + disposeEffect(timeoutMs = 2000): Effect.Effect { + return Effect.suspend(() => + drainCleanupOperations(this.#operations.splice(0).toReversed(), timeoutMs) + ); + } - if (failures.length > 0) { - throw new AggregateError(failures, "Qualification resource cleanup failed"); - } + /** + * Runs every registered cleanup even when an earlier cleanup fails. + * @returns Promise that settles after the complete LIFO drain. + */ + dispose(): Promise { + return Effect.runPromise(this.disposeEffect()); } } diff --git a/qualification/websocket/nativeWebSocketQualification.test.ts b/qualification/websocket/nativeWebSocketQualification.test.ts new file mode 100644 index 000000000..8ed727226 --- /dev/null +++ b/qualification/websocket/nativeWebSocketQualification.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, test } from "bun:test"; + +import { Cause, Effect, Exit, Fiber, Result } from "effect"; + +import { + closedLoopbackWebSocketUrl, + maximumNativeWebSocketMessageBytes, + NativeWebSocketCloseError, + NativeWebSocketClosedError, + NativeWebSocketMessageLimitError, + observeNativeWebSocket, + withNativeWebSocketDeadline, + type NativeWebSocketObservationError, +} from "./nativeWebSocketQualification.ts"; +import { rawWebSocketFixtureResource } from "./rawWebSocketFixture.ts"; +import { + createFragmentedUtf8Evidence, + fragmentedUtf8Message, + oversizedQualificationMessageBytes, + type RawWebSocketScenario, +} from "./rawWebSocketProtocol.ts"; + +interface RejectedScenarioEvidence { + readonly activeConnections: number; + readonly error: NativeWebSocketObservationError; + readonly failure: string | undefined; + readonly peerCloseCode: number | undefined; +} + +interface NonCooperatingCloseFixture { + readonly activeConnections: number; + readonly closeAttempts: number; + readonly factory: (url: string) => WebSocket; + readonly terminationAttempts: number; +} + +function createNonCooperatingCloseFixture(): NonCooperatingCloseFixture { + let activeConnections = 0; + let closeAttempts = 0; + let terminationAttempts = 0; + + class NonCooperatingWebSocket extends EventTarget { + readonly bufferedAmount = 0; + private state: number = WebSocket.OPEN; + + get readyState(): number { + return this.state; + } + + constructor() { + super(); + activeConnections += 1; + queueMicrotask(() => { + this.dispatchEvent(new Event("open")); + this.dispatchEvent( + new MessageEvent("message", { + data: "close timeout evidence", + }) + ); + }); + } + + close(): void { + closeAttempts += 1; + } + + terminate(): void { + terminationAttempts += 1; + if (this.state === WebSocket.CLOSED) return; + this.state = WebSocket.CLOSED; + activeConnections -= 1; + this.dispatchEvent( + new CloseEvent("close", { + code: 1006, + reason: "fixture terminated", + wasClean: false, + }) + ); + } + } + + return { + factory: (url) => { + void url; + return new NonCooperatingWebSocket() as unknown as WebSocket; + }, + get activeConnections() { + return activeConnections; + }, + get closeAttempts() { + return closeAttempts; + }, + get terminationAttempts() { + return terminationAttempts; + }, + }; +} + +async function runRejectedScenario( + scenario: RawWebSocketScenario +): Promise { + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* rawWebSocketFixtureResource(scenario); + const outcome = yield* observeNativeWebSocket(fixture.url).pipe( + Effect.result + ); + yield* withNativeWebSocketDeadline( + fixture.awaitClosed, + `await-${scenario}-close` + ); + if (Result.isSuccess(outcome)) { + return yield* Effect.die( + new Error(`${scenario} unexpectedly delivered a message`) + ); + } + return { + activeConnections: fixture.activeConnections, + error: outcome.failure, + failure: fixture.failure, + peerCloseCode: fixture.peerCloseCode, + }; + }) + ) + ); +} + +describe("Bun native WebSocket RFC 6455 qualification", () => { + test("reassembles continuation frames with a UTF-8 code point split across payloads", async () => { + const split = createFragmentedUtf8Evidence(); + expect(Buffer.concat(split.fragments).equals(split.completeBytes)).toBe(true); + expect(split.splitCodePointBytes).toEqual(Buffer.from("🦀", "utf8")); + const decoder = new TextDecoder("utf-8", { fatal: true }); + expect(() => decoder.decode(split.fragments[0])).toThrow(); + expect(() => decoder.decode(split.fragments[1])).toThrow(); + expect(() => decoder.decode(split.fragments[2])).toThrow(); + + const evidence = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* rawWebSocketFixtureResource("fragmented-utf8"); + const observation = yield* observeNativeWebSocket(fixture.url); + yield* withNativeWebSocketDeadline( + fixture.awaitClosed, + "await-fragmented-close" + ); + return { + activeConnections: fixture.activeConnections, + acceptedConnections: fixture.acceptedConnections, + failure: fixture.failure, + observation, + peerCloseCode: fixture.peerCloseCode, + runtime: { + revision: Bun.revision, + version: Bun.version, + }, + sentBytes: fixture.sentBytes, + writeAttempts: fixture.writeAttempts, + }; + }) + ) + ); + + expect(evidence).toMatchObject({ + acceptedConnections: 1, + activeConnections: 0, + failure: undefined, + observation: { + bufferedAmount: 0, + eventCounts: { + closes: 0, + errors: 0, + messages: 1, + opens: 1, + }, + message: fragmentedUtf8Message, + messageBytes: Buffer.byteLength(fragmentedUtf8Message, "utf8"), + }, + peerCloseCode: 1000, + }); + expect(evidence.runtime.revision).toMatch(/^[a-f\d]{40}$/u); + expect(evidence.runtime.version).toMatch(/^1\.4\.0/u); + expect(evidence.sentBytes).toBeGreaterThan(split.completeBytes.byteLength); + expect(evidence.sentBytes).toBeLessThan(128 * 1024); + expect(evidence.writeAttempts).toBeGreaterThanOrEqual(1); + }); + + test("rejects orphan and interleaved continuation sequences without a message", async () => { + for (const scenario of [ + "orphan-continuation", + "interleaved-text-fragments", + ] as const) { + const evidence = await runRejectedScenario(scenario); + expect(evidence.error).toBeInstanceOf(NativeWebSocketClosedError); + if (!(evidence.error instanceof NativeWebSocketClosedError)) continue; + expect(evidence.error.eventCounts).toMatchObject({ + closes: 1, + errors: 0, + messages: 0, + opens: 1, + }); + expect(evidence.error).toMatchObject({ + code: 1002, + reason: "Protocol error - unexpected opcode", + wasClean: false, + }); + expect(evidence.activeConnections).toBe(0); + expect(evidence.failure).toBeUndefined(); + } + }); + + test("rejects an invalid oversized 64-bit frame declaration before allocation", async () => { + const evidence = await runRejectedScenario("invalid-64-bit-length"); + expect(evidence.error).toBeInstanceOf(NativeWebSocketClosedError); + if (!(evidence.error instanceof NativeWebSocketClosedError)) return; + expect(evidence.error.eventCounts).toMatchObject({ + closes: 1, + errors: 0, + messages: 0, + opens: 1, + }); + expect(evidence.error).toMatchObject({ + code: 1009, + reason: "Message too big", + wasClean: false, + }); + expect(evidence.activeConnections).toBe(0); + expect(evidence.failure).toBeUndefined(); + }); + + test("bounds an otherwise valid assembled native text message and closes with 1009", async () => { + const evidence = await runRejectedScenario("oversized-text"); + expect(evidence.error).toBeInstanceOf(NativeWebSocketMessageLimitError); + if (!(evidence.error instanceof NativeWebSocketMessageLimitError)) return; + expect(evidence.error).toMatchObject({ + actualBytes: oversizedQualificationMessageBytes, + eventCounts: { + closes: 0, + errors: 0, + messages: 1, + opens: 1, + }, + maximumBytes: maximumNativeWebSocketMessageBytes, + }); + expect(evidence.peerCloseCode).toBe(1009); + expect(evidence.activeConnections).toBe(0); + expect(evidence.failure).toBeUndefined(); + }); + + test("normalizes a clean peer close before any message", async () => { + const evidence = await runRejectedScenario("close-before-message"); + expect(evidence.error).toBeInstanceOf(NativeWebSocketClosedError); + if (!(evidence.error instanceof NativeWebSocketClosedError)) return; + expect(evidence.error).toMatchObject({ + code: 1000, + eventCounts: { + closes: 1, + errors: 0, + messages: 0, + opens: 1, + }, + reason: "fixture complete", + wasClean: true, + }); + expect(evidence.peerCloseCode).toBe(1000); + expect(evidence.failure).toBeUndefined(); + }); + + test("keeps the first complete message as the outcome when close follows immediately", async () => { + const evidence = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const fixture = + yield* rawWebSocketFixtureResource("message-then-close"); + const observation = yield* observeNativeWebSocket(fixture.url); + yield* withNativeWebSocketDeadline( + fixture.awaitClosed, + "await-first-outcome-close" + ); + return { + activeConnections: fixture.activeConnections, + failure: fixture.failure, + observation, + peerCloseCode: fixture.peerCloseCode, + }; + }) + ) + ); + expect(evidence).toEqual({ + activeConnections: 0, + failure: undefined, + observation: { + bufferedAmount: 0, + eventCounts: { + closes: 0, + errors: 0, + messages: 1, + opens: 1, + }, + message: "first outcome wins", + messageBytes: 18, + }, + peerCloseCode: 1000, + }); + }); + + test("interrupts a silent native socket and releases the scoped TCP connection", async () => { + const evidence = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* rawWebSocketFixtureResource("silent"); + const fiber = yield* observeNativeWebSocket(fixture.url).pipe( + Effect.forkChild + ); + yield* withNativeWebSocketDeadline( + fixture.awaitPeerPong, + "await-silent-pong" + ); + yield* Fiber.interrupt(fiber); + yield* withNativeWebSocketDeadline( + fixture.awaitClosed, + "await-interrupted-close" + ); + return { + acceptedConnections: fixture.acceptedConnections, + activeConnections: fixture.activeConnections, + closedConnections: fixture.closedConnections, + failure: fixture.failure, + peerCloseCode: fixture.peerCloseCode, + }; + }) + ) + ); + expect(evidence).toEqual({ + acceptedConnections: 1, + activeConnections: 0, + closedConnections: 1, + failure: undefined, + peerCloseCode: 1000, + }); + }); + + test("fails qualification when scoped native close does not cooperate", async () => { + const fixture = createNonCooperatingCloseFixture(); + const exit = await Effect.runPromiseExit( + observeNativeWebSocket("ws://127.0.0.1/qualification", fixture.factory) + ); + + expect(Exit.isFailure(exit)).toBeTrue(); + if (Exit.isFailure(exit)) { + const failure = exit.cause.reasons.find(Cause.isFailReason); + expect(failure?.error).toBeInstanceOf(NativeWebSocketCloseError); + expect(failure?.error).toMatchObject({ + operation: "await-graceful-close", + readyState: WebSocket.OPEN, + }); + } + expect(fixture).toMatchObject({ + activeConnections: 0, + closeAttempts: 1, + terminationAttempts: 1, + }); + }); + + test("fails one native connection refusal and never reconnects", async () => { + const evidence = await Effect.runPromise( + Effect.gen(function* () { + const url = yield* closedLoopbackWebSocketUrl(); + let attempts = 0; + const outcome = yield* observeNativeWebSocket(url, (target) => { + attempts += 1; + return new WebSocket(target); + }).pipe(Effect.result); + yield* Effect.sleep("1200 millis"); + if (Result.isSuccess(outcome)) { + return yield* Effect.die( + new Error("Connection refusal unexpectedly delivered a message") + ); + } + return { attempts, error: outcome.failure }; + }) + ); + expect(evidence.attempts).toBe(1); + expect(evidence.error).toBeInstanceOf(NativeWebSocketClosedError); + if (!(evidence.error instanceof NativeWebSocketClosedError)) return; + expect(evidence.error.eventCounts).toMatchObject({ + closes: 1, + errors: 1, + messages: 0, + opens: 0, + }); + }); +}); diff --git a/qualification/websocket/nativeWebSocketQualification.ts b/qualification/websocket/nativeWebSocketQualification.ts new file mode 100644 index 000000000..5f88f4587 --- /dev/null +++ b/qualification/websocket/nativeWebSocketQualification.ts @@ -0,0 +1,333 @@ +import { Data, Deferred, Effect } from "effect"; + +export const maximumNativeWebSocketMessageBytes = 64 * 1024; + +const observationDeadline = "3 seconds"; +const closeDeadline = "1 second"; + +export interface NativeWebSocketEventCounts { + readonly closes: number; + readonly errors: number; + readonly messages: number; + readonly opens: number; +} + +export interface NativeWebSocketObservation { + readonly bufferedAmount: number; + readonly eventCounts: NativeWebSocketEventCounts; + readonly message: string; + readonly messageBytes: number; +} + +export type NativeWebSocketFactory = (url: string) => WebSocket; + +export class NativeWebSocketConstructionError extends Data.TaggedError( + "NativeWebSocketConstructionError" +)<{ + readonly cause?: unknown; +}> {} + +export class NativeWebSocketDeadlineError extends Data.TaggedError( + "NativeWebSocketDeadlineError" +)<{ + readonly operation: string; +}> {} + +export class NativeWebSocketCloseError extends Data.TaggedError( + "NativeWebSocketCloseError" +)<{ + readonly cause?: unknown; + readonly operation: "await-forced-close" | "await-graceful-close" | "terminate"; + readonly readyState: number; +}> {} + +export class NativeWebSocketClosedError extends Data.TaggedError( + "NativeWebSocketClosedError" +)<{ + readonly code: number; + readonly eventCounts: NativeWebSocketEventCounts; + readonly reason: string; + readonly wasClean: boolean; +}> {} + +export class NativeWebSocketMessageLimitError extends Data.TaggedError( + "NativeWebSocketMessageLimitError" +)<{ + readonly actualBytes: number; + readonly eventCounts: NativeWebSocketEventCounts; + readonly maximumBytes: number; +}> {} + +export class NativeWebSocketMessageTypeError extends Data.TaggedError( + "NativeWebSocketMessageTypeError" +)<{ + readonly eventCounts: NativeWebSocketEventCounts; +}> {} + +export type NativeWebSocketObservationError = + | NativeWebSocketCloseError + | NativeWebSocketClosedError + | NativeWebSocketConstructionError + | NativeWebSocketDeadlineError + | NativeWebSocketMessageLimitError + | NativeWebSocketMessageTypeError; + +interface MutableEventCounts { + closes: number; + errors: number; + messages: number; + opens: number; +} + +interface NativeWebSocketObserver { + readonly awaitObservation: Effect.Effect< + NativeWebSocketObservation, + | NativeWebSocketClosedError + | NativeWebSocketMessageLimitError + | NativeWebSocketMessageTypeError + >; + readonly closeState: { requested: boolean }; + readonly closed: Deferred.Deferred; + readonly removeListeners: () => void; + readonly socket: WebSocket; +} + +function snapshotEventCounts(counts: MutableEventCounts): NativeWebSocketEventCounts { + return Object.freeze({ ...counts }); +} + +function terminateNativeWebSocket(socket: WebSocket): void { + const candidate = socket as WebSocket & { terminate?: () => void }; + if (typeof candidate.terminate !== "function") { + throw new TypeError("Bun native WebSocket terminate is unavailable"); + } + candidate.terminate(); +} + +/** + * Applies the shared Effect deadline policy used by this qualification slice. + * @param effect Operation governed by the qualification deadline. + * @param operation Redacted operation label for a typed timeout. + * @returns The original result or a tagged deadline failure. + */ +export function withNativeWebSocketDeadline( + effect: Effect.Effect, + operation: string +): Effect.Effect { + return effect.pipe( + Effect.timeoutOrElse({ + duration: observationDeadline, + orElse: () => Effect.fail(new NativeWebSocketDeadlineError({ operation })), + }) + ); +} + +function closeObserver( + observer: NativeWebSocketObserver +): Effect.Effect { + const close = Effect.sync(() => { + if ( + !observer.closeState.requested && + (observer.socket.readyState === WebSocket.CONNECTING || + observer.socket.readyState === WebSocket.OPEN) + ) { + try { + observer.closeState.requested = true; + observer.socket.close(1000, "qualification scope closed"); + } catch { + // A simultaneous native transport close still completes the close event. + } + } + }); + const awaitClose = Effect.gen(function* () { + // Decide the graceful timeout before terminate can complete the same + // Deferred; otherwise the original await can win after fallback starts. + const closedGracefully = yield* Deferred.await(observer.closed).pipe( + Effect.as(true), + Effect.timeoutOrElse({ + duration: closeDeadline, + orElse: () => Effect.succeed(false), + }) + ); + if (closedGracefully) return; + const gracefulCloseError = new NativeWebSocketCloseError({ + operation: "await-graceful-close", + readyState: observer.socket.readyState, + }); + yield* Effect.try({ + catch: (cause) => + new NativeWebSocketCloseError({ + cause, + operation: "terminate", + readyState: observer.socket.readyState, + }), + try: () => terminateNativeWebSocket(observer.socket), + }); + const closedAfterTermination = yield* Deferred.await(observer.closed).pipe( + Effect.as(true), + Effect.timeoutOrElse({ + duration: closeDeadline, + orElse: () => Effect.succeed(false), + }) + ); + if (!closedAfterTermination) { + return yield* Effect.fail( + new NativeWebSocketCloseError({ + operation: "await-forced-close", + readyState: observer.socket.readyState, + }) + ); + } + return yield* Effect.fail(gracefulCloseError); + }); + return close.pipe( + Effect.andThen(awaitClose), + Effect.ensuring(Effect.sync(observer.removeListeners)) + ); +} + +function openNativeWebSocketObserver( + url: string, + factory: NativeWebSocketFactory +): Effect.Effect { + return Effect.gen(function* () { + const outcome = yield* Deferred.make< + NativeWebSocketObservation, + | NativeWebSocketClosedError + | NativeWebSocketMessageLimitError + | NativeWebSocketMessageTypeError + >(); + const closed = yield* Deferred.make(); + const observer = yield* Effect.try({ + catch: (cause) => new NativeWebSocketConstructionError({ cause }), + try: () => { + const counts: MutableEventCounts = { + closes: 0, + errors: 0, + messages: 0, + opens: 0, + }; + const closeState = { requested: false }; + const socket = factory(url); + const onClose = (event: CloseEvent): void => { + closeState.requested = true; + counts.closes += 1; + Deferred.doneUnsafe(closed, Effect.void); + const error = new NativeWebSocketClosedError({ + code: event.code, + eventCounts: snapshotEventCounts(counts), + reason: event.reason, + wasClean: event.wasClean, + }); + Deferred.doneUnsafe(outcome, Effect.fail(error)); + }; + const onError = (): void => { + counts.errors += 1; + }; + const onMessage = (event: MessageEvent): void => { + counts.messages += 1; + if (Deferred.isDoneUnsafe(outcome)) return; + if (typeof event.data !== "string") { + closeState.requested = true; + const error = new NativeWebSocketMessageTypeError({ + eventCounts: snapshotEventCounts(counts), + }); + Deferred.doneUnsafe(outcome, Effect.fail(error)); + socket.close(1003, "text messages required"); + return; + } + const messageBytes = Buffer.byteLength(event.data, "utf8"); + if (messageBytes > maximumNativeWebSocketMessageBytes) { + closeState.requested = true; + const error = new NativeWebSocketMessageLimitError({ + actualBytes: messageBytes, + eventCounts: snapshotEventCounts(counts), + maximumBytes: maximumNativeWebSocketMessageBytes, + }); + Deferred.doneUnsafe(outcome, Effect.fail(error)); + socket.close(1009, "message too large"); + return; + } + Deferred.doneUnsafe( + outcome, + Effect.succeed({ + bufferedAmount: socket.bufferedAmount, + eventCounts: snapshotEventCounts(counts), + message: event.data, + messageBytes, + }) + ); + }; + const onOpen = (): void => { + counts.opens += 1; + }; + socket.addEventListener("close", onClose); + socket.addEventListener("error", onError); + socket.addEventListener("message", onMessage); + socket.addEventListener("open", onOpen); + const removeListeners = (): void => { + socket.removeEventListener("close", onClose); + socket.removeEventListener("error", onError); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("open", onOpen); + }; + return Object.freeze({ + awaitObservation: Deferred.await(outcome), + closeState, + closed, + removeListeners, + socket, + }); + }, + }); + return observer; + }); +} + +/** + * Observes exactly one bounded text message through Bun's native global WebSocket. + * The first complete message wins over any subsequent close event, while cancellation + * and deadlines close the socket through the Effect scope. + * @param url Loopback WebSocket URL. + * @param factory Injectable constructor used only to count connection attempts in tests. + * @returns One native message observation or a tagged operational failure. + */ +export function observeNativeWebSocket( + url: string, + factory: NativeWebSocketFactory = (target) => new WebSocket(target) +): Effect.Effect { + return Effect.acquireUseRelease( + openNativeWebSocketObserver(url, factory), + (observer) => + withNativeWebSocketDeadline( + observer.awaitObservation, + "await-native-message" + ), + closeObserver + ); +} + +/** + * Reserves and releases one loopback TCP port before a native refusal test. + * @returns A WebSocket URL with no listener remaining on its port. + */ +export function closedLoopbackWebSocketUrl(): Effect.Effect< + string, + NativeWebSocketConstructionError +> { + return Effect.acquireUseRelease( + Effect.try({ + catch: (cause) => new NativeWebSocketConstructionError({ cause }), + try: () => + Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data() {}, + }, + }), + }), + (listener) => Effect.succeed(`ws://127.0.0.1:${listener.port}/qualification`), + (listener) => Effect.sync(() => listener.stop(true)) + ); +} diff --git a/qualification/websocket/rawWebSocketFixture.ts b/qualification/websocket/rawWebSocketFixture.ts new file mode 100644 index 000000000..550485a64 --- /dev/null +++ b/qualification/websocket/rawWebSocketFixture.ts @@ -0,0 +1,308 @@ +import { Data, Deferred, Effect, Scope } from "effect"; + +import { + createScenarioBytes, + decodeClientFrames, + encodeServerFrame, + maximumRawWebSocketHandshakeBytes, + maximumRawWebSocketPeerBytes, + parseUpgradeRequest, + type RawWebSocketScenario, +} from "./rawWebSocketProtocol.ts"; + +export class RawWebSocketFixtureError extends Data.TaggedError( + "RawWebSocketFixtureError" +)<{ + readonly cause?: unknown; + readonly operation: string; +}> {} + +interface FixtureSharedState { + acceptedConnections: number; + activeConnections: number; + closedConnections: number; + drainCount: number; + failure: string | undefined; + peerCloseCode: number | undefined; + sentBytes: number; + writeAttempts: number; +} + +interface FixtureSocketState { + readonly shared: FixtureSharedState; + closed: boolean; + handshakeBytes: Buffer; + inboundFrames: Buffer; + outboundBytes: Buffer; + outboundOffset: number; + peerPongDeferred: Deferred.Deferred; + serverCloseSent: boolean; + upgradeResponseBytes: number; + upgradedDeferred: Deferred.Deferred; + upgradedNotified: boolean; + upgraded: boolean; +} + +export interface RawWebSocketFixture { + readonly acceptedConnections: number; + readonly activeConnections: number; + readonly awaitAccepted: Effect.Effect; + readonly awaitClosed: Effect.Effect; + readonly awaitPeerPong: Effect.Effect; + readonly awaitUpgraded: Effect.Effect; + readonly closedConnections: number; + readonly drainCount: number; + readonly failure: string | undefined; + readonly peerCloseCode: number | undefined; + readonly sentBytes: number; + readonly url: string; + readonly writeAttempts: number; +} + +function createSocketState( + shared: FixtureSharedState, + peerPongDeferred: Deferred.Deferred, + upgradedDeferred: Deferred.Deferred +): FixtureSocketState { + return { + closed: false, + handshakeBytes: Buffer.alloc(0), + inboundFrames: Buffer.alloc(0), + outboundBytes: Buffer.alloc(0), + outboundOffset: 0, + peerPongDeferred, + serverCloseSent: false, + shared, + upgradeResponseBytes: 0, + upgradedDeferred, + upgradedNotified: false, + upgraded: false, + }; +} + +function scenarioSendsClose(scenario: RawWebSocketScenario): boolean { + return scenario === "close-before-message" || scenario === "message-then-close"; +} + +function recordFixtureFailure( + socket: Bun.Socket, + cause: unknown +): void { + socket.data.shared.failure = + cause instanceof Error ? cause.message : "Unknown WebSocket fixture failure"; + socket.terminate(); +} + +function writePending(socket: Bun.Socket): void { + const state = socket.data; + if (state.closed || socket.readyState <= 0) return; + while (state.outboundOffset < state.outboundBytes.byteLength) { + state.shared.writeAttempts += 1; + const written = socket.write( + state.outboundBytes, + state.outboundOffset, + state.outboundBytes.byteLength - state.outboundOffset + ); + if (written < 0) return; + if (written === 0) return; + state.outboundOffset += written; + state.shared.sentBytes += written; + if ( + !state.upgradedNotified && + state.outboundOffset >= state.upgradeResponseBytes + ) { + state.upgradedNotified = true; + Deferred.doneUnsafe(state.upgradedDeferred, Effect.void); + } + } + socket.flush(); +} + +function decodePeerCloseCode(payload: Buffer): number | undefined { + if (payload.byteLength === 0) return undefined; + if (payload.byteLength === 1) { + throw new Error("Raw WebSocket fixture received a one-byte close payload"); + } + return payload.readUInt16BE(0); +} + +function handlePeerFrames(socket: Bun.Socket, bytes: Buffer): void { + const state = socket.data; + if ( + state.inboundFrames.byteLength + bytes.byteLength > + maximumRawWebSocketPeerBytes + ) { + throw new Error("Raw WebSocket fixture peer stream exceeded its byte budget"); + } + state.inboundFrames = Buffer.concat([state.inboundFrames, bytes]); + const decoded = decodeClientFrames(state.inboundFrames); + state.inboundFrames = decoded.remaining; + for (const frame of decoded.frames) { + if (frame.opcode === 0x08) { + state.shared.peerCloseCode ??= decodePeerCloseCode(frame.payload); + if (state.serverCloseSent) { + socket.end(); + } else { + state.serverCloseSent = true; + socket.end(encodeServerFrame(0x08, frame.payload)); + } + return; + } + if (frame.opcode === 0x09 && frame.fin) { + socket.write(encodeServerFrame(10, frame.payload)); + } + if (frame.opcode === 10 && frame.fin) { + Deferred.doneUnsafe(state.peerPongDeferred, Effect.void); + } + } +} + +/** + * Starts one raw loopback TCP server that performs a bounded RFC 6455 handshake. + * Listener and accepted-socket ownership end with the enclosing Effect scope. + * @param scenario Raw frame sequence sent after a successful native upgrade. + * @returns Scoped fixture state and coordination effects. + */ +export function rawWebSocketFixtureResource( + scenario: RawWebSocketScenario +): Effect.Effect { + return Effect.gen(function* () { + const accepted = yield* Deferred.make(); + const upgraded = yield* Deferred.make(); + const peerPong = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const shared: FixtureSharedState = { + acceptedConnections: 0, + activeConnections: 0, + closedConnections: 0, + drainCount: 0, + failure: undefined, + peerCloseCode: undefined, + sentBytes: 0, + writeAttempts: 0, + }; + const listener = yield* Effect.acquireRelease( + Effect.try({ + catch: (cause) => + new RawWebSocketFixtureError({ + cause, + operation: "start-listener", + }), + try: () => + Bun.listen({ + data: createSocketState(shared, peerPong, upgraded), + hostname: "127.0.0.1", + port: 0, + socket: { + binaryType: "buffer", + close(socket) { + const state = socket.data; + if (state.closed) return; + state.closed = true; + state.shared.activeConnections -= 1; + state.shared.closedConnections += 1; + Deferred.doneUnsafe(closed, Effect.void); + }, + data(socket, bytes) { + try { + const state = socket.data; + if (state.upgraded) { + handlePeerFrames(socket, bytes); + return; + } + if ( + state.handshakeBytes.byteLength + + bytes.byteLength > + maximumRawWebSocketHandshakeBytes + ) { + throw new Error( + "Raw WebSocket fixture handshake exceeded its byte budget" + ); + } + state.handshakeBytes = Buffer.concat([ + state.handshakeBytes, + bytes, + ]); + const upgrade = parseUpgradeRequest( + state.handshakeBytes + ); + if (upgrade === undefined) return; + state.upgraded = true; + state.serverCloseSent = scenarioSendsClose(scenario); + state.outboundBytes = Buffer.concat([ + upgrade.response, + createScenarioBytes(scenario), + ]); + state.upgradeResponseBytes = + upgrade.response.byteLength; + state.handshakeBytes = Buffer.alloc(0); + writePending(socket); + if (upgrade.remaining.byteLength > 0) { + handlePeerFrames(socket, upgrade.remaining); + } + } catch (error) { + recordFixtureFailure(socket, error); + } + }, + drain(socket) { + socket.data.shared.drainCount += 1; + try { + writePending(socket); + } catch (error) { + recordFixtureFailure(socket, error); + } + }, + error(socket, error) { + recordFixtureFailure(socket, error); + }, + open(socket) { + socket.data = createSocketState( + shared, + peerPong, + upgraded + ); + socket.data.shared.acceptedConnections += 1; + socket.data.shared.activeConnections += 1; + Deferred.doneUnsafe(accepted, Effect.void); + }, + }, + }), + }), + (ownedListener) => + Effect.sync(() => { + ownedListener.stop(true); + }) + ); + return Object.freeze({ + awaitAccepted: Deferred.await(accepted), + awaitClosed: Deferred.await(closed), + awaitPeerPong: Deferred.await(peerPong), + awaitUpgraded: Deferred.await(upgraded), + url: `ws://127.0.0.1:${listener.port}/qualification`, + get acceptedConnections() { + return shared.acceptedConnections; + }, + get activeConnections() { + return shared.activeConnections; + }, + get closedConnections() { + return shared.closedConnections; + }, + get drainCount() { + return shared.drainCount; + }, + get failure() { + return shared.failure; + }, + get peerCloseCode() { + return shared.peerCloseCode; + }, + get sentBytes() { + return shared.sentBytes; + }, + get writeAttempts() { + return shared.writeAttempts; + }, + }); + }); +} diff --git a/qualification/websocket/rawWebSocketProtocol.ts b/qualification/websocket/rawWebSocketProtocol.ts new file mode 100644 index 000000000..8a412cb81 --- /dev/null +++ b/qualification/websocket/rawWebSocketProtocol.ts @@ -0,0 +1,343 @@ +import { createHash } from "node:crypto"; + +const webSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const headerTerminator = Buffer.from("\r\n\r\n", "ascii"); +const maximumFixtureOutboundBytes = 128 * 1024; + +export const maximumRawWebSocketHandshakeBytes = 16 * 1024; +export const maximumRawWebSocketPeerBytes = 128 * 1024; + +export const fragmentedUtf8Message = "Mira says: blåbær 🦀 ferdig"; +export const oversizedQualificationMessageBytes = 64 * 1024 + 1; + +export type RawWebSocketScenario = + | "close-before-message" + | "fragmented-utf8" + | "interleaved-text-fragments" + | "invalid-64-bit-length" + | "message-then-close" + | "orphan-continuation" + | "oversized-text" + | "silent"; + +export interface DecodedClientFrame { + readonly fin: boolean; + readonly opcode: number; + readonly payload: Buffer; +} + +export interface DecodedClientFrames { + readonly frames: readonly DecodedClientFrame[]; + readonly remaining: Buffer; +} + +export interface FragmentedUtf8Evidence { + readonly completeBytes: Buffer; + readonly fragments: readonly Buffer[]; + readonly frames: readonly Buffer[]; + readonly splitCodePointBytes: Buffer; +} + +export interface ParsedUpgradeRequest { + readonly response: Buffer; + readonly remaining: Buffer; +} + +function asBoundedPayload(payload: string | Uint8Array): Buffer { + const bytes = + typeof payload === "string" + ? Buffer.from(payload, "utf8") + : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength); + if (bytes.byteLength > maximumFixtureOutboundBytes) { + throw new RangeError("WebSocket fixture payload exceeded its byte budget"); + } + return bytes; +} + +/** + * Encodes one unmasked server-to-client RFC 6455 frame. + * @param opcode RFC 6455 frame opcode. + * @param payload Frame payload. + * @param fin Whether this frame completes its message. + * @returns Encoded frame bytes. + */ +export function encodeServerFrame( + opcode: number, + payload: string | Uint8Array, + fin = true +): Buffer { + if (!Number.isInteger(opcode) || opcode < 0 || opcode > 15) { + throw new RangeError("WebSocket fixture opcode is invalid"); + } + const bytes = asBoundedPayload(payload); + let extendedLengthBytes = 0; + if (bytes.byteLength > 65_535) { + extendedLengthBytes = 8; + } else if (bytes.byteLength > 125) { + extendedLengthBytes = 2; + } + const frame = Buffer.allocUnsafe(2 + extendedLengthBytes + bytes.byteLength); + frame[0] = (fin ? 0x80 : 0) | opcode; + if (extendedLengthBytes === 0) { + frame[1] = bytes.byteLength; + } else if (extendedLengthBytes === 2) { + frame[1] = 126; + frame.writeUInt16BE(bytes.byteLength, 2); + } else { + frame[1] = 127; + frame.writeBigUInt64BE(BigInt(bytes.byteLength), 2); + } + bytes.copy(frame, 2 + extendedLengthBytes); + return frame; +} + +/** + * Encodes a server close frame. + * @param code RFC 6455 close code. + * @param reason UTF-8 close reason. + * @returns Encoded close-frame bytes. + */ +export function encodeServerCloseFrame(code: number, reason = ""): Buffer { + if (!Number.isInteger(code) || code < 1000 || code > 4999) { + throw new RangeError("WebSocket fixture close code is invalid"); + } + const reasonBytes = Buffer.from(reason, "utf8"); + if (reasonBytes.byteLength > 123) { + throw new RangeError("WebSocket fixture close reason exceeded 123 bytes"); + } + const payload = Buffer.allocUnsafe(2 + reasonBytes.byteLength); + payload.writeUInt16BE(code, 0); + reasonBytes.copy(payload, 2); + return encodeServerFrame(0x08, payload); +} + +function parseHeaderLines(headerBytes: Buffer): Map { + const lines = headerBytes.toString("latin1").split("\r\n"); + if (lines.shift() !== "GET /qualification HTTP/1.1") { + throw new Error("WebSocket fixture received an unexpected request target"); + } + const headers = new Map(); + for (const line of lines) { + const separator = line.indexOf(":"); + if (separator <= 0) { + throw new Error("WebSocket fixture received a malformed header"); + } + const name = line.slice(0, separator).trim().toLowerCase(); + const value = line.slice(separator + 1).trim(); + if (headers.has(name)) { + throw new Error(`WebSocket fixture received duplicate ${name} header`); + } + headers.set(name, value); + } + return headers; +} + +function hasHeaderToken(value: string | undefined, expected: string): boolean { + return ( + value?.split(",").some((token) => token.trim().toLowerCase() === expected) ?? + false + ); +} + +function createUpgradeResponse(key: string): Buffer { + const accept = createHash("sha1") + .update(`${key}${webSocketGuid}`, "ascii") + .digest("base64"); + return Buffer.from( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n"), + "ascii" + ); +} + +/** + * Parses one bounded native WebSocket upgrade request. + * @param bytes Incremental raw HTTP request bytes. + * @returns A response and any bytes received after the HTTP headers, or undefined while pending. + */ +export function parseUpgradeRequest(bytes: Buffer): ParsedUpgradeRequest | undefined { + if (bytes.byteLength > maximumRawWebSocketHandshakeBytes) { + throw new Error("WebSocket fixture handshake exceeded its byte budget"); + } + const headerEnd = bytes.indexOf(headerTerminator); + if (headerEnd === -1) return undefined; + const headers = parseHeaderLines(bytes.subarray(0, headerEnd)); + if (!hasHeaderToken(headers.get("connection"), "upgrade")) { + throw new Error("WebSocket fixture requires Connection: Upgrade"); + } + if (headers.get("upgrade")?.toLowerCase() !== "websocket") { + throw new Error("WebSocket fixture requires Upgrade: websocket"); + } + if (headers.get("sec-websocket-version") !== "13") { + throw new Error("WebSocket fixture requires RFC 6455 version 13"); + } + const key = headers.get("sec-websocket-key"); + if (key === undefined || !/^[A-Za-z\d+/]{22}==$/u.test(key)) { + throw new Error("WebSocket fixture received an invalid WebSocket key"); + } + if (Buffer.from(key, "base64").byteLength !== 16) { + throw new Error("WebSocket fixture received a non-128-bit WebSocket key"); + } + return { + remaining: bytes.subarray(headerEnd + headerTerminator.byteLength), + response: createUpgradeResponse(key), + }; +} + +function readPayloadLength( + bytes: Buffer, + offset: number, + shortLength: number +): { readonly headerBytes: number; readonly payloadBytes: number } | undefined { + if (shortLength <= 125) { + return { headerBytes: offset, payloadBytes: shortLength }; + } + if (shortLength === 126) { + if (bytes.byteLength < offset + 2) return undefined; + return { headerBytes: offset + 2, payloadBytes: bytes.readUInt16BE(offset) }; + } + if (bytes.byteLength < offset + 8) return undefined; + const payloadBytes = bytes.readBigUInt64BE(offset); + if (payloadBytes > BigInt(maximumRawWebSocketPeerBytes)) { + throw new Error("WebSocket fixture peer frame exceeded its byte budget"); + } + return { headerBytes: offset + 8, payloadBytes: Number(payloadBytes) }; +} + +/** + * Incrementally decodes bounded, masked client-to-server frames. + * @param bytes Raw bytes retained for one fixture connection. + * @returns Complete frames and the incomplete suffix. + */ +export function decodeClientFrames(bytes: Buffer): DecodedClientFrames { + const frames: DecodedClientFrame[] = []; + let cursor = 0; + while (bytes.byteLength - cursor >= 2) { + const first = bytes[cursor] ?? 0; + const second = bytes[cursor + 1] ?? 0; + if ((first & 0x70) !== 0) { + throw new Error("WebSocket fixture peer frame used reserved bits"); + } + if ((second & 0x80) === 0) { + throw new Error("WebSocket fixture peer frame was not masked"); + } + const length = readPayloadLength(bytes, cursor + 2, second & 127); + if (length === undefined) break; + if (length.payloadBytes > maximumRawWebSocketPeerBytes) { + throw new Error("WebSocket fixture peer frame exceeded its byte budget"); + } + const maskOffset = length.headerBytes; + const payloadOffset = maskOffset + 4; + const frameEnd = payloadOffset + length.payloadBytes; + if (bytes.byteLength < frameEnd) break; + const payload = Buffer.allocUnsafe(length.payloadBytes); + for (let index = 0; index < length.payloadBytes; index += 1) { + payload[index] = + (bytes[payloadOffset + index] ?? 0) ^ + (bytes[maskOffset + (index % 4)] ?? 0); + } + frames.push({ + fin: (first & 0x80) !== 0, + opcode: first & 15, + payload, + }); + cursor = frameEnd; + } + return { frames, remaining: bytes.subarray(cursor) }; +} + +/** + * Creates fragments that split the crab emoji inside its four-byte UTF-8 sequence. + * @returns The raw fragments, encoded frames, and split code-point evidence. + */ +export function createFragmentedUtf8Evidence(): FragmentedUtf8Evidence { + const completeBytes = Buffer.from(fragmentedUtf8Message, "utf8"); + const codePointBytes = Buffer.from("🦀", "utf8"); + const codePointOffset = completeBytes.indexOf(codePointBytes); + if (codePointOffset === -1) { + throw new Error("WebSocket qualification message lost its split code point"); + } + const fragments = [ + completeBytes.subarray(0, codePointOffset + 2), + completeBytes.subarray(codePointOffset + 2, codePointOffset + 3), + completeBytes.subarray(codePointOffset + 3), + ]; + return { + completeBytes, + fragments, + frames: [ + encodeServerFrame(0x01, fragments[0] ?? Buffer.alloc(0), false), + encodeServerFrame(0x00, fragments[1] ?? Buffer.alloc(0), false), + encodeServerFrame(0x00, fragments[2] ?? Buffer.alloc(0)), + ], + splitCodePointBytes: completeBytes.subarray( + codePointOffset, + codePointOffset + codePointBytes.byteLength + ), + }; +} + +/** + * Creates the raw post-upgrade bytes for one native WebSocket scenario. + * @param scenario Scenario selected by a focused qualification test. + * @returns Bounded RFC 6455 bytes sent by the raw TCP fixture. + */ +export function createScenarioBytes(scenario: RawWebSocketScenario): Buffer { + let frames: readonly Buffer[]; + switch (scenario) { + case "close-before-message": { + frames = [encodeServerCloseFrame(1000, "fixture complete")]; + break; + } + case "fragmented-utf8": { + frames = createFragmentedUtf8Evidence().frames; + break; + } + case "interleaved-text-fragments": { + frames = [ + encodeServerFrame(0x01, "first", false), + encodeServerFrame(0x01, "illegal second message"), + ]; + break; + } + case "invalid-64-bit-length": { + frames = [Buffer.from([0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0])]; + break; + } + case "message-then-close": { + frames = [ + encodeServerFrame(0x01, "first outcome wins"), + encodeServerCloseFrame(1000, "fixture complete"), + ]; + break; + } + case "orphan-continuation": { + frames = [encodeServerFrame(0x00, "orphan")]; + break; + } + case "oversized-text": { + frames = [ + encodeServerFrame( + 0x01, + Buffer.alloc(oversizedQualificationMessageBytes, 0x61) + ), + ]; + break; + } + case "silent": { + frames = [encodeServerFrame(0x09, "ready")]; + break; + } + } + const bytes = Buffer.concat(frames); + if (bytes.byteLength > maximumFixtureOutboundBytes) { + throw new Error("WebSocket fixture scenario exceeded its byte budget"); + } + return bytes; +} diff --git a/scripts/frontendBuild.ts b/scripts/frontendBuild.ts index 96837f074..bfc3a625f 100644 --- a/scripts/frontendBuild.ts +++ b/scripts/frontendBuild.ts @@ -70,8 +70,8 @@ export async function buildFrontend({ outdir: resolvedOutdir, plugins: [ ...(isProduction ? [productionDevtoolsPlugin] : []), - tailwindPlugin, reactCompilerPlugin, + tailwindPlugin, ], publicPath: "/", sourcemap: isProduction ? "none" : "linked", diff --git a/scripts/qualification/legacyBackendRouteProbe.ts b/scripts/qualification/legacyBackendRouteProbe.ts new file mode 100644 index 000000000..d2ac01083 --- /dev/null +++ b/scripts/qualification/legacyBackendRouteProbe.ts @@ -0,0 +1,36 @@ +import { routes } from "../../backend/src/routes/registry.ts"; + +const httpMethods = new Set(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]); + +interface RouteIdentity { + readonly id: string; + readonly method: "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; + readonly path: string; +} + +const identities: RouteIdentity[] = []; +for (const [routePath, entry] of Object.entries( + routes as Readonly> +)) { + if (routePath === "/api/*") continue; + if (!routePath.startsWith("/api/")) { + throw new Error(`Legacy route registry contains an unexpected path ${routePath}`); + } + if (typeof entry !== "object" || entry === null || entry instanceof Response) { + throw new Error(`Legacy API route ${routePath} has no explicit method table`); + } + const methods = Object.keys(entry); + if (methods.length === 0 || methods.some((method) => !httpMethods.has(method))) { + throw new Error(`Legacy API route ${routePath} has an unrecognized method table`); + } + for (const method of methods) { + const typedMethod = method as RouteIdentity["method"]; + identities.push({ + id: `${typedMethod} ${routePath}`, + method: typedMethod, + path: routePath, + }); + } +} + +process.stdout.write(`${JSON.stringify(identities)}\n`); diff --git a/src/app/server.ts b/src/app/server.ts index 6e8af8ab9..6773e6ff9 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -33,18 +33,6 @@ const serverGracefulShutdownTimeoutSchema = v.pipe( ) ); -function createShutdownDeadline(timeoutMs: number): { - cancel(): void; - readonly outcome: Promise<"timed-out">; -} { - const deadline = Promise.withResolvers<"timed-out">(); - const timeout = setTimeout(() => deadline.resolve("timed-out"), timeoutMs); - return { - cancel: () => clearTimeout(timeout), - outcome: deadline.promise, - }; -} - async function primaryErrorAfterCleanup( primaryError: unknown, cleanup: () => Promise @@ -53,7 +41,7 @@ async function primaryErrorAfterCleanup( await cleanup(); } catch { // The process boundary cannot recover from a cleanup double-fault. - // Preserve the initiating failure, which identifies the startup defect. + // Preserve the initiating failure, which identifies the original defect. } return primaryError; } @@ -152,43 +140,26 @@ export async function createServer(options: ServerOptions): Promise server.stop(true)); } - const forceStopRequest = Promise.withResolvers<"forced">(); - let forceStopRequested = false; + const forceStopController = new AbortController(); let stopPromise: Promise | undefined; return Object.freeze({ port: serverPort, stop(force = false) { - if (force && !forceStopRequested) { - forceStopRequested = true; - forceStopRequest.resolve("forced"); - } + if (force) forceStopController.abort(); stopPromise ??= (async () => { try { - if (forceStopRequested) { - await server.stop(true); - return; - } - - const gracefulStop = server.stop(false); - const deadline = createShutdownDeadline( - gracefulShutdownTimeoutMs + await options.applicationRuntime.shutdownListener({ + forceSignal: forceStopController.signal, + gracefulShutdownTimeoutMs, + stop: (forceListener) => server.stop(forceListener), + }); + } catch (error) { + throw await primaryErrorAfterCleanup(error, () => + options.applicationRuntime.dispose() ); - try { - const outcome = await Promise.race([ - gracefulStop.then(() => "drained" as const), - forceStopRequest.promise, - deadline.outcome, - ]); - if (outcome !== "drained") { - await server.stop(true); - } - } finally { - deadline.cancel(); - } - } finally { - await options.applicationRuntime.dispose(); } + await options.applicationRuntime.dispose(); })(); return stopPromise; }, diff --git a/src/server/platform/runtime/applicationRuntime.test.ts b/src/server/platform/runtime/applicationRuntime.test.ts index 942bfe8cb..e000b6403 100644 --- a/src/server/platform/runtime/applicationRuntime.test.ts +++ b/src/server/platform/runtime/applicationRuntime.test.ts @@ -4,7 +4,11 @@ import { addMilliseconds, secondsToMilliseconds } from "date-fns"; import { maxTime } from "date-fns/constants"; import { Effect, Layer, Stream } from "effect"; -import { rejectOnAbort, withTestTimeout } from "../../test/support/promise.ts"; +import { + captureFailure, + rejectOnAbort, + withTestTimeout, +} from "../../test/support/promise.ts"; import type { RealtimeEventDelivery } from "../realtime/eventPump.ts"; import { isRealtimeEventStreamError, @@ -12,7 +16,11 @@ import { RealtimeEventStoreStreamError, } from "../realtime/eventPumpService.ts"; import type { RenewableStreamLease } from "../realtime/renewableStreamLease.ts"; -import { createApplicationRuntime } from "./applicationRuntime.ts"; +import { + ApplicationListenerStopError, + ApplicationListenerStopTimeoutError, + createApplicationRuntime, +} from "./applicationRuntime.ts"; const delivery: RealtimeEventDelivery = { event: { @@ -32,7 +40,160 @@ const stableLease: RenewableStreamLease = { renew: () => Promise.resolve(stableLease), }; +function createInertApplicationRuntime() { + const service = RealtimeEventPumpService.of({ + metricsSnapshot: Effect.die("Realtime metrics are not used"), + stream: () => Stream.empty, + wake: Effect.void, + }); + return createApplicationRuntime({ + realtimeEventPumpLayer: Layer.succeed(RealtimeEventPumpService, service), + }); +} + describe("application Effect runtime", () => { + test("coordinates graceful listener completion on the shared runtime", async () => { + const runtime = createInertApplicationRuntime(); + const stopCalls: boolean[] = []; + + try { + await runtime.initialize(); + await runtime.shutdownListener({ + forceSignal: new AbortController().signal, + gracefulShutdownTimeoutMs: 100, + stop(force) { + stopCalls.push(force); + return Promise.resolve(); + }, + }); + + expect(stopCalls).toEqual([false]); + } finally { + await runtime.dispose(); + } + }); + + test("escalates an active graceful listener stop when force is requested", async () => { + const runtime = createInertApplicationRuntime(); + const controller = new AbortController(); + const gracefulStarted = Promise.withResolvers(); + const gracefulStop = Promise.withResolvers(); + const forceCompleted = Promise.withResolvers(); + const stopCalls: boolean[] = []; + let shutdownSettled = false; + + try { + const shutdown = runtime.shutdownListener({ + forceSignal: controller.signal, + gracefulShutdownTimeoutMs: 100, + stop(force) { + stopCalls.push(force); + if (force) { + forceCompleted.resolve(); + return Promise.resolve(); + } else { + gracefulStarted.resolve(); + return gracefulStop.promise; + } + }, + }); + void shutdown.then(() => (shutdownSettled = true)); + await gracefulStarted.promise; + controller.abort(); + await forceCompleted.promise; + await Promise.resolve(); + + expect(shutdownSettled).toBe(false); + gracefulStop.resolve(); + await shutdown; + + expect(stopCalls).toEqual([false, true]); + } finally { + await runtime.dispose(); + } + }); + + test("preserves a graceful listener failure after best-effort force", async () => { + const runtime = createInertApplicationRuntime(); + const gracefulFailure = new Error("simulated graceful listener failure"); + const forceFailure = new Error("simulated force listener failure"); + const stopCalls: boolean[] = []; + + try { + const failure = await captureFailure(() => + runtime.shutdownListener({ + forceSignal: new AbortController().signal, + gracefulShutdownTimeoutMs: 100, + stop(force) { + stopCalls.push(force); + return Promise.reject(force ? forceFailure : gracefulFailure); + }, + }) + ); + + expect(failure).toBeInstanceOf(ApplicationListenerStopError); + expect(failure).toMatchObject({ + cause: gracefulFailure, + operation: "graceful", + }); + expect(stopCalls).toEqual([false, true]); + } finally { + await runtime.dispose(); + } + }); + + test("tags a forced listener stop that exceeds its deadline", async () => { + const runtime = createInertApplicationRuntime(); + const controller = new AbortController(); + controller.abort(); + + try { + const failure = await captureFailure(() => + runtime.shutdownListener({ + forceSignal: controller.signal, + gracefulShutdownTimeoutMs: 1, + stop: () => new Promise(() => {}), + }) + ); + + expect(failure).toBeInstanceOf(ApplicationListenerStopTimeoutError); + expect(failure).toMatchObject({ operation: "force", timeoutMs: 1 }); + } finally { + await runtime.dispose(); + } + }); + + test("tags missing graceful settlement after a successful force stop", async () => { + const runtime = createInertApplicationRuntime(); + const controller = new AbortController(); + const gracefulStarted = Promise.withResolvers(); + const stopCalls: boolean[] = []; + + try { + const shutdown = runtime.shutdownListener({ + forceSignal: controller.signal, + gracefulShutdownTimeoutMs: 1, + stop(force) { + stopCalls.push(force); + if (!force) gracefulStarted.resolve(); + return force ? Promise.resolve() : new Promise(() => {}); + }, + }); + await gracefulStarted.promise; + controller.abort(); + const failure = await captureFailure(() => shutdown); + + expect(failure).toBeInstanceOf(ApplicationListenerStopTimeoutError); + expect(failure).toMatchObject({ + operation: "graceful-settlement", + timeoutMs: 1, + }); + expect(stopCalls).toEqual([false, true]); + } finally { + await runtime.dispose(); + } + }); + test("builds one shared layer and disposes its scope exactly once", async () => { let acquisitions = 0; let releases = 0; diff --git a/src/server/platform/runtime/applicationRuntime.ts b/src/server/platform/runtime/applicationRuntime.ts index b89daca43..b45d3ff0f 100644 --- a/src/server/platform/runtime/applicationRuntime.ts +++ b/src/server/platform/runtime/applicationRuntime.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Data, Effect, Exit, Fiber, Layer, ManagedRuntime, Stream } from "effect"; import { type AuthenticationWorkLayerOptions, @@ -30,12 +30,45 @@ export interface ApplicationRuntimeServices { readonly realtimeEvents: RealtimeEventRuntimeService; } +export type ApplicationListenerStopOperation = "force" | "graceful"; +export type ApplicationListenerStopWait = "force" | "graceful-settlement"; + +/** Expected operational rejection from Bun listener shutdown. */ +export class ApplicationListenerStopError extends Data.TaggedError( + "ApplicationListenerStopError" +)<{ + readonly cause: unknown; + readonly operation: ApplicationListenerStopOperation; +}> {} + +/** Expected failure when listener shutdown does not settle inside its budget. */ +export class ApplicationListenerStopTimeoutError extends Data.TaggedError( + "ApplicationListenerStopTimeoutError" +)<{ + readonly operation: ApplicationListenerStopWait; + readonly timeoutMs: number; +}> {} + +export type ApplicationListenerShutdownError = + | ApplicationListenerStopError + | ApplicationListenerStopTimeoutError; + +/** One process-listener shutdown coordinated by the process Effect runtime. */ +export interface ApplicationListenerShutdownOptions { + /** Synchronous escalation bridge used by repeated `ApplicationServer.stop(true)`. */ + readonly forceSignal: AbortSignal; + readonly gracefulShutdownTimeoutMs: number; + readonly stop: (force: boolean) => Promise; +} + /** Effect-backed lifecycle and request services owned by one long-lived Bun process. */ export interface ApplicationRuntime { readonly services: ApplicationRuntimeServices; dispose(): Promise; /** Eagerly builds and caches every process-owned layer before readiness. */ initialize(): Promise; + /** Drains or force-stops the one process listener before runtime disposal. */ + shutdownListener(options: ApplicationListenerShutdownOptions): Promise; } /** Scoped layers owned by one composition root for the full process lifetime. */ @@ -62,6 +95,104 @@ function abortSignalEffect(signal: AbortSignal): Effect.Effect { }); } +function listenerStopEffect( + options: ApplicationListenerShutdownOptions, + force: boolean +): Effect.Effect { + return Effect.tryPromise({ + catch: (cause) => + new ApplicationListenerStopError({ + cause, + operation: force ? "force" : "graceful", + }), + try: () => options.stop(force), + }); +} + +function boundedForceListenerStop( + options: ApplicationListenerShutdownOptions +): Effect.Effect { + return listenerStopEffect(options, true).pipe( + Effect.timeoutOrElse({ + duration: options.gracefulShutdownTimeoutMs, + orElse: () => + Effect.fail( + new ApplicationListenerStopTimeoutError({ + operation: "force", + timeoutMs: options.gracefulShutdownTimeoutMs, + }) + ), + }) + ); +} + +function awaitGracefulListenerSettlement( + options: ApplicationListenerShutdownOptions, + gracefulFiber: Fiber.Fiber +): Effect.Effect { + return Fiber.await(gracefulFiber).pipe( + Effect.asVoid, + Effect.timeoutOrElse({ + duration: options.gracefulShutdownTimeoutMs, + orElse: () => + Effect.fail( + new ApplicationListenerStopTimeoutError({ + operation: "graceful-settlement", + timeoutMs: options.gracefulShutdownTimeoutMs, + }) + ), + }) + ); +} + +function forceAndSettleGracefulListener( + options: ApplicationListenerShutdownOptions, + gracefulFiber: Fiber.Fiber +): Effect.Effect { + return boundedForceListenerStop(options).pipe( + Effect.andThen(awaitGracefulListenerSettlement(options, gracefulFiber)) + ); +} + +function coordinatedListenerShutdown( + options: ApplicationListenerShutdownOptions +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + if (options.forceSignal.aborted) { + return yield* boundedForceListenerStop(options); + } + + const gracefulFiber = yield* listenerStopEffect(options, false).pipe( + Effect.forkScoped + ); + const gracefulOutcome = Fiber.await(gracefulFiber).pipe( + Effect.map((exit) => ({ exit, kind: "graceful" as const })) + ); + const forceRequested = abortSignalEffect(options.forceSignal).pipe( + Effect.as({ kind: "force-requested" as const }) + ); + const gracefulDeadline = Effect.sleep(options.gracefulShutdownTimeoutMs).pipe( + Effect.as({ kind: "deadline" as const }) + ); + const outcome = yield* Effect.raceFirst( + gracefulOutcome, + Effect.raceFirst(forceRequested, gracefulDeadline) + ); + + if (outcome.kind !== "graceful") { + return yield* forceAndSettleGracefulListener(options, gracefulFiber); + } + if (Exit.isSuccess(outcome.exit)) return; + + // A failed graceful stop still receives one bounded force attempt. Its + // initiating failure remains the externally observable root cause. + yield* boundedForceListenerStop(options).pipe(Effect.ignore); + return yield* Effect.failCause(outcome.exit.cause); + }) + ); +} + /** * Creates one reusable Effect runtime whose scope is owned by the current process. * `initialize` eagerly prewarms the otherwise lazy layer before the listener opens; @@ -206,5 +337,8 @@ export function createApplicationRuntime( await runtime.context(); }, services, + shutdownListener(options: ApplicationListenerShutdownOptions) { + return runtime.runPromise(coordinatedListenerShutdown(options)); + }, }); } diff --git a/src/server/test/support/requestContext.ts b/src/server/test/support/requestContext.ts index ff267b264..ffbc9cc71 100644 --- a/src/server/test/support/requestContext.ts +++ b/src/server/test/support/requestContext.ts @@ -95,6 +95,7 @@ interface TestApplicationRuntimeOverrides { readonly authentication?: AuthenticationWorkRuntimeService; readonly dispose?: ApplicationRuntime["dispose"]; readonly initialize?: ApplicationRuntime["initialize"]; + readonly shutdownListener?: ApplicationRuntime["shutdownListener"]; readonly stream?: RealtimeEventRuntimeService["stream"]; } @@ -316,6 +317,9 @@ export function createTestApplicationRuntime( )), }), }), + shutdownListener: + overrides.shutdownListener ?? + ((options) => options.stop(options.forceSignal.aborted)), }); } diff --git a/src/server/test/system/serverShutdown.test.ts b/src/server/test/system/serverShutdown.test.ts index 471bb7766..e6cfa2b12 100644 --- a/src/server/test/system/serverShutdown.test.ts +++ b/src/server/test/system/serverShutdown.test.ts @@ -1,9 +1,12 @@ import { describe, expect, spyOn, test } from "bun:test"; import { secondsToMilliseconds } from "date-fns"; +import { Effect, Layer, Stream } from "effect"; import { createServer } from "../../../app/server.ts"; import { createReadinessController } from "../../platform/readiness/readinessState.ts"; +import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; +import { createApplicationRuntime } from "../../platform/runtime/applicationRuntime.ts"; import { captureFailure } from "../support/promise.ts"; import { createTestApplicationRuntime, @@ -12,24 +15,73 @@ import { } from "../support/requestContext.ts"; function createPendingBunServer(): { + readonly gracefulStarted: Promise; readonly server: ReturnType; readonly stopCalls: boolean[]; } { const gracefulStop = Promise.withResolvers(); + const gracefulStarted = Promise.withResolvers(); const stopCalls: boolean[] = []; const server = { port: 3100, stop(force = false) { stopCalls.push(force); - if (force) gracefulStop.resolve(); + if (force) { + gracefulStop.resolve(); + } else { + gracefulStarted.resolve(); + } return gracefulStop.promise; }, url: new URL("http://127.0.0.1:3100"), } as unknown as ReturnType; - return { server, stopCalls }; + return { gracefulStarted: gracefulStarted.promise, server, stopCalls }; +} + +function createShutdownTestRuntime(onDispose: () => void) { + const service = RealtimeEventPumpService.of({ + metricsSnapshot: Effect.die("Shutdown tests do not use realtime metrics"), + stream: () => Stream.empty, + wake: Effect.void, + }); + const scopedService = Effect.acquireRelease(Effect.succeed(service), () => + Effect.sync(onDispose) + ); + const layer = Layer.effect(RealtimeEventPumpService, scopedService); + return createApplicationRuntime({ realtimeEventPumpLayer: layer }); } describe("application server shutdown", () => { + test("forces immediately when the first stop request is forced", async () => { + const fake = createPendingBunServer(); + const serveSpy = spyOn(Bun, "serve").mockReturnValue(fake.server); + let disposals = 0; + + try { + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime: createShutdownTestRuntime(() => { + disposals += 1; + }), + authenticationLifecycle: createTestAuthenticationLifecycleService(), + authenticateCredential: () => ({ + authentication: { kind: "anonymous" }, + }), + port: 3100, + readiness: createReadinessController(), + }); + const forcedStop = server.stop(true); + + expect(server.stop()).toBe(forcedStop); + await forcedStop; + expect(server.stop(true)).toBe(forcedStop); + expect(fake.stopCalls).toEqual([true]); + expect(disposals).toBe(1); + } finally { + serveSpy.mockRestore(); + } + }); + test.each([ { forceAfterGracefulStart: false, @@ -45,15 +97,14 @@ describe("application server shutdown", () => { const fake = createPendingBunServer(); const serveSpy = spyOn(Bun, "serve").mockReturnValue(fake.server); let disposals = 0; + let stopCallsAtDisposal: readonly boolean[] = []; try { const server = await createServer({ ...createTestServerSecurityServices(), - applicationRuntime: createTestApplicationRuntime({ - dispose: () => { - disposals += 1; - return Promise.resolve(); - }, + applicationRuntime: createShutdownTestRuntime(() => { + disposals += 1; + stopCallsAtDisposal = [...fake.stopCalls]; }), authenticationLifecycle: createTestAuthenticationLifecycleService(), authenticateCredential: () => ({ @@ -65,13 +116,16 @@ describe("application server shutdown", () => { }); const gracefulStop = server.stop(); if (scenario.forceAfterGracefulStart) { + await fake.gracefulStarted; expect(server.stop(true)).toBe(gracefulStop); } await gracefulStop; + expect(server.stop(true)).toBe(gracefulStop); expect(fake.stopCalls).toEqual([false, true]); expect(disposals).toBe(1); + expect(stopCallsAtDisposal).toEqual([false, true]); } finally { serveSpy.mockRestore(); } From 169136e265ed98a174d6e0691847edfc0d7ea35c Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 08:49:52 +0200 Subject: [PATCH 2/4] fix(qualification): harden Phase 0 evidence --- bun.lock | 1 + docs/architecture/greenfield-rewrite.md | 2 +- .../application-architecture.md | 2 +- .../greenfield-rewrite/implementation-plan.md | 2 +- .../greenfield-rewrite/progress.md | 34 ++- .../runtime-and-delivery.md | 28 +- docs/generated/packages-and-runtime.md | 1 + package.json | 1 + .../browser/queryCollectionAdapter.test.ts | 8 +- .../budgets/resourceBudgetOrchestration.ts | 75 +++-- .../budgets/resourceBudgetPolicy.test.ts | 8 +- qualification/budgets/resourceBudgetPolicy.ts | 12 +- qualification/budgets/resourceBudgetUnit.ts | 26 +- .../runSafeChildCancellationEvidence.ts | 1 + .../build/frontendBuildQualification.test.ts | 63 +++- .../build/frontendBuildQualification.ts | 145 ++++++++- .../build/runFrontendBuildQualification.ts | 13 +- qualification/chat/chatBatchingModel.ts | 8 +- .../chat/chatBatchingQualification.ts | 24 +- qualification/files/boundedFile.test.ts | 275 ++++++++++++++++++ qualification/files/boundedFile.ts | 177 +++++++++++ .../fixtures/2026.7.2-beta.7/manifest.json | 6 + qualification/openclaw/reviewedFixtures.ts | 55 ++-- qualification/openclaw/sourceAudit.test.ts | 31 +- qualification/openclaw/sourceAudit.ts | 81 ++++-- qualification/openclaw/sourceAuditSchemas.ts | 3 +- qualification/outbox/sqliteOutboxChild.ts | 10 +- qualification/outbox/sqliteOutboxProtocol.ts | 21 +- .../outbox/sqliteOutboxQualification.test.ts | 19 +- .../outbox/sqliteOutboxQualification.ts | 17 +- .../parity/legacyBackendRouteInventory.ts | 24 +- .../parity/parityFixtureCandidate.ts | 49 +++- qualification/parity/parityInventory.test.ts | 22 +- .../parity/parityInventorySchemas.ts | 8 +- .../parity/reviewedParityInventory.ts | 69 +++-- .../parity/sourceParityInventory.test.ts | 113 ++++++- qualification/parity/sourceParityInventory.ts | 107 ++++++- qualification/resources/sseMemoryScenario.ts | 150 +++++----- .../completeShutdownQualification.test.ts | 45 ++- .../shutdown/completeShutdownQualification.ts | 53 +++- qualification/shutdown/shutdownGrandchild.ts | 5 +- .../shutdown/shutdownIdleHttpConnection.ts | 29 +- qualification/shutdown/shutdownProtocol.ts | 3 +- qualification/shutdown/shutdownService.ts | 11 +- .../shutdown/shutdownServiceResources.test.ts | 53 ++++ .../shutdown/shutdownServiceResources.ts | 26 +- .../nativeWebSocketQualification.test.ts | 5 +- .../websocket/nativeWebSocketQualification.ts | 2 + .../websocket/rawWebSocketProtocol.ts | 10 +- src/app/server.ts | 13 +- .../runtime/applicationRuntime.test.ts | 45 +++ .../platform/runtime/applicationRuntime.ts | 1 + src/server/test/system/serverShutdown.test.ts | 47 ++- 53 files changed, 1633 insertions(+), 406 deletions(-) create mode 100644 qualification/files/boundedFile.test.ts create mode 100644 qualification/files/boundedFile.ts diff --git a/bun.lock b/bun.lock index 5431a1cb0..11f4abeeb 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", + "@tanstack/db": "0.6.17", "@tanstack/query-core": "5.101.4", "@tanstack/query-db-collection": "1.2.1", "@tanstack/react-db": "0.1.95", diff --git a/docs/architecture/greenfield-rewrite.md b/docs/architecture/greenfield-rewrite.md index be194525d..8a6ed53f9 100644 --- a/docs/architecture/greenfield-rewrite.md +++ b/docs/architecture/greenfield-rewrite.md @@ -2,7 +2,7 @@ > **Status:** implementation active. Phase 0 evidence is complete and Phase 2 is complete for its > stated server scope; the remaining foundation, browser, domain, Gateway/chat, privileged, -> hardening, and cutover phases are not complete. The rewrite is built beside the current +> hardening, and cutover phases are incomplete. The rewrite is built beside the current > production implementation and targets a fresh database with no compatibility layer. > > **Audit date:** 2026-08-06. Package versions and the Bun canary snapshot in this document diff --git a/docs/architecture/greenfield-rewrite/application-architecture.md b/docs/architecture/greenfield-rewrite/application-architecture.md index 02e3ed92b..a9c361455 100644 --- a/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/docs/architecture/greenfield-rewrite/application-architecture.md @@ -285,7 +285,7 @@ not protocol authority. The consolidated controls and executable evidence are in ### Current-protocol Control UI projections -The 2026-08-06 OpenClaw audit separates protocol authority from Control UI projection through 22 +The 2026-08-06 OpenClaw audit separates protocol authority from Control UI projection through 23 hash-pinned, redacted distribution artifacts. The current behavior informs Phase 4, but Dashboard must re-audit the installed source and use a typed protocol adapter rather than scrape, import, or mirror Control UI implementation details: diff --git a/docs/architecture/greenfield-rewrite/implementation-plan.md b/docs/architecture/greenfield-rewrite/implementation-plan.md index 1b48817f3..a6039251e 100644 --- a/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -99,7 +99,7 @@ outcomes remain normative unless a later runtime or dependency qualification dis 2. **Passed — tRPC SSE on exact Bun:** credentials, cancellation, tracked resume, typed errors, proxy/TLS streaming, rolling reconnect, and bounded slow-consumer behavior pass. 3. **Passed — SQLite outbox latency:** separate web and worker processes deliver a WAL-backed - durable outbox without gaps or duplicates, classify real busy/locked outcomes, and recover an + durable outbox without gaps or duplicates, classify observed busy/locked outcomes, and recover an expired claim after hard worker termination. 4. **Passed — chat batching:** use ordered 150 ms token/thinking batches. One, four, and eight concurrent runs meet the selected write/delay policy, while tool/item, terminal, cancel, and diff --git a/docs/architecture/greenfield-rewrite/progress.md b/docs/architecture/greenfield-rewrite/progress.md index 98e1f8361..46dc49248 100644 --- a/docs/architecture/greenfield-rewrite/progress.md +++ b/docs/architecture/greenfield-rewrite/progress.md @@ -599,35 +599,43 @@ closes a phase; dated entries below provide the evidence, not a second status so - Bun `1.4.0-canary.1+17d684360`, full revision `17d6843606d76620cb55d31424d7fb0aed51c367`, passes qualification typecheck and the complete - qualification suite: 133 tests, 682 assertions, zero failures, and 30 files. This is the exact + qualification suite: 151 tests, 756 assertions, zero failures, and 31 files. This is the exact audited candidate for the round, not a repository-wide source-revision pin. - The selected frontend path is one compiler-first Bun HTML AOT build. Executable fixture and - actual-build evidence cover Tailwind, lazy chunks, CSP-compatible assets, hashes, - precompression, absent production source maps, and bundle budgets. The exact-pinned TanStack DB - adapter covers snapshot replacement, direct batch writes, query-cache synchronization, - optimistic conflicts, cancellation, and route-subscription teardown. + actual-build evidence cover Tailwind, lazy chunks, fail-closed inline event/style/base and + URL-bearing attribute CSP policy, hashes, precompression, absent production source maps, and + bundle budgets. The exact-pinned TanStack DB adapter covers snapshot replacement, direct batch + writes, query-cache synchronization, optimistic conflicts, cancellation, and + route-subscription teardown. - File-backed WAL evidence uses separate web and worker processes and covers reader/writer and - writer/writer behavior, real busy/locked classification, no-gap/no-duplicate outbox delivery, + writer/writer behavior, observed busy/locked classification, no-gap/no-duplicate outbox delivery, hard-kill claim recovery, savepoints, prepared-statement disposal, checkpoint, backup, restore, and integrity. Chat qualification selects 150 ms ordered token/thinking batches for one, four, and eight concurrent runs, with immediate tool/item, terminal, cancel, and completion flushes. + Source inputs are read through held no-follow descriptors with deterministic shrink, growth, + overwrite, and requested-path replacement rejection. - Raw RFC 6455 tests cover continuation reassembly, a UTF-8 code point split across three frames, orphan/interleaved-fragment `1002` closes, invalid-length and 64 KiB application-bound `1009` closes, deterministic cancellation/close, partial writes, native refusal, and exactly one connection attempt without reconnect. - The two-generation shutdown test withdraws readiness before cleanup, closes SSE and the local - Gateway connection, disposes the statement and WAL database, recovers the worker lease, ends the - detached process group, and restarts on the same database without a leak. The candidate's + Gateway connection, disposes the statement and WAL database, recovers the worker lease, reaps its + owned child with bounded SIGTERM-to-SIGKILL escalation, and restarts on the same database without + a leak. The candidate's intentional keep-alive behavior requires a scoped Effect graceful-stop fiber followed by a separately bounded force escalation; the candidate records `listener-force-stopped` and closes - every owned resource. The production listener now uses the same process `ManagedRuntime` for its - tagged graceful/deadline/force orchestration, including explicit force requests, original-fiber - settlement, and best-effort containment after graceful rejection. + every owned resource. Stream cancellation is separately bounded so a non-cooperative Fetch body + cannot block older scope finalizers. The production listener now uses the same process + `ManagedRuntime` for its tagged graceful/deadline/force orchestration, including explicit force + requests, original-fiber settlement, and best-effort containment after graceful rejection; a + stop failure preserves runtime services until terminal supervisor containment. - Source-derived parity now accounts for all 156 current HTTP operations plus `/ws`. The OpenClaw - audit pins 22 redacted source/protocol/UI artifacts for installed `2026.7.2-beta.7 (dabe191)`, + audit pins 23 redacted source/protocol/UI artifacts for installed `2026.7.2-beta.7 (dabe191)`, including the generic-event, ephemeral plan/checklist projection, compute-starting companion ask, and background-task list/detail/cancel semantics. These are Phase 4 adapter requirements, not an invitation to scrape the Control UI. + The route-tree source parser accepts only the reviewed recursive `addChildren` grammar and + accounts for every child identifier regardless of naming suffix. - The exact-candidate capped resource matrix passes without `high`, `max`, `oom`, or `oom_kill` memory events, memory pressure, or leaked process, unit, or temporary state: @@ -638,7 +646,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | SQLite outbox/restore | 101,896,192 | 1,218 | 20 | | Chat batching | 42,676,224 | 97 | 12 | | Complete shutdown | 128,774,144 | 3,133 | 25 | - | Child cancellation | 117,194,752 | 1,531 | 24 | + | Child-process cancel | 117,194,752 | 1,531 | 24 | - Phase 0 is complete, but the rewrite is not: Phase 1 remains in progress with browser/worker roots, complete import enforcement, complete generated references, immutable release/rollback, diff --git a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 7bf1d6f0f..15c919680 100644 --- a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -60,11 +60,12 @@ environment against the exact candidate binary: The 2026-08-06 qualification round passes on exact revision `17d6843606d76620cb55d31424d7fb0aed51c367`: qualification typecheck passes, and the full suite -reports 133 tests, 682 assertions, and zero failures across 30 files. Its executable evidence +reports 151 tests, 756 assertions, and zero failures across 31 files. Its executable evidence includes: -- compiler-first Bun HTML AOT output with Tailwind, lazy chunks, CSP-compatible assets, hashes, - precompression, no production source maps, and enforced bundle budgets; +- compiler-first Bun HTML AOT output with Tailwind, lazy chunks, fail-closed inline-code and + URL-bearing-attribute CSP checks, hashes, precompression, no production source maps, and + enforced bundle budgets; - Fetch/tRPC/SSE cancellation, resume, proxy, rolling-restart, and slow-consumer behavior; - raw RFC 6455 continuation reassembly with a UTF-8 code point split across frames, protocol-close `1002`, application-bound `1009`, a 64 KiB limit, deterministic close, and exactly one connection @@ -76,9 +77,9 @@ includes: - 150 ms chat-delta batching for one, four, and eight concurrent runs with immediate boundary and terminal flushes; - a two-generation shutdown with readiness withdrawal, SSE and Gateway closure, statement and - database disposal, worker-lease recovery, child-process-group cleanup, WAL recovery, and no - leaked process; and -- source-derived parity for 156 current HTTP operations plus `/ws`, together with 22 hash-pinned, + database disposal, bounded non-cooperative stream cancellation, worker-lease recovery, + child-process-group cleanup, WAL recovery, and no leaked process; and +- source-derived parity for 156 current HTTP operations plus `/ws`, together with 23 hash-pinned, redacted OpenClaw protocol and Control UI audit artifacts. The candidate intentionally makes `server.stop(false)` wait for idle keep-alive connections. The @@ -87,17 +88,10 @@ a separately bounded `server.stop(true)` escalation. The exact candidate records `listener-force-stopped`, then closes SSE and every owned resource without a leak; the event model permits exactly one graceful or forced terminal outcome. -The candidate resource matrix also passes without `high`, `max`, `oom`, or `oom_kill` memory -events, memory pressure, or leaked process, unit, or temporary state: - -| Scenario | Peak memory (bytes) | Elapsed (ms) | Peak tasks | -| --------------------- | ------------------: | -----------: | ---------: | -| Frontend build | 650,104,832 | 14,793 | 19 | -| Representative tests | 248,758,272 | 2,222 | 18 | -| SQLite outbox/restore | 101,896,192 | 1,218 | 20 | -| Chat batching | 42,676,224 | 97 | 12 | -| Complete shutdown | 128,774,144 | 3,133 | 25 | -| Child-process cancel | 117,194,752 | 1,531 | 24 | +The candidate resource checks also pass without `high`, `max`, `oom`, or `oom_kill` memory +events, memory pressure, or leaked process, unit, or temporary state. The dated +[Phase 0 progress record](progress.md#2026-08-06--phase-0-evidence-and-qualification-closed) +owns the authoritative resource measurements. These measurements qualify the mechanisms and current limits; Phase 6 still owns final production-shaped load, restore, and cutover evidence. diff --git a/docs/generated/packages-and-runtime.md b/docs/generated/packages-and-runtime.md index a85167611..4567acd0e 100644 --- a/docs/generated/packages-and-runtime.md +++ b/docs/generated/packages-and-runtime.md @@ -23,6 +23,7 @@ | `@simplewebauthn/browser` | `13.3.0` | `13.3.0` | runtime | | `@simplewebauthn/server` | `13.3.2` | `13.3.2` | runtime | | `@tailwindcss/typography` | `^0.5.20` | `0.5.20` | runtime | +| `@tanstack/db` | `0.6.17` | `0.6.17` | runtime | | `@tanstack/query-core` | `5.101.4` | `5.101.4` | runtime | | `@tanstack/query-db-collection` | `1.2.1` | `1.2.1` | runtime | | `@tanstack/react-db` | `0.1.95` | `0.1.95` | runtime | diff --git a/package.json b/package.json index 8d4e63ea2..c3cf3450d 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", + "@tanstack/db": "0.6.17", "@tanstack/query-core": "5.101.4", "@tanstack/query-db-collection": "1.2.1", "@tanstack/react-db": "0.1.95", diff --git a/qualification/browser/queryCollectionAdapter.test.ts b/qualification/browser/queryCollectionAdapter.test.ts index 882ebad39..5bc3d2b59 100644 --- a/qualification/browser/queryCollectionAdapter.test.ts +++ b/qualification/browser/queryCollectionAdapter.test.ts @@ -237,11 +237,11 @@ async function readInstalledVersions(): Promise> { ] as const; const versions: Record = {}; for (const packageName of packageNames) { - const packageJsonUrl = new URL( - `../../node_modules/${packageName}/package.json`, - import.meta.url + const packageJsonPath = Bun.resolveSync( + `${packageName}/package.json`, + import.meta.dir ); - const parsed: unknown = JSON.parse(await Bun.file(packageJsonUrl).text()); + const parsed: unknown = JSON.parse(await Bun.file(packageJsonPath).text()); if ( typeof parsed !== "object" || parsed === null || diff --git a/qualification/budgets/resourceBudgetOrchestration.ts b/qualification/budgets/resourceBudgetOrchestration.ts index 0a593d0c5..f6d05592c 100644 --- a/qualification/budgets/resourceBudgetOrchestration.ts +++ b/qualification/budgets/resourceBudgetOrchestration.ts @@ -76,12 +76,27 @@ export interface ResourceBudgetQualificationReport { readonly scenarioEvidence: readonly ResourceBudgetScenarioEvidence[]; } -function requiredExecutable(name: string): string { - const executable = Bun.which(name); - if (executable === null || !path.isAbsolute(executable)) { - throw new Error(`${name} is required for resource-budget qualification`); - } - return executable; +function requiredExecutable( + name: string +): Effect.Effect { + return Effect.try({ + catch: (cause) => + new ResourceBudgetOrchestrationError({ + cause, + operation: `resolve-${name}-executable`, + }), + try: () => Bun.which(name), + }).pipe( + Effect.flatMap((executable) => + executable !== null && path.isAbsolute(executable) + ? Effect.succeed(executable) + : Effect.fail( + new ResourceBudgetOrchestrationError({ + operation: `resolve-${name}-executable`, + }) + ) + ) + ); } function temporaryWorkspace() { @@ -415,6 +430,19 @@ function runScenario( "run-transient-unit", scenarioId ); + if (launcher.exitCode !== 0) { + const diagnostic = [launcher.stderr.trim(), launcher.stdout.trim()] + .filter((value) => value.length > 0) + .join("\n") + .slice(0, 16 * 1024); + return yield* Effect.fail( + new ResourceBudgetOrchestrationError({ + cause: diagnostic, + operation: "transient-unit-exit", + scenarioId, + }) + ); + } const report = yield* readResult(command); return { launcher, report }; }) @@ -423,29 +451,12 @@ function runScenario( [unitIsCollected(command), cgroupIsRemoved(cgroupPath)] as const, { concurrency: "unbounded" } ); - const evidence: ResourceBudgetScenarioEvidence = { + return { cgroupRemoved, launcherExitCode: completed.launcher.exitCode, report: completed.report, unitCollected, - }; - if (completed.launcher.exitCode !== 0) { - const diagnostic = [ - completed.launcher.stderr.trim(), - completed.launcher.stdout.trim(), - ] - .filter((value) => value.length > 0) - .join("\n") - .slice(0, 16 * 1024); - return yield* Effect.fail( - new ResourceBudgetOrchestrationError({ - cause: diagnostic, - operation: "transient-unit-exit", - scenarioId, - }) - ); - } - return evidence; + } satisfies ResourceBudgetScenarioEvidence; }); } @@ -463,11 +474,19 @@ export const resourceBudgetQualification: Effect.Effect< ); } const workspace = yield* temporaryWorkspace(); + const [env, systemctl, systemdRun] = yield* Effect.all( + [ + requiredExecutable("env"), + requiredExecutable("systemctl"), + requiredExecutable("systemd-run"), + ] as const, + { concurrency: "unbounded" } + ); const executables: ResourceBudgetExecutables = { bun: process.execPath, - env: requiredExecutable("env"), - systemctl: requiredExecutable("systemctl"), - systemdRun: requiredExecutable("systemd-run"), + env, + systemctl, + systemdRun, }; const userId = process.getuid(); const scenarioEvidence = yield* Effect.forEach( diff --git a/qualification/budgets/resourceBudgetPolicy.test.ts b/qualification/budgets/resourceBudgetPolicy.test.ts index a0aabf696..9735c0320 100644 --- a/qualification/budgets/resourceBudgetPolicy.test.ts +++ b/qualification/budgets/resourceBudgetPolicy.test.ts @@ -214,11 +214,17 @@ describe("resource-budget policy", () => { }, ], [ - "unit was not collected", + "cgroup was not removed", (candidate) => { candidate.cgroupRemoved = false; }, ], + [ + "unit was not collected", + (candidate) => { + candidate.unitCollected = false; + }, + ], ]; for (const [message, mutate] of cases) { const candidate = structuredClone(validEvidence()); diff --git a/qualification/budgets/resourceBudgetPolicy.ts b/qualification/budgets/resourceBudgetPolicy.ts index 8dd7093ce..93dbeff86 100644 --- a/qualification/budgets/resourceBudgetPolicy.ts +++ b/qualification/budgets/resourceBudgetPolicy.ts @@ -5,8 +5,6 @@ import * as v from "valibot"; const mebibyte = 1024 * 1024; const resourceBudgetUnitIdentifierPattern = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu; -const resourceBudgetUnitNamePattern = - /^mira-dashboard-resource-(?:frontend-build|representative-tests|sqlite-outbox|chat-batching|complete-shutdown|child-cancellation)-[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu; export const resourceBudgetScenarioIds = [ "frontend-build", @@ -17,6 +15,11 @@ export const resourceBudgetScenarioIds = [ "child-cancellation", ] as const; +const resourceBudgetUnitNamePattern = new RegExp( + `^mira-dashboard-resource-(?:${resourceBudgetScenarioIds.join("|")})-[\\da-f]{8}(?:-[\\da-f]{4}){3}-[\\da-f]{12}$`, + "iu" +); + export type ResourceBudgetScenarioId = (typeof resourceBudgetScenarioIds)[number]; export interface ResourceBudgetLimits { @@ -353,9 +356,12 @@ export function assessResourceBudgetEvidence( ) { throw new Error(`Resource-budget ${report.scenarioId} leaked processes`); } - if (!evidence.unitCollected || !evidence.cgroupRemoved) { + if (!evidence.unitCollected) { throw new Error(`Resource-budget ${report.scenarioId} unit was not collected`); } + if (!evidence.cgroupRemoved) { + throw new Error(`Resource-budget ${report.scenarioId} cgroup was not removed`); + } const initialEvents = report.cgroup.initial.memoryEvents; const finalEvents = report.cgroup.final.memoryEvents; diff --git a/qualification/budgets/resourceBudgetUnit.ts b/qualification/budgets/resourceBudgetUnit.ts index aa1e6733b..1df869f84 100644 --- a/qualification/budgets/resourceBudgetUnit.ts +++ b/qualification/budgets/resourceBudgetUnit.ts @@ -418,6 +418,20 @@ function runUnit(arguments_: ResourceBudgetUnitArguments) { new ResourceBudgetUnitError({ operation: "parse-pids-peak" }) ); } + if ( + finalCgroup.cpuQuotaMicros === "max" || + finalCgroup.memoryHighBytes === "max" || + finalCgroup.memoryMaxBytes === "max" || + finalCgroup.memorySwapMaxBytes === "max" || + finalCgroup.pidsMax === "max" || + !finalCgroup.oomGroup + ) { + return yield* Effect.fail( + new ResourceBudgetUnitError({ + operation: "verify-final-cgroup-policy", + }) + ); + } const report: ResourceBudgetUnitReport = { cgroup: { final, @@ -430,12 +444,12 @@ function runUnit(arguments_: ResourceBudgetUnitArguments) { formatVersion: resourceBudgetPolicy.formatVersion, limits: { cpuPeriodMicros: finalCgroup.cpuPeriodMicros, - cpuQuotaMicros: finalCgroup.cpuQuotaMicros as number, - memoryHighBytes: finalCgroup.memoryHighBytes as number, - memoryMaxBytes: finalCgroup.memoryMaxBytes as number, - memorySwapMaxBytes: finalCgroup.memorySwapMaxBytes as number, - oomGroup: true, - pidsMax: finalCgroup.pidsMax as number, + cpuQuotaMicros: finalCgroup.cpuQuotaMicros, + memoryHighBytes: finalCgroup.memoryHighBytes, + memoryMaxBytes: finalCgroup.memoryMaxBytes, + memorySwapMaxBytes: finalCgroup.memorySwapMaxBytes, + oomGroup: finalCgroup.oomGroup, + pidsMax: finalCgroup.pidsMax, }, runtime: { bunRevision: Bun.revision, diff --git a/qualification/budgets/runSafeChildCancellationEvidence.ts b/qualification/budgets/runSafeChildCancellationEvidence.ts index 662cf5ebe..d79aa9278 100644 --- a/qualification/budgets/runSafeChildCancellationEvidence.ts +++ b/qualification/budgets/runSafeChildCancellationEvidence.ts @@ -4,6 +4,7 @@ import { interruptedShutdownQualification } from "../shutdown/completeShutdownQu const report = await Effect.runPromise(interruptedShutdownQualification); if ( + report.processGroupMembersWhileReady.length === 0 || report.processGroupMembersAfterInterruption.length > 0 || report.stoppedStatus.phase !== "stopped" ) { diff --git a/qualification/build/frontendBuildQualification.test.ts b/qualification/build/frontendBuildQualification.test.ts index 42ffc1d1b..5720efe45 100644 --- a/qualification/build/frontendBuildQualification.test.ts +++ b/qualification/build/frontendBuildQualification.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -36,6 +36,13 @@ describe("Bun frontend build qualification", () => { await assertSelfHostedFrontendHtml( path.join(developmentOutdir, "index.html") ); + const developmentFiles = await listRelativeFiles(developmentOutdir); + const developmentJavaScript = await readFilesWithExtension( + developmentOutdir, + developmentFiles, + ".js" + ); + expect(developmentJavaScript).toContain("useMemoCache"); const production = await buildQualificationFrontend( "production", @@ -45,10 +52,14 @@ describe("Bun frontend build qualification", () => { expect( production.outputPaths.some((outputPath) => outputPath.endsWith(".map")) ).toBeFalse(); + const productionAssets = production.outputPaths.filter((outputPath) => + /\.(?:css|js)$/u.test(outputPath) + ); + expect(productionAssets.length).toBeGreaterThan(0); expect( - production.outputPaths - .filter((outputPath) => /\.(?:css|js)$/u.test(outputPath)) - .every((outputPath) => hashedAssetPattern.test(outputPath)) + productionAssets.every((outputPath) => + hashedAssetPattern.test(outputPath) + ) ).toBeTrue(); expect(production.compressedFileCount).toBeGreaterThan(0); expect(productionFiles.some((file) => file.endsWith(".br"))).toBeTrue(); @@ -77,7 +88,7 @@ describe("Bun frontend build qualification", () => { productionFiles, ".css" ); - expect(javascript).toContain("useMemoCache"); + expect(javascript.length).toBeGreaterThan(0); expect(stylesheet).toContain(".bg-indigo-600"); await assertSelfHostedFrontendHtml(path.join(productionOutdir, "index.html")); } finally { @@ -87,6 +98,48 @@ describe("Bun frontend build qualification", () => { ]); } }, 60_000); + + test("parses HTML and rejects encoded or inline CSP dependencies", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-build-html-")); + const indexPath = path.join(directory, "index.html"); + try { + await writeFile( + indexPath, + '', + "utf8" + ); + await assertSelfHostedFrontendHtml(indexPath); + + for (const html of [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]) { + await writeFile(indexPath, html, "utf8"); + let rejected = false; + try { + await assertSelfHostedFrontendHtml(indexPath); + } catch (error) { + rejected = true; + expect(error).toBeInstanceOf(Error); + } + expect(rejected).toBeTrue(); + } + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); }); async function listRelativeFiles(directory: string): Promise { diff --git a/qualification/build/frontendBuildQualification.ts b/qualification/build/frontendBuildQualification.ts index f2b1c06bd..c44e855f9 100644 --- a/qualification/build/frontendBuildQualification.ts +++ b/qualification/build/frontendBuildQualification.ts @@ -24,7 +24,8 @@ export interface QualificationFrontendBuildEvidence { } const qualificationFrontendEntrypoint = path.resolve( - "qualification/build/fixtures/frontend/index.html" + import.meta.dir, + "fixtures/frontend/index.html" ); export const qualificationFrontendPluginOrder = [ @@ -32,6 +33,20 @@ export const qualificationFrontendPluginOrder = [ tailwindPlugin.name, ] as const; +const frontendHtmlResourceAttributes = new Set([ + "action", + "background", + "cite", + "data", + "formaction", + "href", + "manifest", + "poster", + "src", + "xlink:href", +]); +const frontendHtmlSourceSetAttributes = new Set(["imagesrcset", "srcset"]); + /** * Builds a minimal HTML-entry frontend with the target compiler-first pipeline. * The fixture intentionally shares the production artifact policy helpers. @@ -114,33 +129,133 @@ export async function buildQualificationFrontend( */ export async function assertSelfHostedFrontendHtml(indexPath: string): Promise { const html = await readFile(indexPath, "utf8"); - const scripts = [...html.matchAll(/]*)>([\s\S]*?)<\/script>/giu)]; - const styles = [...html.matchAll(/]*>[\s\S]*?<\/style>/giu)]; - if (scripts.length !== 1 || styles.length > 0) { + const scripts: Array<{ body: string; source: string | null; type: string | null }> = + []; + let styleCount = 0; + let hasInlineEventHandler = false; + let hasInlineSourceDocument = false; + let hasInlineStyle = false; + let hasNonSelfHostedResource = false; + let hasBaseElement = false; + const rewriter = new HTMLRewriter() + .on("*", { + element(element) { + for (const [name, value] of element.attributes) { + const normalizedName = name.toLowerCase(); + if (normalizedName.startsWith("on")) { + hasInlineEventHandler = true; + } else if (normalizedName === "srcdoc") { + hasInlineSourceDocument = true; + } else if (normalizedName === "style") { + hasInlineStyle = true; + } else if ( + frontendHtmlResourceAttributes.has(normalizedName) && + !isSelfHostedResourceReference(value) + ) { + hasNonSelfHostedResource = true; + } else if ( + frontendHtmlSourceSetAttributes.has(normalizedName) && + !isSelfHostedSourceSet(value) + ) { + hasNonSelfHostedResource = true; + } + } + }, + }) + .on("base", { + element() { + hasBaseElement = true; + }, + }) + .on("script", { + element(element) { + scripts.push({ + body: "", + source: element.getAttribute("src"), + type: element.getAttribute("type"), + }); + }, + text(text) { + const script = scripts.at(-1); + if (script) script.body += text.text; + }, + }) + .on("style", { + element() { + styleCount += 1; + }, + }); + rewriter.transform(html); + + if ( + scripts.length !== 1 || + styleCount > 0 || + hasInlineEventHandler || + hasInlineSourceDocument || + hasInlineStyle || + hasBaseElement + ) { throw new Error( - "Frontend HTML must contain one external script and no inline styles" + "Frontend HTML must contain one external script and no inline code" ); } - const scriptAttributes = scripts[0]?.[1] ?? ""; - const scriptBody = scripts[0]?.[2] ?? ""; - const source = scriptAttributes.match(/\bsrc=(['"])([^'"]+)\1/iu)?.[2]; + const script = scripts[0]!; if ( - !/\btype=(['"])module\1/iu.test(scriptAttributes) || - !source?.startsWith("/assets/") || - scriptBody.trim().length > 0 + script.type !== "module" || + !script.source?.startsWith("/assets/") || + script.body.trim().length > 0 ) { throw new Error("Frontend HTML module script must be external and self-hosted"); } - const nonSelfHostedResource = html.match( - /\b(?:href|src)=(['"])(?:[a-z][a-z\d+.-]*:|\/\/)[^'"]*\1/iu - ); - if (nonSelfHostedResource) { + if (hasNonSelfHostedResource) { throw new Error("Frontend HTML cannot depend on a third-party CSP origin"); } } +function isSelfHostedResourceReference(value: string): boolean { + const reference = value.trim(); + if (reference.length === 0 || reference.includes("&") || reference.includes("\\")) { + return false; + } + if (/^[a-z][a-z\d+.-]*:/iu.test(reference) || reference.startsWith("//")) { + return false; + } + try { + const base = new URL("https://qualification.invalid/"); + const resolved = new URL(reference, base); + return ( + resolved.origin === base.origin && resolved.pathname.startsWith("/assets/") + ); + } catch { + return false; + } +} + +function isSelfHostedSourceSet(value: string): boolean { + const candidates = value.split(","); + return ( + candidates.length > 0 && + candidates.every((candidate) => { + const tokens = candidate.trim().split(/\s+/u); + if ( + tokens.length === 0 || + tokens.length > 2 || + !isSelfHostedResourceReference(tokens[0] ?? "") + ) { + return false; + } + const descriptor = tokens[1]; + return ( + descriptor === undefined || + /^\d+w$/u.test(descriptor) || + /^(?:\d+|\d*\.\d+)x$/u.test(descriptor) + ); + }) + ); +} + function normalizedOutputPath(outputPath: string, outdir: string): string { return path.relative(outdir, path.resolve(outputPath)).replaceAll("\\", "/"); } diff --git a/qualification/build/runFrontendBuildQualification.ts b/qualification/build/runFrontendBuildQualification.ts index 034002bd7..1690dbb4c 100644 --- a/qualification/build/runFrontendBuildQualification.ts +++ b/qualification/build/runFrontendBuildQualification.ts @@ -34,6 +34,12 @@ export async function runActualFrontendBuildQualification( path.join(outdir, "frontend-bundle-metrics.json"), "utf8" ); + let parsedMetrics: unknown; + try { + parsedMetrics = JSON.parse(metrics) as unknown; + } catch { + parsedMetrics = undefined; + } if (hashedAssetCount < 10) { throw new Error("Existing frontend build did not emit hashed route assets"); @@ -44,7 +50,12 @@ export async function runActualFrontendBuildQualification( if (sourceMapsIncluded) { throw new Error("Production frontend build emitted source maps"); } - if (!metrics.includes('"formatVersion": 1')) { + if ( + typeof parsedMetrics !== "object" || + parsedMetrics === null || + !("formatVersion" in parsedMetrics) || + parsedMetrics.formatVersion !== 1 + ) { throw new Error("Existing frontend build emitted an unknown metrics format"); } await assertSelfHostedFrontendHtml(path.join(outdir, "index.html")); diff --git a/qualification/chat/chatBatchingModel.ts b/qualification/chat/chatBatchingModel.ts index 2e66d1880..38b681f09 100644 --- a/qualification/chat/chatBatchingModel.ts +++ b/qualification/chat/chatBatchingModel.ts @@ -123,7 +123,7 @@ function serializedBytes(value: unknown): number { function percentile95(values: readonly number[]): number { if (values.length === 0) return 0; const sorted = values.toSorted((left, right) => left - right); - return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? 0; + return sorted[Math.ceil((sorted.length * 95) / 100) - 1] ?? 0; } function peakTransactionsPerSecond(commitTimes: readonly number[]): number { @@ -210,6 +210,10 @@ export function simulateChatBatching( } } if (pendingDeadlineMs !== undefined) flush(pendingDeadlineMs, "interval"); + let maximumCommitDelayMs = 0; + for (const delay of commitDelays) { + maximumCommitDelayMs = Math.max(maximumCommitDelayMs, delay); + } return Object.freeze({ batches: Object.freeze(batches), @@ -222,7 +226,7 @@ export function simulateChatBatching( inputBytes: events.reduce((total, event) => total + event.payloadBytes, 0), inputEvents: events.length, intervalMs, - maximumCommitDelayMs: Math.max(0, ...commitDelays), + maximumCommitDelayMs, maximumPendingBytes, p95CommitDelayMs: percentile95(commitDelays), peakScheduledTransactionsPerSecond: diff --git a/qualification/chat/chatBatchingQualification.ts b/qualification/chat/chatBatchingQualification.ts index c28cad512..88dfd2f10 100644 --- a/qualification/chat/chatBatchingQualification.ts +++ b/qualification/chat/chatBatchingQualification.ts @@ -65,14 +65,16 @@ export function buildChatBatchingTrace( throw new RangeError("Chat batching concurrency is outside qualification bounds"); } const throttleMs = fixture.streamingPolicy.deltaThrottleMs; - const thinking = fixtureEvent(fixture, "completed-tool-run", "agent-delta"); - const assistant = fixture.syntheticScenarios - .flatMap(({ events }) => events) - .find( - (event): event is Extract => - event.kind === "agent-delta" && event.stream === "assistant" - ); - if (thinking.kind !== "agent-delta" || assistant === undefined) { + const agentDeltaForStream = (stream: "assistant" | "thinking") => + fixture.syntheticScenarios + .flatMap(({ events }) => events) + .find( + (event): event is Extract => + event.kind === "agent-delta" && event.stream === stream + ); + const thinking = agentDeltaForStream("thinking"); + const assistant = agentDeltaForStream("assistant"); + if (thinking === undefined || assistant === undefined) { throw new Error("Reviewed chat fixture lacks both coalesced agent streams"); } const toolStart = fixtureEvent(fixture, "completed-tool-run", "tool-start"); @@ -137,12 +139,14 @@ function candidateRejectionReasons( fixture: ChatFixture ): readonly string[] { const throttleMs = fixture.streamingPolicy.deltaThrottleMs; + const maximumAdditionalVisualDelayMs = throttleMs; + const maximumCrashWindowMs = throttleMs; const maximumScheduledTransactionsPerSecond = Math.ceil(1000 / throttleMs); return Object.freeze([ - ...(metrics.maximumCommitDelayMs > throttleMs + ...(metrics.maximumCommitDelayMs > maximumAdditionalVisualDelayMs ? ["visual-delay-exceeds-one-source-tick"] : []), - ...(metrics.maximumCommitDelayMs > throttleMs + ...(metrics.maximumCommitDelayMs > maximumCrashWindowMs ? ["crash-window-exceeds-one-source-tick"] : []), ...(metrics.peakScheduledTransactionsPerSecond > diff --git a/qualification/files/boundedFile.test.ts b/qualification/files/boundedFile.test.ts new file mode 100644 index 000000000..ed1f81e89 --- /dev/null +++ b/qualification/files/boundedFile.test.ts @@ -0,0 +1,275 @@ +import { expect, test } from "bun:test"; +import { + appendFile, + mkdir, + mkdtemp, + open, + rename, + rm, + symlink, + truncate, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + type BoundedFileReadQualificationHooks, + readBoundedRegularFile, + readBoundedUtf8RegularFile, +} from "./boundedFile.ts"; + +const invalidStateMessage = "Fixture has invalid file state"; + +async function rejectedError(operation: Promise): Promise { + const result = await operation.catch((error: unknown) => error); + expect(result).toBeInstanceOf(Error); + return result as Error; +} + +function createInitialStatBarrier(): { + hooks: BoundedFileReadQualificationHooks; + reached: Promise; + release: () => void; +} { + const reached = Promise.withResolvers(); + const release = Promise.withResolvers(); + return { + hooks: { + async afterInitialStat() { + reached.resolve(); + await release.promise; + }, + }, + reached: reached.promise, + release: release.resolve, + }; +} + +async function rejectAfterInFlightMutation( + target: string, + allowedRoot: string, + maximumBytes: number, + mutate: () => Promise +): Promise { + const barrier = createInitialStatBarrier(); + const operation = readBoundedRegularFile( + target, + allowedRoot, + maximumBytes, + invalidStateMessage, + barrier.hooks + ); + await barrier.reached; + try { + await mutate(); + } finally { + barrier.release(); + } + return rejectedError(operation); +} + +test("reads an exact bounded regular-file snapshot", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const target = path.join(directory, "fixture.json"); + await writeFile(target, "reviewed fixture", "utf8"); + + const bytes = await readBoundedRegularFile( + target, + directory, + 64, + invalidStateMessage + ); + + expect(bytes.toString("utf8")).toBe("reviewed fixture"); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("allows a descendant whose first segment begins with two dots", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const descendantDirectory = path.join(directory, "..inside"); + const target = path.join(descendantDirectory, "fixture.json"); + await mkdir(descendantDirectory); + await writeFile(target, "reviewed fixture", "utf8"); + + const bytes = await readBoundedRegularFile( + target, + directory, + 64, + invalidStateMessage + ); + + expect(bytes.toString("utf8")).toBe("reviewed fixture"); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("fails closed for leaf and escaping ancestor symlinks, oversized files, and empty files", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const allowedRoot = path.join(directory, "allowed"); + const outsideRoot = path.join(directory, "outside"); + await mkdir(allowedRoot); + await mkdir(outsideRoot); + const target = path.join(allowedRoot, "target.json"); + const empty = path.join(allowedRoot, "empty.json"); + const leafLink = path.join(allowedRoot, "leaf-link.json"); + const ancestorLink = path.join(allowedRoot, "ancestor-link"); + const outsideTarget = path.join(outsideRoot, "outside.json"); + await writeFile(target, "oversized", "utf8"); + await writeFile(empty, "", "utf8"); + await writeFile(outsideTarget, "outside", "utf8"); + await symlink(target, leafLink); + await symlink(outsideRoot, ancestorLink); + + for (const [filePath, maximumBytes] of [ + [leafLink, 64], + [path.join(ancestorLink, "outside.json"), 64], + [target, 4], + [empty, 64], + ] as const) { + const error = await rejectedError( + readBoundedRegularFile( + filePath, + allowedRoot, + maximumBytes, + invalidStateMessage + ) + ); + expect(error.message).toBe(invalidStateMessage); + expect(String(error)).not.toContain(filePath); + } + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("opens FIFOs nonblockingly and redacts the rejected path", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const fifo = path.join(directory, "no-writer.fifo"); + const creation = Bun.spawnSync({ + cmd: ["mkfifo", fifo], + stderr: "pipe", + stdout: "ignore", + }); + expect(creation.success).toBeTrue(); + + const error = await rejectedError( + readBoundedRegularFile(fifo, directory, 64, invalidStateMessage) + ); + expect(error.message).toBe(invalidStateMessage); + expect(String(error)).not.toContain(fifo); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}, 2000); + +test("rejects malformed UTF-8 with only the selected message", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const target = path.join(directory, "invalid-utf8.json"); + await writeFile(target, Buffer.from([195, 40])); + + const error = await rejectedError( + readBoundedUtf8RegularFile( + target, + directory, + 64, + invalidStateMessage, + "Fixture is not valid UTF-8" + ) + ); + expect(error.message).toBe("Fixture is not valid UTF-8"); + expect(String(error)).not.toContain(target); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("rejects an in-flight same-inode shrink after the initial stat", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const target = path.join(directory, "shrinking.bin"); + await writeFile(target, "reviewed fixture", "utf8"); + + const error = await rejectAfterInFlightMutation(target, directory, 64, () => + truncate(target, 4) + ); + + expect(error.message).toBe(invalidStateMessage); + expect(String(error)).not.toContain(target); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("rejects in-flight growth after the initial stat", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const target = path.join(directory, "growing.bin"); + await writeFile(target, "small", "utf8"); + + const error = await rejectAfterInFlightMutation(target, directory, 64, () => + appendFile(target, " growth", "utf8") + ); + + expect(error.message).toBe(invalidStateMessage); + expect(String(error)).not.toContain(target); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("rejects an in-flight same-size overwrite after the initial stat", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + let mutator: Awaited> | undefined; + try { + const target = path.join(directory, "mutating.bin"); + await writeFile(target, "before", "utf8"); + const openMutator = await open(target, "r+"); + mutator = openMutator; + + const error = await rejectAfterInFlightMutation( + target, + directory, + 64, + async () => { + await openMutator.write(Buffer.from("after!"), 0, 6, 0); + await openMutator.sync(); + } + ); + + expect(error.message).toBe(invalidStateMessage); + expect(String(error)).not.toContain(target); + await openMutator.close(); + mutator = undefined; + } finally { + await mutator?.close(); + await rm(directory, { force: true, recursive: true }); + } +}); + +test("rejects requested-path replacement while the original descriptor is held", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "mira-bounded-file-")); + try { + const target = path.join(directory, "target.bin"); + const replacement = path.join(directory, "replacement.bin"); + await writeFile(target, "original", "utf8"); + await writeFile(replacement, "replaced", "utf8"); + + const error = await rejectAfterInFlightMutation(target, directory, 64, () => + rename(replacement, target) + ); + + expect(error.message).toBe(invalidStateMessage); + expect(String(error)).not.toContain(target); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/qualification/files/boundedFile.ts b/qualification/files/boundedFile.ts new file mode 100644 index 000000000..7af365117 --- /dev/null +++ b/qualification/files/boundedFile.ts @@ -0,0 +1,177 @@ +import { constants, type BigIntStats } from "node:fs"; +import { open, realpath } from "node:fs/promises"; +import path from "node:path"; + +export interface BoundedFileReadQualificationHooks { + /** Holds the read after its initial descriptor stat for deterministic mutation tests. */ + readonly afterInitialStat?: () => Promise | void; +} + +function isContainedPath(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative.length > 0 && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function invalidFileState(message: string): Error { + return new Error(message); +} + +function matchesSnapshot(before: BigIntStats, after: BigIntStats): boolean { + return ( + after.dev === before.dev && + after.ino === before.ino && + after.size === before.size && + after.ctimeNs === before.ctimeNs && + after.mtimeNs === before.mtimeNs + ); +} + +/** + * Reads one stable regular file through a held nonblocking, no-follow descriptor. + * A second no-follow descriptor revalidates that the requested path still names the + * same snapshot inside the explicit root before any bytes are returned. + * @param absolutePath Absolute file path selected by the qualification caller. + * @param allowedRoot Explicit root that is permitted to contain the descriptor target. + * @param maximumBytes Maximum accepted file size. + * @param invalidMessage Redacted failure message for every invalid file operation. + * @param qualificationHooks Deterministic qualification-only read boundaries. + * @returns Exact file bytes from the opened descriptor. + */ +export async function readBoundedRegularFile( + absolutePath: string, + allowedRoot: string, + maximumBytes: number, + invalidMessage: string, + qualificationHooks: BoundedFileReadQualificationHooks = {} +): Promise { + if ( + !path.isAbsolute(absolutePath) || + absolutePath.includes("\0") || + !path.isAbsolute(allowedRoot) || + allowedRoot.includes("\0") || + !Number.isSafeInteger(maximumBytes) || + maximumBytes <= 0 || + invalidMessage.length === 0 || + invalidMessage.includes("\0") + ) { + throw new TypeError( + "Bounded file reads require absolute paths, a byte limit, and a failure message" + ); + } + + const requestedRoot = path.resolve(allowedRoot); + const requestedPath = path.resolve(absolutePath); + if (!isContainedPath(requestedRoot, requestedPath)) { + throw invalidFileState(invalidMessage); + } + + let file: Awaited> | undefined; + let pathFile: Awaited> | undefined; + let result: Buffer | undefined; + let failed = false; + try { + const canonicalRoot = await realpath(requestedRoot); + file = await open( + requestedPath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const descriptorPath = await realpath(`/proc/self/fd/${file.fd}`); + if (!isContainedPath(canonicalRoot, descriptorPath)) { + throw invalidFileState(invalidMessage); + } + + const before = await file.stat({ bigint: true }); + if (!before.isFile() || before.size <= 0n || before.size > BigInt(maximumBytes)) { + throw invalidFileState(invalidMessage); + } + await qualificationHooks.afterInitialStat?.(); + + const expectedBytes = Number(before.size); + const buffer = Buffer.alloc(expectedBytes + 1); + let bytesRead = 0; + while (bytesRead < buffer.byteLength) { + const read = await file.read( + buffer, + bytesRead, + buffer.byteLength - bytesRead, + null + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + + const after = await file.stat({ bigint: true }); + pathFile = await open( + requestedPath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const revalidatedDescriptorPath = await realpath(`/proc/self/fd/${pathFile.fd}`); + const pathState = await pathFile.stat({ bigint: true }); + if ( + bytesRead !== expectedBytes || + !matchesSnapshot(before, after) || + !isContainedPath(canonicalRoot, revalidatedDescriptorPath) || + !pathState.isFile() || + !matchesSnapshot(before, pathState) + ) { + throw invalidFileState(invalidMessage); + } + result = buffer.subarray(0, bytesRead); + } catch { + failed = true; + } + + if (pathFile) { + try { + await pathFile.close(); + } catch { + failed = true; + } + } + if (file) { + try { + await file.close(); + } catch { + failed = true; + } + } + if (failed || !result) throw invalidFileState(invalidMessage); + return result; +} + +/** + * Reads a stable bounded file and rejects malformed UTF-8 with a redacted error. + * @param absolutePath Absolute file path selected by the qualification caller. + * @param allowedRoot Explicit root permitted to contain the descriptor target. + * @param maximumBytes Maximum accepted file size. + * @param invalidStateMessage Redacted file-operation failure message. + * @param invalidUtf8Message Redacted malformed-text failure message. + * @returns Exact bytes and their strictly decoded UTF-8 text. + */ +export async function readBoundedUtf8RegularFile( + absolutePath: string, + allowedRoot: string, + maximumBytes: number, + invalidStateMessage: string, + invalidUtf8Message: string +): Promise<{ bytes: Buffer; text: string }> { + const bytes = await readBoundedRegularFile( + absolutePath, + allowedRoot, + maximumBytes, + invalidStateMessage + ); + try { + return { + bytes, + text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), + }; + } catch { + throw new Error(invalidUtf8Message); + } +} diff --git a/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json b/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json index a6cfdb428..e29d484cf 100644 --- a/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json +++ b/qualification/openclaw/fixtures/2026.7.2-beta.7/manifest.json @@ -167,6 +167,12 @@ "role": "task-registry", "sha256": "fb7ab0badc2453c326edb76099a7324340251df46e7ec0b9b9ef7123b3b7e97b" }, + { + "bytes": 2419, + "path": "dist/task-summary-gsUnf0hI.js", + "role": "task-summary", + "sha256": "d0d13f8077a61d3e4d656f18306368360a991a7c6bfaf526be9d01b30f712ab9" + }, { "bytes": 4058, "path": "dist/tasks-Btru2I19.js", diff --git a/qualification/openclaw/reviewedFixtures.ts b/qualification/openclaw/reviewedFixtures.ts index e4ad90db8..b6c40f226 100644 --- a/qualification/openclaw/reviewedFixtures.ts +++ b/qualification/openclaw/reviewedFixtures.ts @@ -1,17 +1,9 @@ import { createHash } from "node:crypto"; -import { - mkdir, - mkdtemp, - readFile, - readdir, - rename, - rm, - stat, - writeFile, -} from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; import { agentsFixtureSchema, chatFixtureSchema, @@ -69,18 +61,14 @@ async function readBoundedFixture( if (!target.startsWith(`${fixtureRoot}${path.sep}`)) { throw new Error("Reviewed OpenClaw fixture escaped its version directory"); } - const fileStat = await stat(target); - if (!fileStat.isFile() || fileStat.size <= 0 || fileStat.size > maximumFixtureBytes) { - throw new Error(`Reviewed OpenClaw fixture ${fileName} has an invalid size`); - } - const bytes = await readFile(target); - let serialized: string; - try { - serialized = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - throw new Error(`Reviewed OpenClaw fixture ${fileName} is not valid UTF-8`); - } - return { bytes, serialized }; + const fixture = await readBoundedUtf8RegularFile( + target, + fixtureRoot, + maximumFixtureBytes, + `Reviewed OpenClaw fixture ${fileName} has invalid file state`, + `Reviewed OpenClaw fixture ${fileName} is not valid UTF-8` + ); + return { bytes: fixture.bytes, serialized: fixture.text }; } /** @@ -206,27 +194,24 @@ export async function writeOpenClawAuditCandidate( "OpenClaw audit output directory must be named after the source version" ); } + let outputDirectoryExists = true; try { await stat(outputDirectory); - throw new Error("OpenClaw audit output directory already exists"); } catch (error) { if ( - error instanceof Error && - error.message === "OpenClaw audit output directory already exists" - ) { - throw error; - } - if ( - !( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" ) { + outputDirectoryExists = false; + } else { throw error; } } + if (outputDirectoryExists) { + throw new Error("OpenClaw audit output directory already exists"); + } const parentDirectory = path.dirname(outputDirectory); await mkdir(parentDirectory, { recursive: true }); diff --git a/qualification/openclaw/sourceAudit.test.ts b/qualification/openclaw/sourceAudit.test.ts index 62410023a..375a5cfc1 100644 --- a/qualification/openclaw/sourceAudit.test.ts +++ b/qualification/openclaw/sourceAudit.test.ts @@ -141,6 +141,10 @@ async function writeSyntheticOpenClawPackage(sourceRoot: string): Promise killSubagentRunAdmin(); "Subagent completed while cancellation was in progress."; `, + "task-summary-fixture.js": ` + const TASK_PROMPT_MAX_CHARS = 4e3; + const prompt = sanitizeTaskPromptText(task.task, TASK_PROMPT_MAX_CHARS); + `, "subagent-control-fixture.js": ` // Admin kill path for a subagent session key, bypassing caller ownership checks. cascadeKillChildren(); @@ -287,7 +291,7 @@ describe("reviewed OpenClaw protocol fixtures", () => { "chat-delta", "chat-terminal", ]); - expect(reviewed.audit.sourceArtifacts).toHaveLength(22); + expect(reviewed.audit.sourceArtifacts).toHaveLength(23); expect(reviewed.audit.sessions.plan.authority).toMatchObject({ dedicatedGatewayEvent: false, gatewayEvent: "agent", @@ -358,13 +362,9 @@ describe("reviewed OpenClaw protocol fixtures", () => { "utf8" ); - try { - await loadReviewedOpenClawFixtures(fixtureRoot); - throw new Error("Expected fixture hash validation to fail"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("hash mismatch for chat.json"); - } + expect(loadReviewedOpenClawFixtures(fixtureRoot)).rejects.toThrow( + "hash mismatch for chat.json" + ); }); }); }); @@ -403,7 +403,7 @@ describe("explicit OpenClaw source audit", () => { "tasks.get", "tasks.list", ]); - expect(audit.sourceArtifacts).toHaveLength(22); + expect(audit.sourceArtifacts).toHaveLength(23); }); }); @@ -422,11 +422,24 @@ describe("explicit OpenClaw source audit", () => { expect(() => assertOpenClawAuditMatchesReviewed(audit, loaded.audit) ).not.toThrow(); + expect( + writeOpenClawAuditCandidate(audit, outputDirectory) + ).rejects.toThrow("output directory already exists"); } ); }); test("requires explicit absolute host paths and one operation", () => { + expect( + parseSourceAuditCliArguments([ + "--source-root=/opt/openclaw", + "--output=/tmp/openclaw-audit/2026.7.2-beta.7", + ]) + ).toEqual({ + mode: "write", + outputDirectory: "/tmp/openclaw-audit/2026.7.2-beta.7", + sourceRoot: "/opt/openclaw", + }); expect( parseSourceAuditCliArguments([ "--check-reviewed", diff --git a/qualification/openclaw/sourceAudit.ts b/qualification/openclaw/sourceAudit.ts index 7ff92f6b7..0570981d8 100644 --- a/qualification/openclaw/sourceAudit.ts +++ b/qualification/openclaw/sourceAudit.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; -import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; import * as v from "valibot"; +import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; import { parseSourceAuditResult, type SourceArtifact, @@ -167,6 +168,14 @@ const distributionArtifactSpecs: readonly DistributionArtifactSpec[] = [ ], role: "task-registry", }, + { + fileNamePattern: /^task-summary-[A-Za-z0-9_-]+\.js$/u, + markers: [ + "const TASK_PROMPT_MAX_CHARS = 4e3", + "sanitizeTaskPromptText(task.task, TASK_PROMPT_MAX_CHARS)", + ], + role: "task-summary", + }, { fileNamePattern: /^tasks-[A-Za-z0-9_-]+\.js$/u, markers: ["LEDGER_STATUS_TO_TASK_STATUSES", '"tasks.list"', '"tasks.cancel"'], @@ -212,25 +221,19 @@ async function loadSourceArtifact( ): Promise { const requestedPath = path.resolve(sourceRoot, relativePath); assertContainedPath(sourceRoot, requestedPath); - const absolutePath = await realpath(requestedPath); - assertContainedPath(sourceRoot, absolutePath); - const fileStat = await stat(absolutePath); - if (!fileStat.isFile() || fileStat.size <= 0 || fileStat.size > maximumBytes) { - throw new Error(`OpenClaw ${role} artifact has an invalid size`); - } - const bytes = await readFile(absolutePath); - let contents: string; - try { - contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - throw new Error(`OpenClaw ${role} artifact is not valid UTF-8`); - } + const artifact = await readBoundedUtf8RegularFile( + requestedPath, + sourceRoot, + maximumBytes, + `OpenClaw ${role} artifact has invalid file state`, + `OpenClaw ${role} artifact is not valid UTF-8` + ); return { - bytes: bytes.byteLength, - contents, + bytes: artifact.bytes.byteLength, + contents: artifact.text, path: relativePath, role, - sha256: sha256(bytes), + sha256: sha256(artifact.bytes), }; } @@ -276,14 +279,36 @@ function artifactByRole( return artifact; } -function parseIntegerConstant(source: string, name: string): number { - const match = source.match(new RegExp(`const ${name} = ([^;]+);`, "u")); - if (!match?.[1]) throw new Error(`OpenClaw source is missing ${name}`); - const factors = match[1] +const reviewedIntegerConstantNames = [ + "MAX_PAYLOAD_BYTES", + "MAX_PREAUTH_PAYLOAD_BYTES", + "MIN_CLIENT_PROTOCOL_VERSION", + "MIN_NODE_PROTOCOL_VERSION", + "MIN_PROBE_PROTOCOL_VERSION", + "PROTOCOL_VERSION", + "TASK_PROMPT_MAX_CHARS", +] as const; + +type ReviewedIntegerConstantName = (typeof reviewedIntegerConstantNames)[number]; + +function parseIntegerConstant(source: string, name: ReviewedIntegerConstantName): number { + const prefix = `const ${name} = `; + const expressions = source + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.startsWith(prefix) && line.endsWith(";")) + .map((line) => line.slice(prefix.length, -1)); + if (expressions.length !== 1) { + throw new Error(`OpenClaw source must define ${name} exactly once`); + } + const factors = expressions[0]! .trim() .split("*") .map((factor) => factor.trim()); - if (factors.length === 0 || factors.some((factor) => !/^\d+$/u.test(factor))) { + if ( + factors.length === 0 || + factors.some((factor) => !/^\d+(?:e\d+)?$/u.test(factor)) + ) { throw new Error(`OpenClaw ${name} is not a reviewed integer product`); } const result = factors.reduce((product, factor) => product * Number(factor), 1); @@ -368,7 +393,7 @@ function assertMethodPermission( } } -function assertPlanCompanionAndTasks(artifacts: readonly LoadedSourceArtifact[]): void { +function assertPlanCompanionAndTasks(artifacts: readonly LoadedSourceArtifact[]): number { const planTool = artifactByRole(artifacts, "plan-tool").contents; assertRequiredMarkers(planTool, "plan producer", [ '"pending"', @@ -504,6 +529,11 @@ function assertPlanCompanionAndTasks(artifacts: readonly LoadedSourceArtifact[]) "respond(true, {", ] ); + const taskSummary = artifactByRole(artifacts, "task-summary").contents; + assertRequiredMarkers(taskSummary, "task prompt projection", [ + "const TASK_PROMPT_MAX_CHARS = 4e3", + "sanitizeTaskPromptText(task.task, TASK_PROMPT_MAX_CHARS)", + ]); assertRequiredMarkers( artifactByRole(artifacts, "task-registry").contents, "task cancellation", @@ -533,6 +563,7 @@ function assertPlanCompanionAndTasks(artifacts: readonly LoadedSourceArtifact[]) "tasks.cancel", ] ); + return parseIntegerConstant(taskSummary, "TASK_PROMPT_MAX_CHARS"); } function extractGatewayEvents(source: string): string[] { @@ -732,7 +763,7 @@ export async function auditInstalledOpenClaw( artifactByRole(artifacts, "gateway-websocket").contents, declarations ); - assertPlanCompanionAndTasks(artifacts); + const taskPromptChars = assertPlanCompanionAndTasks(artifacts); return parseSourceAuditResult({ agents: { @@ -1016,7 +1047,7 @@ export async function auditInstalledOpenClaw( promptVisibility: { getIncludesBoundedPrompt: true, listAndEventsOmitPrompt: true, - promptChars: 4000, + promptChars: taskPromptChars, }, runtimeMappings: [ { internal: "cancelled", wire: "cancelled" }, diff --git a/qualification/openclaw/sourceAuditSchemas.ts b/qualification/openclaw/sourceAuditSchemas.ts index 2b7616fb8..11d99650b 100644 --- a/qualification/openclaw/sourceAuditSchemas.ts +++ b/qualification/openclaw/sourceAuditSchemas.ts @@ -442,6 +442,7 @@ export const sourceArtifactSchema = v.strictObject({ "session-companion-runtime", "subagent-control", "task-registry", + "task-summary", "tasks-handlers", ]), sha256: sha256Schema, @@ -449,7 +450,7 @@ export const sourceArtifactSchema = v.strictObject({ const sourceArtifactsSchema = v.pipe( v.array(sourceArtifactSchema), - v.length(22), + v.length(23), v.check( (artifacts) => isSortedAndUnique(artifacts.map((artifact) => artifact.role)), "Source artifact roles must be sorted and unique" diff --git a/qualification/outbox/sqliteOutboxChild.ts b/qualification/outbox/sqliteOutboxChild.ts index ba06c1c9e..4b6b14764 100644 --- a/qualification/outbox/sqliteOutboxChild.ts +++ b/qualification/outbox/sqliteOutboxChild.ts @@ -1,7 +1,11 @@ import { Data, Effect } from "effect"; import * as v from "valibot"; -import type { SqliteOutboxChildStatus } from "./sqliteOutboxProtocol.ts"; +import { + sqliteOutboxMaximumBatchSize, + sqliteOutboxMaximumDrainNonemptyPolls, + type SqliteOutboxChildStatus, +} from "./sqliteOutboxProtocol.ts"; import { appendQualificationOutboxBatch, claimQualificationOutboxBatch, @@ -18,7 +22,7 @@ const boundedBatchSchema = v.pipe( v.number(), v.integer(), v.minValue(1), - v.maxValue(1000) + v.maxValue(sqliteOutboxMaximumBatchSize) ); const produceCommandSchema = v.strictObject({ @@ -141,7 +145,7 @@ function runDrainCommand( database: ReturnType, command: Extract ) { - const maximumPolls = 1001; + const maximumPolls = sqliteOutboxMaximumDrainNonemptyPolls + 1; return Effect.gen(function* () { let claimedCount = 0; let deliveredCount = 0; diff --git a/qualification/outbox/sqliteOutboxProtocol.ts b/qualification/outbox/sqliteOutboxProtocol.ts index 9494567cb..8cc1409f5 100644 --- a/qualification/outbox/sqliteOutboxProtocol.ts +++ b/qualification/outbox/sqliteOutboxProtocol.ts @@ -1,12 +1,25 @@ import * as v from "valibot"; const identifierSchema = v.pipe(v.string(), v.regex(/^[a-z][a-z0-9-]{0,63}$/u)); -const countSchema = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(10_000)); +export const sqliteOutboxMaximumBatchSize = 1000; +export const sqliteOutboxMaximumDrainNonemptyPolls = 1000; +const batchCountSchema = v.pipe( + v.number(), + v.integer(), + v.minValue(0), + v.maxValue(sqliteOutboxMaximumBatchSize) +); +const drainCountSchema = v.pipe( + v.number(), + v.integer(), + v.minValue(0), + v.maxValue(sqliteOutboxMaximumBatchSize * sqliteOutboxMaximumDrainNonemptyPolls) +); const eventIdSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); export const sqliteOutboxChildStatusSchema = v.variant("kind", [ v.strictObject({ - count: countSchema, + count: batchCountSchema, eventIds: v.array(eventIdSchema), kind: v.literal("produced"), producerId: identifierSchema, @@ -17,8 +30,8 @@ export const sqliteOutboxChildStatusSchema = v.variant("kind", [ workerId: identifierSchema, }), v.strictObject({ - claimedCount: countSchema, - deliveredCount: countSchema, + claimedCount: drainCountSchema, + deliveredCount: drainCountSchema, kind: v.literal("drained"), workerId: identifierSchema, }), diff --git a/qualification/outbox/sqliteOutboxQualification.test.ts b/qualification/outbox/sqliteOutboxQualification.test.ts index 8f45a5b27..304fae072 100644 --- a/qualification/outbox/sqliteOutboxQualification.test.ts +++ b/qualification/outbox/sqliteOutboxQualification.test.ts @@ -73,16 +73,27 @@ describe("file-backed Bun SQLite qualification", () => { const contention = yield* Effect.sync(() => { writer.run("BEGIN IMMEDIATE"); + let competingWriterAcquired = false; + let classifiedError: ReturnType< + typeof classifyQualificationSqliteError + >; try { competingWriter.run("BEGIN IMMEDIATE"); - throw new Error( - "Competing writer unexpectedly acquired WAL lock" - ); + competingWriterAcquired = true; } catch (error) { - return classifyQualificationSqliteError(error); + classifiedError = classifyQualificationSqliteError(error); } finally { + if (competingWriterAcquired) { + competingWriter.run("ROLLBACK"); + } writer.run("ROLLBACK"); } + if (competingWriterAcquired) { + throw new Error( + "Competing writer unexpectedly acquired WAL lock" + ); + } + return classifiedError; }); return { diff --git a/qualification/outbox/sqliteOutboxQualification.ts b/qualification/outbox/sqliteOutboxQualification.ts index 6ce083425..dc312cd5f 100644 --- a/qualification/outbox/sqliteOutboxQualification.ts +++ b/qualification/outbox/sqliteOutboxQualification.ts @@ -60,7 +60,7 @@ export interface OutboxLatencySummary { export interface SqliteOutboxQualificationReport { readonly crashedClaimEventIds: readonly number[]; - readonly crashedWorkerSignal: NodeJS.Signals | null; + readonly crashedWorkerSignal: NodeJS.Signals; readonly finalSnapshot: QualificationOutboxSnapshot; readonly integrityCheck: string; readonly journalMode: string; @@ -215,7 +215,7 @@ function claimAndTerminateChild( statusPath: string ): Effect.Effect< { - readonly child: QualificationChildProcess; + readonly signal: NodeJS.Signals; readonly status: SqliteOutboxChildStatus; }, QualificationChildProcessError | QualificationDeadlineError @@ -235,7 +235,16 @@ function claimAndTerminateChild( const status = yield* readStatus(statusPath, operation); yield* Effect.sync(() => child.kill("SIGKILL")); yield* awaitChildExit(child, `${operation}:crash`); - return { child, status }; + const signal = child.signalCode; + if (signal === null) { + return yield* Effect.fail( + new QualificationChildProcessError({ + exitCode: child.exitCode ?? undefined, + operation: `${operation}:missing-signal`, + }) + ); + } + return { signal, status }; }) ); } @@ -364,7 +373,7 @@ export const sqliteOutboxQualification = Effect.scoped( return Object.freeze({ crashedClaimEventIds: Object.freeze([...terminated.status.eventIds]), - crashedWorkerSignal: terminated.child.signalCode, + crashedWorkerSignal: terminated.signal, finalSnapshot, integrityCheck, journalMode, diff --git a/qualification/parity/legacyBackendRouteInventory.ts b/qualification/parity/legacyBackendRouteInventory.ts index d20dbed34..390eed510 100644 --- a/qualification/parity/legacyBackendRouteInventory.ts +++ b/qualification/parity/legacyBackendRouteInventory.ts @@ -1,9 +1,11 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import * as v from "valibot"; +import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; + const maximumServerSourceBytes = 256 * 1024; const maximumProbeOutputBytes = 64 * 1024; const importedRepositoryRoot = path.resolve(import.meta.dir, "../.."); @@ -50,22 +52,20 @@ function assertSingleSourceMatch( async function assertWebSocketRouteSource(repositoryRoot: string): Promise { const sourcePath = path.join(repositoryRoot, "backend/src/server/app.ts"); - const sourceStat = await stat(sourcePath); - if ( - !sourceStat.isFile() || - sourceStat.size <= 0 || - sourceStat.size > maximumServerSourceBytes - ) { - throw new Error("Legacy WebSocket server source has an invalid size"); - } - const source = await readFile(sourcePath, "utf8"); + const source = await readBoundedUtf8RegularFile( + sourcePath, + repositoryRoot, + maximumServerSourceBytes, + "Legacy WebSocket server source has invalid file state", + "Legacy WebSocket server source is not valid UTF-8" + ); assertSingleSourceMatch( - source, + source.text, /if \(url\.pathname === "\/ws"\) \{/gu, "WebSocket route branch" ); assertSingleSourceMatch( - source, + source.text, /server\.upgrade\(request, \{/gu, "WebSocket upgrade call" ); diff --git a/qualification/parity/parityFixtureCandidate.ts b/qualification/parity/parityFixtureCandidate.ts index a35bd43a4..eec050364 100644 --- a/qualification/parity/parityFixtureCandidate.ts +++ b/qualification/parity/parityFixtureCandidate.ts @@ -14,22 +14,56 @@ export interface ParityFixtureCandidate { legacyEndpoints: LegacyEndpointParityFixture; } -interface ProcedureContractCandidate { +export interface ProcedureContractCandidate { kind: "mutation" | "query" | "subscription"; name: string; } -interface RawHttpContractCandidate { +export type ProcedureContractIdentity = ProcedureContractCandidate; + +export interface RawHttpContractCandidate { method: "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; path: string; } +export interface RawHttpContractIdentity { + method: string; + path: string; +} + function compareStrings(left: string, right: string): number { if (left < right) return -1; if (left > right) return 1; return 0; } +/** + * Projects live registry entries into their deterministic reviewed identity shape. + * @param procedureContracts Live procedure registry entries. + * @param rawHttpContracts Live raw HTTP registry entries. + * @returns Sorted procedure and raw HTTP identities. + */ +export function projectGreenfieldContractIdentities( + procedureContracts: readonly ProcedureContractIdentity[], + rawHttpContracts: readonly { readonly method: TMethod; readonly path: string }[] +): { + readonly procedures: readonly ProcedureContractIdentity[]; + readonly rawHttp: readonly { + readonly id: string; + readonly method: TMethod; + readonly path: string; + }[]; +} { + return { + procedures: procedureContracts + .map(({ kind, name }) => ({ kind, name })) + .toSorted((left, right) => compareStrings(left.name, right.name)), + rawHttp: rawHttpContracts + .map(({ method, path }) => ({ id: `${method} ${path}`, method, path })) + .toSorted((left, right) => compareStrings(left.id, right.id)), + }; +} + /** * Builds a deterministic greenfield registry identity candidate. * @param procedureContracts Live procedure registry entries. @@ -40,6 +74,10 @@ export function buildGreenfieldContractFixtureCandidate( procedureContracts: readonly ProcedureContractCandidate[], rawHttpContracts: readonly RawHttpContractCandidate[] ): GreenfieldContractParityFixture { + const identities = projectGreenfieldContractIdentities( + procedureContracts, + rawHttpContracts + ); return parseGreenfieldContractParityFixture({ contentPolicy: { containsHostConfiguration: false, @@ -47,12 +85,7 @@ export function buildGreenfieldContractFixtureCandidate( containsSecrets: false, sourceBacked: true, }, - procedures: procedureContracts - .map(({ kind, name }) => ({ kind, name })) - .toSorted((left, right) => compareStrings(left.name, right.name)), - rawHttp: rawHttpContracts - .map(({ method, path }) => ({ id: `${method} ${path}`, method, path })) - .toSorted((left, right) => compareStrings(left.id, right.id)), + ...identities, schemaVersion: 1, source: "src/contracts/contractRegistry.ts", }); diff --git a/qualification/parity/parityInventory.test.ts b/qualification/parity/parityInventory.test.ts index 7d7dde4c7..3500c8d4e 100644 --- a/qualification/parity/parityInventory.test.ts +++ b/qualification/parity/parityInventory.test.ts @@ -11,7 +11,12 @@ import { buildGreenfieldContractFixtureCandidate, buildParityFixtureCandidate, } from "./parityFixtureCandidate.ts"; -import { parseFrontendParityFixture } from "./parityInventorySchemas.ts"; +import { + parseFrontendParityFixture, + reviewedLegacyEndpointRowCount, + type FrontendRouteInventory, + type LegacyEndpointInventory, +} from "./parityInventorySchemas.ts"; import { assertGreenfieldRegistryMatchesReviewed, assertGreenfieldTargetAccounting, @@ -29,11 +34,14 @@ const repositoryRoot = path.resolve( ); function countByPhase( - values: readonly { target: { kind?: string; phase?: string } }[] + values: readonly ( + | Pick + | Pick + )[] ): Record { const counts: Record = {}; for (const value of values) { - if (value.target.kind === "reviewed-removal" || !value.target.phase) continue; + if ("kind" in value.target && value.target.kind === "reviewed-removal") continue; counts[value.target.phase] = (counts[value.target.phase] ?? 0) + 1; } return counts; @@ -116,9 +124,11 @@ describe("reviewed legacy endpoint parity inventory", () => { expect(backendRoutes.filter(({ method }) => method === "WebSocket")).toEqual([ { id: "WebSocket /ws", method: "WebSocket", path: "/ws" }, ]); - expect(reviewed.legacyEndpoints.endpoints).toHaveLength(157); + expect(reviewed.legacyEndpoints.endpoints).toHaveLength( + reviewedLegacyEndpointRowCount + ); expect(new Set(reviewed.legacyEndpoints.endpoints.map(({ id }) => id)).size).toBe( - 157 + reviewedLegacyEndpointRowCount ); expect(countByPhase(reviewed.legacyEndpoints.endpoints)).toEqual({ "phase-1": 7, @@ -139,7 +149,7 @@ describe("reviewed legacy endpoint parity inventory", () => { ({ target }) => target.kind === "reviewed-removal" ) ).toHaveLength(0); - }); + }, 20_000); test("checks implemented mappings against the greenfield registries", async () => { const reviewed = await loadReviewedParityInventory(); diff --git a/qualification/parity/parityInventorySchemas.ts b/qualification/parity/parityInventorySchemas.ts index 77ddf57ea..cf5dbbc18 100644 --- a/qualification/parity/parityInventorySchemas.ts +++ b/qualification/parity/parityInventorySchemas.ts @@ -16,7 +16,7 @@ const routePathSchema = v.pipe( ); const rawHttpPathSchema = v.pipe( v.string(), - v.regex(/^\/api\/[A-Za-z0-9._~!$&'()*+,;=:@%*/-]+$/u) + v.regex(/^\/api\/[A-Za-z0-9._~!$&'()+,;=:@%*/-]+$/u) ); const phaseSchema = v.picklist([ "phase-1", @@ -38,6 +38,8 @@ const sourceMethodSchema = v.picklist([ ]); const rawHttpMethodSchema = v.picklist(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]); +export const reviewedLegacyEndpointRowCount = 157; + function valuesAreSortedAndUnique(values: string[]): boolean { return values.every((value, index) => index === 0 || values[index - 1]! < value); } @@ -189,8 +191,8 @@ export const legacyEndpointParityFixtureSchema = v.pipe( }), }), v.check( - (fixture) => fixture.endpoints.length === 157, - "The reviewed legacy endpoint inventory must contain exactly 157 rows" + (fixture) => fixture.endpoints.length === reviewedLegacyEndpointRowCount, + `The reviewed legacy endpoint inventory must contain exactly ${reviewedLegacyEndpointRowCount} rows` ) ); diff --git a/qualification/parity/reviewedParityInventory.ts b/qualification/parity/reviewedParityInventory.ts index 420367989..d288c62d3 100644 --- a/qualification/parity/reviewedParityInventory.ts +++ b/qualification/parity/reviewedParityInventory.ts @@ -1,7 +1,12 @@ -import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; +import { + projectGreenfieldContractIdentities, + type ProcedureContractIdentity, + type RawHttpContractIdentity, +} from "./parityFixtureCandidate.ts"; import { parseFrontendParityFixture, parseGreenfieldContractParityFixture, @@ -24,39 +29,39 @@ export interface ReviewedParityInventory { legacyEndpoints: LegacyEndpointParityFixture; } -interface ProcedureContractIdentity { - kind: "mutation" | "query" | "subscription"; - name: string; +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; } -interface RawHttpContractIdentity { - method: string; - path: string; +function withCanonicalObjectKeyOrder(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((nested) => withCanonicalObjectKeyOrder(nested)); + } + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value) + .toSorted(([left], [right]) => compareStrings(left, right)) + .map(([key, nested]) => [key, withCanonicalObjectKeyOrder(nested)]) + ); } function canonicalJson(value: unknown): string { - return `${JSON.stringify(value, undefined, 2)}\n`; -} - -function compareStrings(left: string, right: string): number { - if (left < right) return -1; - if (left > right) return 1; - return 0; + return `${JSON.stringify(withCanonicalObjectKeyOrder(value), undefined, 2)}\n`; } async function loadJsonFixture(fileName: string): Promise { const fixturePath = path.join(fixtureDirectory, fileName); - const fixtureStat = await stat(fixturePath); - if ( - !fixtureStat.isFile() || - fixtureStat.size <= 0 || - fixtureStat.size > maximumFixtureBytes - ) { - throw new Error(`Parity fixture ${fileName} has an invalid size`); - } - const serialized = await readFile(fixturePath, "utf8"); + const fixture = await readBoundedUtf8RegularFile( + fixturePath, + fixtureDirectory, + maximumFixtureBytes, + `Parity fixture ${fileName} has invalid file state`, + `Parity fixture ${fileName} is not valid UTF-8` + ); try { - return JSON.parse(serialized) as unknown; + return JSON.parse(fixture.text) as unknown; } catch { throw new Error(`Parity fixture ${fileName} is not valid JSON`); } @@ -138,18 +143,10 @@ export function assertGreenfieldRegistryMatchesReviewed( procedureContracts: readonly ProcedureContractIdentity[], rawHttpContracts: readonly RawHttpContractIdentity[] ): void { - const observed = { - procedures: procedureContracts - .map(({ kind, name }) => ({ kind, name })) - .toSorted((left, right) => compareStrings(left.name, right.name)), - rawHttp: rawHttpContracts - .map(({ method, path: routePath }) => ({ - id: contractKey(method, routePath), - method, - path: routePath, - })) - .toSorted((left, right) => compareStrings(left.id, right.id)), - }; + const observed = projectGreenfieldContractIdentities( + procedureContracts, + rawHttpContracts + ); const expected = { procedures: reviewed.greenfieldContracts.procedures, rawHttp: reviewed.greenfieldContracts.rawHttp, diff --git a/qualification/parity/sourceParityInventory.test.ts b/qualification/parity/sourceParityInventory.test.ts index 837bdae77..2d9d94ec2 100644 --- a/qualification/parity/sourceParityInventory.test.ts +++ b/qualification/parity/sourceParityInventory.test.ts @@ -19,12 +19,13 @@ function replaceExactly(source: string, target: string, replacement: string): st return `${parts[0]}${replacement}${parts[1]}`; } -async function withModifiedRouter( - modifyRouter: (source: string) => string, +async function withModifiedSource( + relativeSourcePath: (typeof paritySourcePaths)[keyof typeof paritySourcePaths], + modifySource: (source: string) => string, verify: (temporaryRepositoryRoot: string) => Promise ): Promise { const temporaryRepositoryRoot = await mkdtemp( - path.join(tmpdir(), "mira-parity-router-") + path.join(tmpdir(), "mira-parity-source-") ); try { await Promise.all( @@ -34,9 +35,9 @@ async function withModifiedRouter( await copyFile(path.join(repositoryRoot, relativePath), destination); }) ); - const routerPath = path.join(temporaryRepositoryRoot, paritySourcePaths.router); - const routerSource = await readFile(routerPath, "utf8"); - await writeFile(routerPath, modifyRouter(routerSource), "utf8"); + const sourcePath = path.join(temporaryRepositoryRoot, relativeSourcePath); + const source = await readFile(sourcePath, "utf8"); + await writeFile(sourcePath, modifySource(source), "utf8"); await verify(temporaryRepositoryRoot); } finally { await rm(temporaryRepositoryRoot, { force: true, recursive: true }); @@ -68,7 +69,8 @@ test("allows the explicitly reviewed authenticated pathless layout", async () => }); test("rejects a pathless layout whose authentication guard is weakened", async () => { - await withModifiedRouter( + await withModifiedSource( + paritySourcePaths.router, (source) => replaceExactly( source, @@ -84,7 +86,8 @@ test("rejects a pathless layout whose authentication guard is weakened", async ( }); test("rejects an unreviewed pathless createRoute declaration", async () => { - await withModifiedRouter( + await withModifiedSource( + paritySourcePaths.router, (source) => replaceExactly( source, @@ -106,7 +109,8 @@ const routeTree = rootRoute.addChildren([` }); test("rejects a createRoute path that is no longer a reviewed literal", async () => { - await withModifiedRouter( + await withModifiedSource( + paritySourcePaths.router, (source) => replaceExactly(source, ' path: "/login",', " path: loginPath,"), (temporaryRepositoryRoot) => expectInventoryLoadFailure( @@ -117,7 +121,8 @@ test("rejects a createRoute path that is no longer a reviewed literal", async () }); test("rejects a createRoute declaration outside the reviewed block shape", async () => { - await withModifiedRouter( + await withModifiedSource( + paritySourcePaths.router, (source) => replaceExactly( source, @@ -133,7 +138,8 @@ test("rejects a createRoute declaration outside the reviewed block shape", async }); test("rejects routeTree identifiers that do not exactly match declarations", async () => { - await withModifiedRouter( + await withModifiedSource( + paritySourcePaths.router, (source) => replaceExactly(source, " settingsRoute,", " loginRoute,"), (temporaryRepositoryRoot) => @@ -143,3 +149,88 @@ test("rejects routeTree identifiers that do not exactly match declarations", asy ) ); }); + +test("rejects a routeTree child identifier without the Route suffix", async () => { + await withModifiedSource( + paritySourcePaths.router, + (source) => + replaceExactly( + source, + " settingsRoute,", + " settingsRoute,\n settingsPage," + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Reviewed route tree identifiers differ" + ) + ); +}); + +test("rejects unrecognized routeTree syntax", async () => { + await withModifiedSource( + paritySourcePaths.router, + (source) => + replaceExactly( + source, + " loginRoute,", + " loginRoute,\n ...conditionallyIncludedRoutes," + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Reviewed route tree contains unrecognized syntax" + ) + ); +}); + +test("rejects navigation entries outside the reviewed literal shape", async () => { + await withModifiedSource( + paritySourcePaths.navigation, + (source) => + replaceExactly( + source, + ' { to: "/", icon: Home, label: "Dashboard" },', + ' { to: "/", icon: Home, label: "Dashboard", unreviewed: true },' + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Reviewed navigation contains an unrecognized item shape" + ) + ); +}); + +test("rejects route modules outside the reviewed literal shape", async () => { + await withModifiedSource( + paritySourcePaths.routeModules, + (source) => + replaceExactly( + source, + ' agents: () => import("../pages/Agents"),', + ' agents: () => import("../pages/Agents") ,' + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Reviewed route module registry changed outside the literal shape" + ) + ); +}); + +test("rejects preload entries outside the reviewed literal shape", async () => { + await withModifiedSource( + paritySourcePaths.routeModules, + (source) => + replaceExactly( + source, + ' "/agents": routeModules.agents,', + ' "/agents": routeModules.agents ,' + ), + (temporaryRepositoryRoot) => + expectInventoryLoadFailure( + temporaryRepositoryRoot, + "Reviewed route preload registry changed outside the literal shape" + ) + ); +}); diff --git a/qualification/parity/sourceParityInventory.ts b/qualification/parity/sourceParityInventory.ts index 8c520fffd..06374fce3 100644 --- a/qualification/parity/sourceParityInventory.ts +++ b/qualification/parity/sourceParityInventory.ts @@ -1,6 +1,6 @@ -import { readFile, stat } from "node:fs/promises"; import path from "node:path"; +import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; import type { FrontendRouteInventory, LegacyEndpointInventory, @@ -58,6 +58,12 @@ interface RouteDeclaration { sourceRouteName: string; } +interface RouteTreeToken { + kind: "identifier" | "punctuation"; + offset: number; + value: string; +} + const reviewedPathlessRoutes = { authenticatedRoute: { id: "authenticated", @@ -227,6 +233,83 @@ function assertReviewedPathlessRoute(declaration: RouteDeclaration): void { } } +function tokenizeRouteTree(routeTree: string): RouteTreeToken[] { + const tokens: RouteTreeToken[] = []; + let offset = 0; + while (offset < routeTree.length) { + const character = routeTree[offset]!; + if (/\s/u.test(character)) { + offset += 1; + continue; + } + const identifier = routeTree + .slice(offset) + .match(/^[A-Za-z_$][A-Za-z0-9_$]*/u)?.[0]; + if (identifier) { + tokens.push({ kind: "identifier", offset, value: identifier }); + offset += identifier.length; + continue; + } + if (".()[],;".includes(character)) { + tokens.push({ kind: "punctuation", offset, value: character }); + offset += 1; + continue; + } + throw new Error( + `Reviewed route tree contains unrecognized syntax at offset ${offset}` + ); + } + return tokens; +} + +function parseRouteTreeIdentifiers(routeTree: string): string[] { + const tokens = tokenizeRouteTree(routeTree); + const identifiers: string[] = []; + let position = 0; + + function syntaxError(): Error { + const token = tokens[position]; + const context = token + ? `${JSON.stringify(token.value)} at offset ${token.offset}` + : "the end of the route tree"; + return new Error( + `Reviewed route tree contains unrecognized syntax near ${context}` + ); + } + + function consume(value: string): void { + if (tokens[position]?.value !== value) throw syntaxError(); + position += 1; + } + + function consumeIdentifier(): string { + const token = tokens[position]; + if (token?.kind !== "identifier") throw syntaxError(); + position += 1; + return token.value; + } + + function parseNode(): void { + identifiers.push(consumeIdentifier()); + if (tokens[position]?.value !== ".") return; + consume("."); + if (consumeIdentifier() !== "addChildren") throw syntaxError(); + consume("("); + consume("["); + while (tokens[position]?.value !== "]") { + parseNode(); + consume(","); + } + consume("]"); + consume(")"); + } + + parseNode(); + consume(";"); + if (position !== tokens.length) throw syntaxError(); + return identifiers; +} + function assertExactRouteTreeIdentifiers( source: string, declarations: readonly RouteDeclaration[] @@ -236,9 +319,8 @@ function assertExactRouteTreeIdentifiers( /^const routeTree = ([\s\S]*?)^\/\*\* Defines router\. \*\/$/mu, "route tree" ); - const observedIdentifiers = [...routeTree.matchAll(/\b([a-z][A-Za-z0-9]*Route)\b/gu)] - .map((match) => match[1]!) - .toSorted(compareStrings); + const observedIdentifiers = + parseRouteTreeIdentifiers(routeTree).toSorted(compareStrings); const expectedIdentifiers = [ "rootRoute", ...declarations.map((declaration) => declaration.identifier), @@ -432,15 +514,14 @@ async function readBoundedUtf8( if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error("Parity source path escaped the repository root"); } - const sourceStat = await stat(absolutePath); - if ( - !sourceStat.isFile() || - sourceStat.size <= 0 || - sourceStat.size > maximumSourceBytes - ) { - throw new Error(`Parity source ${relativePath} has an invalid size`); - } - return readFile(absolutePath, "utf8"); + const source = await readBoundedUtf8RegularFile( + absolutePath, + repositoryRoot, + maximumSourceBytes, + `Parity source ${relativePath} has invalid file state`, + `Parity source ${relativePath} is not valid UTF-8` + ); + return source.text; } /** diff --git a/qualification/resources/sseMemoryScenario.ts b/qualification/resources/sseMemoryScenario.ts index bd4e6e2f2..9b9080d34 100644 --- a/qualification/resources/sseMemoryScenario.ts +++ b/qualification/resources/sseMemoryScenario.ts @@ -12,10 +12,7 @@ import { type CgroupV2AncestorSnapshot, readCgroupV2AncestorSnapshots, } from "./cgroupV2Hierarchy.ts"; -import { - pausedTlsSseClientResource, - type PausedTlsSseClient, -} from "./pausedTlsSseClient.ts"; +import { pausedTlsSseClientResource } from "./pausedTlsSseClient.ts"; import { maximumProcessMemory, readProcessMemorySnapshot, @@ -53,21 +50,6 @@ function qualificationPayload(sequence: number, size: number): string { return `${prefix}${seed.repeat(Math.ceil(size / seed.length))}`.slice(0, size); } -async function closeConsumers(consumers: readonly PausedTlsSseClient[]): Promise { - const results = await Promise.allSettled( - consumers.map((consumer) => consumer.close()) - ); - const failures: unknown[] = []; - for (const result of results) { - if (result.status === "rejected") { - failures.push(result.reason as unknown); - } - } - if (failures.length > 0) { - throw new AggregateError(failures, "Could not close SSE slow consumers"); - } -} - async function settleMemory(): Promise { Bun.gc(true); await Bun.sleep(sseMemoryQualificationPolicy.scenario.stabilizationMs); @@ -171,11 +153,6 @@ export async function runSseMemoryScenario( target: new URL(`http://127.0.0.1:${release.port}`), }); cleanup.defer("SSE memory qualification proxy", () => proxy.stop(true)); - const consumerScope = await Effect.runPromise(Scope.make("parallel")); - cleanup.defer("SSE memory slow-consumer scope", () => - Effect.runPromise(Scope.close(consumerScope, Exit.void)) - ); - await settleMemory(); baselineCgroup = await readCurrentCgroupV2Snapshot(); baselineCgroupAncestors = await readCgroupV2AncestorSnapshots( @@ -204,72 +181,79 @@ export async function runSseMemoryScenario( const roundDeadline = roundStartedAt + sseMemoryQualificationPolicy.scenario.roundDisconnectTimeoutMs; - const roundConsumers: PausedTlsSseClient[] = []; + const roundScope = await Effect.runPromise(Scope.make("parallel")); + let roundScopeClosed = false; + const closeRoundScope = async (): Promise => { + if (roundScopeClosed) return; + roundScopeClosed = true; + await Effect.runPromise(Scope.close(roundScope, Exit.void)); + }; const expectedDrops = (roundIndex + 1) * sseMemoryQualificationPolicy.scenario.consumerCount; - for ( - let consumerIndex = 0; - consumerIndex < sseMemoryQualificationPolicy.scenario.consumerCount; - consumerIndex += 1 - ) { - const timeoutMs = remainingRoundTime(roundDeadline); - const consumerResource = pausedTlsSseClientResource( - proxy.url, - tlsIdentity.certificate, - qualificationCookie, - timeoutMs - ); - const consumer = await Effect.runPromise( - Scope.provide(consumerScope)(consumerResource) - ); - roundConsumers.push(consumer); - } - traceScenario(`round-${roundIndex + 1}-clients-paused`, startedAt); - await waitFor( - () => - eventFeed.activeSubscriberCount === - sseMemoryQualificationPolicy.scenario.consumerCount, - remainingRoundTime(roundDeadline) - ); - let roundPublishedEvents = 0; - while ( - roundPublishedEvents < - sseMemoryQualificationPolicy.scenario.maximumEventsPerRound && - eventFeed.metricsSnapshot().droppedSlowSubscribers < expectedDrops - ) { - const remaining = - sseMemoryQualificationPolicy.scenario.maximumEventsPerRound - - roundPublishedEvents; - const batchSize = Math.min( - remaining, - sseMemoryQualificationPolicy.scenario.publishBatchSize + try { + for ( + let consumerIndex = 0; + consumerIndex < sseMemoryQualificationPolicy.scenario.consumerCount; + consumerIndex += 1 + ) { + const timeoutMs = remainingRoundTime(roundDeadline); + const consumerResource = pausedTlsSseClientResource( + proxy.url, + tlsIdentity.certificate, + qualificationCookie, + timeoutMs + ); + await Effect.runPromise(Scope.provide(roundScope)(consumerResource)); + } + traceScenario(`round-${roundIndex + 1}-clients-paused`, startedAt); + await waitFor( + () => + eventFeed.activeSubscriberCount === + sseMemoryQualificationPolicy.scenario.consumerCount, + remainingRoundTime(roundDeadline) ); - for (let index = 0; index < batchSize; index += 1) { - publishedEvents += 1; - roundPublishedEvents += 1; - eventFeed.publish({ - kind: "qualification.changed", - payload: qualificationPayload( - publishedEvents, - sseMemoryQualificationPolicy.scenario.payloadBytes - ), - value: publishedEvents, - }); + + while ( + roundPublishedEvents < + sseMemoryQualificationPolicy.scenario.maximumEventsPerRound && + eventFeed.metricsSnapshot().droppedSlowSubscribers < expectedDrops + ) { + const remaining = + sseMemoryQualificationPolicy.scenario.maximumEventsPerRound - + roundPublishedEvents; + const batchSize = Math.min( + remaining, + sseMemoryQualificationPolicy.scenario.publishBatchSize + ); + for (let index = 0; index < batchSize; index += 1) { + publishedEvents += 1; + roundPublishedEvents += 1; + eventFeed.publish({ + kind: "qualification.changed", + payload: qualificationPayload( + publishedEvents, + sseMemoryQualificationPolicy.scenario.payloadBytes + ), + value: publishedEvents, + }); + } + await Bun.sleep(0); + sampledPeak = activeProcessSampler.sample(); + remainingRoundTime(roundDeadline); } - await Bun.sleep(0); - sampledPeak = activeProcessSampler.sample(); - remainingRoundTime(roundDeadline); - } - await waitForApplicationDisconnect( - eventFeed, - expectedDrops, - remainingRoundTime(roundDeadline) - ); - traceScenario(`round-${roundIndex + 1}-queues-detached`, startedAt); - await closeConsumers(roundConsumers); + await waitForApplicationDisconnect( + eventFeed, + expectedDrops, + remainingRoundTime(roundDeadline) + ); + traceScenario(`round-${roundIndex + 1}-queues-detached`, startedAt); + await closeRoundScope(); + } finally { + await closeRoundScope(); + } traceScenario(`round-${roundIndex + 1}-clients-closed`, startedAt); await waitForTransportCleanup( release, diff --git a/qualification/shutdown/completeShutdownQualification.test.ts b/qualification/shutdown/completeShutdownQualification.test.ts index 55c45a3fb..97e18c931 100644 --- a/qualification/shutdown/completeShutdownQualification.test.ts +++ b/qualification/shutdown/completeShutdownQualification.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { Effect } from "effect"; +import { Deferred, Effect, Fiber } from "effect"; +import { TestClock } from "effect/testing"; import { + cancelShutdownStreamBeforeDeadline, collectLinuxProcessGroupMembers, completeShutdownQualification, interruptedShutdownQualification, @@ -11,6 +13,43 @@ import { } from "./completeShutdownQualification.ts"; describe("complete process shutdown qualification", () => { + test("bounds a non-cooperative stream finalizer and continues older cleanup", async () => { + const events: string[] = []; + const program = Effect.gen(function* () { + const cancelStarted = yield* Deferred.make(); + const cleanupFiber = yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + events.push("fallback"); + }) + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + events.push("cancel"); + }).pipe( + Effect.andThen(Deferred.succeed(cancelStarted, undefined)), + Effect.andThen( + cancelShutdownStreamBeforeDeadline( + () => new Promise(() => {}), + 25 + ) + ) + ) + ); + }) + ).pipe(Effect.forkChild); + + yield* Deferred.await(cancelStarted); + yield* TestClock.adjust(25); + yield* Fiber.join(cleanupFiber); + }); + + await Effect.runPromise(Effect.provide(program, TestClock.layer())); + + expect(events).toEqual(["cancel", "fallback"]); + }); + test("parses Linux stat records whose command contains spaces and parentheses", () => { expect( parseLinuxProcessStat( @@ -125,7 +164,7 @@ describe("complete process shutdown qualification", () => { expect(events.indexOf(cleanupEvent)).toBeGreaterThan(readinessDownIndex); } } - }); + }, 60_000); test("interrupts the owner scope without leaking its detached process group", async () => { const report = await Effect.runPromise(interruptedShutdownQualification); @@ -139,5 +178,5 @@ describe("complete process shutdown qualification", () => { expect(report.stoppedStatus.phase).toBe("stopped"); expect(report.stoppedStatus.events).toContain("readiness-down"); expect(report.stoppedStatus.events.at(-1)).toBe("stopped"); - }); + }, 30_000); }); diff --git a/qualification/shutdown/completeShutdownQualification.ts b/qualification/shutdown/completeShutdownQualification.ts index f7028e035..08e0cd826 100644 --- a/qualification/shutdown/completeShutdownQualification.ts +++ b/qualification/shutdown/completeShutdownQualification.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { Data, Deferred, Effect, Fiber, Schedule, Scope } from "effect"; +import { Data, Deferred, Duration, Effect, Fiber, Schedule, Scope } from "effect"; import * as v from "valibot"; import { @@ -23,6 +23,7 @@ const serviceModulePath = path.join( ); const statusMaximumBytes = 64 * 1024; const operationDeadline = "10 seconds"; +const streamCancellationDeadline = "250 millis"; export const linuxProcessStatReadConcurrency = 16; const statusPollingSchedule = Schedule.spaced("5 millis").pipe( Schedule.upTo({ times: 2000 }) @@ -101,6 +102,31 @@ function withDeadline( ); } +/** + * Attempts one stream cancellation without allowing a non-cooperative promise to + * block the owning scope's remaining finalizers. + * @param cancel Promise-returning Web Stream cancellation operation. + * @param deadline Maximum time to wait before continuing cleanup. + * @returns Best-effort, Effect-owned cancellation. + */ +export function cancelShutdownStreamBeforeDeadline( + cancel: () => Promise, + deadline: Duration.Input = streamCancellationDeadline +): Effect.Effect { + return Effect.tryPromise(cancel).pipe( + // Scope finalizers are uninterruptible. Restore interruptibility only for + // the promise bridge so timeoutOrElse can detach a cancel promise that + // never settles and let older finalizers continue. + Effect.interruptible, + Effect.timeoutOrElse({ + duration: deadline, + orElse: () => Effect.void, + }), + Effect.ignore, + Effect.asVoid + ); +} + function temporaryWorkspace() { return Effect.acquireRelease( Effect.tryPromise({ @@ -160,7 +186,9 @@ function stopServiceProcess( child: QualificationServiceProcess, acknowledgePath: string ): Effect.Effect { - if (child.exitCode !== null || child.signalCode !== null) return Effect.void; + if (child.exitCode !== null || child.signalCode !== null) { + return Effect.sync(() => killProcessGroup(child.pid)); + } const graceful = writeMarker(acknowledgePath).pipe( Effect.andThen(Effect.sync(() => child.kill("SIGTERM"))), Effect.andThen(awaitServiceExit(child, "release-service-process")) @@ -261,7 +289,7 @@ function fetchResponse( > { return Effect.gen(function* () { const signal = yield* Effect.abortSignal; - return yield* withDeadline( + const response = yield* withDeadline( Effect.tryPromise({ catch: (cause) => new CompleteShutdownQualificationError({ cause, operation }), @@ -269,6 +297,13 @@ function fetchResponse( }), operation ); + const body = response.body; + if (body !== null) { + yield* Effect.addFinalizer(() => + cancelShutdownStreamBeforeDeadline(() => body.cancel()) + ); + } + return response; }); } @@ -332,6 +367,9 @@ function sseConnectionResource( ); } const reader = response.body.getReader(); + const cancelReader = cancelShutdownStreamBeforeDeadline(() => + reader.cancel() + ); const first = yield* withDeadline( Effect.tryPromise({ catch: (cause) => @@ -342,12 +380,13 @@ function sseConnectionResource( try: () => reader.read(), }), "read-sse-opening-event" - ); + ).pipe(Effect.onError(() => cancelReader)); if ( first.done || first.value === undefined || !new TextDecoder().decode(first.value).includes("event: ready") ) { + yield* cancelReader; return yield* Effect.fail( new CompleteShutdownQualificationError({ operation: "validate-sse-opening-event", @@ -356,11 +395,7 @@ function sseConnectionResource( } return Object.freeze({ reader }); }), - ({ reader }) => - Effect.tryPromise({ - catch: () => null, - try: () => reader.cancel(), - }).pipe(Effect.ignore, Effect.asVoid) + ({ reader }) => cancelShutdownStreamBeforeDeadline(() => reader.cancel()) ); } diff --git a/qualification/shutdown/shutdownGrandchild.ts b/qualification/shutdown/shutdownGrandchild.ts index 7f7c90ec8..bac6c79c5 100644 --- a/qualification/shutdown/shutdownGrandchild.ts +++ b/qualification/shutdown/shutdownGrandchild.ts @@ -1,3 +1,6 @@ import { Effect } from "effect"; -await Effect.runPromise(Effect.never); +const oneDayMs = 24 * 60 * 60 * 1000; + +// A real timer handle keeps the fixture alive until the owner sends SIGTERM or SIGKILL. +await Effect.runPromise(Effect.sleep(oneDayMs)); diff --git a/qualification/shutdown/shutdownIdleHttpConnection.ts b/qualification/shutdown/shutdownIdleHttpConnection.ts index 944b8584d..6ca5dc0c1 100644 --- a/qualification/shutdown/shutdownIdleHttpConnection.ts +++ b/qualification/shutdown/shutdownIdleHttpConnection.ts @@ -89,10 +89,11 @@ function acquireIdleHttpConnection( ); const headerEnd = state.response.indexOf("\r\n\r\n"); if (headerEnd === -1) return; - const statusLine = state.response + const headerLines = state.response .subarray(0, headerEnd) .toString("utf8") - .split("\r\n", 1)[0]; + .split("\r\n"); + const statusLine = headerLines[0]; if (statusLine !== "HTTP/1.1 200 OK") { fail( "validate-idle-http-response", @@ -100,6 +101,30 @@ function acquireIdleHttpConnection( ); return; } + const contentLengthHeaders = headerLines + .slice(1) + .filter((line) => /^content-length\s*:/iu.test(line)); + const contentLengthText = contentLengthHeaders[0] + ?.slice(contentLengthHeaders[0].indexOf(":") + 1) + .trim(); + if ( + contentLengthHeaders.length !== 1 || + contentLengthText === undefined || + !/^(?:0|[1-9][0-9]*)$/u.test(contentLengthText) + ) { + fail("validate-idle-http-content-length"); + return; + } + const contentLength = Number(contentLengthText); + const completeResponseBytes = headerEnd + 4 + contentLength; + if ( + !Number.isSafeInteger(contentLength) || + completeResponseBytes > responseMaximumBytes + ) { + fail("validate-idle-http-content-length"); + return; + } + if (state.response.length < completeResponseBytes) return; state.settled = true; socket.pause(); resume(Effect.succeed(socket)); diff --git a/qualification/shutdown/shutdownProtocol.ts b/qualification/shutdown/shutdownProtocol.ts index 546108ed6..a35de7caf 100644 --- a/qualification/shutdown/shutdownProtocol.ts +++ b/qualification/shutdown/shutdownProtocol.ts @@ -43,7 +43,6 @@ export const shutdownServiceStatusSchema = v.strictObject({ phase: v.picklist(["starting", "ready", "draining", "stopped"]), pid: positiveIntegerSchema, port: portSchema, - processGroupId: positiveIntegerSchema, readiness: v.boolean(), recoveredGenerationCount: nonnegativeIntegerSchema, schemaVersion: v.literal(1), @@ -77,6 +76,7 @@ const gatewayConnectParametersSchema = v.strictObject({ client: gatewayConnectClientSchema, maxProtocol: v.literal(4), minProtocol: v.literal(4), + nonce: gatewayNonceSchema, role: v.literal("operator"), scopes: gatewayOperatorReadScopesSchema, }); @@ -149,6 +149,7 @@ export function createGatewayConnectRequest(nonce: string) { }, maxProtocol: 4 as const, minProtocol: 4 as const, + nonce, role: "operator" as const, scopes: ["operator.read" as const] as const, }, diff --git a/qualification/shutdown/shutdownService.ts b/qualification/shutdown/shutdownService.ts index b461e8bc6..00c55716f 100644 --- a/qualification/shutdown/shutdownService.ts +++ b/qualification/shutdown/shutdownService.ts @@ -165,7 +165,6 @@ function statusSnapshot(options: { phase: options.phase, pid: process.pid, port: options.application.port, - processGroupId: process.pid, readiness: options.readiness, recoveredGenerationCount: options.recoveredGenerationCount, schemaVersion: 1, @@ -305,7 +304,13 @@ function runService(command: ServiceCommand) { try { const command = parseCommand(process.argv.slice(2)); await Effect.runPromise(runService(command)); -} catch { - process.stderr.write("Complete-shutdown qualification service failed\n"); +} catch (error) { + const diagnostic = Bun.inspect(error, { colors: false, depth: 6 }).slice( + 0, + 16 * 1024 + ); + process.stderr.write( + `Complete-shutdown qualification service failed\n${diagnostic}\n` + ); process.exitCode = 1; } diff --git a/qualification/shutdown/shutdownServiceResources.test.ts b/qualification/shutdown/shutdownServiceResources.test.ts index c9e37e4e2..2c9c92e91 100644 --- a/qualification/shutdown/shutdownServiceResources.test.ts +++ b/qualification/shutdown/shutdownServiceResources.test.ts @@ -2,6 +2,10 @@ import { describe, expect, spyOn, test } from "bun:test"; import { Effect } from "effect"; +import { + createGatewayConnectRequest, + parseGatewayConnectRequest, +} from "./shutdownProtocol.ts"; import { applicationServerResource, ShutdownQualificationDeadlineError, @@ -10,6 +14,15 @@ import { } from "./shutdownServiceResources.ts"; describe("shutdown application listener policy", () => { + test("binds the Gateway connect request to its challenge nonce", () => { + const nonce = "qualification-challenge"; + const request = parseGatewayConnectRequest( + JSON.stringify(createGatewayConnectRequest(nonce)) + ); + + expect(request.params.nonce).toBe(nonce); + }); + test("reports a graceful stop without escalation", async () => { const stopCalls: boolean[] = []; @@ -124,4 +137,44 @@ describe("shutdown application listener policy", () => { serveSpy.mockRestore(); } }); + + test("closes every registered SSE controller on a real listener", async () => { + const evidence = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* applicationServerResource({ + gatewaySocketOpen: false, + leaseActive: false, + readiness: true, + }); + const response = yield* Effect.tryPromise(() => + fetch(`http://127.0.0.1:${server.port}/api/events`) + ); + if (response.body === null) { + return yield* Effect.die("SSE response body is unavailable"); + } + const reader = response.body.getReader(); + yield* Effect.addFinalizer(() => + Effect.tryPromise(() => reader.cancel()).pipe( + Effect.ignore, + Effect.asVoid + ) + ); + const openingEvent = yield* Effect.tryPromise(() => reader.read()); + if (openingEvent.done || openingEvent.value === undefined) { + return yield* Effect.die("SSE opening event is unavailable"); + } + const registeredBeforeClose = server.sseConnectionCount; + yield* server.close(); + return { + registeredAfterClose: server.sseConnectionCount, + registeredBeforeClose, + }; + }) + ) + ); + + expect(evidence.registeredBeforeClose).toBe(1); + expect(evidence.registeredAfterClose).toBe(0); + }); }); diff --git a/qualification/shutdown/shutdownServiceResources.ts b/qualification/shutdown/shutdownServiceResources.ts index 28334e438..4c68efe23 100644 --- a/qualification/shutdown/shutdownServiceResources.ts +++ b/qualification/shutdown/shutdownServiceResources.ts @@ -365,11 +365,19 @@ export function gatewayFixtureResource(): Effect.Effect< websocket: { message(socket, message) { try { - parseGatewayConnectRequest( + const request = parseGatewayConnectRequest( typeof message === "string" ? message : message.toString("utf8") ); + if ( + request.params.nonce !== + "shutdown-qualification-nonce" + ) { + throw new Error( + "Gateway connect request did not echo its challenge" + ); + } socket.send(JSON.stringify(createGatewayHelloResponse())); } catch { socket.close(1008, "invalid connect request"); @@ -427,10 +435,19 @@ function openGatewaySocket( socket.removeEventListener("error", onError); socket.removeEventListener("message", onMessage); }; + const closeSocket = (reason: string) => { + if ( + socket.readyState === WebSocket.CONNECTING || + socket.readyState === WebSocket.OPEN + ) { + socket.close(1000, reason); + } + }; const fail = (operation: string, cause?: unknown) => { if (settled) return; settled = true; removeListeners(); + closeSocket("qualification handshake failed"); resume( Effect.fail( new ShutdownQualificationResourceError({ cause, operation }) @@ -468,12 +485,7 @@ function openGatewaySocket( socket.addEventListener("message", onMessage); return Effect.sync(() => { removeListeners(); - if ( - socket.readyState === WebSocket.CONNECTING || - socket.readyState === WebSocket.OPEN - ) { - socket.close(1000, "qualification interrupted"); - } + closeSocket("qualification interrupted"); }); } ); diff --git a/qualification/websocket/nativeWebSocketQualification.test.ts b/qualification/websocket/nativeWebSocketQualification.test.ts index 8ed727226..3ae1d0161 100644 --- a/qualification/websocket/nativeWebSocketQualification.test.ts +++ b/qualification/websocket/nativeWebSocketQualification.test.ts @@ -16,6 +16,7 @@ import { rawWebSocketFixtureResource } from "./rawWebSocketFixture.ts"; import { createFragmentedUtf8Evidence, fragmentedUtf8Message, + maximumRawWebSocketFixtureOutboundBytes, oversizedQualificationMessageBytes, type RawWebSocketScenario, } from "./rawWebSocketProtocol.ts"; @@ -182,7 +183,7 @@ describe("Bun native WebSocket RFC 6455 qualification", () => { expect(evidence.runtime.revision).toMatch(/^[a-f\d]{40}$/u); expect(evidence.runtime.version).toMatch(/^1\.4\.0/u); expect(evidence.sentBytes).toBeGreaterThan(split.completeBytes.byteLength); - expect(evidence.sentBytes).toBeLessThan(128 * 1024); + expect(evidence.sentBytes).toBeLessThan(maximumRawWebSocketFixtureOutboundBytes); expect(evidence.writeAttempts).toBeGreaterThanOrEqual(1); }); @@ -372,7 +373,7 @@ describe("Bun native WebSocket RFC 6455 qualification", () => { attempts += 1; return new WebSocket(target); }).pipe(Effect.result); - yield* Effect.sleep("1200 millis"); + yield* Effect.yieldNow; if (Result.isSuccess(outcome)) { return yield* Effect.die( new Error("Connection refusal unexpectedly delivered a message") diff --git a/qualification/websocket/nativeWebSocketQualification.ts b/qualification/websocket/nativeWebSocketQualification.ts index 5f88f4587..649032b37 100644 --- a/qualification/websocket/nativeWebSocketQualification.ts +++ b/qualification/websocket/nativeWebSocketQualification.ts @@ -309,6 +309,8 @@ export function observeNativeWebSocket( /** * Reserves and releases one loopback TCP port before a native refusal test. + * The unavoidable release-to-connect port-reuse window is deliberately kept local + * to the refusal test; the caller still verifies exactly one native connection attempt. * @returns A WebSocket URL with no listener remaining on its port. */ export function closedLoopbackWebSocketUrl(): Effect.Effect< diff --git a/qualification/websocket/rawWebSocketProtocol.ts b/qualification/websocket/rawWebSocketProtocol.ts index 8a412cb81..8065e6e95 100644 --- a/qualification/websocket/rawWebSocketProtocol.ts +++ b/qualification/websocket/rawWebSocketProtocol.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; const webSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const headerTerminator = Buffer.from("\r\n\r\n", "ascii"); -const maximumFixtureOutboundBytes = 128 * 1024; +export const maximumRawWebSocketFixtureOutboundBytes = 128 * 1024; export const maximumRawWebSocketHandshakeBytes = 16 * 1024; export const maximumRawWebSocketPeerBytes = 128 * 1024; @@ -48,7 +48,7 @@ function asBoundedPayload(payload: string | Uint8Array): Buffer { typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength); - if (bytes.byteLength > maximumFixtureOutboundBytes) { + if (bytes.byteLength > maximumRawWebSocketFixtureOutboundBytes) { throw new RangeError("WebSocket fixture payload exceeded its byte budget"); } return bytes; @@ -140,7 +140,9 @@ function hasHeaderToken(value: string | undefined, expected: string): boolean { } function createUpgradeResponse(key: string): Buffer { - const accept = createHash("sha1") + // RFC 6455 section 4.2.2 mandates SHA-1 for Sec-WebSocket-Accept; this is + // protocol framing, not a cryptographic integrity or credential decision. + const accept = createHash("sha1") // lgtm[js/weak-cryptographic-algorithm] .update(`${key}${webSocketGuid}`, "ascii") .digest("base64"); return Buffer.from( @@ -336,7 +338,7 @@ export function createScenarioBytes(scenario: RawWebSocketScenario): Buffer { } } const bytes = Buffer.concat(frames); - if (bytes.byteLength > maximumFixtureOutboundBytes) { + if (bytes.byteLength > maximumRawWebSocketFixtureOutboundBytes) { throw new Error("WebSocket fixture scenario exceeded its byte budget"); } return bytes; diff --git a/src/app/server.ts b/src/app/server.ts index 6773e6ff9..71706b444 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -6,7 +6,7 @@ import type { AuthenticationLifecycleService } from "../server/domains/security/ import type { AutomationSecurityLifecycleService } from "../server/domains/security/automation/lifecycle.ts"; import type { MfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; import type { MfaLoginLifecycleService } from "../server/domains/security/mfa/loginLifecycle.ts"; -import type { ReadinessState } from "../server/platform/readiness/readinessState.ts"; +import type { ReadinessController } from "../server/platform/readiness/readinessState.ts"; import type { ApplicationRuntime } from "../server/platform/runtime/applicationRuntime.ts"; import { readRuntimeIdentity } from "../server/platform/runtime/readRuntimeIdentity.ts"; import { @@ -67,7 +67,7 @@ export interface ServerOptions { readonly mfaAccountLifecycle: MfaAccountLifecycleService; readonly mfaLoginLifecycle: MfaLoginLifecycleService; readonly port: number; - readonly readiness: ReadinessState; + readonly readiness: ReadinessController; /** Exact proxy peers allowed to supply one overwritten client address. */ readonly trustedProxyAddresses?: readonly string[]; } @@ -155,9 +155,12 @@ export async function createServer(options: ServerOptions): Promise server.stop(forceListener), }); } catch (error) { - throw await primaryErrorAfterCleanup(error, () => - options.applicationRuntime.dispose() - ); + // A listener-stop rejection cannot prove that no request can still enter + // the runtime. Withdraw readiness and preserve process services for the + // supervisor's terminal containment instead of disposing them underneath + // a potentially live listener. + options.readiness.markUnavailable(); + throw error; } await options.applicationRuntime.dispose(); })(); diff --git a/src/server/platform/runtime/applicationRuntime.test.ts b/src/server/platform/runtime/applicationRuntime.test.ts index e000b6403..682b5e4aa 100644 --- a/src/server/platform/runtime/applicationRuntime.test.ts +++ b/src/server/platform/runtime/applicationRuntime.test.ts @@ -163,6 +163,51 @@ describe("application Effect runtime", () => { } }); + test("gives forced stop a fresh budget after graceful draining expires", async () => { + const runtime = createInertApplicationRuntime(); + const gracefulStop = Promise.withResolvers(); + const stopCalls: boolean[] = []; + + try { + await runtime.shutdownListener({ + forceSignal: new AbortController().signal, + gracefulShutdownTimeoutMs: 30, + stop(force) { + stopCalls.push(force); + if (!force) return gracefulStop.promise; + return Bun.sleep(5).then(() => gracefulStop.resolve()); + }, + }); + + expect(stopCalls).toEqual([false, true]); + } finally { + await runtime.dispose(); + } + }); + + test("gives graceful settlement a fresh budget after forced stop", async () => { + const runtime = createInertApplicationRuntime(); + const gracefulStop = Promise.withResolvers(); + const stopCalls: boolean[] = []; + + try { + await runtime.shutdownListener({ + forceSignal: new AbortController().signal, + gracefulShutdownTimeoutMs: 30, + stop(force) { + stopCalls.push(force); + if (!force) return gracefulStop.promise; + void Bun.sleep(5).then(() => gracefulStop.resolve()); + return Promise.resolve(); + }, + }); + + expect(stopCalls).toEqual([false, true]); + } finally { + await runtime.dispose(); + } + }); + test("tags missing graceful settlement after a successful force stop", async () => { const runtime = createInertApplicationRuntime(); const controller = new AbortController(); diff --git a/src/server/platform/runtime/applicationRuntime.ts b/src/server/platform/runtime/applicationRuntime.ts index b45d3ff0f..a00802246 100644 --- a/src/server/platform/runtime/applicationRuntime.ts +++ b/src/server/platform/runtime/applicationRuntime.ts @@ -57,6 +57,7 @@ export type ApplicationListenerShutdownError = export interface ApplicationListenerShutdownOptions { /** Synchronous escalation bridge used by repeated `ApplicationServer.stop(true)`. */ readonly forceSignal: AbortSignal; + /** Independent budget applied to each graceful, forced, and settlement phase. */ readonly gracefulShutdownTimeoutMs: number; readonly stop: (force: boolean) => Promise; } diff --git a/src/server/test/system/serverShutdown.test.ts b/src/server/test/system/serverShutdown.test.ts index e6cfa2b12..5b7577ec3 100644 --- a/src/server/test/system/serverShutdown.test.ts +++ b/src/server/test/system/serverShutdown.test.ts @@ -6,7 +6,10 @@ import { Effect, Layer, Stream } from "effect"; import { createServer } from "../../../app/server.ts"; import { createReadinessController } from "../../platform/readiness/readinessState.ts"; import { RealtimeEventPumpService } from "../../platform/realtime/eventPumpService.ts"; -import { createApplicationRuntime } from "../../platform/runtime/applicationRuntime.ts"; +import { + ApplicationListenerStopTimeoutError, + createApplicationRuntime, +} from "../../platform/runtime/applicationRuntime.ts"; import { captureFailure } from "../support/promise.ts"; import { createTestApplicationRuntime, @@ -14,7 +17,7 @@ import { createTestServerSecurityServices, } from "../support/requestContext.ts"; -function createPendingBunServer(): { +function createPendingBunServer(resolveWhenForced = true): { readonly gracefulStarted: Promise; readonly server: ReturnType; readonly stopCalls: boolean[]; @@ -26,9 +29,9 @@ function createPendingBunServer(): { port: 3100, stop(force = false) { stopCalls.push(force); - if (force) { + if (force && resolveWhenForced) { gracefulStop.resolve(); - } else { + } else if (!force) { gracefulStarted.resolve(); } return gracefulStop.promise; @@ -131,6 +134,42 @@ describe("application server shutdown", () => { } }); + test("preserves runtime services when listener escalation remains unsettled", async () => { + const fake = createPendingBunServer(false); + const serveSpy = spyOn(Bun, "serve").mockReturnValue(fake.server); + const readiness = createReadinessController(); + readiness.markReady(); + let disposals = 0; + const applicationRuntime = createShutdownTestRuntime(() => { + disposals += 1; + }); + + try { + const server = await createServer({ + ...createTestServerSecurityServices(), + applicationRuntime, + authenticationLifecycle: createTestAuthenticationLifecycleService(), + authenticateCredential: () => ({ + authentication: { kind: "anonymous" }, + }), + gracefulShutdownTimeoutMs: 1, + port: 3100, + readiness, + }); + + const failure = await captureFailure(() => server.stop()); + + expect(failure).toBeInstanceOf(ApplicationListenerStopTimeoutError); + expect(readiness.isReady()).toBe(false); + expect(fake.stopCalls).toEqual([false, true]); + expect(disposals).toBe(0); + } finally { + await applicationRuntime.dispose(); + serveSpy.mockRestore(); + } + expect(disposals).toBe(1); + }); + test("preserves a startup failure when runtime disposal also fails", async () => { const startupError = new Error("simulated listener startup failure"); const disposalError = new Error("simulated runtime disposal failure"); From 184ee58201c1316db99596b8d9c906e0fb42f143 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 08:59:12 +0200 Subject: [PATCH 3/4] fix(qualification): avoid path re-open race --- qualification/files/boundedFile.ts | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/qualification/files/boundedFile.ts b/qualification/files/boundedFile.ts index 7af365117..56fce7ba7 100644 --- a/qualification/files/boundedFile.ts +++ b/qualification/files/boundedFile.ts @@ -1,5 +1,5 @@ import { constants, type BigIntStats } from "node:fs"; -import { open, realpath } from "node:fs/promises"; +import { lstat, open, realpath } from "node:fs/promises"; import path from "node:path"; export interface BoundedFileReadQualificationHooks { @@ -33,8 +33,8 @@ function matchesSnapshot(before: BigIntStats, after: BigIntStats): boolean { /** * Reads one stable regular file through a held nonblocking, no-follow descriptor. - * A second no-follow descriptor revalidates that the requested path still names the - * same snapshot inside the explicit root before any bytes are returned. + * A post-read no-follow path snapshot revalidates that the requested path still names + * the same held descriptor snapshot before any bytes are returned. * @param absolutePath Absolute file path selected by the qualification caller. * @param allowedRoot Explicit root that is permitted to contain the descriptor target. * @param maximumBytes Maximum accepted file size. @@ -71,7 +71,6 @@ export async function readBoundedRegularFile( } let file: Awaited> | undefined; - let pathFile: Awaited> | undefined; let result: Buffer | undefined; let failed = false; try { @@ -106,16 +105,10 @@ export async function readBoundedRegularFile( } const after = await file.stat({ bigint: true }); - pathFile = await open( - requestedPath, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK - ); - const revalidatedDescriptorPath = await realpath(`/proc/self/fd/${pathFile.fd}`); - const pathState = await pathFile.stat({ bigint: true }); + const pathState = await lstat(requestedPath, { bigint: true }); if ( bytesRead !== expectedBytes || !matchesSnapshot(before, after) || - !isContainedPath(canonicalRoot, revalidatedDescriptorPath) || !pathState.isFile() || !matchesSnapshot(before, pathState) ) { @@ -126,13 +119,6 @@ export async function readBoundedRegularFile( failed = true; } - if (pathFile) { - try { - await pathFile.close(); - } catch { - failed = true; - } - } if (file) { try { await file.close(); From fc238b3f01cd4427bde17d71b1d42bd51307aa04 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Thu, 6 Aug 2026 09:05:53 +0200 Subject: [PATCH 4/4] test(openclaw): await negative audit paths --- .../greenfield-rewrite/progress.md | 2 +- .../greenfield-rewrite/runtime-and-delivery.md | 2 +- qualification/openclaw/sourceAudit.test.ts | 18 ++++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/architecture/greenfield-rewrite/progress.md b/docs/architecture/greenfield-rewrite/progress.md index 46dc49248..25ebeb4d3 100644 --- a/docs/architecture/greenfield-rewrite/progress.md +++ b/docs/architecture/greenfield-rewrite/progress.md @@ -599,7 +599,7 @@ closes a phase; dated entries below provide the evidence, not a second status so - Bun `1.4.0-canary.1+17d684360`, full revision `17d6843606d76620cb55d31424d7fb0aed51c367`, passes qualification typecheck and the complete - qualification suite: 151 tests, 756 assertions, zero failures, and 31 files. This is the exact + qualification suite: 151 tests, 758 assertions, zero failures, and 31 files. This is the exact audited candidate for the round, not a repository-wide source-revision pin. - The selected frontend path is one compiler-first Bun HTML AOT build. Executable fixture and actual-build evidence cover Tailwind, lazy chunks, fail-closed inline event/style/base and diff --git a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 15c919680..842816337 100644 --- a/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -60,7 +60,7 @@ environment against the exact candidate binary: The 2026-08-06 qualification round passes on exact revision `17d6843606d76620cb55d31424d7fb0aed51c367`: qualification typecheck passes, and the full suite -reports 151 tests, 756 assertions, and zero failures across 31 files. Its executable evidence +reports 151 tests, 758 assertions, and zero failures across 31 files. Its executable evidence includes: - compiler-first Bun HTML AOT output with Tailwind, lazy chunks, fail-closed inline-code and diff --git a/qualification/openclaw/sourceAudit.test.ts b/qualification/openclaw/sourceAudit.test.ts index 375a5cfc1..dd15cf3c2 100644 --- a/qualification/openclaw/sourceAudit.test.ts +++ b/qualification/openclaw/sourceAudit.test.ts @@ -18,6 +18,12 @@ const sourceVersion = "2026.7.2-beta.7"; const sourceCommit = "dabe1915362e20c25704af91612a32a8f4c96e83"; const sourceBuiltAt = "2026-08-01T19:22:56.002Z"; +async function rejectedError(operation: Promise): Promise { + const result = await operation.catch((error: unknown) => error); + expect(result).toBeInstanceOf(Error); + return result as Error; +} + async function writeSyntheticOpenClawPackage(sourceRoot: string): Promise { const dist = path.join(sourceRoot, "dist"); const controlUiAssets = path.join(dist, "control-ui", "assets"); @@ -362,9 +368,10 @@ describe("reviewed OpenClaw protocol fixtures", () => { "utf8" ); - expect(loadReviewedOpenClawFixtures(fixtureRoot)).rejects.toThrow( - "hash mismatch for chat.json" + const mismatchError = await rejectedError( + loadReviewedOpenClawFixtures(fixtureRoot) ); + expect(mismatchError.message).toContain("hash mismatch for chat.json"); }); }); }); @@ -422,9 +429,12 @@ describe("explicit OpenClaw source audit", () => { expect(() => assertOpenClawAuditMatchesReviewed(audit, loaded.audit) ).not.toThrow(); - expect( + const existingOutputError = await rejectedError( writeOpenClawAuditCandidate(audit, outputDirectory) - ).rejects.toThrow("output directory already exists"); + ); + expect(existingOutputError.message).toContain( + "output directory already exists" + ); } ); });