@nanobpm/urban 0.28.0 → 0.29.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/runtime/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +18 -1
- package/dist/runtime/core/modules/datasource.js +56 -2
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
- package/dist/runtime/core/modules/instance-tracking.js +141 -0
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +49 -0
- package/package.json +2 -2
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +72 -2
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +158 -0
- package/src/runtime/core/modules/pages.test.ts +1 -0
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +73 -0
- package/src/runtime/engine/nanosdk.ts +63 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createNodeHost } from "../../adapters/node.ts";
|
|
7
|
+
import { makeGateway } from "./gateway.ts";
|
|
8
|
+
import {
|
|
9
|
+
ensureProvenanceTable,
|
|
10
|
+
makeProvenanceRecorder,
|
|
11
|
+
WRITE_PROVENANCE_TABLE,
|
|
12
|
+
} from "./datasource.ts";
|
|
13
|
+
import {
|
|
14
|
+
__resetExecStoreForTests,
|
|
15
|
+
currentJobContext,
|
|
16
|
+
installExecStore,
|
|
17
|
+
runInJobContext,
|
|
18
|
+
type JobExecContext,
|
|
19
|
+
} from "../execContext.ts";
|
|
20
|
+
import type { HostContext, SqliteDb } from "../host.ts";
|
|
21
|
+
|
|
22
|
+
interface Note {
|
|
23
|
+
id: number;
|
|
24
|
+
body: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ProvRow {
|
|
28
|
+
source: string;
|
|
29
|
+
table_name: string;
|
|
30
|
+
pk_value: string;
|
|
31
|
+
instance_key: string | null;
|
|
32
|
+
element_id: string | null;
|
|
33
|
+
job_type: string | null;
|
|
34
|
+
op: string;
|
|
35
|
+
at: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function withSource(
|
|
39
|
+
fn: (host: HostContext, db: SqliteDb) => Promise<void>,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-prov-"));
|
|
42
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
43
|
+
const db = host.openSqlite(join(dir, "test.db"));
|
|
44
|
+
db.exec("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL)");
|
|
45
|
+
try {
|
|
46
|
+
await fn(host, db);
|
|
47
|
+
} finally {
|
|
48
|
+
db.close();
|
|
49
|
+
await rm(dir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function provRows(db: SqliteDb): ProvRow[] {
|
|
54
|
+
return db.all<ProvRow>(`SELECT * FROM ${WRITE_PROVENANCE_TABLE} ORDER BY seq`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("insert inside a job context records a provenance row linking row → instance/element", async () => {
|
|
58
|
+
__resetExecStoreForTests();
|
|
59
|
+
await withSource(async (host, db) => {
|
|
60
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
61
|
+
ensureProvenanceTable(db);
|
|
62
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
63
|
+
const notes = src.table<Note>("notes");
|
|
64
|
+
|
|
65
|
+
let id: number | bigint = 0;
|
|
66
|
+
await runInJobContext(
|
|
67
|
+
{ instanceKey: "inst-1", elementId: "Task_persist", jobType: "notes.write" },
|
|
68
|
+
async () => {
|
|
69
|
+
id = await notes.insert({ body: "hello" });
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const rows = provRows(db);
|
|
74
|
+
assert.equal(rows.length, 1);
|
|
75
|
+
assert.equal(rows[0].source, "main");
|
|
76
|
+
assert.equal(rows[0].table_name, "notes");
|
|
77
|
+
assert.equal(rows[0].pk_value, String(id));
|
|
78
|
+
assert.equal(rows[0].instance_key, "inst-1");
|
|
79
|
+
assert.equal(rows[0].element_id, "Task_persist");
|
|
80
|
+
assert.equal(rows[0].job_type, "notes.write");
|
|
81
|
+
assert.equal(rows[0].op, "insert");
|
|
82
|
+
assert.ok(rows[0].at.length > 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("insert outside any job context records no provenance (graceful degradation)", async () => {
|
|
87
|
+
__resetExecStoreForTests();
|
|
88
|
+
await withSource(async (host, db) => {
|
|
89
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
90
|
+
ensureProvenanceTable(db);
|
|
91
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
92
|
+
await src.table<Note>("notes").insert({ body: "no ctx" });
|
|
93
|
+
assert.equal(provRows(db).length, 0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a job context without an instance key records no provenance", async () => {
|
|
98
|
+
__resetExecStoreForTests();
|
|
99
|
+
await withSource(async (host, db) => {
|
|
100
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
101
|
+
ensureProvenanceTable(db);
|
|
102
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
103
|
+
await runInJobContext({ jobType: "notes.write" }, async () => {
|
|
104
|
+
await src.table<Note>("notes").insert({ body: "no instance" });
|
|
105
|
+
});
|
|
106
|
+
assert.equal(provRows(db).length, 0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a provenance-write failure never breaks the app insert", async () => {
|
|
111
|
+
__resetExecStoreForTests();
|
|
112
|
+
await withSource(async (host, db) => {
|
|
113
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
114
|
+
// Deliberately do NOT create the provenance table, so the recorder's own INSERT throws.
|
|
115
|
+
const src = makeGateway(db, makeProvenanceRecorder(host, db, "main"));
|
|
116
|
+
let id: number | bigint = 0;
|
|
117
|
+
await runInJobContext({ instanceKey: "inst-9" }, async () => {
|
|
118
|
+
id = await src.table<Note>("notes").insert({ body: "resilient" });
|
|
119
|
+
});
|
|
120
|
+
// The app row is written despite provenance capture failing.
|
|
121
|
+
assert.equal((await src.table<Note>("notes").get(id))?.body, "resilient");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("the recorder does not record writes to urban/engine-internal tables", async () => {
|
|
126
|
+
__resetExecStoreForTests();
|
|
127
|
+
await withSource(async (host, db) => {
|
|
128
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
129
|
+
ensureProvenanceTable(db);
|
|
130
|
+
const recorder = makeProvenanceRecorder(host, db, "main");
|
|
131
|
+
runInJobContext({ instanceKey: "inst-1" }, () => {
|
|
132
|
+
recorder("_urban_migrations", 1);
|
|
133
|
+
recorder(WRITE_PROVENANCE_TABLE, 2);
|
|
134
|
+
recorder("_nano_events", 3);
|
|
135
|
+
recorder("sqlite_sequence", 4);
|
|
136
|
+
});
|
|
137
|
+
assert.equal(provRows(db).length, 0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("with no store installed, capture is a transparent no-op", async () => {
|
|
142
|
+
__resetExecStoreForTests();
|
|
143
|
+
// No installExecStore call: runInJobContext must pass through and currentJobContext be undefined.
|
|
144
|
+
let ran = false;
|
|
145
|
+
const out = runInJobContext({ instanceKey: "x" }, () => {
|
|
146
|
+
ran = true;
|
|
147
|
+
assert.equal(currentJobContext(), undefined);
|
|
148
|
+
return 42;
|
|
149
|
+
});
|
|
150
|
+
assert.equal(ran, true);
|
|
151
|
+
assert.equal(out, 42);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("installExecStore is idempotent — the first install wins", async () => {
|
|
155
|
+
__resetExecStoreForTests();
|
|
156
|
+
await withSource(async (host, db) => {
|
|
157
|
+
void db;
|
|
158
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
159
|
+
// A second install with a factory that would throw must be ignored (never invoked).
|
|
160
|
+
installExecStore(() => {
|
|
161
|
+
throw new Error("second install must not run");
|
|
162
|
+
});
|
|
163
|
+
runInJobContext({ instanceKey: "inst-2" }, () => {
|
|
164
|
+
assert.equal(currentJobContext()?.instanceKey, "inst-2");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("installExecStore can install after an incapable host returns no store", async () => {
|
|
170
|
+
__resetExecStoreForTests();
|
|
171
|
+
await withSource(async (host, db) => {
|
|
172
|
+
void db;
|
|
173
|
+
installExecStore(() => undefined);
|
|
174
|
+
assert.equal(currentJobContext(), undefined);
|
|
175
|
+
|
|
176
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
177
|
+
runInJobContext({ instanceKey: "inst-3" }, () => {
|
|
178
|
+
assert.equal(currentJobContext()?.instanceKey, "inst-3");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
/** Injectable timer + clock seam so background scheduling is deterministic under test. */
|
|
8
|
+
export interface SchedulerDeps {
|
|
9
|
+
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
10
|
+
clearTimer: (handle: unknown) => void;
|
|
11
|
+
/** Current wall-clock time in ms since epoch. */
|
|
12
|
+
now: () => number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
16
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
17
|
+
|
|
18
|
+
function isTimerHandle(handle: unknown): handle is ReturnType<typeof setTimeout> {
|
|
19
|
+
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
23
|
+
export function defaultScheduler(): SchedulerDeps {
|
|
24
|
+
return {
|
|
25
|
+
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
26
|
+
clearTimer: (h) => {
|
|
27
|
+
if (isTimerHandle(h)) globalThis.clearTimeout(h);
|
|
28
|
+
},
|
|
29
|
+
now: () => Date.now(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
|
|
|
29
29
|
publishMessage: async () => {},
|
|
30
30
|
searchUserTasks: async () => [],
|
|
31
31
|
completeUserTask: async () => {},
|
|
32
|
+
searchProcessInstances: async () => [],
|
|
32
33
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
33
34
|
close: async () => {},
|
|
34
35
|
};
|
|
@@ -42,6 +43,7 @@ const fakeEngine: EngineClient = {
|
|
|
42
43
|
publishMessage: async () => {},
|
|
43
44
|
searchUserTasks: async () => [],
|
|
44
45
|
completeUserTask: async () => {},
|
|
46
|
+
searchProcessInstances: async () => [],
|
|
45
47
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
46
48
|
close: async () => {},
|
|
47
49
|
};
|
|
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
|
|
|
30
30
|
}
|
|
31
31
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
32
32
|
async completeUserTask(): Promise<void> {}
|
|
33
|
+
async searchProcessInstances(): Promise<[]> { return []; }
|
|
33
34
|
async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
|
|
34
35
|
return { jobType, unsubscribe: async () => {} };
|
|
35
36
|
}
|
|
@@ -9,18 +9,14 @@ import type { HttpRequest } from "../host.ts";
|
|
|
9
9
|
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
10
10
|
import type { Trigger } from "../manifest.ts";
|
|
11
11
|
import { nextCronFire, parseCron } from "./cron.ts";
|
|
12
|
+
import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
13
|
+
|
|
14
|
+
export type { SchedulerDeps };
|
|
12
15
|
|
|
13
16
|
export interface TriggersHandle extends Mounted {
|
|
14
17
|
routes: Route[];
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
/** Injectable timer + clock seam so cron scheduling is deterministic under test. */
|
|
18
|
-
export interface SchedulerDeps {
|
|
19
|
-
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
20
|
-
clearTimer: (handle: unknown) => void;
|
|
21
|
-
/** Current wall-clock time in ms since epoch. */
|
|
22
|
-
now: () => number;
|
|
23
|
-
}
|
|
24
20
|
|
|
25
21
|
type Scope = { body: unknown; headers: Record<string, string>; query: Record<string, string> };
|
|
26
22
|
|
|
@@ -149,24 +145,6 @@ export async function runTriggerAction(
|
|
|
149
145
|
|
|
150
146
|
const emptyScope: Scope = { body: {}, headers: {}, query: {} };
|
|
151
147
|
|
|
152
|
-
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
153
|
-
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
154
|
-
|
|
155
|
-
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
156
|
-
function defaultScheduler(): SchedulerDeps {
|
|
157
|
-
return {
|
|
158
|
-
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
159
|
-
clearTimer: (h) => {
|
|
160
|
-
if (isTimerHandle(h)) globalThis.clearTimeout(h);
|
|
161
|
-
},
|
|
162
|
-
now: () => Date.now(),
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function isTimerHandle(handle: unknown): handle is ReturnType<typeof setTimeout> {
|
|
167
|
-
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
148
|
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
171
149
|
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
172
150
|
export function mountTriggers(
|
|
@@ -15,6 +15,7 @@ class MiniEngine implements EngineClient {
|
|
|
15
15
|
async publishMessage(): Promise<void> {}
|
|
16
16
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
17
17
|
async completeUserTask(): Promise<void> {}
|
|
18
|
+
async searchProcessInstances(): Promise<[]> { return []; }
|
|
18
19
|
async close(): Promise<void> {}
|
|
19
20
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
20
21
|
this.workers.set(jobType, handler);
|
|
@@ -7,6 +7,7 @@ import { isRecord } from "../guards.ts";
|
|
|
7
7
|
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
8
8
|
import { workerJobType, type Worker } from "../manifest.ts";
|
|
9
9
|
import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
|
|
10
|
+
import { runInJobContext } from "../execContext.ts";
|
|
10
11
|
|
|
11
12
|
/** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
|
|
12
13
|
* whose `output` comes back as a JSON string (the orchestration-cluster contract). */
|
|
@@ -142,7 +143,11 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
142
143
|
env: (n) => app.env(n),
|
|
143
144
|
evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
|
|
144
145
|
};
|
|
145
|
-
const wrapped: JobHandler = (job) =>
|
|
146
|
+
const wrapped: JobHandler = (job) =>
|
|
147
|
+
runInJobContext(
|
|
148
|
+
{ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
|
|
149
|
+
() => runLlmJob(job.variables, binding, rt),
|
|
150
|
+
);
|
|
146
151
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
147
152
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
148
153
|
});
|
|
@@ -159,7 +164,11 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
159
164
|
`(expected handlers["${jobType}"], a named export, or a default function)`,
|
|
160
165
|
);
|
|
161
166
|
}
|
|
162
|
-
const wrapped: JobHandler = (job) =>
|
|
167
|
+
const wrapped: JobHandler = (job) =>
|
|
168
|
+
runInJobContext(
|
|
169
|
+
{ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
|
|
170
|
+
() => handler(job, app),
|
|
171
|
+
);
|
|
163
172
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
164
173
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
165
174
|
});
|
|
@@ -42,6 +42,9 @@ class FakeEngine implements EngineClient {
|
|
|
42
42
|
async completeUserTask(key: string, variables?: Record<string, unknown>) {
|
|
43
43
|
this.completedTasks.push({ key, variables });
|
|
44
44
|
}
|
|
45
|
+
async searchProcessInstances() {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
45
48
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
46
49
|
this.workers.set(jobType, handler);
|
|
47
50
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -12,10 +12,12 @@ import { makeRouter, type Route } from "./router.ts";
|
|
|
12
12
|
import { deployModels } from "./modules/deploy.ts";
|
|
13
13
|
import type { TemplateSource } from "./modules/templates.ts";
|
|
14
14
|
import { provisionData, DataLayer } from "./modules/datasource.ts";
|
|
15
|
+
import { installExecStore, type JobExecContext } from "./execContext.ts";
|
|
15
16
|
import { mountWorkers } from "./modules/workers.ts";
|
|
16
17
|
import { mountConnectors } from "./modules/connectors.ts";
|
|
17
18
|
import { mountSurfaces } from "./modules/surfaces.ts";
|
|
18
19
|
import { mountTriggers } from "./modules/triggers.ts";
|
|
20
|
+
import { mountInstanceTracking } from "./modules/instance-tracking.ts";
|
|
19
21
|
import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
|
|
20
22
|
|
|
21
23
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
@@ -52,6 +54,9 @@ export interface MountFlags {
|
|
|
52
54
|
surfaces?: boolean;
|
|
53
55
|
triggers?: boolean;
|
|
54
56
|
security?: boolean;
|
|
57
|
+
/** The `instanceTracking` reconciler poll loop. Shares no HTTP surface; safe to disable
|
|
58
|
+
* in hosts that only render surfaces (e.g. the console preview). */
|
|
59
|
+
instanceTracking?: boolean;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export interface CreateUrbanAppOptions {
|
|
@@ -104,9 +109,15 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
104
109
|
surfaces: opts.mount?.surfaces ?? true,
|
|
105
110
|
triggers: opts.mount?.triggers ?? true,
|
|
106
111
|
security: opts.mount?.security ?? true,
|
|
112
|
+
instanceTracking: opts.mount?.instanceTracking ?? true,
|
|
107
113
|
};
|
|
108
114
|
const ctx = { manifest, host, engine, root, templates: opts.templates };
|
|
109
115
|
|
|
116
|
+
// Install the ambient job-execution store once per process (idempotent) so worker dispatch can
|
|
117
|
+
// stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
|
|
118
|
+
// leaves provenance capture disabled.
|
|
119
|
+
installExecStore(() => host.createAsyncStore?.<JobExecContext>());
|
|
120
|
+
|
|
110
121
|
let data: DataLayer | undefined;
|
|
111
122
|
let security: SecurityPolicy | undefined;
|
|
112
123
|
let server: HttpServer | undefined;
|
|
@@ -204,6 +215,11 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
204
215
|
mounted.push(t);
|
|
205
216
|
describe.triggers = t.describe?.();
|
|
206
217
|
}
|
|
218
|
+
if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
|
|
219
|
+
const it = mountInstanceTracking(ctx, api);
|
|
220
|
+
mounted.push(it);
|
|
221
|
+
describe.instanceTracking = it.describe?.();
|
|
222
|
+
}
|
|
207
223
|
if (security) describe.security = security.describe();
|
|
208
224
|
if (data) describe.data = data.describe();
|
|
209
225
|
|
|
@@ -111,3 +111,99 @@ test("validateManifest throws ManifestValidationError with issues", () => {
|
|
|
111
111
|
return true;
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
test("a well-formed instanceTracking binding has no issues", () => {
|
|
116
|
+
const issues = collectManifestIssues({
|
|
117
|
+
...valid,
|
|
118
|
+
instanceTracking: [
|
|
119
|
+
{
|
|
120
|
+
table: "plans",
|
|
121
|
+
keyField: "process_key",
|
|
122
|
+
statusField: "status",
|
|
123
|
+
activeStatuses: ["planning"],
|
|
124
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
assert.deepEqual(issues, []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("an instanceTracking binding missing table/keyField/onTerminated.set is flagged", () => {
|
|
132
|
+
const issues = collectManifestIssues({
|
|
133
|
+
...valid,
|
|
134
|
+
instanceTracking: [{ onTerminated: { set: {} } }],
|
|
135
|
+
});
|
|
136
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].table"));
|
|
137
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].keyField"));
|
|
138
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].onTerminated.set"));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("instanceTracking activeStatuses without statusField is flagged", () => {
|
|
142
|
+
const issues = collectManifestIssues({
|
|
143
|
+
...valid,
|
|
144
|
+
instanceTracking: [
|
|
145
|
+
{
|
|
146
|
+
table: "plans",
|
|
147
|
+
keyField: "process_key",
|
|
148
|
+
activeStatuses: ["planning"],
|
|
149
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].activeStatuses"));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("instanceTracking activeStatuses with an empty-string statusField is flagged (silently polls every row otherwise)", () => {
|
|
157
|
+
const issues = collectManifestIssues({
|
|
158
|
+
...valid,
|
|
159
|
+
instanceTracking: [
|
|
160
|
+
{
|
|
161
|
+
table: "plans",
|
|
162
|
+
keyField: "process_key",
|
|
163
|
+
statusField: "",
|
|
164
|
+
activeStatuses: ["planning"],
|
|
165
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].activeStatuses"));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("instanceTracking pollMs that is non-positive/NaN/non-number is flagged (would hot-loop the poll timer)", () => {
|
|
173
|
+
for (const badPollMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, "5000"]) {
|
|
174
|
+
const issues = collectManifestIssues({
|
|
175
|
+
...valid,
|
|
176
|
+
instanceTracking: [
|
|
177
|
+
{
|
|
178
|
+
table: "plans",
|
|
179
|
+
keyField: "process_key",
|
|
180
|
+
statusField: "status",
|
|
181
|
+
activeStatuses: ["planning"],
|
|
182
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
183
|
+
pollMs: badPollMs,
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
assert.ok(
|
|
188
|
+
issues.some((i) => i.path === "instanceTracking[0].pollMs"),
|
|
189
|
+
`expected pollMs=${String(badPollMs)} to be flagged`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("instanceTracking with a valid positive pollMs has no pollMs issue", () => {
|
|
195
|
+
const issues = collectManifestIssues({
|
|
196
|
+
...valid,
|
|
197
|
+
instanceTracking: [
|
|
198
|
+
{
|
|
199
|
+
table: "plans",
|
|
200
|
+
keyField: "process_key",
|
|
201
|
+
statusField: "status",
|
|
202
|
+
activeStatuses: ["planning"],
|
|
203
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
204
|
+
pollMs: 5000,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
assert.deepEqual(issues, []);
|
|
209
|
+
});
|
|
@@ -150,6 +150,45 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
|
|
|
150
150
|
});
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// instanceTracking: each binding must name the table + key column it tracks and a
|
|
154
|
+
// non-empty `onTerminated.set` patch — a reconciler with nothing to write is inert
|
|
155
|
+
// and almost certainly a mistake. `activeStatuses` without `statusField` is incoherent
|
|
156
|
+
// (no column to read the statuses from), so the runtime would silently poll every row.
|
|
157
|
+
const tracking = Array.isArray(obj.instanceTracking) ? obj.instanceTracking : undefined;
|
|
158
|
+
if (tracking) {
|
|
159
|
+
tracking.forEach((t, i) => {
|
|
160
|
+
const b = isRecord(t) ? t : undefined;
|
|
161
|
+
if (typeof b?.table !== "string" || b.table.length === 0) {
|
|
162
|
+
issues.push({ path: `instanceTracking[${i}].table`, message: "missing table" });
|
|
163
|
+
}
|
|
164
|
+
if (typeof b?.keyField !== "string" || b.keyField.length === 0) {
|
|
165
|
+
issues.push({ path: `instanceTracking[${i}].keyField`, message: "missing keyField" });
|
|
166
|
+
}
|
|
167
|
+
const onTerminated = isRecord(b?.onTerminated) ? b.onTerminated : undefined;
|
|
168
|
+
const set = isRecord(onTerminated?.set) ? onTerminated.set : undefined;
|
|
169
|
+
if (!set || Object.keys(set).length === 0) {
|
|
170
|
+
issues.push({
|
|
171
|
+
path: `instanceTracking[${i}].onTerminated.set`,
|
|
172
|
+
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
176
|
+
issues.push({
|
|
177
|
+
path: `instanceTracking[${i}].activeStatuses`,
|
|
178
|
+
message: "activeStatuses requires statusField",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
|
|
182
|
+
// value would become a 0-delay hot loop at runtime, so reject it at author time.
|
|
183
|
+
if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
|
|
184
|
+
issues.push({
|
|
185
|
+
path: `instanceTracking[${i}].pollMs`,
|
|
186
|
+
message: "pollMs must be a finite positive number of milliseconds",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
153
192
|
return issues;
|
|
154
193
|
}
|
|
155
194
|
|
|
@@ -2,6 +2,7 @@ import { test } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
4
|
createNanoSdkEngineClient,
|
|
5
|
+
normalizeProcessInstanceState,
|
|
5
6
|
requireProcessInstanceKey,
|
|
6
7
|
SdkEngineClient,
|
|
7
8
|
type NanoSdkActivatedJob,
|
|
@@ -66,6 +67,10 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
|
|
|
66
67
|
calls.push("searchUserTasks");
|
|
67
68
|
return { items: [] };
|
|
68
69
|
},
|
|
70
|
+
async searchProcessInstances() {
|
|
71
|
+
calls.push("searchProcessInstances");
|
|
72
|
+
return { items: [] };
|
|
73
|
+
},
|
|
69
74
|
async completeUserTask() {
|
|
70
75
|
calls.push("completeUserTask");
|
|
71
76
|
return {};
|
|
@@ -202,6 +207,74 @@ test("completeUserTask routes through the SDK", async () => {
|
|
|
202
207
|
assert.deepEqual(seen, { userTaskKey: "utk", variables: { done: true } });
|
|
203
208
|
});
|
|
204
209
|
|
|
210
|
+
test("searchProcessInstances filters by keys ($in) + state with a matching page cap", async () => {
|
|
211
|
+
let seenInput: unknown;
|
|
212
|
+
let seenConsistency: unknown;
|
|
213
|
+
const client = fakeSdkClient({
|
|
214
|
+
searchProcessInstances: async (input, c) => {
|
|
215
|
+
seenInput = input;
|
|
216
|
+
seenConsistency = c;
|
|
217
|
+
return {
|
|
218
|
+
items: [
|
|
219
|
+
{ processInstanceKey: 7, state: "TERMINATED" },
|
|
220
|
+
{ processInstanceKey: "8", state: "ACTIVE" },
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
const engine = new SdkEngineClient(client);
|
|
226
|
+
const out = await engine.searchProcessInstances({
|
|
227
|
+
processInstanceKeys: ["7", "8", ""], // empty key dropped from the $in
|
|
228
|
+
state: "TERMINATED",
|
|
229
|
+
});
|
|
230
|
+
assert.deepEqual(seenInput, {
|
|
231
|
+
filter: { state: "TERMINATED", processInstanceKey: { $in: ["7", "8"] } },
|
|
232
|
+
page: { limit: 2 },
|
|
233
|
+
});
|
|
234
|
+
assert.deepEqual(seenConsistency, { consistency: { waitUpToMs: 0 } });
|
|
235
|
+
assert.deepEqual(out, [
|
|
236
|
+
{ processInstanceKey: "7", state: "TERMINATED" },
|
|
237
|
+
{ processInstanceKey: "8", state: "ACTIVE" },
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("searchProcessInstances with no keys sends no key filter and no page cap", async () => {
|
|
242
|
+
let seenInput: unknown;
|
|
243
|
+
const client = fakeSdkClient({
|
|
244
|
+
searchProcessInstances: async (input) => {
|
|
245
|
+
seenInput = input;
|
|
246
|
+
return { items: [] };
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const engine = new SdkEngineClient(client);
|
|
250
|
+
await engine.searchProcessInstances();
|
|
251
|
+
assert.deepEqual(seenInput, { filter: {} });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("searchProcessInstances skips keyless and unrecognized-state rows", async () => {
|
|
255
|
+
const client = fakeSdkClient({
|
|
256
|
+
searchProcessInstances: async () => ({
|
|
257
|
+
items: [
|
|
258
|
+
{ processInstanceKey: "", state: "TERMINATED" }, // keyless → skipped
|
|
259
|
+
{ processInstanceKey: 9, state: "SUSPENDED" }, // unknown state → skipped
|
|
260
|
+
{ processInstanceKey: 10, state: "completed" }, // case-insensitive → mapped
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
const engine = new SdkEngineClient(client);
|
|
265
|
+
const out = await engine.searchProcessInstances();
|
|
266
|
+
assert.deepEqual(out, [{ processInstanceKey: "10", state: "COMPLETED" }]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("normalizeProcessInstanceState maps the terminal set and rejects others", () => {
|
|
270
|
+
assert.equal(normalizeProcessInstanceState("ACTIVE"), "ACTIVE");
|
|
271
|
+
assert.equal(normalizeProcessInstanceState("completed"), "COMPLETED");
|
|
272
|
+
assert.equal(normalizeProcessInstanceState("Terminated"), "TERMINATED");
|
|
273
|
+
assert.equal(normalizeProcessInstanceState("SUSPENDED"), undefined);
|
|
274
|
+
assert.equal(normalizeProcessInstanceState(42), undefined);
|
|
275
|
+
assert.equal(normalizeProcessInstanceState(undefined), undefined);
|
|
276
|
+
});
|
|
277
|
+
|
|
205
278
|
test("registerWorker creates a worker the SDK auto-starts, and dispatches + completes", async () => {
|
|
206
279
|
const client = fakeSdkClient();
|
|
207
280
|
const engine = new SdkEngineClient(client);
|