Skip to content

Commit ca96837

Browse files
authored
feat(harness/fleet): generalize enumerator + control-plane for N producer schedules (CopilotKit#5285)
## Summary Producer-side foundation for fleet framework item 3b. Two **behavior-preserving** generalizations that make the seam capable of multiple browser families and multiple producer cadences, while keeping the d6 case **byte-identical**. No wiring is flipped on yet (see Out of Scope). ### 1. Parameterized service enumerator `showcase/harness/src/fleet/control-plane/catalog-enumerator.ts` - New generic `createServiceEnumerator(params)` (catalog-enumerator.ts:215) takes the service-set `filter`, the `driverKind`, and a `probeKeyPrefix` (string prefix → `<prefix>:<slug>`, or a builder fn). - `createD6ServiceEnumerator` (catalog-enumerator.ts:280) is re-expressed as a thin call passing the d6 params: `D6_DRIVER_KIND` (`e2e_d6`), prefix `"d6"` (→ `d6:<slug>`), and `D6_DISCOVERY_FILTER`. d6 output is unchanged — same services, same filter, same kind, same keys. ### 2. Control-plane accepts an array of producer schedules `showcase/harness/src/fleet/control-plane/control-plane.ts` - New `ProducerSchedule` type (`{ scheduleId, cron, producer }`) + a `schedules?` dep on `ControlPlaneDeps`. - `createControlPlane` normalizes to an array (control-plane.ts:~232); omitting `schedules` degenerates to the single d6 schedule on `FLEET_PRODUCER_SCHEDULE_ID` (`fleet-job-producer`) @ `40 * * * *` — current behavior preserved exactly. - `start()` / `stop()` iterate the array, registering/unregistering each scheduler entry and starting/stopping each producer. ## Out of scope (deferred — gated on other in-flight PRs) - **No `runControlPlane` wiring** to actually PASS multiple schedules — that edit conflicts with in-flight **CopilotKit#5284** (which edits `runControlPlane`) and is deferred. This PR only makes `control-plane.ts` *capable* of N schedules + generalizes the enumerator seam; the wiring lands later. - No `e2e_smoke` / `e2e_demos` / `e2e_deep` enumerators or producers (Phase 2). - No changes to `worker-loop.ts` / `payload-mapper.ts` / `probe-loader.ts` (other PRs own those). - No driverKind constant / contract changes. ## Test plan - [x] Red→green TDD: 3 new enumerator tests (generic kind/keys/filter/fn-prefix) + 2 new control-plane tests (N entries registered with distinct crons; stop tears all down) failed before impl, pass after. - [x] Equivalence: all pre-existing d6-enumerator + single-schedule control-plane tests pass unchanged. - [x] Full harness suite green: **2078 passed** (119 files). - [x] `tsc -p tsconfig.build.json` clean (exit 0). - [x] Only the 4 intended files changed; no lockfile drift. Do not merge — producer-side foundation only; wiring follows after CopilotKit#5284 lands.
2 parents ca620d0 + ef44130 commit ca96837

4 files changed

Lines changed: 634 additions & 37 deletions

File tree

showcase/harness/src/fleet/control-plane/catalog-enumerator.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import {
33
createD6ServiceEnumerator,
4+
createServiceEnumerator,
45
D6_DRIVER_KIND,
56
D6_DISCOVERY_FILTER,
67
} from "./catalog-enumerator.js";
@@ -207,3 +208,129 @@ describe("createD6ServiceEnumerator", () => {
207208
expect(specs.length).toBe(3);
208209
});
209210
});
211+
212+
describe("createServiceEnumerator (generic seam)", () => {
213+
it("stamps the passed driverKind and probeKey prefix (non-d6 params)", async () => {
214+
const source = fakeSource([
215+
svc({ name: "showcase-langgraph-python" }),
216+
svc({ name: "showcase-crewai", publicUrl: "http://crewai:10000" }),
217+
]);
218+
const enumerate = createServiceEnumerator({
219+
source,
220+
env: {},
221+
fetchImpl: globalThis.fetch,
222+
logger: SILENT_LOGGER,
223+
driverKind: "e2e_smoke",
224+
probeKeyPrefix: "smoke",
225+
filter: { namePrefix: "showcase-" },
226+
});
227+
228+
const specs = await enumerate(CTX);
229+
230+
expect(specs).toHaveLength(2);
231+
const lg = specs.find((s) => s.serviceSlug === "langgraph-python");
232+
expect(lg).toBeDefined();
233+
expect(lg?.driverKind).toBe("e2e_smoke");
234+
expect(lg?.probeKey).toBe("smoke:langgraph-python");
235+
expect(lg?.driverInputs?.key).toBe("smoke:langgraph-python");
236+
expect(lg?.driverInputs?.backendUrl).toBe("http://langgraph-python:10000");
237+
238+
const cr = specs.find((s) => s.serviceSlug === "crewai");
239+
expect(cr?.probeKey).toBe("smoke:crewai");
240+
expect(cr?.driverKind).toBe("e2e_smoke");
241+
});
242+
243+
it("passes the param-supplied filter (namePrefix + nameExcludes) to the source", async () => {
244+
const source = fakeSource([svc()]);
245+
const filter = {
246+
namePrefix: "showcase-",
247+
nameExcludes: ["showcase-harness", "showcase-shell"],
248+
} as const;
249+
const enumerate = createServiceEnumerator({
250+
source,
251+
env: {},
252+
fetchImpl: globalThis.fetch,
253+
logger: SILENT_LOGGER,
254+
driverKind: "e2e_smoke",
255+
probeKeyPrefix: "smoke",
256+
filter,
257+
});
258+
259+
await enumerate(CTX);
260+
261+
expect(source.configs).toHaveLength(1);
262+
const cfg = source.configs[0] as {
263+
namePrefix?: string;
264+
nameExcludes?: string[];
265+
};
266+
expect(cfg.namePrefix).toBe("showcase-");
267+
expect(cfg.nameExcludes).toEqual([...filter.nameExcludes]);
268+
});
269+
270+
it("accepts a function probeKey prefix builder", async () => {
271+
const source = fakeSource([svc({ name: "showcase-langgraph-python" })]);
272+
const enumerate = createServiceEnumerator({
273+
source,
274+
env: {},
275+
fetchImpl: globalThis.fetch,
276+
logger: SILENT_LOGGER,
277+
driverKind: "e2e_deep",
278+
probeKeyPrefix: (slug) => `deep-${slug}`,
279+
filter: { namePrefix: "showcase-" },
280+
});
281+
282+
const specs = await enumerate(CTX);
283+
const lg = specs.find((s) => s.serviceSlug === "langgraph-python");
284+
expect(lg?.probeKey).toBe("deep-langgraph-python");
285+
expect(lg?.driverInputs?.key).toBe("deep-langgraph-python");
286+
});
287+
288+
// A5: a filter without a namePrefix would make the discovery source enumerate
289+
// ALL services (a documented incident class). Fail loud at construction.
290+
it("throws when the filter has no namePrefix (fail loud, never enumerate all)", () => {
291+
const source = fakeSource([svc()]);
292+
expect(() =>
293+
createServiceEnumerator({
294+
source,
295+
env: {},
296+
fetchImpl: globalThis.fetch,
297+
logger: SILENT_LOGGER,
298+
driverKind: "e2e_smoke",
299+
probeKeyPrefix: "smoke",
300+
filter: { nameExcludes: ["showcase-harness"] },
301+
}),
302+
).toThrow(/namePrefix/);
303+
});
304+
305+
it("throws when the filter namePrefix is an empty string", () => {
306+
const source = fakeSource([svc()]);
307+
expect(() =>
308+
createServiceEnumerator({
309+
source,
310+
env: {},
311+
fetchImpl: globalThis.fetch,
312+
logger: SILENT_LOGGER,
313+
driverKind: "e2e_smoke",
314+
probeKeyPrefix: "smoke",
315+
filter: { namePrefix: "" },
316+
}),
317+
).toThrow(/namePrefix/);
318+
});
319+
320+
// A6: a function-form probeKeyPrefix returning "" yields an empty
321+
// probeKey/driverInputs.key (a bad join key) — fail loud naming the slug.
322+
it("throws when a function probeKeyPrefix yields an empty key for a service", async () => {
323+
const source = fakeSource([svc({ name: "showcase-langgraph-python" })]);
324+
const enumerate = createServiceEnumerator({
325+
source,
326+
env: {},
327+
fetchImpl: globalThis.fetch,
328+
logger: SILENT_LOGGER,
329+
driverKind: "e2e_deep",
330+
probeKeyPrefix: () => "",
331+
filter: { namePrefix: "showcase-" },
332+
});
333+
334+
await expect(enumerate(CTX)).rejects.toThrow(/langgraph-python/);
335+
});
336+
});

showcase/harness/src/fleet/control-plane/catalog-enumerator.ts

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ export const D6_DISCOVERY_FILTER = {
9393
],
9494
} as const;
9595

96+
/**
97+
* A discovery service-set filter — the `namePrefix` / `nameExcludes` block the
98+
* discovery source narrows on. Shared by the d6 wrapper deps and the generic
99+
* enumerator params so the shape stays in one place.
100+
*/
101+
export interface ServiceSetFilter {
102+
namePrefix?: string;
103+
nameExcludes?: readonly string[];
104+
}
105+
96106
export interface D6ServiceEnumeratorDeps {
97107
/** The discovery source to enumerate (production: `railwayServicesSource`). */
98108
source: DiscoverySource<RailwayServiceInfo>;
@@ -105,7 +115,48 @@ export interface D6ServiceEnumeratorDeps {
105115
* Service-set filter. Defaults to `D6_DISCOVERY_FILTER`. Exposed so a test
106116
* (or a future operator config) can narrow the set without editing the SSOT.
107117
*/
108-
filter?: { namePrefix?: string; nameExcludes?: readonly string[] };
118+
filter?: ServiceSetFilter;
119+
}
120+
121+
/**
122+
* Build the dashboard `probeKey` for a given service slug. A bare string is
123+
* treated as a prefix (`<prefix>:<slug>` — the d6 `d6:<slug>` convention); a
124+
* function gives a family full control over the key shape. The same value is
125+
* stamped onto `driverInputs.key` so the driver emits the exact dashboard keys.
126+
*/
127+
export type ProbeKeyPrefix = string | ((slug: string) => string);
128+
129+
export interface ServiceEnumeratorParams {
130+
/** The discovery source to enumerate (production: `railwayServicesSource`). */
131+
source: DiscoverySource<RailwayServiceInfo>;
132+
/** Frozen env snapshot threaded to the discovery context. */
133+
env: Readonly<Record<string, string | undefined>>;
134+
/** Fetch impl threaded to the discovery context (tests stub network). */
135+
fetchImpl: typeof fetch;
136+
logger: Logger;
137+
/** The driver kind every enumerated spec runs under (e.g. `e2e_d6`). */
138+
driverKind: string;
139+
/**
140+
* Builds the dashboard `probeKey` (and `driverInputs.key`) per service. A
141+
* string is used as a `<prefix>:<slug>` prefix; a function gives full control.
142+
*/
143+
probeKeyPrefix: ProbeKeyPrefix;
144+
/**
145+
* Service-set filter selecting which discovered services this family runs.
146+
* Carries the discovery source's `namePrefix` / `nameExcludes` block.
147+
*/
148+
filter: ServiceSetFilter;
149+
}
150+
151+
/**
152+
* Resolve a `ProbeKeyPrefix` into the concrete dashboard key for a slug. The
153+
* string arm OWNS the `:` separator (`<prefix>:<slug>`), so a string-form caller
154+
* passes the bare prefix WITHOUT a trailing `:` (e.g. `"d6"`, not `"d6:"`). The
155+
* function arm gives a family full control over the key shape (and is
156+
* responsible for its own separators).
157+
*/
158+
function buildProbeKey(prefix: ProbeKeyPrefix, slug: string): string {
159+
return typeof prefix === "function" ? prefix(slug) : `${prefix}:${slug}`;
109160
}
110161

111162
/**
@@ -148,17 +199,36 @@ function toDriverInputs(
148199
}
149200

150201
/**
151-
* Build the real d6 `ServiceEnumerator`. Each call enumerates the discovery
152-
* source under the d6 filter and maps every service to one `ServiceJobSpec`.
153-
* The `EnumerateContext.filter` (operator slug scoping on a triggered run) is
154-
* applied AFTER discovery so an operator can scope a manual run to a subset of
155-
* services without re-querying — mirroring the in-process invoker's slug filter.
202+
* Build a generic per-service `ServiceEnumerator` parameterized by the
203+
* service-set filter, the `driverKind`, and the `probeKey` prefix builder. Each
204+
* call enumerates the discovery source under the filter and maps every service
205+
* to one `ServiceJobSpec`. The `EnumerateContext.filter` (operator slug scoping
206+
* on a triggered run) is applied AFTER discovery so an operator can scope a
207+
* manual run to a subset of services without re-querying — mirroring the
208+
* in-process invoker's slug filter.
209+
*
210+
* `createD6ServiceEnumerator` is the d6 specialization of this seam; other
211+
* browser families re-express their enumerators the same way, each passing its
212+
* own filter / kind / prefix.
156213
*/
157-
export function createD6ServiceEnumerator(
158-
deps: D6ServiceEnumeratorDeps,
214+
export function createServiceEnumerator(
215+
params: ServiceEnumeratorParams,
159216
): ServiceEnumerator {
160-
const { source, env, fetchImpl, logger } = deps;
161-
const filter = deps.filter ?? D6_DISCOVERY_FILTER;
217+
const { source, env, fetchImpl, logger, driverKind, probeKeyPrefix, filter } =
218+
params;
219+
220+
// Fail loud on a filter with no `namePrefix`: the discovery source treats an
221+
// absent/empty prefix as "match everything", which would enumerate ALL
222+
// services (the railway-services source documents this incident class). The
223+
// d6 wrapper always passes a concrete filter, so it is unaffected. A future
224+
// family that legitimately needs "match all" can revisit this; default to
225+
// fail-loud now.
226+
if (typeof filter.namePrefix !== "string" || filter.namePrefix === "") {
227+
throw new Error(
228+
`createServiceEnumerator: filter.namePrefix must be a non-empty string ` +
229+
`(driverKind ${driverKind}); an absent/empty prefix would enumerate ALL services.`,
230+
);
231+
}
162232

163233
return async (ctx: EnumerateContext): Promise<ServiceJobSpec[]> => {
164234
const discoveryCtx: DiscoveryContext = { fetchImpl, env, logger };
@@ -178,11 +248,20 @@ export function createD6ServiceEnumerator(
178248
for (const svc of services) {
179249
const slug = deriveSlug(svc.name);
180250
if (slugSet && !slugSet.has(slug) && !slugSet.has(svc.name)) continue;
181-
const probeKey = `d6:${slug}`;
251+
const probeKey = buildProbeKey(probeKeyPrefix, slug);
252+
// A function-form prefix returning "" yields an empty probeKey (and
253+
// `driverInputs.key`) — a bad dashboard/claim join key. Fail loud naming
254+
// the offending slug rather than emitting a spec keyed "".
255+
if (probeKey === "") {
256+
throw new Error(
257+
`createServiceEnumerator: probeKeyPrefix produced an empty probeKey ` +
258+
`for service "${svc.name}" (slug "${slug}", driverKind ${driverKind}).`,
259+
);
260+
}
182261
const spec: ServiceJobSpec = {
183262
probeKey,
184263
serviceSlug: slug,
185-
driverKind: D6_DRIVER_KIND,
264+
driverKind,
186265
driverInputs: toDriverInputs(svc, slug, probeKey),
187266
};
188267
// Forward the operator feature-type scoping to the worker as a cell
@@ -197,9 +276,36 @@ export function createD6ServiceEnumerator(
197276
logger.info("fleet.control-plane.catalog-enumerated", {
198277
runId: ctx.runId,
199278
triggered: ctx.triggered,
279+
driverKind,
200280
discovered: services.length,
201281
enqueueable: specs.length,
202282
});
203283
return specs;
204284
};
205285
}
286+
287+
/**
288+
* Build the real d6 `ServiceEnumerator` — a thin specialization of
289+
* `createServiceEnumerator` with the d6 filter, the `e2e_d6` driver kind, and
290+
* the `d6:<slug>` probeKey prefix. The produced SPECS are identical to the prior
291+
* hardwired implementation (same services, same filter, same kind, same keys);
292+
* the only observable change is the diagnostic log line — the generic
293+
* enumerator's `fleet.control-plane.catalog-enumerated` log additionally carries
294+
* a `driverKind` field (the specs themselves are unchanged).
295+
*/
296+
export function createD6ServiceEnumerator(
297+
deps: D6ServiceEnumeratorDeps,
298+
): ServiceEnumerator {
299+
const { source, env, fetchImpl, logger } = deps;
300+
const filter = deps.filter ?? D6_DISCOVERY_FILTER;
301+
302+
return createServiceEnumerator({
303+
source,
304+
env,
305+
fetchImpl,
306+
logger,
307+
driverKind: D6_DRIVER_KIND,
308+
probeKeyPrefix: "d6",
309+
filter,
310+
});
311+
}

0 commit comments

Comments
 (0)