diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a353634006..4e67a69e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,15 @@ jobs: run: bun run test:e2e env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/workbench + E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs + + - name: Upload hub/sidecar logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: ${{ runner.temp }}/e2e-logs + if-no-files-found: ignore isolation: runs-on: ubuntu-latest diff --git a/scripts/e2e/channel-reconnect.test.ts b/scripts/e2e/channel-reconnect.test.ts index 59e254c197..3c3e8e6377 100644 --- a/scripts/e2e/channel-reconnect.test.ts +++ b/scripts/e2e/channel-reconnect.test.ts @@ -29,12 +29,9 @@ import { e2eDatabaseUrl, expectStatus, freePort, - provisionSidecar, startHub, - startSidecar, type ApiResult, type HubHandle, - type SpawnedApp, } from "./harness.ts"; const databaseUrl = e2eDatabaseUrl(); @@ -76,7 +73,6 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { let hubPort: number; let sessionSecret: string; let hub: HubHandle; - let sidecar: SpawnedApp; let api: ApiCall; let cookies: string[]; let tenantId: string; @@ -93,10 +89,6 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { expect(report.action).toBe("migrated"); expect(report.migrations).toBeGreaterThan(0); - const sidecarId = "workbench-reconnect-e2e-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - hubPort = freePort(); sessionSecret = Buffer.from( crypto.getRandomValues(new Uint8Array(32)), @@ -110,14 +102,6 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { }); track(hub); - sidecar = startSidecar({ - hubPort, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-workbench-reconnect-sidecar-data-"), - }); - track(sidecar); - api = createHubAPI(hub.baseUrl); const email = `workbench-reconnect-e2e-${crypto.randomUUID()}@example.invalid`; @@ -159,9 +143,9 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before workbench creation; output:\n${sidecar.output()}`, + `hub exited before workbench creation; output:\n${hub.output()}`, ); } res = await api( @@ -174,7 +158,7 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { if (Date.now() > deadline) { throw new Error( `workbench never became launchable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(1000); @@ -244,10 +228,10 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { // hub process on the same port against the same database, exactly // as a real restart looks from the sidecar's and the browser's // side: same DB rows, same session cookie, new process. The port - // must stay the same — the sidecar's own `HUB_WS_URL` is fixed at - // its boot and is never reconfigured here (only the hub restarts), - // so a different port would leave the sidecar dialing a hub that - // is no longer there. + // must stay the same — the process-provisioner-spawned sidecar's + // own `HUB_WS_URL` is fixed at its boot and is never reconfigured + // here (only the hub restarts), so a different port would leave the + // sidecar dialing a hub that is no longer there. await hub.stop(); const restarted = await startHub({ @@ -269,9 +253,9 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { const deadline = Date.now() + 180_000; let posted: ApiResult; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited after hub restart; output:\n${sidecar.output()}`, + `hub exited after hub restart; output:\n${hub.output()}`, ); } posted = await postMessage(after); @@ -279,8 +263,7 @@ describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { if (Date.now() > deadline) { throw new Error( `message after hub restart never accepted: status=${posted.status} ` + - `${JSON.stringify(posted.data)}\nsidecar output:\n${sidecar.output()}` + - `\nhub output:\n${hub.output()}`, + `${JSON.stringify(posted.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(1000); diff --git a/scripts/e2e/chat.test.ts b/scripts/e2e/chat.test.ts index da9b255c89..c50da08946 100644 --- a/scripts/e2e/chat.test.ts +++ b/scripts/e2e/chat.test.ts @@ -46,14 +46,11 @@ import { e2eDatabaseUrl, expectStatus, freePort, - provisionSidecar, pushWorkflowSource, workflowDeployBody, startHub, - startSidecar, type ApiResult, type HubHandle, - type SpawnedApp, } from "./harness.ts"; const databaseUrl = e2eDatabaseUrl(); @@ -139,7 +136,6 @@ const textPart = (text: string): Part[] => [{ kind: "text", text }]; describe.skipIf(databaseUrl === undefined)("chat e2e", () => { let hub: HubHandle; - let sidecar: SpawnedApp; let api: ApiCall; let user1: SignedUpUser; let user2: SignedUpUser; @@ -159,10 +155,6 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { expect(report.action).toBe("migrated"); expect(report.migrations).toBeGreaterThan(0); - const sidecarId = "chat-e2e-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - hub = await startHub({ databaseUrl: url, port: freePort(), @@ -173,16 +165,6 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { }); track(hub); - sidecar = startSidecar({ - hubPort: new URL(hub.baseUrl).port - ? Number(new URL(hub.baseUrl).port) - : 80, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-chat-sidecar-data-"), - }); - track(sidecar); - api = createHubAPI(hub.baseUrl); // Two independent browser-shaped accounts, each carrying its own @@ -358,15 +340,15 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { ), }); - // Retries while the hub still answers 502 (the sidecar's dial-in - // may not have completed yet), matching `createWorkbench`'s own - // retry loop below. + // Retries while the hub still answers 502 (the process + // provisioner's spawned sidecar may not have dialed in yet), + // matching `createWorkbench`'s own retry loop below. const echoDeployDeadline = Date.now() + 60_000; let echoDeployed: ApiResult; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before echo deploy; output:\n${sidecar.output()}`, + `hub exited before echo deploy; output:\n${hub.output()}`, ); } echoDeployed = await api( @@ -384,7 +366,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { if (Date.now() > echoDeployDeadline) { throw new Error( `echo workflow never became deployable (hub kept answering 502): ` + - `${JSON.stringify(echoDeployed.data)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(echoDeployed.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(1000); @@ -393,22 +375,22 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { }, 120_000); // Launching a workbench is the go/no-go signal for the whole suite: it - // launches the anchor instance in-process, which needs the - // sidecar's dial-in to have completed. `packages/chat/src/routes.ts` - // has no 502-style retry translation of its own (unlike the native - // workflow deploy route), so a launch attempted before the sidecar - // connects fails with an uncaught 500 — retried here directly until - // the sidecar is ready, exactly as the walking skeleton retries a - // 502 for the native deploy route. + // launches the anchor instance in-process, which needs its + // process-provisioner-spawned sidecar's dial-in to have completed. + // `packages/chat/src/routes.ts` has no 502-style retry translation of + // its own (unlike the native workflow deploy route), so a launch + // attempted before the sidecar connects fails with an uncaught 500 — + // retried here directly until the sidecar is ready, exactly as the + // walking skeleton retries a 502 for the native deploy route. async function createWorkbench( body: Record, ): Promise { const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before workbench creation; output:\n${sidecar.output()}`, + `hub exited before workbench creation; output:\n${hub.output()}`, ); } res = await api( @@ -421,7 +403,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { if (Date.now() > deadline) { throw new Error( `workbench never became launchable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(1000); @@ -1021,10 +1003,13 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { // temporarily reverting the self-anchor fix locally: the sidecar log // then carries ten `enqueueInbox failed, withholding ack` lines, one // per rejected pack, for this same suite; with the fix applied, zero. + // Every process-provisioner-spawned sidecar inherits the hub + // process's own stdio (`process-runner.ts`'s `stdout: "inherit"`), so + // `hub.output()` already carries every sidecar's log lines. test("no chat/folded run's workflow-run pack was ever permanently rejected", () => { - const sidecarOutput = sidecar.output(); - expect(sidecarOutput).not.toContain("enqueueInbox failed"); - expect(sidecarOutput).not.toContain("withholding ack"); - expect(sidecarOutput).not.toContain("rejecting inbound mail"); + const hubOutput = hub.output(); + expect(hubOutput).not.toContain("enqueueInbox failed"); + expect(hubOutput).not.toContain("withholding ack"); + expect(hubOutput).not.toContain("rejecting inbound mail"); }); }); diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts index fd67ec86dd..92e10d5212 100644 --- a/scripts/e2e/greeting-delivery.test.ts +++ b/scripts/e2e/greeting-delivery.test.ts @@ -41,12 +41,9 @@ import { expectStatus, freePort, hop, - provisionSidecar, startHub, - startSidecar, type ApiResult, type HubHandle, - type SpawnedApp, } from "./harness.ts"; const { tempDir, track } = createCleanupHarness(); @@ -135,10 +132,6 @@ describe.skipIf(databaseUrl === undefined)( expect(report.action).toBe("migrated"); }); - const sidecarId = "greeting-delivery-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - const hub: HubHandle = await hop("hub boot", async () => startHub({ databaseUrl: url, @@ -151,16 +144,6 @@ describe.skipIf(databaseUrl === undefined)( ); track(hub); - const sidecar: SpawnedApp = startSidecar({ - hubPort: new URL(hub.baseUrl).port - ? Number(new URL(hub.baseUrl).port) - : 80, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-greeting-delivery-sidecar-data-"), - }); - track(sidecar); - const hubApi: ApiCall = createHubAPI(hub.baseUrl); const user = await hop("sign-up", () => @@ -236,9 +219,9 @@ describe.skipIf(databaseUrl === undefined)( async () => { const deadline = Date.now() + 60_000; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before ensureSeeded could run; output:\n${sidecar.output()}`, + `hub exited before ensureSeeded could run; output:\n${hub.output()}`, ); } try { @@ -269,9 +252,9 @@ describe.skipIf(databaseUrl === undefined)( async function deploySeededWorkflows(): Promise { const deadline = Date.now() + 60_000; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before default workflows could deploy; output:\n${sidecar.output()}`, + `hub exited before default workflows could deploy; output:\n${hub.output()}`, ); } try { @@ -345,9 +328,9 @@ describe.skipIf(databaseUrl === undefined)( const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before chat creation; output:\n${sidecar.output()}`, + `hub exited before chat creation; output:\n${hub.output()}`, ); } res = await api( @@ -361,7 +344,7 @@ describe.skipIf(databaseUrl === undefined)( if (Date.now() > deadline) { throw new Error( `chat never became mintable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(200); @@ -437,7 +420,7 @@ describe.skipIf(databaseUrl === undefined)( throw new Error( `no agent-authored message landed in chat ${chatId} within ` + `60s of mint with zero user messages sent; messages seen: ` + - `${JSON.stringify(items)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(items)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(200); @@ -516,8 +499,7 @@ describe.skipIf(databaseUrl === undefined)( throw new Error( `no answer to turn ${turn} within 180s; agent messages: ` + `${JSON.stringify(agentTexts)}\nhub output (tail):\n` + - `${hub.output().slice(-60000)}\nsidecar output (tail):\n` + - `${sidecar.output().slice(-6000)}`, + `${hub.output().slice(-60000)}`, ); } await Bun.sleep(2000); diff --git a/scripts/e2e/harness.test.ts b/scripts/e2e/harness.test.ts index 37f4f93e4e..3503b8131c 100644 --- a/scripts/e2e/harness.test.ts +++ b/scripts/e2e/harness.test.ts @@ -6,10 +6,14 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; +import path from "node:path"; +import { resetSchema, setupDatabase } from "../db-setup.ts"; import { createCleanupHarness, + e2eDatabaseUrl, parseEnvFileDatabaseUrl, + provisionSidecar, runCleanups, workflowDeployBody, type SpawnedApp, @@ -127,3 +131,71 @@ describe("parseEnvFileDatabaseUrl", () => { ).toBe("postgres://second/db"); }); }); + +// CL-7472 regression: at pin 692c3106, the hub authenticates a +// sidecar's register/reconnect frame only against a `sidecar` row that +// resolves to a live `sidecar_allocation` (or `workflow_probe`) row — +// upstream dropped the older "shared" credential scope this repo used +// to provision a single, standalone sidecar identity for (see +// `provisionSidecar`'s own doc comment). CI's e2e suites hit exactly +// this: a `provisionSidecar`-only row connects and is rejected with +// "invalid token" in a loop (never a working dial-in), which is why +// every suite now lets the hub's own process-provisioner spawn a +// dedicated, already-allocated sidecar per deployment instead (see +// `startHub`'s doc comment). This test pins the negative: an +// unallocated identity must keep failing to authenticate, so a future +// upstream re-vendor that silently restores (or a caller that +// silently starts relying on) the old "shared" scope surfaces here +// first rather than as a mystery disconnect loop in CI. +describe("provisionSidecar", () => { + const databaseUrl = e2eDatabaseUrl(); + const HUB_DIR = path.resolve(import.meta.dir, "..", "..", "apps", "hub"); + + test.skipIf(databaseUrl === undefined)( + "a provisioned identity with no allocation or probe never authenticates", + async () => { + const url = databaseUrl; + if (url === undefined) throw new Error("unreachable: suite is skipped"); + + await resetSchema(url); + await setupDatabase(url); + + const sidecarId = "harness-test-unallocated-sidecar"; + const token = crypto.randomUUID(); + await provisionSidecar(url, sidecarId, token); + + // Resolved through the hub's own dependency tree, exactly as + // `connectE2eDb` resolves `postgres` above — scripts/e2e is not + // itself a workspace member of these `@intx/*` packages. + const dbModule = (await import(Bun.resolveSync("@intx/db", HUB_DIR))) as { + createDB: (raw: unknown) => { db: unknown; close: () => Promise }; + }; + const hubSessionsModule = (await import( + Bun.resolveSync("@intx/hub-sessions", HUB_DIR) + )) as { + createSidecarCredentialResolver: (deps: { db: unknown }) => { + resolve: (token: string) => Promise; + }; + }; + + const parsed = new URL(url); + const db = dbModule.createDB({ + host: parsed.hostname, + port: parsed.port === "" ? 5432 : Number(parsed.port), + user: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + database: parsed.pathname.replace(/^\//, ""), + }); + try { + const resolver = hubSessionsModule.createSidecarCredentialResolver({ + db: db.db, + }); + const identity = await resolver.resolve(token); + expect(identity).toBeNull(); + } finally { + await db.close(); + } + }, + 60_000, + ); +}); diff --git a/scripts/e2e/harness.ts b/scripts/e2e/harness.ts index 39aa8393c2..68c56ead17 100644 --- a/scripts/e2e/harness.ts +++ b/scripts/e2e/harness.ts @@ -221,6 +221,20 @@ export async function connectE2eDb(databaseUrl: string): Promise { * WebSocket dial-in against the token hash on the `sidecar` table, so * the row must exist before the sidecar process starts. Mirrors * Interchange's dev provisioning (id + sha256(token), placeholder url). + * + * At pin 692c3106, the hub only accepts a register/reconnect frame from + * a sidecar identity that resolves to a live `sidecar_allocation` (or + * `workflow_probe`) row — the standalone, unallocated ("shared" + * credential scope) sidecar this used to provision no longer + * authenticates at all (CL-7472), so a caller of this function gets a + * connect-and-immediately-reject loop, not a usable dial-in. Every + * suite this repo runs against CI now lets the hub's own + * process-provisioner spawn a dedicated sidecar per allocation instead + * (see `startHub`'s doc comment and `scripts/e2e/chat.test.ts`); this + * function is kept only for the handful of scripts not yet migrated off + * a directly-controlled sidecar process (e.g. ones that kill and + * restart a specific sidecar mid-scenario) and is not a working dial-in + * path until they are. */ export async function provisionSidecar( databaseUrl: string, @@ -400,11 +414,13 @@ export async function startHub(options: { } /** - * Boot the sidecar as a real process, pointed at the hub's WebSocket - * dial-in route with the provisioned identity. Connection readiness is - * observed by the deploy call itself (the hub answers 502 until a - * sidecar is connected), so this only guards against an immediate - * crash. + * Boot a standalone sidecar as a real process, pointed at the hub's + * WebSocket dial-in route with a `provisionSidecar`-issued identity. + * See `provisionSidecar`'s own doc comment: at pin 692c3106 this + * identity never authenticates (CL-7472), so the process this starts + * connects and is immediately rejected in a loop rather than dialing + * in. Kept only for the scripts not yet migrated to the hub's + * automatic, per-allocation process-provisioner spawn. */ export function startSidecar(options: { hubPort: number; diff --git a/scripts/e2e/heartbeat.test.ts b/scripts/e2e/heartbeat.test.ts index 668ed3b024..f7d218ec42 100644 --- a/scripts/e2e/heartbeat.test.ts +++ b/scripts/e2e/heartbeat.test.ts @@ -28,11 +28,9 @@ import { expectStepCompleted, freePort, hop, - provisionSidecar, pushWorkflowSource, workflowDeployBody, startHub, - startSidecar, waitForRunCompletion, type ApiResult, type HubHandle, @@ -95,12 +93,6 @@ describe.skipIf(databaseUrl === undefined)("heartbeat workflow", () => { await setupDatabase(url); }); - const sidecarId = "sidecar-e2e-heartbeat"; - const sidecarToken = crypto.randomUUID(); - await hop("sidecar provisioning", () => - provisionSidecar(url, sidecarId, sidecarToken), - ); - const hub: HubHandle = await hop("hub boot", async () => { const handle = await startHub({ databaseUrl: url, @@ -114,19 +106,6 @@ describe.skipIf(databaseUrl === undefined)("heartbeat workflow", () => { return handle; }); - const sidecar = await hop("sidecar boot", async () => { - const app = startSidecar({ - hubPort: new URL(hub.baseUrl).port - ? Number(new URL(hub.baseUrl).port) - : 80, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-heartbeat-sidecar-data-"), - }); - track(app); - return app; - }); - const user = await hop("sign-up", async () => { const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { name: "Heartbeat Tester", @@ -225,10 +204,8 @@ describe.skipIf(databaseUrl === undefined)("heartbeat workflow", () => { const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { - if (sidecar.exited()) { - throw new Error( - `sidecar exited before deploy; output:\n${sidecar.output()}`, - ); + if (hub.exited()) { + throw new Error(`hub exited before deploy; output:\n${hub.output()}`); } res = await api( hub.baseUrl, @@ -241,7 +218,7 @@ describe.skipIf(databaseUrl === undefined)("heartbeat workflow", () => { if (Date.now() > deadline) { throw new Error( `sidecar never became deployable (hub kept answering 502): ` + - `${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(200); diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 601147cc6e..6bb94f2a41 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -81,9 +81,7 @@ import { expectStatus, freePort, hop, - provisionSidecar, startHub, - startSidecar, type HubHandle, } from "./harness.ts"; @@ -187,10 +185,6 @@ describe.skipIf(databaseUrl === undefined)( } }); - const sidecarId = "local-rip-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - const hub: HubHandle = await hop("hub boot", async () => startHub({ databaseUrl: url, @@ -208,16 +202,6 @@ describe.skipIf(databaseUrl === undefined)( ); track(hub); - const sidecar = startSidecar({ - hubPort: new URL(hub.baseUrl).port - ? Number(new URL(hub.baseUrl).port) - : 80, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-local-rip-sidecar-data-"), - }); - track(sidecar); - const hubApi: ApiCall = createHubAPI(hub.baseUrl); const user = await hop("sign-up", () => @@ -341,9 +325,9 @@ describe.skipIf(databaseUrl === undefined)( ): Promise>> { const deadline = Date.now() + 60_000; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before default workflows could deploy; output:\n${sidecar.output()}`, + `hub exited before default workflows could deploy; output:\n${hub.output()}`, ); } try { @@ -397,9 +381,9 @@ describe.skipIf(databaseUrl === undefined)( async () => { const deadline = Date.now() + 60_000; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before ensureSeeded could run; output:\n${sidecar.output()}`, + `hub exited before ensureSeeded could run; output:\n${hub.output()}`, ); } try { diff --git a/scripts/e2e/smoke-chat.test.ts b/scripts/e2e/smoke-chat.test.ts index 98b5983321..eaf0da399c 100644 --- a/scripts/e2e/smoke-chat.test.ts +++ b/scripts/e2e/smoke-chat.test.ts @@ -20,9 +20,7 @@ import { expectStatus, freePort, hop, - provisionSidecar, startHub, - startSidecar, type HubHandle, } from "./harness.ts"; @@ -58,13 +56,6 @@ describe.skipIf(databaseUrl === undefined)("smoke: chat round-trip", () => { }); const hubDataDir = await tempDir("e2e-smoke-chat-hub-data-"); - const sidecarDataDir = await tempDir("e2e-smoke-chat-sidecar-data-"); - - const sidecarId = "sidecar-e2e-smoke-chat"; - const sidecarToken = crypto.randomUUID(); - await hop("sidecar provisioning", () => - provisionSidecar(url, sidecarId, sidecarToken), - ); const hub: HubHandle = await hop("hub boot", () => startHub({ @@ -78,18 +69,6 @@ describe.skipIf(databaseUrl === undefined)("smoke: chat round-trip", () => { ); track(hub); - const sidecar = await hop("sidecar boot", () => - Promise.resolve( - startSidecar({ - hubPort: Number(new URL(hub.baseUrl).port), - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }), - ), - ); - track(sidecar); - { const cookies = await hop("sign-up", async () => { const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { @@ -123,9 +102,9 @@ describe.skipIf(databaseUrl === undefined)("smoke: chat round-trip", () => { // through the transient 500 the same way chat.test.ts does. const deadline = Date.now() + 60_000; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before workbench creation; output:\n${sidecar.output()}`, + `hub exited before workbench creation; output:\n${hub.output()}`, ); } const res = await api( @@ -141,7 +120,7 @@ describe.skipIf(databaseUrl === undefined)("smoke: chat round-trip", () => { } if (Date.now() > deadline) { throw new Error( - `workbench never became launchable (hub kept answering 500): ${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `workbench never became launchable (hub kept answering 500): ${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(1000); @@ -171,7 +150,7 @@ describe.skipIf(databaseUrl === undefined)("smoke: chat round-trip", () => { if (Date.now() > deadline) { throw new Error( `post message kept answering 500: ${JSON.stringify(res.data)}` + - `\nsidecar output:\n${sidecar.output()}`, + `\nhub output:\n${hub.output()}`, ); } await Bun.sleep(1000); diff --git a/scripts/e2e/smoke-webhook.test.ts b/scripts/e2e/smoke-webhook.test.ts index 66a258c601..28dd70d43d 100644 --- a/scripts/e2e/smoke-webhook.test.ts +++ b/scripts/e2e/smoke-webhook.test.ts @@ -12,9 +12,8 @@ // events by bare id (the native events route requires a deployment-anchor // run). This test instead reads the run row the ingress delivery created // straight out of the database — a harness-side fact with no route, -// exactly as `provisionSidecar` already does for the sidecar identity -// row — and confirms the trigger itself recorded the delivery -// (`lastFiredAt`). +// via `connectE2eDb` — and confirms the trigger itself recorded the +// delivery (`lastFiredAt`). import { signPayload, @@ -37,11 +36,9 @@ import { expectStatus, freePort, hop, - provisionSidecar, pushWorkflowSource, workflowDeployBody, startHub, - startSidecar, type HubHandle, } from "./harness.ts"; @@ -77,13 +74,6 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { }); const hubDataDir = await tempDir("e2e-smoke-webhook-hub-data-"); - const sidecarDataDir = await tempDir("e2e-smoke-webhook-sidecar-data-"); - - const sidecarId = "sidecar-e2e-smoke-webhook"; - const sidecarToken = crypto.randomUUID(); - await hop("sidecar provisioning", () => - provisionSidecar(url, sidecarId, sidecarToken), - ); const hub: HubHandle = await hop("hub boot", () => startHub({ @@ -97,18 +87,6 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { ); track(hub); - const sidecar = await hop("sidecar boot", () => - Promise.resolve( - startSidecar({ - hubPort: Number(new URL(hub.baseUrl).port), - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }), - ), - ); - track(sidecar); - { const cookies = await hop("sign-up", async () => { const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { @@ -204,9 +182,9 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { }); const deadline = Date.now() + 60_000; for (;;) { - if (sidecar.exited()) { + if (hub.exited()) { throw new Error( - `sidecar exited before deploy; output:\n${sidecar.output()}`, + `hub exited before deploy; output:\n${hub.output()}`, ); } const res = await api( @@ -219,7 +197,7 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { if (res.status === 502) { if (Date.now() > deadline) { throw new Error( - `sidecar never became deployable (hub kept answering 502): ${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `sidecar never became deployable (hub kept answering 502): ${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(200); @@ -346,8 +324,8 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { // No platform route reads a non-anchored run's row by bare id // (see the file header); this reads the fact the ingress // route's own launch is required to have produced, the same - // way `provisionSidecar` reaches the database directly for a - // fact no route exposes. + // way `connectE2eDb` reaches the database directly for a fact + // no route exposes. const sql = await connectE2eDb(url); try { const rows = await sql.unsafe( diff --git a/scripts/e2e/walking-skeleton.test.ts b/scripts/e2e/walking-skeleton.test.ts index 4713eba043..66d3d7e9db 100644 --- a/scripts/e2e/walking-skeleton.test.ts +++ b/scripts/e2e/walking-skeleton.test.ts @@ -34,11 +34,9 @@ import { expectStatus, freePort, hop, - provisionSidecar, pushWorkflowSource, workflowDeployBody, startHub, - startSidecar, type ApiResult, type HubHandle, } from "./harness.ts"; @@ -168,15 +166,10 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { expect(second.migrations).toBe(first.migrations); }); - // Hop: sidecar provisioning. The identity row the hub checks the - // sidecar's dial-in token against. - const sidecarId = "sidecar-e2e"; - const sidecarToken = crypto.randomUUID(); - await hop("sidecar provisioning", () => - provisionSidecar(url, sidecarId, sidecarToken), - ); - - // Hop: hub boot. The composition root as a real process. + // Hop: hub boot. The composition root as a real process. The hub's + // own process provisioner spawns a dedicated sidecar for every + // allocation on demand (one `apps/sidecar` per deployment) — there + // is no shared, pre-provisioned sidecar identity to dial in here. const hub: HubHandle = await hop("hub boot", async () => { const handle = await startHub({ databaseUrl: url, @@ -190,21 +183,6 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { return handle; }); - // Hop: sidecar boot. Dial-in readiness is observed at the deploy - // hop (the hub answers 502 until a sidecar is connected). - const sidecar = await hop("sidecar boot", async () => { - const app = startSidecar({ - hubPort: new URL(hub.baseUrl).port - ? Number(new URL(hub.baseUrl).port) - : 80, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-sidecar-data-"), - }); - track(app); - return app; - }); - // Hop: sign-up. A browser-shaped account creation through the // platform's better-auth surface; the session cookie carries the // rest of the skeleton. @@ -302,9 +280,10 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { ); // Hop: workflow deploy via the native deploy API. Retries while - // the hub still answers 502 (the sidecar's dial-in may not have - // completed yet); any other failure is final. The inference - // source is a placeholder — deployment does not call inference. + // the hub still answers 502 (the process provisioner's spawned + // sidecar may not have dialed in yet); any other failure is final. + // The inference source is a placeholder — deployment does not call + // inference. const deploymentId = await hop("workflow deploy", async () => { const body = workflowDeployBody({ assetId, @@ -315,10 +294,8 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { - if (sidecar.exited()) { - throw new Error( - `sidecar exited before deploy; output:\n${sidecar.output()}`, - ); + if (hub.exited()) { + throw new Error(`hub exited before deploy; output:\n${hub.output()}`); } res = await api( hub.baseUrl, @@ -331,7 +308,7 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { if (Date.now() > deadline) { throw new Error( `sidecar never became deployable (hub kept answering 502): ` + - `${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(200); diff --git a/scripts/e2e/workbench-digest.test.ts b/scripts/e2e/workbench-digest.test.ts index b560c98a29..e830c9f226 100644 --- a/scripts/e2e/workbench-digest.test.ts +++ b/scripts/e2e/workbench-digest.test.ts @@ -28,11 +28,9 @@ import { expectStepCompleted, freePort, hop, - provisionSidecar, pushWorkflowSource, workflowDeployBody, startHub, - startSidecar, waitForRunCompletion, type ApiResult, type HubHandle, @@ -100,12 +98,6 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { await setupDatabase(url); }); - const sidecarId = "sidecar-e2e-workbench-digest"; - const sidecarToken = crypto.randomUUID(); - await hop("sidecar provisioning", () => - provisionSidecar(url, sidecarId, sidecarToken), - ); - const hub: HubHandle = await hop("hub boot", async () => { const handle = await startHub({ databaseUrl: url, @@ -119,19 +111,6 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { return handle; }); - const sidecar = await hop("sidecar boot", async () => { - const app = startSidecar({ - hubPort: new URL(hub.baseUrl).port - ? Number(new URL(hub.baseUrl).port) - : 80, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-workbench-digest-sidecar-data-"), - }); - track(app); - return app; - }); - const user = await hop("sign-up", async () => { const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { name: "Workbench Digest Tester", @@ -229,10 +208,8 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { - if (sidecar.exited()) { - throw new Error( - `sidecar exited before deploy; output:\n${sidecar.output()}`, - ); + if (hub.exited()) { + throw new Error(`hub exited before deploy; output:\n${hub.output()}`); } res = await api( hub.baseUrl, @@ -245,7 +222,7 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { if (Date.now() > deadline) { throw new Error( `sidecar never became deployable (hub kept answering 502): ` + - `${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(200); diff --git a/test/isolation/setup.ts b/test/isolation/setup.ts index 3644750fa6..07271b0c16 100644 --- a/test/isolation/setup.ts +++ b/test/isolation/setup.ts @@ -10,11 +10,8 @@ import path from "node:path"; import { freePort, - provisionSidecar, startHub, - startSidecar, type HubHandle, - type SpawnedApp, } from "../../scripts/e2e/harness.ts"; import { assertDatabaseConfigured } from "../../scripts/e2e/db-gate.ts"; @@ -90,29 +87,21 @@ export async function prepareDatabase(databaseUrl: string): Promise { } /** - * Boots the real hub and a real sidecar as spawned processes, exactly - * as `scripts/e2e/harness.ts` does for the walking skeleton — a - * chat-workbench create launches a real agent instance over the - * sidecar's WebSocket dial-in, so an in-process hub with no sidecar - * attached cannot exercise the "chat workbench move" block below; it - * would fail every one of those cases with "no sidecar available" - * rather than proving isolation. `AppLike.request` wraps `fetch` - * against the hub's real port, matching how a client actually reaches - * it. + * Boots the real hub as a spawned process, exactly as + * `scripts/e2e/harness.ts` does for the walking skeleton — a + * chat-workbench create launches a real agent instance over its own + * process-provisioner-spawned sidecar's WebSocket dial-in, so an + * in-process hub with no sidecar plugin wired up cannot exercise the + * "chat workbench move" block below; it would fail every one of those + * cases with "no sidecar available" rather than proving isolation. + * `AppLike.request` wraps `fetch` against the hub's real port, matching + * how a client actually reaches it. */ export async function bootIsolationHub( databaseUrl: string, ): Promise { const root = mkdtempSync(path.join(tmpdir(), "isolation-suite-")); const hubDataDir = path.join(root, "hub-data"); - const sidecarDataDir = path.join(root, "sidecar-data"); - - // Unique per run: the `sidecar` table's id is a primary key, and a - // prior run's row is never cleaned up (the isolation suite runs - // against a shared database, never a scratch one it owns outright). - const sidecarId = `sidecar-isolation-suite-${crypto.randomUUID()}`; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(databaseUrl, sidecarId, sidecarToken); const hub: HubHandle = await startHub({ databaseUrl, @@ -133,23 +122,15 @@ export async function bootIsolationHub( }, }); - const sidecar: SpawnedApp = startSidecar({ - hubPort: Number(new URL(hub.baseUrl).port), - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }); - const app: AppLike = { request: (requestPath, init) => fetch(`${hub.baseUrl}${requestPath}`, init), }; - await waitForSidecarDialIn(app, sidecar); + await waitForSidecarDialIn(app, hub); return { app, shutdown: async () => { - await sidecar.stop(); await hub.stop(); rmSync(root, { recursive: true, force: true }); }, @@ -169,7 +150,7 @@ export async function bootIsolationHub( */ async function waitForSidecarDialIn( app: AppLike, - sidecar: SpawnedApp, + hub: HubHandle, ): Promise { const cookie = await signUpUser( app, @@ -194,10 +175,8 @@ async function waitForSidecarDialIn( const deadline = Date.now() + 30_000; for (;;) { - if (sidecar.exited()) { - throw new Error( - `sidecar exited before dialing in; output:\n${sidecar.output()}`, - ); + if (hub.exited()) { + throw new Error(`hub exited before dialing in; output:\n${hub.output()}`); } const workbenchResponse = await app.request( `/api/tenants/${tenant.id}/chat/workbenches`, @@ -212,7 +191,7 @@ async function waitForSidecarDialIn( throw new Error( `sidecar never dialed in within 30s (workbench create kept ` + `failing with ${workbenchResponse.status}): ` + - `${await workbenchResponse.text()}\nsidecar output:\n${sidecar.output()}`, + `${await workbenchResponse.text()}\nhub output:\n${hub.output()}`, ); } await Bun.sleep(500);