@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.
- package/dist/context/adversarial/harness.d.ts +24 -0
- package/dist/context/adversarial/harness.js +65 -0
- package/dist/runtime/core/context.d.ts +16 -0
- package/dist/runtime/core/modules/scheduler.d.ts +24 -0
- package/dist/runtime/core/modules/scheduler.js +23 -4
- package/dist/runtime/core/modules/workers.d.ts +2 -1
- package/dist/runtime/core/modules/workers.js +11 -2
- package/dist/runtime/core/runtime.d.ts +8 -6
- package/dist/runtime/core/runtime.js +12 -3
- package/package.json +2 -2
- package/src/context/adversarial/conformance-drift.test.ts +89 -0
- package/src/context/adversarial/governance.test.ts +97 -0
- package/src/context/adversarial/harness.ts +84 -0
- package/src/context/adversarial/pii-guard.test.ts +119 -0
- package/src/context/adversarial/provenance-forgery.test.ts +126 -0
- package/src/context/adversarial/seams.test.ts +104 -0
- package/src/context/adversarial/sharing.test.ts +84 -0
- package/src/runtime/core/context.ts +16 -0
- package/src/runtime/core/extensions.test.ts +2 -0
- package/src/runtime/core/modules/actions.test.ts +2 -0
- package/src/runtime/core/modules/api.test.ts +3 -1
- package/src/runtime/core/modules/cancel.test.ts +2 -0
- package/src/runtime/core/modules/connectors.test.ts +2 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +3 -41
- package/src/runtime/core/modules/scheduler.test-utils.ts +62 -0
- package/src/runtime/core/modules/scheduler.test.ts +44 -0
- package/src/runtime/core/modules/scheduler.ts +39 -4
- package/src/runtime/core/modules/surfaces.test.ts +4 -0
- package/src/runtime/core/modules/triggers.test.ts +4 -45
- package/src/runtime/core/modules/workers.test.ts +95 -0
- package/src/runtime/core/modules/workers.ts +16 -2
- 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)
|
|
3
|
-
// than a copy per module — means a
|
|
4
|
-
// way, and the "clamp a far-future delay to
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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)
|
|
81
|
-
* wall clock). The e2e test kit
|
|
82
|
-
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
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,
|
|
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,
|
|
313
|
+
const it = mountInstanceTracking(ctx, api, scheduler);
|
|
304
314
|
mounted.push(it);
|
|
305
315
|
describe.instanceTracking = it.describe?.();
|
|
306
316
|
}
|