@nanobpm/urban 0.71.1 → 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.
@@ -37,6 +37,22 @@ export interface AppApi {
37
37
  */
38
38
  sdk?: EngineSdkClient;
39
39
  env(name: string): string | undefined;
40
+ /**
41
+ * App clock seam — current time in ms since epoch, sourced from the runtime's injectable
42
+ * scheduler (`CreateUrbanAppOptions.scheduler`). The real wall clock in production; the virtual
43
+ * clock under the test kit. A handler doing time-bounded work (poll loops, backoff,
44
+ * budgets) should read `app.now()` instead of `Date.now()` so a whole-app `advanceTime()`
45
+ * bounds it deterministically rather than the loop burning real wall-time. See
46
+ * {@link AppApi.wait}.
47
+ */
48
+ now(): number;
49
+ /**
50
+ * Sleep `ms` on the app clock, resolving once that much time has elapsed on the runtime
51
+ * scheduler. Real timers in production (no behavior change on the live transport); under
52
+ * the test kit it advances with `advanceTime()`. Prefer over a hand-rolled `setTimeout`
53
+ * sleep in time-bounded work so the delay shares the engine's (virtual) clock.
54
+ */
55
+ wait(ms: number): Promise<void>;
40
56
  /**
41
57
  * Structured logger (see {@link Logger}). Callable for back-compat
42
58
  * (`log("info", msg, fields)`), but prefer the level methods and bound context:
@@ -9,3 +9,27 @@ export interface SchedulerDeps {
9
9
  export declare const MAX_TIMER_DELAY_MS = 2147483647;
10
10
  /** Default (live) scheduler seam, backed by the global timer functions. */
11
11
  export declare function defaultScheduler(): SchedulerDeps;
12
+ /**
13
+ * The app-facing clock/`wait` seam handed to handlers (see `AppApi.now`/`AppApi.wait`).
14
+ * A narrow read-only projection of a {@link SchedulerDeps}: `now()` reads its clock and
15
+ * `wait(ms)` sleeps on its timer. Real wall clock + real timers in production; the
16
+ * virtual clock under the test kit — so time-bounded worker work advances with
17
+ * `advanceTime()` instead of burning real wall-time.
18
+ */
19
+ export interface AppClock {
20
+ /** Current time in ms since epoch on the scheduler's clock. */
21
+ now(): number;
22
+ /** Resolve after `ms` have elapsed on the scheduler's clock (armed via its timer seam). */
23
+ wait(ms: number): Promise<void>;
24
+ }
25
+ /**
26
+ * Derive the app clock/`wait` seam from a scheduler — the single canonical mapping from the
27
+ * runtime's injectable timer seam to the surface handlers use, so worker/trigger/surface
28
+ * handlers doing time-bounded work share the same clock as the background loops (no drift
29
+ * between a handler's budget and the engine's timers). A non-positive/non-finite `wait`
30
+ * delay clamps to fire at the current instant, and a far-future delay clamps to
31
+ * {@link MAX_TIMER_DELAY_MS} — matching the same clamp the background loops apply to their
32
+ * `setTimer` delays (see `mountTriggers`/`mountInstanceTracking`) — so `app.wait()` can
33
+ * never reintroduce a Node `TimeoutOverflowWarning`.
34
+ */
35
+ export declare function schedulerClock(sched: SchedulerDeps): AppClock;
@@ -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
  /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
7
7
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
8
8
  function isTimerHandle(handle) {
@@ -19,3 +19,22 @@ export function defaultScheduler() {
19
19
  now: () => Date.now(),
20
20
  };
21
21
  }
22
+ /**
23
+ * Derive the app clock/`wait` seam from a scheduler — the single canonical mapping from the
24
+ * runtime's injectable timer seam to the surface handlers use, so worker/trigger/surface
25
+ * handlers doing time-bounded work share the same clock as the background loops (no drift
26
+ * between a handler's budget and the engine's timers). A non-positive/non-finite `wait`
27
+ * delay clamps to fire at the current instant, and a far-future delay clamps to
28
+ * {@link MAX_TIMER_DELAY_MS} — matching the same clamp the background loops apply to their
29
+ * `setTimer` delays (see `mountTriggers`/`mountInstanceTracking`) — so `app.wait()` can
30
+ * never reintroduce a Node `TimeoutOverflowWarning`.
31
+ */
32
+ export function schedulerClock(sched) {
33
+ return {
34
+ now: () => sched.now(),
35
+ wait: (ms) => new Promise((resolve) => {
36
+ const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
37
+ sched.setTimer(() => resolve(), delay);
38
+ }),
39
+ };
40
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
2
  import type { EngineJob } from "../host.ts";
3
3
  import { type DecisionEvaluator } from "./llm.ts";
4
+ import { type SchedulerDeps } from "./scheduler.ts";
4
5
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
5
6
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
6
7
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
@@ -34,4 +35,4 @@ export interface WorkersHandle extends Mounted {
34
35
  readonly jobTypes: string[];
35
36
  }
36
37
  /** Load handler modules and register a push worker per declared worker. */
37
- export declare function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<WorkersHandle>;
38
+ export declare function mountWorkers(ctx: RuntimeContext, app: AppApi, sched?: SchedulerDeps): Promise<WorkersHandle>;
@@ -7,6 +7,7 @@ import { runLlmJob } from "./llm.js";
7
7
  import { runInJobContext } from "../execContext.js";
8
8
  import { readLineage } from "../lineage.js";
9
9
  import { LineageStore } from "./lineage-store.js";
10
+ import { defaultScheduler, schedulerClock } from "./scheduler.js";
10
11
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
11
12
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
12
13
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
@@ -125,10 +126,18 @@ export function resolveHandler(mod, jobType) {
125
126
  return undefined;
126
127
  }
127
128
  /** Load handler modules and register a push worker per declared worker. */
128
- export async function mountWorkers(ctx, app) {
129
+ export async function mountWorkers(ctx, app, sched = defaultScheduler()) {
129
130
  const decls = ctx.manifest.workers ?? [];
130
131
  const subs = [];
131
132
  const jobTypes = [];
133
+ // Bind the app clock/`wait` seam to the injected scheduler, so a handler's time-bounded
134
+ // work (poll loops, backoff, budgets) sources `now`/`wait` from the same clock that drives
135
+ // the engine's timers and the other background loops. Under the test kit that is the
136
+ // virtual clock, so `advanceTime()` bounds the loop instead of it burning real wall-time;
137
+ // in production it is the live scheduler (real timers) — no behavior change. Mirrors how
138
+ // `mountTriggers`/`mountInstanceTracking` receive the same seam.
139
+ const clock = schedulerClock(sched);
140
+ const clockedApp = { ...app, now: clock.now, wait: clock.wait };
132
141
  // The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
133
142
  // edge its instance carries, so intent→progress lineage materialises for free. Absent when the
134
143
  // app configures no default data source.
@@ -195,7 +204,7 @@ export async function mountWorkers(ctx, app) {
195
204
  const wrapped = (job) => {
196
205
  if (lineageStore)
197
206
  recordJobLineage(lineageStore, job, ctx.host.log);
198
- return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(app, job)));
207
+ return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(clockedApp, job)));
199
208
  };
200
209
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
201
210
  workerName: `${ctx.manifest.id}:${jobType}`,
@@ -2,7 +2,7 @@ import type { EngineClient, HostContext } from "./host.ts";
2
2
  import { type AppManifest } from "./manifest.ts";
3
3
  import { DataLayer } from "./modules/datasource.ts";
4
4
  import { type Logger } from "./logger.ts";
5
- import type { SchedulerDeps } from "./modules/scheduler.ts";
5
+ import { type SchedulerDeps } from "./modules/scheduler.ts";
6
6
  import { type SecurityPolicy } from "./modules/security.ts";
7
7
  import { type UrbanEvents, type UrbanExtension } from "./extensions.ts";
8
8
  /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
@@ -34,11 +34,13 @@ export interface CreateUrbanAppOptions {
34
34
  mount?: MountFlags;
35
35
  /**
36
36
  * Injectable timer + clock seam driving every background loop (the cron trigger loops
37
- * and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
38
- * wall clock). The e2e test kit injects a manual scheduler so those loops advance
39
- * deterministically over a virtual clock — the single seam that makes a whole-app
40
- * `settle()` possible. Threaded verbatim into `mountTriggers` and `mountInstanceTracking`
41
- * so one injected scheduler drives them both (no per-module wiring, no drift).
37
+ * and the instance-tracking reconciler) and the app clock/`wait` seam handed to worker
38
+ * handlers. Defaults to the live scheduler (real timers, wall clock). The e2e test kit
39
+ * injects a manual scheduler so those loops — and any time-bounded worker sourcing
40
+ * `app.now()`/`app.wait()` — advance deterministically over a virtual clock: the single
41
+ * seam that makes a whole-app `settle()` possible. Threaded verbatim into `mountWorkers`,
42
+ * `mountTriggers` and `mountInstanceTracking` so one injected scheduler drives them all
43
+ * (no per-module wiring, no drift).
42
44
  */
43
45
  scheduler?: SchedulerDeps;
44
46
  /**
@@ -14,6 +14,7 @@ import { mountConnectors } from "./modules/connectors.js";
14
14
  import { mountSurfaces } from "./modules/surfaces.js";
15
15
  import { mountTriggers } from "./modules/triggers.js";
16
16
  import { mountInstanceTracking } from "./modules/instance-tracking.js";
17
+ import { defaultScheduler, schedulerClock } from "./modules/scheduler.js";
17
18
  import { mountSecurity } from "./modules/security.js";
18
19
  import { EventBus } from "./events.js";
19
20
  import { createUrbanEvents, mountExtensions } from "./extensions.js";
@@ -157,6 +158,12 @@ export async function createUrbanApp(opts) {
157
158
  if (flags.deploy)
158
159
  describe.deploy = await deployModels(ctx);
159
160
  data = flags.data ? await provisionData(ctx) : new DataLayer(new Map(), undefined, {});
161
+ // Resolve the injectable scheduler once (default: live real-timer seam) so the app
162
+ // clock/`wait` seam handed to handlers and every background loop share one clock —
163
+ // the single source the test kit swaps for a virtual clock to make `settle()` /
164
+ // `advanceTime()` bound the whole app (workers included).
165
+ const scheduler = opts.scheduler ?? defaultScheduler();
166
+ const clock = schedulerClock(scheduler);
160
167
  const api = {
161
168
  manifest,
162
169
  data,
@@ -164,9 +171,11 @@ export async function createUrbanApp(opts) {
164
171
  sdk: engineSdk(engine),
165
172
  env: (n) => host.env(n),
166
173
  log: appLog,
174
+ now: clock.now,
175
+ wait: clock.wait,
167
176
  };
168
177
  if (flags.workers) {
169
- const w = await mountWorkers(ctx, api);
178
+ const w = await mountWorkers(ctx, api, scheduler);
170
179
  mounted.push(w);
171
180
  describe.workers = w.describe?.();
172
181
  // Connector-pack workers share the `workers` flag: same lifecycle, same
@@ -191,13 +200,13 @@ export async function createUrbanApp(opts) {
191
200
  describe.surfaces = s.describe();
192
201
  }
193
202
  if (flags.triggers) {
194
- const t = mountTriggers(ctx, api, opts.scheduler);
203
+ const t = mountTriggers(ctx, api, scheduler);
195
204
  routes.push(...t.routes);
196
205
  mounted.push(t);
197
206
  describe.triggers = t.describe?.();
198
207
  }
199
208
  if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
200
- const it = mountInstanceTracking(ctx, api, opts.scheduler);
209
+ const it = mountInstanceTracking(ctx, api, scheduler);
201
210
  mounted.push(it);
202
211
  describe.instanceTracking = it.describe?.();
203
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.71.1",
3
+ "version": "0.72.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -42,6 +42,22 @@ export interface AppApi {
42
42
  */
43
43
  sdk?: EngineSdkClient;
44
44
  env(name: string): string | undefined;
45
+ /**
46
+ * App clock seam — current time in ms since epoch, sourced from the runtime's injectable
47
+ * scheduler (`CreateUrbanAppOptions.scheduler`). The real wall clock in production; the virtual
48
+ * clock under the test kit. A handler doing time-bounded work (poll loops, backoff,
49
+ * budgets) should read `app.now()` instead of `Date.now()` so a whole-app `advanceTime()`
50
+ * bounds it deterministically rather than the loop burning real wall-time. See
51
+ * {@link AppApi.wait}.
52
+ */
53
+ now(): number;
54
+ /**
55
+ * Sleep `ms` on the app clock, resolving once that much time has elapsed on the runtime
56
+ * scheduler. Real timers in production (no behavior change on the live transport); under
57
+ * the test kit it advances with `advanceTime()`. Prefer over a hand-rolled `setTimeout`
58
+ * sleep in time-bounded work so the delay shares the engine's (virtual) clock.
59
+ */
60
+ wait(ms: number): Promise<void>;
45
61
  /**
46
62
  * Structured logger (see {@link Logger}). Callable for back-compat
47
63
  * (`log("info", msg, fields)`), but prefer the level methods and bound context:
@@ -27,6 +27,8 @@ function fakeApi(): AppApi {
27
27
  data: unused(),
28
28
  engine: unused(),
29
29
  env: () => undefined,
30
+ now: () => 0,
31
+ wait: () => Promise.resolve(),
30
32
  log: createLogger(() => {}),
31
33
  };
32
34
  }
@@ -45,6 +45,8 @@ function appFixture(over: Partial<AppApi> = {}): AppApi {
45
45
  data,
46
46
  engine,
47
47
  env: () => undefined,
48
+ now: () => 0,
49
+ wait: () => Promise.resolve(),
48
50
  log: createLogger(() => {}),
49
51
  ...over,
50
52
  };
@@ -39,7 +39,7 @@ const engine: EngineClient = {
39
39
  const data = new DataLayer(new Map(), undefined, {});
40
40
 
41
41
  function appFixture(): AppApi {
42
- return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: createLogger(() => {}) };
42
+ return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, now: () => 0, wait: () => Promise.resolve(), log: createLogger(() => {}) };
43
43
  }
44
44
 
45
45
  const spec = JSON.stringify({
@@ -148,6 +148,8 @@ test("a delegate's app.log is bound to the request's correlation context", async
148
148
  data,
149
149
  engine,
150
150
  env: () => undefined,
151
+ now: () => 0,
152
+ wait: () => Promise.resolve(),
151
153
  log: createLogger((level, msg, fields) => {
152
154
  captured.push({ level, msg, fields });
153
155
  }),
@@ -111,6 +111,8 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
111
111
  data,
112
112
  engine,
113
113
  env: () => undefined,
114
+ now: () => 0,
115
+ wait: () => Promise.resolve(),
114
116
  log: createLogger((level, msg) => {
115
117
  logs.push({ level, msg });
116
118
  }),
@@ -86,6 +86,8 @@ function makeApp(env: Record<string, string> = {}): AppApi {
86
86
  data: new DataLayer(new Map(), undefined, {}),
87
87
  engine: new MiniEngine(),
88
88
  env: (n: string) => env[n],
89
+ now: () => 0,
90
+ wait: () => Promise.resolve(),
89
91
  log: createLogger(() => {}),
90
92
  };
91
93
  }
@@ -17,53 +17,13 @@ import {
17
17
  WAITING_HUMAN_PROBE_CONCURRENCY,
18
18
  } from "./instance-tracking.ts";
19
19
  import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
20
+ import { fakeScheduler } from "./scheduler.test-utils.ts";
20
21
 
21
22
  // A real-timer flush drains the entire pending microtask chain (find → engine → update)
22
23
  // each time a fake timer fires — deeper than a fixed number of `await Promise.resolve()`s.
23
24
  const realSetTimeout = globalThis.setTimeout;
24
25
  const flush = () => new Promise<void>((r) => realSetTimeout(r, 0));
25
26
 
26
- // A deterministic timer + clock seam (mirrors triggers.test.ts): `advance` fires every due
27
- // timer in order, draining microtasks between fires so the async reconcile completes.
28
- function fakeScheduler(): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
29
- let clock = 0;
30
- let seq = 0;
31
- const timers = new Map<number, { at: number; fn: () => void }>();
32
- return {
33
- now: () => clock,
34
- setTimer: (fn, delayMs) => {
35
- const id = ++seq;
36
- timers.set(id, { at: clock + delayMs, fn });
37
- return id;
38
- },
39
- clearTimer: (h) => {
40
- if (typeof h === "number") timers.delete(h);
41
- },
42
- pending: () => timers.size,
43
- advance: async (ms) => {
44
- const target = clock + ms;
45
- // deno-lint-ignore no-constant-condition
46
- while (true) {
47
- let nextId = -1;
48
- let nextAt = Infinity;
49
- for (const [id, t] of timers) {
50
- if (t.at <= target && t.at < nextAt) {
51
- nextAt = t.at;
52
- nextId = id;
53
- }
54
- }
55
- if (nextId < 0) break;
56
- const t = timers.get(nextId)!;
57
- timers.delete(nextId);
58
- clock = t.at;
59
- t.fn();
60
- await flush();
61
- }
62
- clock = target;
63
- },
64
- };
65
- }
66
-
67
27
  /** An EngineClient driven by a fixed map of processInstanceKey → state (for searchProcessInstances)
68
28
  * and an optional map of processInstanceKey → number-of-open-user-tasks (for openUserTasks, the
69
29
  * wait-on-human edge). Records the keys asked for on each call. `openUserTasks` models the engine
@@ -167,6 +127,8 @@ async function withHarness(
167
127
  data,
168
128
  engine,
169
129
  env: () => undefined,
130
+ now: () => 0,
131
+ wait: () => Promise.resolve(),
170
132
  log: createLogger((level, msg) => {
171
133
  logs.push({ level, msg });
172
134
  }),
@@ -0,0 +1,62 @@
1
+ // Shared test-only fake for the injectable {@link SchedulerDeps} timer/clock seam. A single
2
+ // canonical virtual-clock scheduler for every time-based test (workers, triggers, the
3
+ // instance-tracking reconciler) so their virtual-time behavior can't drift apart — the drift
4
+ // surface three per-file copies used to create. Excluded from the published build via the
5
+ // `**/*.test-utils.ts` rule in tsconfig.build.json.
6
+
7
+ import type { SchedulerDeps } from "./scheduler.ts";
8
+
9
+ // A real-timer flush drains the entire pending microtask chain each time a fake timer fires —
10
+ // deeper than a fixed number of `await Promise.resolve()`s, so an async follow-on chain (find →
11
+ // engine → update, or a worker loop's next iteration) fully settles before the next timer fires.
12
+ const realSetTimeout = globalThis.setTimeout;
13
+ const flush = (): Promise<void> => new Promise<void>((resolve) => realSetTimeout(resolve, 0));
14
+
15
+ /** A deterministic virtual-clock {@link SchedulerDeps}: timers fire only when the clock is advanced
16
+ * past their deadline. `advance(ms)` fires every timer due within the interval in order, draining
17
+ * microtasks between fires so an async loop's follow-on chain settles before the next fire, then
18
+ * parks the clock at the target. `pending()` reports the number of armed timers. A non-positive /
19
+ * non-finite delay clamps to fire at the current instant, mirroring the production
20
+ * `schedulerClock` / `setTimer` clamp. Lets a test bound time-based work over virtual time without
21
+ * touching the real wall clock. */
22
+ export function fakeScheduler(
23
+ startMs = 0,
24
+ ): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
25
+ let clock = startMs;
26
+ let seq = 0;
27
+ const timers = new Map<number, { at: number; fn: () => void }>();
28
+ return {
29
+ now: () => clock,
30
+ setTimer: (fn, delayMs) => {
31
+ const id = ++seq;
32
+ timers.set(id, { at: clock + (Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0), fn });
33
+ return id;
34
+ },
35
+ clearTimer: (h) => {
36
+ if (typeof h === "number") timers.delete(h);
37
+ },
38
+ pending: () => timers.size,
39
+ advance: async (ms) => {
40
+ const target = clock + Math.max(0, ms);
41
+ // Fire due timers one at a time; a timer's callback may schedule a new one.
42
+ for (;;) {
43
+ let nextId = -1;
44
+ let nextAt = Number.POSITIVE_INFINITY;
45
+ for (const [id, t] of timers) {
46
+ if (t.at <= target && t.at < nextAt) {
47
+ nextAt = t.at;
48
+ nextId = id;
49
+ }
50
+ }
51
+ if (nextId < 0) break;
52
+ const t = timers.get(nextId);
53
+ if (!t) break;
54
+ timers.delete(nextId);
55
+ clock = t.at;
56
+ t.fn();
57
+ await flush();
58
+ }
59
+ clock = target;
60
+ },
61
+ };
62
+ }
@@ -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
  }