Skip to content

Commit 2a9b38b

Browse files
committed
fix(showcase): repair self-contained worker boot + generalize driver registry seams
CR fixes for the fleet worker driverKind→driver registry (PR CopilotKit#5283): - Fix default (self-contained) worker boot: build the default d6 as a registry entry { driver, payloadToInput, aggregateSlugKey } instead of a bare driver with no mapper, so startWorkerLoop's construction guard no longer throws "Fleet worker has no drivers". - Thread aggregate-key derivation through DriverRegistryEntry (aggregateSlugKey?), defaulting to d6:<slug> so the d6 cell-capture filter stays byte-identical while non-d6 kinds can supply their own scheme. - Add construction-time fail-loud assert that each registry entry's factory kind matches its key; raise unknown-driver-kind log to error to match the sibling protocol-violation logs. - Extract shared buildPooledBrowserDrivers consumed by both registerAllProbeDrivers and the worker registry; collapse no-op per-kind payload-mapper aliases to the single createPayloadToInput. - Introduce typed DriverKind union (contained to worker/payload-mapper); keep contracts.driverKind a string wire boundary. - Tests: new fleet/orchestrator.test.ts (default-boot equivalence), driver construction-guard + custom aggregateSlugKey coverage, and lock-step + registry-wiring pins (factory kind == constant).
1 parent bb46436 commit 2a9b38b

9 files changed

Lines changed: 557 additions & 113 deletions

File tree

showcase/harness/src/fleet/contracts.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,14 @@ export interface ServiceJobPayload {
7575
/** The showcase service / integration slug, e.g. `"langgraph-python"`. */
7676
serviceSlug: string;
7777
/**
78-
* The d6 driver kind that runs the cells. Always `"e2e_d6"` for the
79-
* per-service d6 unit, but carried explicitly so the same job shape can
80-
* later host other per-service drivers without a payload migration.
78+
* The driver kind that runs the cells. The producer currently stamps
79+
* `"e2e_d6"` (the per-service d6 unit), but the WORKER now routes by this
80+
* field through its `DriverRegistry` — so any registered kind (e2e_d6 /
81+
* e2e_deep / e2e_demos / e2e_smoke) dispatches to the matching driver, and an
82+
* unregistered kind is reported as a `worker-protocol-violation`. Kept a
83+
* `string` (not narrowed to the worker's `DriverKind` union) because this is
84+
* the WIRE boundary that receives whatever the producer serialized; the
85+
* runtime unknown-kind guard is the validation gate.
8186
*/
8287
driverKind: string;
8388
/**
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import type { Browser } from "playwright";
3+
import { runWorker } from "./orchestrator.js";
4+
import type { FleetRoleConfig } from "./role-config.js";
5+
import type {
6+
FleetQueueClient,
7+
ClaimedJob,
8+
JobLease,
9+
JobView,
10+
ServiceJobPayload,
11+
ServiceJobResult,
12+
} from "./contracts.js";
13+
import type { Logger } from "../types/index.js";
14+
import type { LaunchBrowser, CgroupPidsReader } from "../probes/helpers/browser-pool.js";
15+
16+
/**
17+
* Fleet WORKER entrypoint (`runWorker`, fleet/orchestrator.ts) — DEFAULT
18+
* (self-contained) boot equivalence.
19+
*
20+
* REGRESSION: the default boot path (neither a `drivers` registry nor a legacy
21+
* `driver` injected) USED to set a bare `driver = d6Driver` WITHOUT a
22+
* `payloadToInput`, so `startWorkerLoop`'s construction guard threw "Fleet
23+
* worker has no drivers" and the worker could never boot self-contained. The fix
24+
* builds the default d6 as a REGISTRY entry (`e2e_d6 → { driver, payloadToInput,
25+
* aggregateSlugKey }`), so the self-contained boot SUCCEEDS and routes an
26+
* `e2e_d6` job through the d6 driver.
27+
*
28+
* The d6 driver is the REAL pooled one; we feed it an input with NO declared
29+
* features so it returns its green "no D5 features declared" aggregate WITHOUT
30+
* touching chromium (the BrowserPool's launcher is a no-op fake injected via the
31+
* test-only `launchBrowser` seam, so no real browser ever spawns).
32+
*/
33+
34+
const silentLogger: Logger = {
35+
info: () => {},
36+
warn: () => {},
37+
error: () => {},
38+
debug: () => {},
39+
};
40+
41+
/** A no-op connected Browser — the d6 driver never opens a context in this
42+
* test (zero features), so `init()` just needs a launchable fake. */
43+
function makeNoopLauncher(): LaunchBrowser {
44+
return async () =>
45+
({
46+
isConnected: () => true,
47+
on: () => {},
48+
async close() {},
49+
async newContext() {
50+
return { async close() {} };
51+
},
52+
}) as unknown as Browser;
53+
}
54+
55+
/** Always reports plenty of headroom so the claim gate never blocks. */
56+
const headroomPidsReader: CgroupPidsReader = () => ({ current: 10, max: 1000 });
57+
58+
function makeJobView(overrides: Partial<JobView> = {}): JobView {
59+
return {
60+
id: "job-1",
61+
probe_key: "d6:langgraph-python",
62+
status: "claimed",
63+
claimed_by: "worker-test",
64+
lease_expires_at: "2026-06-04T00:05:00.000Z",
65+
version: 1,
66+
...overrides,
67+
};
68+
}
69+
70+
function makePayload(
71+
overrides: Partial<ServiceJobPayload> = {},
72+
): ServiceJobPayload {
73+
return {
74+
probeKey: "d6:langgraph-python",
75+
serviceSlug: "langgraph-python",
76+
driverKind: "e2e_d6",
77+
// A runnable d6 input with NO declared features → the d6 driver returns its
78+
// green "no D5 features declared" aggregate without acquiring a browser.
79+
driverInputs: {
80+
key: "e2e_d6:langgraph-python",
81+
backendUrl: "https://lg.example.com",
82+
},
83+
meta: {
84+
runId: "run-42",
85+
triggered: false,
86+
enqueuedAt: "2026-06-04T00:00:00.000Z",
87+
},
88+
...overrides,
89+
};
90+
}
91+
92+
function makeLease(payload?: Partial<ServiceJobPayload>): JobLease {
93+
return {
94+
job: makeJobView(),
95+
payload: makePayload(payload),
96+
leaseExpiresAt: "2026-06-04T00:05:00.000Z",
97+
};
98+
}
99+
100+
interface RecordingQueue extends FleetQueueClient {
101+
reports: ServiceJobResult[];
102+
}
103+
104+
/** A queue fake that hands out a fixed sequence of claims, then idles. */
105+
function makeQueue(claims: ClaimedJob[]): RecordingQueue {
106+
const reports: ServiceJobResult[] = [];
107+
let i = 0;
108+
return {
109+
reports,
110+
async enqueue() {
111+
throw new Error("enqueue not used by worker");
112+
},
113+
async claimNext(): Promise<ClaimedJob> {
114+
const next = claims[i] ?? { claimed: false };
115+
if (i < claims.length) i++;
116+
return next;
117+
},
118+
async renewLease(): Promise<JobLease | null> {
119+
return makeLease();
120+
},
121+
async report({ result }): Promise<void> {
122+
reports.push(result);
123+
},
124+
async sweepExpired() {
125+
return { reclaimed: 0, commErrors: [] };
126+
},
127+
};
128+
}
129+
130+
const config: FleetRoleConfig = { role: "worker", poolCount: 1 };
131+
132+
describe("runWorker default (self-contained) boot", () => {
133+
it("boots WITHOUT an injected registry or driver and routes an e2e_d6 job through the d6 driver", async () => {
134+
const queue = makeQueue([{ claimed: true, lease: makeLease() }]);
135+
136+
// No `drivers`, no `driver`, no `budgetSource` → the default boot path
137+
// constructs its own pool + the default d6 REGISTRY entry. Pre-fix this
138+
// threw "Fleet worker has no drivers"; post-fix it boots and routes.
139+
const worker = await runWorker(config, {
140+
queue,
141+
workerId: "worker-test",
142+
logger: silentLogger,
143+
env: {},
144+
skipHealthServer: true,
145+
leaseSeconds: 2000,
146+
heartbeatMs: 1_000_000,
147+
pollIntervalMs: 1,
148+
// Test-only seams so the default-boot BrowserPool never spawns chromium.
149+
launchBrowser: makeNoopLauncher(),
150+
cgroupPidsReader: headroomPidsReader,
151+
});
152+
153+
try {
154+
await vi.waitFor(() => expect(queue.reports).toHaveLength(1), {
155+
timeout: 5000,
156+
});
157+
} finally {
158+
await worker.stop();
159+
}
160+
161+
const result = queue.reports[0]!;
162+
// The e2e_d6 job was routed to the REAL d6 driver (not a protocol
163+
// violation): the d6 driver's green "no D5 features declared" aggregate.
164+
expect(result.commError).toBeUndefined();
165+
expect(result.aggregateState).toBe("green");
166+
expect(result.aggregateKey).toBe("e2e_d6:langgraph-python");
167+
});
168+
});

showcase/harness/src/fleet/orchestrator.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import { serve } from "@hono/node-server";
2929
import { createEventBus } from "../events/event-bus.js";
3030
import { logger as defaultLogger } from "../logger.js";
3131
import { BrowserPool } from "../probes/helpers/browser-pool.js";
32+
import type {
33+
LaunchBrowser,
34+
CgroupPidsReader,
35+
} from "../probes/helpers/browser-pool.js";
3236
import {
3337
createE2eFullDriver,
3438
createPooledE2eFullLauncher,
@@ -44,6 +48,10 @@ import {
4448
type DriverRegistry,
4549
type WorkerLoopHandle,
4650
} from "./worker/worker-loop.js";
51+
import {
52+
createD6PayloadToInput,
53+
E2E_D6_DRIVER_KIND,
54+
} from "./worker/payload-mapper.js";
4755

4856
/** The worker boot handle — symmetric with the control-plane `boot()` shape. */
4957
export interface WorkerHandle {
@@ -132,6 +140,20 @@ export interface RunWorkerOptions {
132140
budgetSource?: {
133141
budget(): import("../probes/helpers/browser-pool.js").BrowserPoolBudget;
134142
};
143+
/**
144+
* Test-only seam: injected chromium launcher forwarded to the `BrowserPool`
145+
* the DEFAULT (self-contained) boot path constructs, so the default-boot
146+
* equivalence test can exercise that path WITHOUT spawning real chromium.
147+
* Production omits it → the pool uses the real launcher. Ignored when a
148+
* `budgetSource` + run path are injected (no pool is constructed).
149+
*/
150+
launchBrowser?: LaunchBrowser;
151+
/**
152+
* Test-only seam: injected cgroup PID reader forwarded to the default-boot
153+
* `BrowserPool` (powers `budget()`), so the default-boot test reports
154+
* headroom deterministically. Production omits it → the real reader is used.
155+
*/
156+
cgroupPidsReader?: CgroupPidsReader;
135157
}
136158

137159
/**
@@ -185,8 +207,12 @@ export async function runWorker(
185207
// injected `budgetSource`, the worker runs entirely on the caller's wiring
186208
// and this entrypoint constructs NO pool of its own.
187209
// 2. A legacy single `driver` + `budgetSource` (back-compat test path).
188-
// 3. Neither → construct the worker's own pool + default pooled d6 driver
189-
// (the original self-contained boot).
210+
// 3. Neither → construct the worker's own pool and a DEFAULT REGISTRY holding
211+
// the single pooled d6 driver under its `e2e_d6` kind (the self-contained
212+
// boot). Building it as a registry entry — not a bare `driver` — means the
213+
// self-contained path also routes by driverKind AND carries the d6
214+
// payload mapper, so `startWorkerLoop`'s construction guard is satisfied
215+
// (equivalence with the pre-registry single-d6 worker).
190216
// The pool is the self-bounded context budget that gates claiming and keeps the
191217
// worker under its pids ceiling.
192218
let pool: BrowserPool | undefined;
@@ -196,20 +222,33 @@ export async function runWorker(
196222

197223
// We have all the wiring we need iff a budget source exists AND at least one
198224
// run path (a registry or a single driver) is supplied. Otherwise build the
199-
// default pool + d6 driver so the worker is self-contained.
225+
// default pool + d6 registry so the worker is self-contained.
200226
const haveRunPath = drivers !== undefined || driver !== undefined;
201227
if (!budgetSource || !haveRunPath) {
202-
pool = new BrowserPool({ logger });
228+
pool = new BrowserPool({
229+
logger,
230+
launchBrowser: opts.launchBrowser,
231+
cgroupPidsReader: opts.cgroupPidsReader,
232+
});
203233
await pool.init();
204234
budgetSource = budgetSource ?? pool;
205235
if (!haveRunPath) {
206-
// Default to the single pooled d6 driver registered under its kind, so the
207-
// self-contained boot path also routes by driverKind (equivalence with the
208-
// pre-registry single-d6 worker).
209-
const d6Driver = createE2eFullDriver({
210-
launcher: createPooledE2eFullLauncher(pool, logger),
211-
});
212-
driver = d6Driver;
236+
// Default to the single pooled d6 driver registered under its kind, paired
237+
// with the d6 payload→input mapper, so the self-contained boot routes by
238+
// driverKind through the registry (equivalence with the pre-registry
239+
// single-d6 worker) and the loop's construction guard is satisfied.
240+
drivers = new Map([
241+
[
242+
E2E_D6_DRIVER_KIND,
243+
{
244+
driver: createE2eFullDriver({
245+
launcher: createPooledE2eFullLauncher(pool, logger),
246+
}),
247+
payloadToInput: createD6PayloadToInput(),
248+
aggregateSlugKey: (serviceSlug: string) => `d6:${serviceSlug}`,
249+
},
250+
],
251+
]);
213252
}
214253
}
215254

showcase/harness/src/fleet/worker/payload-mapper.test.ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
createD6PayloadToInput,
4-
createDeepPayloadToInput,
5-
createDemosPayloadToInput,
6-
createSmokePayloadToInput,
74
createPayloadToInput,
85
E2E_D6_DRIVER_KIND,
96
E2E_DEEP_DRIVER_KIND,
@@ -82,38 +79,32 @@ describe("driver-kind constants", () => {
8279
});
8380
});
8481

85-
describe("per-kind payload mappers", () => {
82+
describe("shared payload mapper", () => {
8683
// The four browser driver families share the SAME re-hydration logic (each
8784
// serializes a `{ key, backendUrl, … }` object and validates via its own zod
88-
// schema), so the per-kind mappers all re-hydrate the input the same way.
85+
// schema), so every registry entry wires the single `createPayloadToInput`.
86+
// `createD6PayloadToInput` is retained as a back-compat alias of it.
8987
const driverInputs = {
9088
key: "k:langgraph-python",
9189
backendUrl: "https://lg.example.com",
9290
};
9391

94-
it.each([
95-
["deep", createDeepPayloadToInput],
96-
["demos", createDemosPayloadToInput],
97-
["smoke", createSmokePayloadToInput],
98-
["base", createPayloadToInput],
99-
])("%s mapper re-hydrates the serialized input", (_name, factory) => {
100-
const map = factory();
92+
it("re-hydrates the serialized input", () => {
93+
const map = createPayloadToInput();
10194
const input = map(payload({ driverInputs })) as Record<string, unknown>;
10295
expect(input.key).toBe("k:langgraph-python");
10396
expect(input.backendUrl).toBe("https://lg.example.com");
10497
});
10598

106-
it("each per-kind mapper defaults a missing key to the payload probeKey", () => {
107-
for (const factory of [
108-
createDeepPayloadToInput,
109-
createDemosPayloadToInput,
110-
createSmokePayloadToInput,
111-
]) {
112-
const map = factory();
113-
const input = map(
114-
payload({ driverInputs: { backendUrl: "https://lg.example.com" } }),
115-
) as Record<string, unknown>;
116-
expect(input.key).toBe("d6:langgraph-python");
117-
}
99+
it("defaults a missing key to the payload probeKey", () => {
100+
const map = createPayloadToInput();
101+
const input = map(
102+
payload({ driverInputs: { backendUrl: "https://lg.example.com" } }),
103+
) as Record<string, unknown>;
104+
expect(input.key).toBe("d6:langgraph-python");
105+
});
106+
107+
it("createD6PayloadToInput is the same factory (back-compat alias)", () => {
108+
expect(createD6PayloadToInput).toBe(createPayloadToInput);
118109
});
119110
});

0 commit comments

Comments
 (0)