@nanobpm/urban 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/adapters/deno.js +5 -0
  4. package/dist/runtime/adapters/node.js +5 -0
  5. package/dist/runtime/core/execContext.d.ts +28 -0
  6. package/dist/runtime/core/execContext.js +39 -0
  7. package/dist/runtime/core/host.d.ts +41 -0
  8. package/dist/runtime/core/manifest.d.ts +1 -1
  9. package/dist/runtime/core/modules/api.d.ts +61 -0
  10. package/dist/runtime/core/modules/api.js +359 -0
  11. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  12. package/dist/runtime/core/modules/cancel.js +90 -0
  13. package/dist/runtime/core/modules/connectors.js +3 -1
  14. package/dist/runtime/core/modules/datasource.d.ts +23 -3
  15. package/dist/runtime/core/modules/datasource.js +61 -6
  16. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  17. package/dist/runtime/core/modules/gateway.js +23 -6
  18. package/dist/runtime/core/modules/instance-tracking.d.ts +22 -0
  19. package/dist/runtime/core/modules/instance-tracking.js +158 -0
  20. package/dist/runtime/core/modules/pages.d.ts +8 -0
  21. package/dist/runtime/core/modules/pages.js +17 -1
  22. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  23. package/dist/runtime/core/modules/scheduler.js +21 -0
  24. package/dist/runtime/core/modules/surfaces.js +9 -0
  25. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  26. package/dist/runtime/core/modules/triggers.js +1 -16
  27. package/dist/runtime/core/modules/workers.js +3 -2
  28. package/dist/runtime/core/runtime.d.ts +3 -0
  29. package/dist/runtime/core/runtime.js +12 -0
  30. package/dist/runtime/core/validate.js +38 -0
  31. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  32. package/dist/runtime/engine/nanosdk.js +90 -7
  33. package/dist/runtime/index.d.ts +4 -0
  34. package/dist/runtime/index.js +2 -0
  35. package/dist/toolkit/artifact.d.ts +5 -0
  36. package/dist/toolkit/artifact.js +7 -0
  37. package/dist/toolkit/derivers/api.d.ts +19 -0
  38. package/dist/toolkit/derivers/api.js +224 -0
  39. package/dist/toolkit/gen.js +23 -4
  40. package/dist/toolkit/index.d.ts +5 -0
  41. package/dist/toolkit/index.js +12 -1
  42. package/package.json +2 -2
  43. package/src/openapi/spec.test.ts +292 -0
  44. package/src/openapi/spec.ts +540 -0
  45. package/src/runtime/adapters/deno.ts +6 -0
  46. package/src/runtime/adapters/node.ts +6 -0
  47. package/src/runtime/core/execContext.ts +56 -0
  48. package/src/runtime/core/host.ts +44 -0
  49. package/src/runtime/core/manifest.ts +1 -0
  50. package/src/runtime/core/modules/actions.test.ts +1 -0
  51. package/src/runtime/core/modules/api.test.ts +445 -0
  52. package/src/runtime/core/modules/api.ts +453 -0
  53. package/src/runtime/core/modules/cancel.test.ts +310 -0
  54. package/src/runtime/core/modules/cancel.ts +119 -0
  55. package/src/runtime/core/modules/connectors.test.ts +1 -0
  56. package/src/runtime/core/modules/connectors.ts +7 -1
  57. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  58. package/src/runtime/core/modules/datasource.ts +77 -6
  59. package/src/runtime/core/modules/gateway.ts +28 -6
  60. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  61. package/src/runtime/core/modules/instance-tracking.ts +180 -0
  62. package/src/runtime/core/modules/pages.test.ts +47 -2
  63. package/src/runtime/core/modules/pages.ts +23 -1
  64. package/src/runtime/core/modules/provenance.test.ts +181 -0
  65. package/src/runtime/core/modules/scheduler.ts +31 -0
  66. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  67. package/src/runtime/core/modules/surfaces.ts +10 -0
  68. package/src/runtime/core/modules/triggers.test.ts +1 -0
  69. package/src/runtime/core/modules/triggers.ts +3 -25
  70. package/src/runtime/core/modules/workers.test.ts +1 -0
  71. package/src/runtime/core/modules/workers.ts +11 -2
  72. package/src/runtime/core/runtime.test.ts +3 -0
  73. package/src/runtime/core/runtime.ts +16 -0
  74. package/src/runtime/core/validate.test.ts +96 -0
  75. package/src/runtime/core/validate.ts +39 -0
  76. package/src/runtime/engine/nanosdk.test.ts +118 -5
  77. package/src/runtime/engine/nanosdk.ts +102 -7
  78. package/src/runtime/index.ts +12 -0
  79. package/src/toolkit/artifact.ts +8 -0
  80. package/src/toolkit/derivers/api.test.ts +219 -0
  81. package/src/toolkit/derivers/api.ts +255 -0
  82. package/src/toolkit/gen.test.ts +39 -0
  83. package/src/toolkit/gen.ts +24 -4
  84. package/src/toolkit/index.ts +41 -1
@@ -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
  };
@@ -6,6 +6,7 @@
6
6
  import type { AppApi, RuntimeContext } from "../context.ts";
7
7
  import { html, json, normalizeRoutePath, type Route } from "../router.ts";
8
8
  import { mountActions } from "./actions.ts";
9
+ import { mountApi } from "./api.ts";
9
10
  import { mountPages } from "./pages.ts";
10
11
 
11
12
  /** Escape HTML-significant characters before embedding a value in markup. */
@@ -109,6 +110,15 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
109
110
  enabled.push(`actions(${actions.routes.length})`);
110
111
  }
111
112
 
113
+ // The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
114
+ // still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
115
+ // namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
116
+ const api = mountApi(ctx, app);
117
+ if (api.routes.length > 0) {
118
+ routes.push(...api.routes);
119
+ enabled.push("api");
120
+ }
121
+
112
122
  // The pages surface (the schema-driven page runtime) mounts its own routes.
113
123
  const pages = mountPages(ctx, app);
114
124
  if (pages.routes.length > 0) {
@@ -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) => runLlmJob(job.variables, binding, rt);
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) => handler(job, app);
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