@nanobpm/urban 0.71.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/context/adversarial/harness.d.ts +24 -0
  2. package/dist/context/adversarial/harness.js +65 -0
  3. package/dist/runtime/core/context.d.ts +16 -0
  4. package/dist/runtime/core/modules/scheduler.d.ts +24 -0
  5. package/dist/runtime/core/modules/scheduler.js +23 -4
  6. package/dist/runtime/core/modules/workers.d.ts +2 -1
  7. package/dist/runtime/core/modules/workers.js +11 -2
  8. package/dist/runtime/core/runtime.d.ts +8 -6
  9. package/dist/runtime/core/runtime.js +12 -3
  10. package/package.json +2 -2
  11. package/src/context/adversarial/conformance-drift.test.ts +89 -0
  12. package/src/context/adversarial/governance.test.ts +97 -0
  13. package/src/context/adversarial/harness.ts +84 -0
  14. package/src/context/adversarial/pii-guard.test.ts +119 -0
  15. package/src/context/adversarial/provenance-forgery.test.ts +126 -0
  16. package/src/context/adversarial/seams.test.ts +104 -0
  17. package/src/context/adversarial/sharing.test.ts +84 -0
  18. package/src/runtime/core/context.ts +16 -0
  19. package/src/runtime/core/extensions.test.ts +2 -0
  20. package/src/runtime/core/modules/actions.test.ts +2 -0
  21. package/src/runtime/core/modules/api.test.ts +3 -1
  22. package/src/runtime/core/modules/cancel.test.ts +2 -0
  23. package/src/runtime/core/modules/connectors.test.ts +2 -0
  24. package/src/runtime/core/modules/instance-tracking.test.ts +3 -41
  25. package/src/runtime/core/modules/scheduler.test-utils.ts +62 -0
  26. package/src/runtime/core/modules/scheduler.test.ts +44 -0
  27. package/src/runtime/core/modules/scheduler.ts +39 -4
  28. package/src/runtime/core/modules/surfaces.test.ts +4 -0
  29. package/src/runtime/core/modules/triggers.test.ts +4 -45
  30. package/src/runtime/core/modules/workers.test.ts +95 -0
  31. package/src/runtime/core/modules/workers.ts +16 -2
  32. package/src/runtime/core/runtime.ts +19 -9
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { MAX_TIMER_DELAY_MS, type SchedulerDeps, schedulerClock } from "./scheduler.ts";
5
+
6
+ // A capturing scheduler that records every delay handed to `setTimer` and fires it
7
+ // synchronously, so a test can assert exactly what `schedulerClock().wait()` arms.
8
+ function capturingScheduler(): SchedulerDeps & { delays: number[] } {
9
+ const delays: number[] = [];
10
+ return {
11
+ now: () => 0,
12
+ setTimer: (fn, delayMs) => {
13
+ delays.push(delayMs);
14
+ fn();
15
+ return 0;
16
+ },
17
+ clearTimer: () => {},
18
+ delays,
19
+ };
20
+ }
21
+
22
+ // Regression (PR #409 review, suppressed advisory scheduler.ts): `schedulerClock().wait()`
23
+ // must clamp a far-future delay to MAX_TIMER_DELAY_MS so `app.wait()` can never pass a value
24
+ // > setTimeout's 32-bit range and reintroduce a Node `TimeoutOverflowWarning`. The clamp lives
25
+ // only here (the single canonical clock/`wait` seam), so it needs its own guard.
26
+ test("schedulerClock.wait clamps a far-future delay to MAX_TIMER_DELAY_MS", async () => {
27
+ const sched = capturingScheduler();
28
+ await schedulerClock(sched).wait(MAX_TIMER_DELAY_MS * 3);
29
+ assert.deepEqual(sched.delays, [MAX_TIMER_DELAY_MS]);
30
+ });
31
+
32
+ test("schedulerClock.wait passes an in-range delay through unchanged", async () => {
33
+ const sched = capturingScheduler();
34
+ await schedulerClock(sched).wait(1234);
35
+ assert.deepEqual(sched.delays, [1234]);
36
+ });
37
+
38
+ test("schedulerClock.wait clamps non-positive / non-finite delays to the current instant", async () => {
39
+ const sched = capturingScheduler();
40
+ await schedulerClock(sched).wait(-5);
41
+ await schedulerClock(sched).wait(Number.NaN);
42
+ await schedulerClock(sched).wait(Number.POSITIVE_INFINITY);
43
+ assert.deepEqual(sched.delays, [0, 0, 0]);
44
+ });
@@ -1,8 +1,8 @@
1
1
  // The injectable timer + clock seam shared by every background loop in urban (cron
2
- // triggers, the instance-tracking reconciler). Keeping one definition here — rather
3
- // than a copy per module — means a test's fake scheduler drives them all the same
4
- // way, and the "clamp a far-future delay to setTimeout's 32-bit range" rule has a
5
- // single home.
2
+ // triggers, the instance-tracking reconciler) and the app clock/`wait` seam handed to
3
+ // worker handlers. Keeping one definition here — rather than a copy per module — means a
4
+ // test's fake scheduler drives them all the same way, and the "clamp a far-future delay to
5
+ // setTimeout's 32-bit range" rule has a single home.
6
6
 
7
7
  /** Injectable timer + clock seam so background scheduling is deterministic under test. */
8
8
  export interface SchedulerDeps {
@@ -29,3 +29,38 @@ export function defaultScheduler(): SchedulerDeps {
29
29
  now: () => Date.now(),
30
30
  };
31
31
  }
32
+
33
+ /**
34
+ * The app-facing clock/`wait` seam handed to handlers (see `AppApi.now`/`AppApi.wait`).
35
+ * A narrow read-only projection of a {@link SchedulerDeps}: `now()` reads its clock and
36
+ * `wait(ms)` sleeps on its timer. Real wall clock + real timers in production; the
37
+ * virtual clock under the test kit — so time-bounded worker work advances with
38
+ * `advanceTime()` instead of burning real wall-time.
39
+ */
40
+ export interface AppClock {
41
+ /** Current time in ms since epoch on the scheduler's clock. */
42
+ now(): number;
43
+ /** Resolve after `ms` have elapsed on the scheduler's clock (armed via its timer seam). */
44
+ wait(ms: number): Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * Derive the app clock/`wait` seam from a scheduler — the single canonical mapping from the
49
+ * runtime's injectable timer seam to the surface handlers use, so worker/trigger/surface
50
+ * handlers doing time-bounded work share the same clock as the background loops (no drift
51
+ * between a handler's budget and the engine's timers). A non-positive/non-finite `wait`
52
+ * delay clamps to fire at the current instant, and a far-future delay clamps to
53
+ * {@link MAX_TIMER_DELAY_MS} — matching the same clamp the background loops apply to their
54
+ * `setTimer` delays (see `mountTriggers`/`mountInstanceTracking`) — so `app.wait()` can
55
+ * never reintroduce a Node `TimeoutOverflowWarning`.
56
+ */
57
+ export function schedulerClock(sched: SchedulerDeps): AppClock {
58
+ return {
59
+ now: () => sched.now(),
60
+ wait: (ms) =>
61
+ new Promise<void>((resolve) => {
62
+ const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
63
+ sched.setTimer(() => resolve(), delay);
64
+ }),
65
+ };
66
+ }
@@ -58,6 +58,8 @@ const fakeApp: Parameters<typeof mountSurfaces>[1] = {
58
58
  data: new DataLayer(new Map(), undefined, {}),
59
59
  engine: fakeEngine,
60
60
  env: () => undefined,
61
+ now: () => 0,
62
+ wait: () => Promise.resolve(),
61
63
  log: createLogger(() => {}),
62
64
  };
63
65
 
@@ -108,6 +110,8 @@ function appWith(engine: EngineClient): Parameters<typeof mountSurfaces>[1] {
108
110
  data: new DataLayer(new Map(), undefined, {}),
109
111
  engine,
110
112
  env: () => undefined,
113
+ now: () => 0,
114
+ wait: () => Promise.resolve(),
111
115
  log: createLogger(() => {}),
112
116
  };
113
117
  }
@@ -6,7 +6,8 @@ import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
6
6
  import type { Trigger } from "../manifest.ts";
7
7
  import { makeRouter } from "../router.ts";
8
8
  import { DataLayer } from "./datasource.ts";
9
- import { evalCorrelation, mountTriggers, runTriggerAction, type SchedulerDeps } from "./triggers.ts";
9
+ import { evalCorrelation, mountTriggers, runTriggerAction } from "./triggers.ts";
10
+ import { fakeScheduler } from "./scheduler.test-utils.ts";
10
11
 
11
12
  interface EngineCall {
12
13
  kind: "start" | "message";
@@ -49,6 +50,8 @@ function fakeApp(): { app: AppApi; calls: EngineCall[]; logs: Array<{ level: str
49
50
  data: new DataLayer(new Map(), undefined, {}),
50
51
  engine,
51
52
  env: () => undefined,
53
+ now: () => 0,
54
+ wait: () => Promise.resolve(),
52
55
  log: createLogger((level: string, msg: string) => {
53
56
  logs.push({ level, msg });
54
57
  }),
@@ -81,50 +84,6 @@ function fakeCtx(triggers: Trigger[]): { ctx: RuntimeContext; hostLogs: Array<{
81
84
  return { ctx, hostLogs };
82
85
  }
83
86
 
84
- /** A deterministic scheduler: timers fire only when the clock is advanced past their deadline. */
85
- function fakeScheduler(startMs: number): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
86
- let clock = startMs;
87
- let seq = 0;
88
- const timers = new Map<number, { at: number; fn: () => void }>();
89
- return {
90
- now: () => clock,
91
- setTimer: (fn, delayMs) => {
92
- const id = ++seq;
93
- timers.set(id, { at: clock + delayMs, fn });
94
- return id;
95
- },
96
- clearTimer: (h) => {
97
- if (typeof h === "number") timers.delete(h);
98
- },
99
- pending: () => timers.size,
100
- // Advance the clock, firing every timer whose deadline has passed (in order), letting
101
- // microtasks (the async action) drain between fires.
102
- advance: async (ms) => {
103
- const target = clock + ms;
104
- // Fire due timers one at a time; a timer's callback may schedule a new one.
105
- // deno-lint-ignore no-constant-condition
106
- while (true) {
107
- let nextId = -1;
108
- let nextAt = Infinity;
109
- for (const [id, t] of timers) {
110
- if (t.at <= target && t.at < nextAt) {
111
- nextAt = t.at;
112
- nextId = id;
113
- }
114
- }
115
- if (nextId < 0) break;
116
- const t = timers.get(nextId)!;
117
- timers.delete(nextId);
118
- clock = t.at;
119
- t.fn();
120
- await Promise.resolve();
121
- await Promise.resolve();
122
- }
123
- clock = target;
124
- },
125
- };
126
- }
127
-
128
87
  test("evalCorrelation resolves literals and = body paths", () => {
129
88
  const scope = { body: { taskId: 42 }, headers: {}, query: {} };
130
89
  assert.equal(evalCorrelation("lit", scope), "lit");
@@ -6,6 +6,7 @@ import type { EngineClient, EngineJob, HostContext, JobHandler, WorkerSubscripti
6
6
  import type { AppManifest } from "../manifest.ts";
7
7
  import { DataLayer, type ProvisionedSource } from "./datasource.ts";
8
8
  import { mountWorkers, sdkDecisionEvaluator, type AppJobHandler } from "./workers.ts";
9
+ import { fakeScheduler } from "./scheduler.test-utils.ts";
9
10
  import { makeGateway } from "./gateway.ts";
10
11
  import { LineageStore } from "./lineage-store.ts";
11
12
  import {
@@ -79,6 +80,8 @@ function makeApp(over: Partial<AppApi> = {}): AppApi {
79
80
  engine: new MiniEngine(),
80
81
  env: (n) => env[n],
81
82
  log: createLogger(() => {}),
83
+ now: () => Date.now(),
84
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
82
85
  ...over,
83
86
  };
84
87
  }
@@ -439,3 +442,95 @@ test("mountWorkers surfaces a configured-but-missing default source as a warn (n
439
442
  assert.ok(warned, "a configured-but-missing default source must warn, not silently degrade");
440
443
  assert.ok(!logs.some((l) => l.msg.includes("no default data source")), "must not claim the datasource is absent");
441
444
  });
445
+ test("mountWorkers threads the injected scheduler: a worker's time-bounded loop is bounded by advanceTime, not real wall-time", async () => {
446
+ // The regression this guards (#408): before the scheduler was threaded into mountWorkers,
447
+ // a handler doing time-bounded work had to hardwire `Date.now()`/`setTimeout`, so under the
448
+ // test kit it burned the FULL real budget while virtual time had already moved on. Now the
449
+ // handler sources `now`/`wait` from `app`, so the loop advances only when the virtual clock
450
+ // does — and a whole-app `advanceTime` bounds it.
451
+ const BUDGET_MS = 60_000; // a real PT1M budget, like the readiness-probe symptom in the issue
452
+ const INTERVAL_MS = 10_000;
453
+ const sched = fakeScheduler(0);
454
+
455
+ let attempts = 0;
456
+ // A never-ready poll loop bound to the APP clock (deps-injected `now`/`wait` are the point).
457
+ const probe: AppJobHandler = async (_job, appApi) => {
458
+ const deadline = appApi.now() + BUDGET_MS;
459
+ while (appApi.now() < deadline) {
460
+ attempts += 1;
461
+ await appApi.wait(INTERVAL_MS); // never becomes ready; only the budget stops it
462
+ }
463
+ return { readied: false, attempts };
464
+ };
465
+
466
+ const engine = new MiniEngine();
467
+ const { ctx } = makeCtx({ workers: [{ taskType: "probe", handler: "workers/probe.ts" }] }, engine);
468
+ ctx.host.importModule = async () => ({ default: probe });
469
+
470
+ const handle = await mountWorkers(ctx, makeApp(), sched);
471
+ assert.deepEqual(handle.jobTypes, ["probe"]);
472
+
473
+ const wallStart = Date.now();
474
+ // Deliver the job: the handler runs up to its first `app.wait` and parks on the virtual timer.
475
+ const done = engine.deliver("probe", { jobKey: "j1", jobType: "probe", processInstanceKey: "pi", variables: {} });
476
+
477
+ // Nothing has advanced the clock yet, so the loop is parked — not spinning on real time.
478
+ assert.equal(sched.pending(), 1, "the handler must be parked on the virtual-clock wait, not a real timer");
479
+
480
+ // Advance the virtual clock across the whole budget; this is what bounds the loop.
481
+ await sched.advance(BUDGET_MS);
482
+ const out = await done;
483
+
484
+ const wallElapsed = Date.now() - wallStart;
485
+ assert.deepEqual(out, { readied: false, attempts: 6 }, "the loop must run exactly over its virtual budget");
486
+ assert.equal(sched.pending(), 0, "no virtual timers may leak past the budget");
487
+ assert.ok(
488
+ wallElapsed < 5_000,
489
+ `the loop must settle in virtual time, not burn the real ${BUDGET_MS}ms budget (took ${wallElapsed}ms real)`,
490
+ );
491
+
492
+ await handle.stop();
493
+ });
494
+
495
+ test("mountWorkers defaults the app clock to real timers when no scheduler is injected", async () => {
496
+ // Production parity: with no injected scheduler the handler's `app.now()`/`app.wait()` must fall
497
+ // through to the default seam — `Date.now()` + `globalThis.setTimeout`. Assert that seam directly
498
+ // by stubbing both globals, so the test is deterministic (no real 5ms sleep, no timer-jitter/clock-
499
+ // resolution race) and proves the exact wiring rather than an observable side effect of it.
500
+ const realSetTimeout = globalThis.setTimeout;
501
+ const realDateNow = Date.now;
502
+ const armedDelays: number[] = [];
503
+ let clock = 1_000;
504
+ try {
505
+ Date.now = () => clock;
506
+ // Object.assign carries realSetTimeout's full type (incl. `__promisify__`), so the merged value
507
+ // stays assignable to `typeof globalThis.setTimeout` without a cast; the spy fires ASAP and
508
+ // advances the fake clock by the armed delay, so no real wall-time elapses.
509
+ globalThis.setTimeout = Object.assign((fn: () => void, ms?: number) => {
510
+ armedDelays.push(ms ?? 0);
511
+ clock += ms ?? 0;
512
+ return realSetTimeout(fn, 0);
513
+ }, realSetTimeout);
514
+
515
+ let observed = -1;
516
+ const handler: AppJobHandler = async (_job, appApi) => {
517
+ const before = appApi.now();
518
+ await appApi.wait(5); // must arm the default (stubbed) setTimeout with delay 5
519
+ observed = appApi.now() - before;
520
+ return { ok: true };
521
+ };
522
+ const engine = new MiniEngine();
523
+ const { ctx } = makeCtx({ workers: [{ taskType: "tick", handler: "workers/tick.ts" }] }, engine);
524
+ ctx.host.importModule = async () => ({ default: handler });
525
+
526
+ const handle = await mountWorkers(ctx, makeApp()); // no scheduler → defaultScheduler()
527
+ const out = await engine.deliver("tick", { jobKey: "j1", jobType: "tick", processInstanceKey: "pi", variables: {} });
528
+ assert.deepEqual(out, { ok: true });
529
+ assert.ok(armedDelays.includes(5), "app.wait(5) must arm the default globalThis.setTimeout seam with delay 5");
530
+ assert.equal(observed, 5, "app.now must read the default Date.now clock (advanced by the armed 5ms timer)");
531
+ await handle.stop();
532
+ } finally {
533
+ globalThis.setTimeout = realSetTimeout;
534
+ Date.now = realDateNow;
535
+ }
536
+ });
@@ -11,6 +11,7 @@ import { runInJobContext } from "../execContext.ts";
11
11
  import type { JobExecContext } from "../execContext.ts";
12
12
  import { readLineage } from "../lineage.ts";
13
13
  import { LineageStore } from "./lineage-store.ts";
14
+ import { defaultScheduler, schedulerClock, type SchedulerDeps } from "./scheduler.ts";
14
15
 
15
16
  /** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
16
17
  * whose `output` comes back as a JSON string (the orchestration-cluster contract). */
@@ -168,11 +169,24 @@ export interface WorkersHandle extends Mounted {
168
169
  }
169
170
 
170
171
  /** Load handler modules and register a push worker per declared worker. */
171
- export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<WorkersHandle> {
172
+ export async function mountWorkers(
173
+ ctx: RuntimeContext,
174
+ app: AppApi,
175
+ sched: SchedulerDeps = defaultScheduler(),
176
+ ): Promise<WorkersHandle> {
172
177
  const decls = ctx.manifest.workers ?? [];
173
178
  const subs: WorkerSubscription[] = [];
174
179
  const jobTypes: string[] = [];
175
180
 
181
+ // Bind the app clock/`wait` seam to the injected scheduler, so a handler's time-bounded
182
+ // work (poll loops, backoff, budgets) sources `now`/`wait` from the same clock that drives
183
+ // the engine's timers and the other background loops. Under the test kit that is the
184
+ // virtual clock, so `advanceTime()` bounds the loop instead of it burning real wall-time;
185
+ // in production it is the live scheduler (real timers) — no behavior change. Mirrors how
186
+ // `mountTriggers`/`mountInstanceTracking` receive the same seam.
187
+ const clock = schedulerClock(sched);
188
+ const clockedApp: AppApi = { ...app, now: clock.now, wait: clock.wait };
189
+
176
190
  // The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
177
191
  // edge its instance carries, so intent→progress lineage materialises for free. Absent when the
178
192
  // app configures no default data source.
@@ -246,7 +260,7 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
246
260
  if (lineageStore) recordJobLineage(lineageStore, job, ctx.host.log);
247
261
  return runInJobContext(
248
262
  jobExecContext(job, jobType),
249
- () => handler(job, withJobLog(app, job)),
263
+ () => handler(job, withJobLog(clockedApp, job)),
250
264
  );
251
265
  };
252
266
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
@@ -18,7 +18,7 @@ import { mountConnectors } from "./modules/connectors.ts";
18
18
  import { mountSurfaces } from "./modules/surfaces.ts";
19
19
  import { mountTriggers } from "./modules/triggers.ts";
20
20
  import { mountInstanceTracking } from "./modules/instance-tracking.ts";
21
- import type { SchedulerDeps } from "./modules/scheduler.ts";
21
+ import { defaultScheduler, schedulerClock, type SchedulerDeps } from "./modules/scheduler.ts";
22
22
  import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
23
23
  import { EventBus } from "./events.ts";
24
24
  import { createUrbanEvents, mountExtensions, type UrbanEvents, type UrbanExtension } from "./extensions.ts";
@@ -77,11 +77,13 @@ export interface CreateUrbanAppOptions {
77
77
  mount?: MountFlags;
78
78
  /**
79
79
  * Injectable timer + clock seam driving every background loop (the cron trigger loops
80
- * and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
81
- * wall clock). The e2e test kit injects a manual scheduler so those loops advance
82
- * deterministically over a virtual clock — the single seam that makes a whole-app
83
- * `settle()` possible. Threaded verbatim into `mountTriggers` and `mountInstanceTracking`
84
- * so one injected scheduler drives them both (no per-module wiring, no drift).
80
+ * and the instance-tracking reconciler) and the app clock/`wait` seam handed to worker
81
+ * handlers. Defaults to the live scheduler (real timers, wall clock). The e2e test kit
82
+ * injects a manual scheduler so those loops — and any time-bounded worker sourcing
83
+ * `app.now()`/`app.wait()` — advance deterministically over a virtual clock: the single
84
+ * seam that makes a whole-app `settle()` possible. Threaded verbatim into `mountWorkers`,
85
+ * `mountTriggers` and `mountInstanceTracking` so one injected scheduler drives them all
86
+ * (no per-module wiring, no drift).
85
87
  */
86
88
  scheduler?: SchedulerDeps;
87
89
  /**
@@ -258,6 +260,12 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
258
260
  if (flags.deploy) describe.deploy = await deployModels(ctx);
259
261
 
260
262
  data = flags.data ? await provisionData(ctx) : new DataLayer(new Map(), undefined, {});
263
+ // Resolve the injectable scheduler once (default: live real-timer seam) so the app
264
+ // clock/`wait` seam handed to handlers and every background loop share one clock —
265
+ // the single source the test kit swaps for a virtual clock to make `settle()` /
266
+ // `advanceTime()` bound the whole app (workers included).
267
+ const scheduler = opts.scheduler ?? defaultScheduler();
268
+ const clock = schedulerClock(scheduler);
261
269
  const api: AppApi = {
262
270
  manifest,
263
271
  data,
@@ -265,10 +273,12 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
265
273
  sdk: engineSdk(engine),
266
274
  env: (n) => host.env(n),
267
275
  log: appLog,
276
+ now: clock.now,
277
+ wait: clock.wait,
268
278
  };
269
279
 
270
280
  if (flags.workers) {
271
- const w = await mountWorkers(ctx, api);
281
+ const w = await mountWorkers(ctx, api, scheduler);
272
282
  mounted.push(w);
273
283
  describe.workers = w.describe?.();
274
284
  // Connector-pack workers share the `workers` flag: same lifecycle, same
@@ -294,13 +304,13 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
294
304
  describe.surfaces = s.describe();
295
305
  }
296
306
  if (flags.triggers) {
297
- const t = mountTriggers(ctx, api, opts.scheduler);
307
+ const t = mountTriggers(ctx, api, scheduler);
298
308
  routes.push(...t.routes);
299
309
  mounted.push(t);
300
310
  describe.triggers = t.describe?.();
301
311
  }
302
312
  if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
303
- const it = mountInstanceTracking(ctx, api, opts.scheduler);
313
+ const it = mountInstanceTracking(ctx, api, scheduler);
304
314
  mounted.push(it);
305
315
  describe.instanceTracking = it.describe?.();
306
316
  }