From 010b5dd75e3339c3ef1ccbc7b5f286692f4f227b Mon Sep 17 00:00:00 2001 From: Mauro Date: Sat, 25 Jul 2026 13:11:43 +0800 Subject: [PATCH 01/10] ci: fix pnpm setup failure and bump workflow actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm/action-setup now hard-errors when the version is given both as the action's `version` input and as `packageManager` in package.json. Since v4 is a floating tag, the action got stricter underneath us and every job died at setup — typecheck, lint, test, and the db-schema drift check have produced no real signal since mid-July. Drop the `version: 9` input at all five sites and let packageManager be the single source of truth. Also bump checkout v4->v7, setup-node v4->v7, and pnpm/action-setup v4->v6, clearing the Node 20 runtime deprecation. The breaking changes in those majors do not apply here: checkout v7 only restricts fork checkouts under pull_request_target/workflow_run, and the setup-node v5/v6 changes concern automatic caching, which these jobs do not use. node-version stays at 20 (the project's documented floor) and the KVM job still queues without a self-hosted runner, which is the intended fail-closed shape (docs/deploy.md 9). --- .github/workflows/ci.yml | 40 ++++++++++++++++----------------- .github/workflows/db-schema.yml | 10 ++++----- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 103a323..171a9c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,13 +23,13 @@ jobs: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: "20" - - uses: pnpm/action-setup@v4 - with: - version: 9 + # No `version` input: pnpm/action-setup errors out when the version is given both + # here and as `packageManager` in package.json, which is the single source of truth. + - uses: pnpm/action-setup@v6 - run: pnpm install --frozen-lockfile - run: pnpm typecheck @@ -37,13 +37,13 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: "20" - - uses: pnpm/action-setup@v4 - with: - version: 9 + # No `version` input: pnpm/action-setup errors out when the version is given both + # here and as `packageManager` in package.json, which is the single source of truth. + - uses: pnpm/action-setup@v6 - run: pnpm install --frozen-lockfile - run: pnpm lint @@ -63,13 +63,13 @@ jobs: DOCKER_HOST: unix:///var/run/docker.sock TESTCONTAINERS_RYUK_DISABLED: "true" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: "20" - - uses: pnpm/action-setup@v4 - with: - version: 9 + # No `version` input: pnpm/action-setup errors out when the version is given both + # here and as `packageManager` in package.json, which is the single source of truth. + - uses: pnpm/action-setup@v6 - name: Verify container runtime (fail-closed prerequisite) run: docker info - name: Pre-pull test images @@ -108,13 +108,13 @@ jobs: PI_REQUIRE_INTEGRATION: "1" TESTCONTAINERS_RYUK_DISABLED: "true" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: "20" - - uses: pnpm/action-setup@v4 - with: - version: 9 + # No `version` input: pnpm/action-setup errors out when the version is given both + # here and as `packageManager` in package.json, which is the single source of truth. + - uses: pnpm/action-setup@v6 - name: Verify /dev/kvm run: | test -e /dev/kvm || { echo "::error::/dev/kvm missing — this runner is not KVM-capable"; exit 1; } diff --git a/.github/workflows/db-schema.yml b/.github/workflows/db-schema.yml index 1818afa..143f0e7 100644 --- a/.github/workflows/db-schema.yml +++ b/.github/workflows/db-schema.yml @@ -24,13 +24,13 @@ jobs: name: docs/db-schema.md matches migrations runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: "20" - - uses: pnpm/action-setup@v4 - with: - version: 9 + # No `version` input: pnpm/action-setup errors out when the version is given both + # here and as `packageManager` in package.json, which is the single source of truth. + - uses: pnpm/action-setup@v6 - run: pnpm install --frozen-lockfile # The generator boots postgres:16-alpine itself via the container CLI (docker on the # GitHub runner, podman locally) and runs psql inside it — no host pg client needed. From f02efaa0facb9ab8128e1689989736c4a96d906a Mon Sep 17 00:00:00 2001 From: Mauro Date: Sat, 25 Jul 2026 14:07:52 +0800 Subject: [PATCH 02/10] ci: build before typecheck/test; worker: handle EPIPE on spawn stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing bugs that the pnpm setup fix uncovered — both were masked for weeks because every job died before running anything. CI had no build step. Cross-package types resolve only through each package's built dist/*.d.ts (no project references, no path mapping) and dist/ is gitignored, so a clean checkout failed with TS2307 on every @pi-managed/* import. Reproduced locally by moving packages/contracts/dist aside; `pnpm build` before typecheck/test fixes it. Added to the typecheck, test, and kvm jobs; lint needs no build and is left alone. SpawnExecutor wrote the payload to child.stdin with no error listener on that stream. When the spawn script exits before reading stdin — which the close handler already treats as a legitimate error result — the pipe closes and the write raises EPIPE, which without a listener becomes an unhandled error that takes the process down instead. Verified with a forced-race repro: without the listener the process dies with EPIPE before the close handler finishes; with it, the intended error result is returned. This crashed the worker in production, not just in tests. --- .github/workflows/ci.yml | 10 ++++++++++ packages/worker/src/control-levels.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 171a9c8..6b62e86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,10 @@ jobs: # here and as `packageManager` in package.json, which is the single source of truth. - uses: pnpm/action-setup@v6 - run: pnpm install --frozen-lockfile + # Cross-package types resolve through each package's built dist/*.d.ts (no project + # references, no path mapping), and dist/ is gitignored — so a clean checkout must + # build before it can typecheck, or every @pi-managed/* import fails with TS2307. + - run: pnpm build - run: pnpm typecheck lint: @@ -75,6 +79,9 @@ jobs: - name: Pre-pull test images run: docker pull postgres:16-alpine - run: pnpm install --frozen-lockfile + # Same reason as the typecheck job: suites import @pi-managed/* by package name, + # which resolves to the gitignored dist/. + - run: pnpm build - run: pnpm test # @kvm tests: the real microsandbox provider, the sandbox-operations adapter, the @@ -121,6 +128,9 @@ jobs: test -w /dev/kvm || { echo "::error::/dev/kvm not writable by the runner user (add it to the 'kvm' group)"; exit 1; } ls -l /dev/kvm - run: pnpm install --frozen-lockfile + # Same reason as the typecheck job: the @kvm suites import @pi-managed/* by package + # name, which resolves to the gitignored dist/. + - run: pnpm build - name: Verify msb native package installed run: ls node_modules/@superradcompany/ - name: Bootstrap microsandbox runtime (idempotent) diff --git a/packages/worker/src/control-levels.ts b/packages/worker/src/control-levels.ts index cecaf6d..a61e20a 100644 --- a/packages/worker/src/control-levels.ts +++ b/packages/worker/src/control-levels.ts @@ -208,6 +208,10 @@ export class SpawnExecutor implements Executor { } }); + // The script may exit before reading stdin, closing the pipe mid-write. `close` + // already reports that exit as an error result, so the EPIPE has nothing to add — + // but with no listener it surfaces as an unhandled error and takes the process down. + child.stdin.on("error", () => {}); child.stdin.end(payload); }); } From a5f8f2ae77fb05fb665621a0985552f69bda0021 Mon Sep 17 00:00:00 2001 From: Mauro Date: Sat, 25 Jul 2026 14:11:52 +0800 Subject: [PATCH 03/10] build: order the workspace build around the testkit<->backend cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm -r build` cannot order this workspace. testkit depends on backend for port types while backend keeps testkit as a devDependency, and pnpm resolves that cycle by building testkit first — which fails on any clean tree, since cross-package types resolve only through the gitignored dist/*.d.ts (no project references, no path mapping). This is why CI still failed after adding the build step, and it means `pnpm build` has never worked on a fresh clone either; every local run succeeded only against stale dist artifacts. Build everything except testkit first, then testkit. Verified by removing all packages/*/dist and running the script from scratch: all 7 packages build, typecheck is clean. Documented the constraint in architecture.md 3.2 next to the cycle it follows from. --- docs/architecture.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index a64ea6d..69d8311 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -257,6 +257,13 @@ Two deliberate wrinkles: the `testkit`↔`backend` relationship looks circular b acyclic (types one way, devDependency the other), and the worker's isolation means the work-item shape exists in two places that must be kept in sync by hand. +That first wrinkle has one concrete consequence: **`pnpm -r build` cannot order the +workspace on its own.** pnpm sees the cycle and may build `testkit` before `backend`, +which fails on a clean tree because cross-package types resolve only through each +package's built (gitignored) `dist/*.d.ts` — there are no project references and no path +mapping. The root `build` script therefore builds everything *except* `testkit` first, +then `testkit`. Run `pnpm build` — not `pnpm -r build` — after a fresh clone. + ### 3.3 Deployment shapes 1. **Single-host self-hosted (v1 default):** one process, one implicit tenant, local diff --git a/package.json b/package.json index 886af6a..69ccd3b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "packageManager": "pnpm@9.15.9", "scripts": { - "build": "pnpm -r build", + "build": "pnpm --filter '!@pi-managed/testkit' build && pnpm --filter @pi-managed/testkit build", "test": "pnpm -r test", "lint": "eslint .", "typecheck": "pnpm -r typecheck", From 2fbe51721aa23adac6c6abb23cbbcf507e5752ca Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 27 Jul 2026 09:31:45 +0800 Subject: [PATCH 04/10] chore: raise the Node floor to 22.19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded Pi agent (@earendil-works/pi-coding-agent) pulls undici 8.5.0, whose engines field is >=22.19.0. On Node 20 it throws "webidl.util.markAsUncloneable is not a function" at import time, which is what was failing the client-extension suites in CI — the declared floor was simply below what a dependency required. Node 20 is also EOL. Raise engines to >=22.19.0, move every workflow to node-version 22, and update the prerequisite wherever it is stated (README, getting-started, deploy x2, user-journeys, spec.md, and the KVM runner notes in ci.yml), recording the reason so the number is not later lowered on a guess. --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/db-schema.yml | 2 +- README.md | 2 +- docs/deploy.md | 4 ++-- docs/getting-started.md | 2 +- docs/spec/spec.md | 2 +- docs/user-journeys.md | 2 +- package.json | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b62e86..71f9bb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" # No `version` input: pnpm/action-setup errors out when the version is given both # here and as `packageManager` in package.json, which is the single source of truth. - uses: pnpm/action-setup@v6 @@ -44,7 +44,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" # No `version` input: pnpm/action-setup errors out when the version is given both # here and as `packageManager` in package.json, which is the single source of truth. - uses: pnpm/action-setup@v6 @@ -70,7 +70,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" # No `version` input: pnpm/action-setup errors out when the version is given both # here and as `packageManager` in package.json, which is the single source of truth. - uses: pnpm/action-setup@v6 @@ -95,7 +95,7 @@ jobs: # goes green) rather than passing vacuously — that is the intended fail-closed shape. # * Linux host — bare metal, or a VM with NESTED VIRTUALIZATION enabled — exposing # /dev/kvm, with the runner's user in the `kvm` group (/dev/kvm must be writable). - # * Node.js >= 20 + pnpm on PATH; a container runtime (docker or podman) for the + # * Node.js >= 22.19 + pnpm on PATH; a container runtime (docker or podman) for the # Postgres-backed halves of the E2E and credential-injection gates. # * microsandbox one-time bootstrap: # node -e "import('microsandbox').then(m => m.install())" @@ -118,7 +118,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" # No `version` input: pnpm/action-setup errors out when the version is given both # here and as `packageManager` in package.json, which is the single source of truth. - uses: pnpm/action-setup@v6 diff --git a/.github/workflows/db-schema.yml b/.github/workflows/db-schema.yml index 143f0e7..137bfbb 100644 --- a/.github/workflows/db-schema.yml +++ b/.github/workflows/db-schema.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" # No `version` input: pnpm/action-setup errors out when the version is given both # here and as `packageManager` in package.json, which is the single source of truth. - uses: pnpm/action-setup@v6 diff --git a/README.md b/README.md index 1845082..46fcab0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The service is **tailored for Pi** — it adapts the *concepts* of managed agent ### Prerequisites -- **Node.js ≥ 20** +- **Node.js ≥ 22.19** (the embedded Pi agent's `undici` requires it) - **pnpm** (`corepack enable` — the version is pinned via `packageManager` in `package.json`) - **Docker** (for the dev Postgres; the object store defaults to the local filesystem) - **Linux with `/dev/kvm`** (for real microVM sandboxes — macOS works for everything *except* sandbox execution) diff --git a/docs/deploy.md b/docs/deploy.md index 6ae74dc..ef292f9 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -10,7 +10,7 @@ runtime** — plus a Linux host with **KVM** for microVM isolation. | Requirement | Why | Notes | |---|---|---| | **Linux host with `/dev/kvm`** | microsandbox microVMs run via libkrunfw + KVM | The sandbox provider is inert without it. `@kvm`-gated tests skip cleanly when absent. | -| **Node.js ≥ 20** | ESM service, `dist/main.js` entrypoint | Build with `pnpm --filter @pi-managed/backend build`. | +| **Node.js ≥ 22.19** | ESM service, `dist/main.js` entrypoint | The floor is set by the embedded Pi agent's `undici` (`engines: >=22.19.0`), not by our own code. Build with `pnpm --filter @pi-managed/backend build`. | | **Postgres 16** | Control-plane DB (sessions, agents, environments, vaults, usage, …) | Migrations run **forward-only on boot** (`runMigrations(dbUrl, "up")`). | | **Object store** | File payloads, memory stores, snapshots, JSONL sync (§28) | **Local filesystem by default** (`OBJECT_STORE_ROOT` — point it at durable storage), or **Google Cloud Storage** via `OBJECT_STORE_KIND=gcs` + `GCS_BUCKET` (§2). An S3-compatible adapter also exists (`infra/objectstore/s3.ts`) but is composition-time only. | | **microsandbox runtime** | Provisiones/execs detached microVMs (§5.4, §10) | One-time `microsandbox` install bootstrap (§3 below). | @@ -252,7 +252,7 @@ Runner prerequisites (infra work, outside this repo): intended fail-closed behavior (a placeholder that echoes and exits 0 is not). 2. **Hardware virtualization**: bare metal, or a VM with nested virtualization enabled. `/dev/kvm` must exist and be writable by the runner user (add it to the `kvm` group). -3. **Node ≥ 20 + pnpm** on `PATH`, and a **container runtime** (docker or podman) for the +3. **Node ≥ 22.19 + pnpm** on `PATH`, and a **container runtime** (docker or podman) for the Postgres-backed halves of the E2E and credential-injection gates. 4. **microsandbox bootstrap**: `node -e "import('microsandbox').then(m => m.install())"` (§3) populating `~/.microsandbox/`. The workflow re-runs this per job (idempotent), so diff --git a/docs/getting-started.md b/docs/getting-started.md index 64e13ef..2f46bbf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,7 +17,7 @@ path over production hardening. When you are ready to deploy for real, follow | Tool | Why | Check | |---|---|---| -| **Node.js ≥ 20** | The service is an ESM Node process | `node --version` | +| **Node.js ≥ 22.19** | The service is an ESM Node process; the embedded Pi agent's `undici` requires 22.19+ | `node --version` | | **pnpm** | Workspace package manager (pinned via `packageManager`) | `corepack enable` then `pnpm --version` | | **Docker** | Runs the dev Postgres | `docker info` | | **Linux + `/dev/kvm`** | *Only* for real microVM sandboxes — optional to start | `ls -l /dev/kvm` | diff --git a/docs/spec/spec.md b/docs/spec/spec.md index 4590f4b..e1b439d 100644 --- a/docs/spec/spec.md +++ b/docs/spec/spec.md @@ -450,7 +450,7 @@ the start so neither shape is a retrofit. virtualization requires an Intel x86 machine type (Haswell+) with `enableNestedVirtualization` set — unavailable on ARM (T2A) — and carries a measurable performance tax vs bare metal. -- **Runtime:** Node 20+. +- **Runtime:** Node 22.19+ (floor set by the embedded Pi agent's `undici`). - **Dependencies:** Postgres (metadata, quotas, job records, webhook deliveries), an object store (filesystem in v1; S3-compatible or GCS for SaaS), the microsandbox runtime (`~/.microsandbox/` with libkrunfw + agentd). diff --git a/docs/user-journeys.md b/docs/user-journeys.md index 6963d55..7ace13f 100644 --- a/docs/user-journeys.md +++ b/docs/user-journeys.md @@ -37,7 +37,7 @@ all three roles — the layering still holds, they just switch hats (and keys). *Goal: a working backend on one Linux/KVM machine.* -1. Provision a Linux host with `/dev/kvm`, Node ≥ 20, and Docker (or a managed +1. Provision a Linux host with `/dev/kvm`, Node ≥ 22.19, and Docker (or a managed Postgres 16). The object store is a **local directory** by default — put it on durable disk. 2. `pnpm install && pnpm build`; start the dependency (`docker compose up -d postgres` diff --git a/package.json b/package.json index 69ccd3b..037d98c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Pi Managed Backend — a control plane around the Pi coding agent with microsandbox microVM execution.", "type": "module", "engines": { - "node": ">=20" + "node": ">=22.19.0" }, "packageManager": "pnpm@9.15.9", "scripts": { From 680185405450397982b5b1a53e35d8e0dcd342c7 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 27 Jul 2026 09:36:48 +0800 Subject: [PATCH 05/10] chore: align @types/node with the Node 22 runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @types/node was pinned at ^26.1.1 while the runtime floor is 22.19, so typecheck was validating against APIs the target runtime does not have — the same class of gap that let the undici/Node 20 break reach CI. Pin all three declarations (root, worker, billing-adapter) to ^22.20.1. Doing so surfaced one real error: fileStream() in jsonl-sync typed its data handler as (chunk: Buffer), but Readable's data event is string | Buffer. The stream is created locally with no encoding so chunks are always Buffer; narrow at that boundary and say why. --- package.json | 2 +- .../src/domain/session-manager/jsonl-sync.ts | 6 +- packages/billing-adapter/package.json | 2 +- packages/worker/package.json | 2 +- pnpm-lock.yaml | 100 ++++++++++++++---- 5 files changed, 88 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 037d98c..914bb40 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "db:schema:check": "node scripts/gen-db-schema.mjs --check" }, "devDependencies": { - "@types/node": "^26.1.1", + "@types/node": "^22.20.1", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.9.5", diff --git a/packages/backend/src/domain/session-manager/jsonl-sync.ts b/packages/backend/src/domain/session-manager/jsonl-sync.ts index e50e61d..d6a2fd0 100644 --- a/packages/backend/src/domain/session-manager/jsonl-sync.ts +++ b/packages/backend/src/domain/session-manager/jsonl-sync.ts @@ -202,8 +202,10 @@ function fileStream(path: string): ReadableStream { const nodeStream = createReadStream(path); return new ReadableStream({ start(controller) { - nodeStream.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)); + // No encoding is set on the stream above, so chunks are always Buffer; `data` + // widens to string only for streams that set one. + nodeStream.on("data", (chunk: string | Buffer) => { + controller.enqueue(new Uint8Array(chunk as Buffer)); }); nodeStream.on("end", () => controller.close()); nodeStream.on("error", (err) => controller.error(err)); diff --git a/packages/billing-adapter/package.json b/packages/billing-adapter/package.json index 010a762..c74867e 100644 --- a/packages/billing-adapter/package.json +++ b/packages/billing-adapter/package.json @@ -25,7 +25,7 @@ "@pi-managed/backend": "workspace:*", "@pi-managed/contracts": "workspace:*", "@testcontainers/postgresql": "^10.18.1", - "@types/node": "^26.1.1", + "@types/node": "^22.20.1", "pino": "^10.1.0", "typescript": "^5.9.3", "vitest": "^4.1.10" diff --git a/packages/worker/package.json b/packages/worker/package.json index 5fb7cf3..70f42c3 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -23,7 +23,7 @@ }, "dependencies": {}, "devDependencies": { - "@types/node": "^26.1.1", + "@types/node": "^22.20.1", "typescript": "^5.9.3", "vitest": "^4.1.10" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5d4b76..0eb9ff0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@types/node': - specifier: ^26.1.1 - version: 26.1.1 + specifier: ^22.20.1 + version: 22.20.1 eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) @@ -28,7 +28,7 @@ importers: version: 8.63.0(eslint@10.7.0(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) packages/backend: dependencies: @@ -102,7 +102,7 @@ importers: dependencies: stripe: specifier: ^22.3.0 - version: 22.3.2(@types/node@26.1.1) + version: 22.3.2(@types/node@22.20.1) devDependencies: '@pi-managed/backend': specifier: workspace:* @@ -114,8 +114,8 @@ importers: specifier: ^10.18.1 version: 10.28.0 '@types/node': - specifier: ^26.1.1 - version: 26.1.1 + specifier: ^22.20.1 + version: 22.20.1 pino: specifier: ^10.1.0 version: 10.3.1 @@ -124,7 +124,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) packages/client-extension: dependencies: @@ -211,14 +211,14 @@ importers: packages/worker: devDependencies: '@types/node': - specifier: ^26.1.1 - version: 26.1.1 + specifier: ^22.20.1 + version: 22.20.1 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -1139,6 +1139,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} @@ -2873,6 +2876,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -4215,19 +4221,19 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 26.1.1 + '@types/node': 22.20.1 '@types/ssh2': 1.15.5 '@types/dockerode@3.3.47': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 26.1.1 + '@types/node': 22.20.1 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 26.1.1 + '@types/node': 22.20.1 '@types/ssh2': 1.15.5 '@types/esrecurse@4.3.1': {} @@ -4240,6 +4246,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 @@ -4261,7 +4271,7 @@ snapshots: '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 - '@types/node': 26.1.1 + '@types/node': 22.20.1 '@types/tough-cookie': 4.0.5 form-data: 2.5.6 @@ -4269,11 +4279,11 @@ snapshots: '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 26.1.1 + '@types/node': 22.20.1 '@types/ssh2@0.5.52': dependencies: - '@types/node': 26.1.1 + '@types/node': 22.20.1 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -4387,6 +4397,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 @@ -5675,7 +5693,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 - '@types/node': 26.1.1 + '@types/node': 22.20.1 long: 5.3.2 pump@3.0.4: @@ -5910,9 +5928,9 @@ snapshots: dependencies: min-indent: 1.0.1 - stripe@22.3.2(@types/node@26.1.1): + stripe@22.3.2(@types/node@22.20.1): optionalDependencies: - '@types/node': 26.1.1 + '@types/node': 22.20.1 strnum@2.4.1: dependencies: @@ -6098,6 +6116,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.21.0: {} + undici-types@8.3.0: {} undici@5.29.0: @@ -6122,6 +6142,19 @@ snapshots: uuid@9.0.1: {} + vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.17 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -6145,6 +6178,35 @@ snapshots: redent: 3.0.0 vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 22.20.1 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 From b400a2daae34ba60319f9508ac619951201e3b39 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 27 Jul 2026 09:36:53 +0800 Subject: [PATCH 06/10] test: compare the >1MiB conformance payload natively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The large-streaming ObjectStore case asserted with toEqual on a 2 MiB Uint8Array, which vitest walks element by element: 2432ms of the suite's 2551ms, and a timeout against the 5s default on a shared CI runner. Buffer.compare gives the identical byte-exact guarantee natively — the test drops to 9ms. The small-payload case keeps toEqual, where the element-wise diff is cheap and worth having on failure. --- packages/testkit/src/conformance/object-store.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/testkit/src/conformance/object-store.ts b/packages/testkit/src/conformance/object-store.ts index ee9a1c6..d5bd363 100644 --- a/packages/testkit/src/conformance/object-store.ts +++ b/packages/testkit/src/conformance/object-store.ts @@ -257,7 +257,10 @@ export function runObjectStoreConformance( const put = await store.put(key, byteStream(payload)); const got = await readAll(await store.get(key)); expect(got.byteLength).toBe(payload.byteLength); - expect(got).toEqual(payload); + // Same byte-exact guarantee as the small-payload case, but native: `toEqual` + // walks a multi-MiB typed array element by element, which cost ~2.4s of this + // suite's ~2.5s and timed out the 5s default on a shared CI runner. + expect(Buffer.compare(Buffer.from(got), Buffer.from(payload))).toBe(0); // ETag must be stable across reads of the same content: a conditional put // with the returned etag must succeed. const cp = await store.conditionalPut( From ebc369b380ffeb7e4f6756397dc2863c8dafc8e6 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 27 Jul 2026 09:46:11 +0800 Subject: [PATCH 07/10] test: unblock two CI timeouts in the backend suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both surfaced on the first backend run in weeks; neither is a weakening. contract.test.ts duplicates the testkit ObjectStore conformance suite by hand rather than importing the kit, so it carried the same 2 MiB toEqual that timed out the 5s default — here three times over (Fake, Filesystem, GCS). Same native Buffer.compare fix, same guarantee. Worth noting the duplication itself: the two copies must be kept in sync by hand. The ROB-22 catch-up storm enumerates 5,200 minute-occurrences and inserts 5,200 rows, twice. Its author already flagged it slow and set 60s, which held on developer hardware but not on a 2-core runner sharing the box with the parallel suites. Raised to the package's 180s hookTimeout. --- .../src/domain/scheduler/__tests__/tick-batching.test.ts | 6 +++++- .../src/infra/objectstore/__tests__/contract.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/domain/scheduler/__tests__/tick-batching.test.ts b/packages/backend/src/domain/scheduler/__tests__/tick-batching.test.ts index 63d3fb4..0722ba4 100644 --- a/packages/backend/src/domain/scheduler/__tests__/tick-batching.test.ts +++ b/packages/backend/src/domain/scheduler/__tests__/tick-batching.test.ts @@ -195,5 +195,9 @@ describe.skipIf(!RUNTIME)("scheduler batching (§17.8)", () => { // instant re-enumerates from MAX(scheduled_at) and records nothing new. await scheduler.tick(); expect(await runsFor(jobId)).toHaveLength(minutes); - }, 60_000); // enumerating > one chunk of minute occurrences is the slow part + // Enumerating > one chunk of minute occurrences is the slow part: 5,200 of them, + // twice, against a containerized Postgres. 60s was enough on developer hardware but + // not on a 2-core CI runner sharing the box with the other parallel suites, so this + // matches the package's 180s hookTimeout rather than sitting just under the wire. + }, 180_000); }); diff --git a/packages/backend/src/infra/objectstore/__tests__/contract.test.ts b/packages/backend/src/infra/objectstore/__tests__/contract.test.ts index 7081615..037bb34 100644 --- a/packages/backend/src/infra/objectstore/__tests__/contract.test.ts +++ b/packages/backend/src/infra/objectstore/__tests__/contract.test.ts @@ -393,7 +393,10 @@ function runContract( const put = await store.put(key, byteStream(payload)); const got = await readAll(await store.get(key)); expect(got.byteLength).toBe(payload.byteLength); - expect(got).toEqual(payload); + // Same byte-exact guarantee as the small-payload case, but native: `toEqual` + // walks a multi-MiB typed array element by element, which timed out the 5s + // default on a shared CI runner (~2.4s per store even on fast hardware). + expect(Buffer.compare(Buffer.from(got), Buffer.from(payload))).toBe(0); // ETag must be stable across reads of the same content: a conditional put // with the returned etag must succeed. const cp = await store.conditionalPut( From de255b578af48593c292889fa4091b852348991c Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 27 Jul 2026 09:47:41 +0800 Subject: [PATCH 08/10] test: drop the unverifiable ledger ordering assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency test asserted that the entry last in (created_at DESC, id DESC) carries the final balance. Nothing can identify the last-APPLIED entry that way: created_at defaults to now(), which is transaction-START time in Postgres and so does not track the order 40 concurrent transactions take the FOR UPDATE lock, and id is a random newId(). The assertion only ever passed on timing luck; on CI it read 1000100. The ledger itself is correct — appendEntry serializes every credit and debit under FOR UPDATE on tenant_billing, and the two authoritative assertions (final balance 1_000_000, all 41 entries present) both passed. Drop the ordering assertion and its now-dead subquery, recording why it cannot be asserted so it is not reintroduced. --- .../billing/__tests__/ledger.integration.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/domain/billing/__tests__/ledger.integration.test.ts b/packages/backend/src/domain/billing/__tests__/ledger.integration.test.ts index 25d5abe..4b8c3d3 100644 --- a/packages/backend/src/domain/billing/__tests__/ledger.integration.test.ts +++ b/packages/backend/src/domain/billing/__tests__/ledger.integration.test.ts @@ -105,17 +105,19 @@ describe("ledger (integration)", () => { const row = await getBillingRow(pool, tenantId); expect(row?.balanceMicros).toBe(1_000_000); - // 41 entries: seed + 40 ops. The last entry's balance_after equals the balance. - const { rows } = await query<{ count: string; maxbal: string }>( + // 41 entries: seed + 40 ops, each recorded exactly once. + // + // We deliberately do NOT assert that the last entry's balance_after equals the + // balance: nothing here can identify the last-APPLIED entry. `created_at` defaults + // to `now()`, which is transaction-START time in Postgres, so it does not track the + // order in which concurrent transactions take the `FOR UPDATE` lock, and `id` is + // random. The authoritative no-lost-update guarantee is `balanceMicros` above. + const { rows } = await query<{ count: string }>( pool, - `SELECT count(*)::text AS count, - (SELECT balance_after_micros FROM ledger_entries - WHERE tenant_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1)::text AS maxbal - FROM ledger_entries WHERE tenant_id = $1`, + "SELECT count(*)::text AS count FROM ledger_entries WHERE tenant_id = $1", [tenantId], ); expect(rows[0].count).toBe("41"); - expect(rows[0].maxbal).toBe("1000000"); }); it("concurrent replays of the SAME key apply exactly once", async () => { From 335cb43229ee72e57d41586f14290b47f8db03a3 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 27 Jul 2026 09:53:53 +0800 Subject: [PATCH 09/10] test: poll for the session to settle idle after a child kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool test sampled rtB.status() once, immediately after sendEvent resolved. sendEvent returns when the event is accepted; the turn settles back to idle asynchronously after that, so on a loaded CI runner the sample caught 'running'. Poll with the waitFor helper already used a few lines above for the respawn. This keeps the assertion's strength — a session that never leaves 'running' still fails when waitFor times out at 10s. --- .../domain/session-worker/__tests__/pool.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/domain/session-worker/__tests__/pool.integration.test.ts b/packages/backend/src/domain/session-worker/__tests__/pool.integration.test.ts index 2b16413..958254e 100644 --- a/packages/backend/src/domain/session-worker/__tests__/pool.integration.test.ts +++ b/packages/backend/src/domain/session-worker/__tests__/pool.integration.test.ts @@ -264,7 +264,10 @@ d("session-worker process pool (R7.1)", () => { expect(rtB).toBeDefined(); await rtB.sendEvent(userMessage("still alive?")); expect(await managed.eventsStore.head(sessionB)).toBeGreaterThan(headBBefore); - expect(rtB.status()).toBe("idle"); + // `sendEvent` resolves once the event is accepted; the turn settles back to idle + // asynchronously after that, so poll rather than sampling once. A session that + // never leaves `running` still fails the assertion when `waitFor` times out. + expect(await waitFor(() => rtB.status() === "idle")).toBe(true); }, 60_000); it("(c) a killed child's session recovers (re-attach) on the next getOrCreate", async () => { From 6b2d7075029d23b493e206a12dd822b6547f9f46 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 27 Jul 2026 10:01:25 +0800 Subject: [PATCH 10/10] fix: attach an idle-client error listener to the pg pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test file passed but the job still exited 1: vitest caught two unhandled errors, both Postgres 57P01 (admin_shutdown) raised when testcontainers stopped the database while pooled connections were open. The cause is production code, not the tests. createPool never attached an 'error' listener, and pg emits that event on IDLE pooled clients whenever the server drops them — a restart, a failover, or pg_terminate_backend. In Node an 'error' event with no listener is fatal, so a routine Postgres restart would have taken the backend process down. The pool discards the dead client itself and stays usable, and an error on a checked-out client still rejects that client's own query, so the listener hides nothing a caller would otherwise observe. --- packages/backend/src/infra/db/pool.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/infra/db/pool.ts b/packages/backend/src/infra/db/pool.ts index e23ee38..47a2db6 100644 --- a/packages/backend/src/infra/db/pool.ts +++ b/packages/backend/src/infra/db/pool.ts @@ -55,7 +55,15 @@ export interface TenantCtx { * {@link closePool}. */ export function createPool(config: PoolConfig): Pool { - return new pg.Pool(config); + const pool = new pg.Pool(config); + // `pg` emits `error` on IDLE pooled clients when the server drops them — + // a restart, a failover, or `pg_terminate_backend` (`57P01`). An `error` event with no + // listener is fatal to the Node process, so without this a routine Postgres restart + // takes the backend down with it. The pool discards the dead client on its own and + // stays usable, and an error on a CHECKED-OUT client still rejects that client's own + // query, so nothing is being swallowed that a caller would otherwise see. + pool.on("error", () => {}); + return pool; } /**