Skip to content

Commit 2ae9f50

Browse files
authored
Turnkey fresh-worktree dev env: atomic per-checkout e2e ports, bootstrap, leak reaper (#973)
* Make fresh-worktree e2e turnkey: per-checkout ports, port-squat fail-fast, bootstrap script - e2e ports are now derived from a hash of the checkout root (42000-45999 blocks) so concurrent worktrees never collide; E2E_*_PORT/URL still override. - globalsetups fail fast with the squatting PID when a port is taken instead of silently attaching to a foreign checkout's dev server (the cause of 31-scenario auth-error cascades). - scripts/bootstrap.ts: one idempotent command for submodules + install + playwright; scripts/reap-dev-servers.ts kills orphaned dev stacks. - e2e: bun run ports prints this checkout's derived ports. - sources-api scenario: align with payload-first OpenAPI results (PR #956 changed the product, the cloud-only scenario was not updated). - AGENTS.md: fresh-worktree setup + environment gotchas (fish shell, /tmp scripts, bun.lock conflicts, per-checkout ports). * Add e2e run summary script + agent notes; format * Make e2e port claiming atomic: lock port per block, walk past collisions The hash-derived block was only collision-unlikely (28 checkouts over 400 blocks is birthday-paradox territory), and probe-then-bind raced. Each block now reserves its last port as a lock held for the suite's lifetime: claimPorts binds the lock (atomic — two racing suites can't both win), probes the remaining ports for squatters, and walks forward block-by-block until it owns a fully free block, publishing the claimed ports via E2E_*_PORT env so test workers agree. Explicit env pins skip claiming entirely. Verified by squatting the preferred block's lock+selfhost ports and watching the suite relocate one block over and pass 17/17. * bootstrap: vendor submodules opt-in via --forks Nothing imports from vendor/ at runtime — the forks are consumed as published npm packages (vendor/README.md is explicit). Forcing the submodule init in bootstrap taught every fresh worktree a false dependency. --forks remains for deliberately developing a fork.
1 parent cbf9365 commit 2ae9f50

15 files changed

Lines changed: 415 additions & 36 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ executor.jsonc
102102
MISTAKES.md
103103
DESIRES.md
104104
LEARNINGS.md
105+
106+
# Agent scratch scripts (workspace-resolvable alternative to /tmp)
107+
scratch/
105108
.alchemy/
106109

107110
# Pi coding agent

AGENTS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# AGENTS.md
22

3+
## Fresh Checkout / Worktree Setup
4+
5+
Run `bun run bootstrap` first in any fresh checkout or worktree. It is
6+
idempotent: runs `bun install` (whose prepare hook builds the internal
7+
packages dev servers fail without) and installs Playwright chromium.
8+
Skipping it is why fresh worktrees die with "Failed to resolve entry for
9+
package '@executor-js/vite-plugin'". The `vendor/` submodules are NOT
10+
needed — nothing imports from `vendor/` at runtime; those forks are consumed
11+
from npm (see `vendor/README.md`). `bun run bootstrap --forks` inits them
12+
only when you're deliberately developing a fork.
13+
14+
## Environment Gotchas (learned the hard way)
15+
16+
- The shell is fish, and the working directory resets between Bash calls. Use
17+
absolute paths rooted at THIS worktree (check `pwd`), never
18+
`/Users/rhys/src/executor` from memory, and don't rely on a prior `cd`.
19+
- Don't write probe scripts to `/tmp` — they can't resolve workspace packages
20+
(`effect`, `playwright`, ...). Put scratch scripts under the repo root
21+
(`scratch/` is gitignored) so bun resolves the workspace.
22+
- `bun.lock` conflicts on rebase/merge: take either side, then re-run
23+
`bun install` to regenerate it — never hand-merge the lockfile.
24+
- e2e dev-server ports are derived per checkout (`cd e2e && bun run ports`).
25+
If a boot reports a squatted port, an old dev server leaked — kill it by
26+
PID from the error message; don't move your own ports to dodge it.
27+
328
## Task Completion Requirements
429

530
- Use Effect Vitest for tests.

e2e/AGENTS.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,28 @@ const r = yield * session.call("execute", { code: "return 1 + 1;" });
8383
cd e2e
8484
bun run test # boots both dev servers, runs everything
8585
bun run test:cloud # one target
86-
# attach to an already-running server while iterating:
87-
E2E_CLOUD_URL=http://127.0.0.1:4798 ../node_modules/.bin/vitest run --project cloud <file>
88-
E2E_SELFHOST_URL=http://localhost:4799 ../node_modules/.bin/vitest run --project selfhost <file>
86+
bun run ports # print THIS checkout's derived ports
87+
# attach to an already-running server while iterating (use `bun run ports` URLs):
88+
E2E_CLOUD_URL=http://127.0.0.1:<port> ../node_modules/.bin/vitest run --project cloud <file>
89+
E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file>
8990
```
9091

92+
Ports are claimed at boot (see `src/ports.ts`): each checkout hashes its repo
93+
root to a preferred block, atomically locks it (a held lock port makes races
94+
impossible), and walks to the next free block if it's locked or squatted — so
95+
concurrent suites in different worktrees can never collide or attach to each
96+
other's servers. `bun run ports` shows the preferred block; the boot log says
97+
if a suite moved. `E2E_*_PORT` env vars pin ports explicitly (no probing) and
98+
`E2E_<TARGET>_URL` attaches to a running instance.
99+
91100
Each run writes `runs/<target>/<slug>/result.json` plus any browser artifacts
92101
(trace.zip / session.mp4 / screenshots). `bun run serve` hosts the scenario ×
93102
target matrix; a run page links the trace into Playwright's trace viewer.
94103

95104
## Discovering endpoints
96105

97-
- The full OpenAPI spec: `curl http://127.0.0.1:4798/api/openapi.json` (cloud).
106+
- The full OpenAPI spec: `curl http://127.0.0.1:<cloud port>/api/openapi.json`
107+
(cloud; port from `bun run ports`).
98108
- The typed client mirrors it: `client.<group>.<endpoint>(...)` with groups
99109
tools/integrations/connections/providers/executions/oauth/policies.
100110
- To see payload shapes, read the API definitions under

e2e/cloud/sources-api.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,13 +184,12 @@ scenario(
184184
}),
185185
);
186186
expect(execution.isError, "the execution succeeded").toBe(false);
187+
// Payload-first: `data` IS the upstream body; transport facts (status,
188+
// headers) ride in the optional `http` side channel.
187189
expect(execution.structured, "the tool returned the upstream's echo").toMatchObject({
188190
result: {
189191
ok: true,
190-
data: {
191-
status: 200,
192-
data: { message: "hello", suffix: "world", path: "/echo/hello" },
193-
},
192+
data: { message: "hello", suffix: "world", path: "/echo/hello" },
194193
},
195194
});
196195

e2e/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"test:cloud": "vitest run --project cloud",
99
"test:selfhost": "vitest run --project selfhost",
1010
"test:watch": "vitest",
11+
"ports": "bun scripts/ports.ts",
12+
"summary": "bun scripts/summary.ts",
1113
"viewer:build": "bun scripts/rebuild-viewer.ts",
1214
"serve": "bun scripts/rebuild-viewer.ts && bun scripts/serve.ts",
1315
"typecheck": "tsc --noEmit",

e2e/scripts/ports.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Print this checkout's PREFERRED e2e ports (see src/ports.ts). These are
2+
// where a suite normally boots; if the block is locked or squatted at boot
3+
// time, claimPorts walks to the next free block and the suite logs the move.
4+
// When attaching mid-run, the booted vite's actual port is authoritative —
5+
// check the suite's log line or `ps | grep 'vite dev'`.
6+
import {
7+
AUTUMN_EMULATOR_PORT,
8+
CLOUD_DB_PORT,
9+
CLOUD_PORT,
10+
WORKOS_EMULATOR_PORT,
11+
} from "../targets/cloud";
12+
import { SELFHOST_PORT } from "../targets/selfhost";
13+
import { repoRoot } from "../src/ports";
14+
15+
console.log(`preferred e2e ports for ${repoRoot}`);
16+
console.log(` cloud http://127.0.0.1:${CLOUD_PORT}`);
17+
console.log(` cloud dev-db ${CLOUD_DB_PORT}`);
18+
console.log(` workos emulator ${WORKOS_EMULATOR_PORT}`);
19+
console.log(` autumn emulator ${AUTUMN_EMULATOR_PORT}`);
20+
console.log(` selfhost http://localhost:${SELFHOST_PORT}`);

e2e/scripts/summary.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// One-line-per-failure digest of the last e2e run: reads
2+
// runs/<target>/<slug>/result.json and prints pass/fail counts plus each
3+
// failure's scenario name. `bun run summary [target...]` (default: all).
4+
import { readdirSync, readFileSync } from "node:fs";
5+
import { join } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
8+
const runsDir = fileURLToPath(new URL("../runs/", import.meta.url));
9+
10+
const targets =
11+
process.argv.length > 2
12+
? process.argv.slice(2)
13+
: readdirSync(runsDir, { withFileTypes: true })
14+
.filter((entry) => entry.isDirectory())
15+
.map((entry) => entry.name);
16+
17+
for (const target of targets) {
18+
const targetDir = join(runsDir, target);
19+
let slugs: string[];
20+
try {
21+
slugs = readdirSync(targetDir);
22+
} catch {
23+
console.log(`${target}: no runs`);
24+
continue;
25+
}
26+
const failures: { scenario: string; endedAt: string }[] = [];
27+
let passed = 0;
28+
for (const slug of slugs) {
29+
try {
30+
const result = JSON.parse(readFileSync(join(targetDir, slug, "result.json"), "utf8"));
31+
if (result.ok) passed++;
32+
else failures.push({ scenario: result.scenario ?? slug, endedAt: result.endedAt ?? "?" });
33+
} catch {
34+
// No result.json (partial run dir) — not a verdict either way.
35+
}
36+
}
37+
console.log(`${target}: ${passed} passed, ${failures.length} failed`);
38+
for (const failure of failures) console.log(` FAIL ${failure.scenario} (${failure.endedAt})`);
39+
}

e2e/setup/cloud.globalsetup.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,9 @@ import { fileURLToPath } from "node:url";
1010
// Vendored fork import (same pattern as mcporter).
1111
import { createEmulator } from "@executor-js/emulate";
1212

13+
import { claimPorts } from "../src/ports";
14+
import { E2E_COOKIE_PASSWORD, E2E_WORKOS_CLIENT_ID } from "../targets/cloud";
1315
import { bootProcesses, waitForHttp } from "./boot";
14-
import {
15-
CLOUD_BASE_URL,
16-
CLOUD_DB_PORT,
17-
CLOUD_PORT,
18-
WORKOS_EMULATOR_PORT,
19-
AUTUMN_EMULATOR_PORT,
20-
E2E_WORKOS_CLIENT_ID,
21-
E2E_COOKIE_PASSWORD,
22-
} from "../targets/cloud";
2316

2417
const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));
2518

@@ -29,6 +22,20 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
2922
return;
3023
}
3124

25+
// Claim a free port block (preferred block first, walk forward past
26+
// squatters/colliding checkouts) and publish via env so the test workers —
27+
// spawned after this — derive the same URLs. The imported targets/cloud
28+
// constants were computed BEFORE the claim, so use the claimed values here.
29+
const { ports, release } = await claimPorts([
30+
{ envVar: "E2E_CLOUD_PORT", offset: 0, label: "cloud vite dev" },
31+
{ envVar: "E2E_CLOUD_DB_PORT", offset: 1, label: "cloud dev-db (PGlite)" },
32+
{ envVar: "E2E_WORKOS_EMULATOR_PORT", offset: 2, label: "WorkOS emulator" },
33+
{ envVar: "E2E_AUTUMN_EMULATOR_PORT", offset: 3, label: "Autumn emulator" },
34+
]);
35+
const cloudPort = ports.E2E_CLOUD_PORT!;
36+
const dbPort = ports.E2E_CLOUD_DB_PORT!;
37+
const baseUrl = `http://127.0.0.1:${cloudPort}`;
38+
3239
// Fresh dev DB per suite run — hermetic, like the selfhost data dir. The
3340
// WorkOS emulator mints org ids from a per-process counter, so a persisted
3441
// DB from a previous invocation collides with the new boot's ids (identities
@@ -39,8 +46,8 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
3946
// MCP access tokens minted by the emulator's OAuth server must carry the
4047
// app's client id as audience (what the resource server verifies).
4148
process.env.EMULATE_WORKOS_AUDIENCE = E2E_WORKOS_CLIENT_ID;
42-
const workos = await createEmulator({ service: "workos", port: WORKOS_EMULATOR_PORT });
43-
const autumn = await createEmulator({ service: "autumn", port: AUTUMN_EMULATOR_PORT });
49+
const workos = await createEmulator({ service: "workos", port: ports.E2E_WORKOS_EMULATOR_PORT! });
50+
const autumn = await createEmulator({ service: "autumn", port: ports.E2E_AUTUMN_EMULATOR_PORT! });
4451

4552
const env = {
4653
// Real client, emulated service.
@@ -51,16 +58,16 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
5158
WORKOS_COOKIE_PASSWORD: E2E_COOKIE_PASSWORD,
5259
AUTUMN_SECRET_KEY: "am_test_emulate",
5360
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
54-
DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${CLOUD_DB_PORT}/postgres`,
61+
DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${dbPort}/postgres`,
5562
EXECUTOR_DIRECT_DATABASE_URL: "true",
5663
CLOUDFLARE_INCLUDE_PROCESS_ENV: "true",
57-
VITE_PUBLIC_SITE_URL: CLOUD_BASE_URL,
64+
VITE_PUBLIC_SITE_URL: baseUrl,
5865
// The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too.
5966
MCP_AUTHKIT_DOMAIN: workos.url,
60-
MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL,
67+
MCP_RESOURCE_ORIGIN: baseUrl,
6168
ALLOW_LOCAL_NETWORK: "true",
6269
// Throwaway PGlite on its own port + dir so it never fights `bun dev`.
63-
DEV_DB_PORT: String(CLOUD_DB_PORT),
70+
DEV_DB_PORT: String(dbPort),
6471
DEV_DB_PATH: dbPath,
6572
};
6673

@@ -69,7 +76,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
6976
{ cmd: "bun", args: ["run", "scripts/dev-db.ts"], cwd: cloudDir, env },
7077
{
7178
cmd: "bunx",
72-
args: ["vite", "dev", "--port", String(CLOUD_PORT), "--strictPort", "--host", "127.0.0.1"],
79+
args: ["vite", "dev", "--port", String(cloudPort), "--strictPort", "--host", "127.0.0.1"],
7380
cwd: cloudDir,
7481
env,
7582
},
@@ -78,18 +85,20 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
7885
);
7986

8087
try {
81-
await waitForHttp(CLOUD_BASE_URL);
88+
await waitForHttp(baseUrl);
8289
// The API plane is ready when login actually redirects to AuthKit.
83-
await waitForHttp(`${CLOUD_BASE_URL}/api/auth/login`, { expectRedirect: true });
90+
await waitForHttp(`${baseUrl}/api/auth/login`, { expectRedirect: true });
8491
} catch (error) {
8592
await procs.teardown();
8693
await workos.close();
8794
await autumn.close();
95+
await release();
8896
throw error;
8997
}
9098
return async () => {
9199
await procs.teardown();
92100
await workos.close();
93101
await autumn.close();
102+
await release();
94103
};
95104
}

e2e/setup/selfhost.globalsetup.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { rmSync } from "node:fs";
66
import { resolve } from "node:path";
77
import { fileURLToPath } from "node:url";
88

9+
import { claimPorts } from "../src/ports";
10+
import { SELFHOST_ADMIN } from "../targets/selfhost";
911
import { bootProcesses, waitForHttp } from "./boot";
10-
import { SELFHOST_ADMIN, SELFHOST_BASE_URL, SELFHOST_PORT } from "../targets/selfhost";
1112

1213
const selfhostDir = fileURLToPath(new URL("../../apps/host-selfhost/", import.meta.url));
1314

@@ -17,6 +18,16 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
1718
return;
1819
}
1920

21+
// Claim a free port (preferred block first, walk forward past squatters)
22+
// and publish via env so the test workers derive the same URL. The imported
23+
// targets/selfhost constants were computed BEFORE the claim — don't use them
24+
// for ports/URLs here.
25+
const { ports, release } = await claimPorts([
26+
{ envVar: "E2E_SELFHOST_PORT", offset: 4, label: "selfhost vite dev" },
27+
]);
28+
const port = ports.E2E_SELFHOST_PORT!;
29+
const baseUrl = `http://localhost:${port}`;
30+
2031
// Fresh data dir per suite run — hermetic; in-suite isolation comes from
2132
// fresh identities, not resets.
2233
const dataDir = resolve(selfhostDir, ".e2e-data");
@@ -26,14 +37,14 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
2637
[
2738
{
2839
cmd: "bunx",
29-
args: ["--bun", "vite", "dev", "--port", String(SELFHOST_PORT), "--strictPort"],
40+
args: ["--bun", "vite", "dev", "--port", String(port), "--strictPort"],
3041
cwd: selfhostDir,
3142
env: {
3243
EXECUTOR_DATA_DIR: dataDir,
3344
BETTER_AUTH_SECRET: "executor-selfhost-e2e-secret-0123456789",
3445
EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email,
3546
EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password,
36-
EXECUTOR_WEB_BASE_URL: SELFHOST_BASE_URL,
47+
EXECUTOR_WEB_BASE_URL: baseUrl,
3748
// The harness boots loopback MCP/OAuth test servers and points the
3849
// instance at them; the hosted SSRF guard would otherwise block
3950
// outbound probes/dials to localhost. Hermetic test instance only.
@@ -45,10 +56,14 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
4556
);
4657

4758
try {
48-
await waitForHttp(SELFHOST_BASE_URL);
59+
await waitForHttp(baseUrl);
4960
} catch (error) {
5061
await procs.teardown();
62+
await release();
5163
throw error;
5264
}
53-
return procs.teardown;
65+
return async () => {
66+
await procs.teardown();
67+
await release();
68+
};
5469
}

0 commit comments

Comments
 (0)