@nanobpm/urban 0.71.1 → 0.72.1
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/runtime/core/context.d.ts +16 -0
- package/dist/runtime/core/modules/connectors.js +9 -1
- 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 +1 -1
- 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 +23 -1
- package/src/runtime/core/modules/connectors.ts +9 -1
- 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
|
@@ -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:
|
|
@@ -85,11 +85,19 @@ export async function resolveInstalledConnectors(ctx) {
|
|
|
85
85
|
* error boundary by the engine adapter); `job.fail(msg)` a plain throw (retry). */
|
|
86
86
|
export function adaptConnectorHandler(worker) {
|
|
87
87
|
return async (job) => {
|
|
88
|
+
// A connector job always belongs to a process instance; a missing key is a
|
|
89
|
+
// protocol fault. Fail loud (the engine retries / raises an incident) rather
|
|
90
|
+
// than handing the connector a fabricated "" that would silently corrupt any
|
|
91
|
+
// instance-scoped call it makes.
|
|
92
|
+
const processInstanceKey = job.processInstanceKey;
|
|
93
|
+
if (processInstanceKey === undefined || processInstanceKey === "") {
|
|
94
|
+
throw new Error(`connector job '${job.jobType}' (${job.jobKey}) has no processInstanceKey`);
|
|
95
|
+
}
|
|
88
96
|
let outcome;
|
|
89
97
|
const facade = {
|
|
90
98
|
jobKey: job.jobKey,
|
|
91
99
|
type: job.jobType,
|
|
92
|
-
processInstanceKey
|
|
100
|
+
processInstanceKey,
|
|
93
101
|
elementId: job.elementId,
|
|
94
102
|
variables: job.variables,
|
|
95
103
|
async complete(vars) {
|
|
@@ -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)
|
|
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
|
/** 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(
|
|
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
|
|
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)
|
|
38
|
-
* wall clock). The e2e test kit
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
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,
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.72.1",
|
|
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:
|
|
@@ -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
|
}
|
|
@@ -143,6 +145,7 @@ test("mountConnectors imports the pack entry and registers the drained worker",
|
|
|
143
145
|
const out = await engine.deliver("slack:send-message", {
|
|
144
146
|
jobKey: "j1",
|
|
145
147
|
jobType: "slack:send-message",
|
|
148
|
+
processInstanceKey: "pi1",
|
|
146
149
|
variables: { text: "hi" },
|
|
147
150
|
});
|
|
148
151
|
assert.deepEqual(out, { echoed: "hi" });
|
|
@@ -212,7 +215,7 @@ test("mountConnectors fails closed on a duplicate worker type in one pack entry"
|
|
|
212
215
|
});
|
|
213
216
|
|
|
214
217
|
test("adaptConnectorHandler maps complete/fail/error(BpmnError) and a returned value", async () => {
|
|
215
|
-
const job: EngineJob = { jobKey: "j", jobType: "t", variables: {} };
|
|
218
|
+
const job: EngineJob = { jobKey: "j", jobType: "t", processInstanceKey: "pi", variables: {} };
|
|
216
219
|
|
|
217
220
|
const completes = adaptConnectorHandler({ type: "t", handle: async (j) => void (await j.complete({ a: 1 })) });
|
|
218
221
|
assert.deepEqual(await completes(job), { a: 1 });
|
|
@@ -229,3 +232,22 @@ test("adaptConnectorHandler maps complete/fail/error(BpmnError) and a returned v
|
|
|
229
232
|
(e: unknown) => e instanceof BpmnError && e.errorCode === "CODE",
|
|
230
233
|
);
|
|
231
234
|
});
|
|
235
|
+
|
|
236
|
+
test("adaptConnectorHandler fails loud on a job with no processInstanceKey (no masking to '')", async () => {
|
|
237
|
+
// Guard for the decode-boundary defect class (Magikcraft/nano-bpm#940): a
|
|
238
|
+
// keyless job must reject, never invoke the connector with a fabricated "".
|
|
239
|
+
let handlerRan = false;
|
|
240
|
+
const h = adaptConnectorHandler({
|
|
241
|
+
type: "t",
|
|
242
|
+
handle: async () => {
|
|
243
|
+
handlerRan = true;
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const keyless: EngineJob = { jobKey: "j", jobType: "t", variables: {} };
|
|
247
|
+
await assert.rejects(() => Promise.resolve(h(keyless)), /no processInstanceKey/);
|
|
248
|
+
assert.equal(handlerRan, false, "connector handler must not run for a keyless job");
|
|
249
|
+
|
|
250
|
+
// An empty-string key is the exact masking source and is rejected too.
|
|
251
|
+
const emptyKey: EngineJob = { jobKey: "j", jobType: "t", processInstanceKey: "", variables: {} };
|
|
252
|
+
await assert.rejects(() => Promise.resolve(h(emptyKey)), /no processInstanceKey/);
|
|
253
|
+
});
|
|
@@ -134,6 +134,14 @@ export async function resolveInstalledConnectors(
|
|
|
134
134
|
* error boundary by the engine adapter); `job.fail(msg)` a plain throw (retry). */
|
|
135
135
|
export function adaptConnectorHandler(worker: DefinedConnectorWorker): JobHandler {
|
|
136
136
|
return async (job: EngineJob) => {
|
|
137
|
+
// A connector job always belongs to a process instance; a missing key is a
|
|
138
|
+
// protocol fault. Fail loud (the engine retries / raises an incident) rather
|
|
139
|
+
// than handing the connector a fabricated "" that would silently corrupt any
|
|
140
|
+
// instance-scoped call it makes.
|
|
141
|
+
const processInstanceKey = job.processInstanceKey;
|
|
142
|
+
if (processInstanceKey === undefined || processInstanceKey === "") {
|
|
143
|
+
throw new Error(`connector job '${job.jobType}' (${job.jobKey}) has no processInstanceKey`);
|
|
144
|
+
}
|
|
137
145
|
let outcome:
|
|
138
146
|
| { kind: "complete"; vars?: Record<string, unknown> }
|
|
139
147
|
| { kind: "fail"; message: string }
|
|
@@ -142,7 +150,7 @@ export function adaptConnectorHandler(worker: DefinedConnectorWorker): JobHandle
|
|
|
142
150
|
const facade: ConnectorWorkerJob = {
|
|
143
151
|
jobKey: job.jobKey,
|
|
144
152
|
type: job.jobType,
|
|
145
|
-
processInstanceKey
|
|
153
|
+
processInstanceKey,
|
|
146
154
|
elementId: job.elementId,
|
|
147
155
|
variables: job.variables,
|
|
148
156
|
async complete(vars) {
|
|
@@ -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)
|
|
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
|
}
|