@nanobpm/urban 0.27.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.
Files changed (54) hide show
  1. package/dist/runtime/adapters/deno.js +5 -0
  2. package/dist/runtime/adapters/node.js +5 -0
  3. package/dist/runtime/core/execContext.d.ts +28 -0
  4. package/dist/runtime/core/execContext.js +39 -0
  5. package/dist/runtime/core/host.d.ts +41 -0
  6. package/dist/runtime/core/manifest.d.ts +1 -1
  7. package/dist/runtime/core/modules/connectors.js +3 -1
  8. package/dist/runtime/core/modules/datasource.d.ts +18 -1
  9. package/dist/runtime/core/modules/datasource.js +56 -2
  10. package/dist/runtime/core/modules/gateway.d.ts +10 -3
  11. package/dist/runtime/core/modules/gateway.js +23 -6
  12. package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
  13. package/dist/runtime/core/modules/instance-tracking.js +141 -0
  14. package/dist/runtime/core/modules/pages.d.ts +6 -0
  15. package/dist/runtime/core/modules/pages.js +167 -13
  16. package/dist/runtime/core/modules/scheduler.d.ts +11 -0
  17. package/dist/runtime/core/modules/scheduler.js +21 -0
  18. package/dist/runtime/core/modules/triggers.d.ts +2 -8
  19. package/dist/runtime/core/modules/triggers.js +1 -16
  20. package/dist/runtime/core/modules/workers.js +3 -2
  21. package/dist/runtime/core/runtime.d.ts +3 -0
  22. package/dist/runtime/core/runtime.js +12 -0
  23. package/dist/runtime/core/validate.js +38 -0
  24. package/dist/runtime/engine/nanosdk.d.ts +14 -1
  25. package/dist/runtime/engine/nanosdk.js +49 -0
  26. package/package.json +2 -2
  27. package/src/runtime/adapters/deno.ts +6 -0
  28. package/src/runtime/adapters/node.ts +6 -0
  29. package/src/runtime/core/execContext.ts +56 -0
  30. package/src/runtime/core/host.ts +44 -0
  31. package/src/runtime/core/manifest.ts +1 -0
  32. package/src/runtime/core/modules/actions.test.ts +1 -0
  33. package/src/runtime/core/modules/connectors.test.ts +1 -0
  34. package/src/runtime/core/modules/connectors.ts +7 -1
  35. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  36. package/src/runtime/core/modules/datasource.ts +72 -2
  37. package/src/runtime/core/modules/gateway.ts +28 -6
  38. package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
  39. package/src/runtime/core/modules/instance-tracking.ts +158 -0
  40. package/src/runtime/core/modules/pages.test.ts +77 -2
  41. package/src/runtime/core/modules/pages.ts +171 -13
  42. package/src/runtime/core/modules/provenance.test.ts +181 -0
  43. package/src/runtime/core/modules/scheduler.ts +31 -0
  44. package/src/runtime/core/modules/surfaces.test.ts +2 -0
  45. package/src/runtime/core/modules/triggers.test.ts +1 -0
  46. package/src/runtime/core/modules/triggers.ts +3 -25
  47. package/src/runtime/core/modules/workers.test.ts +1 -0
  48. package/src/runtime/core/modules/workers.ts +11 -2
  49. package/src/runtime/core/runtime.test.ts +3 -0
  50. package/src/runtime/core/runtime.ts +16 -0
  51. package/src/runtime/core/validate.test.ts +96 -0
  52. package/src/runtime/core/validate.ts +39 -0
  53. package/src/runtime/engine/nanosdk.test.ts +73 -0
  54. package/src/runtime/engine/nanosdk.ts +63 -0
@@ -43,6 +43,12 @@ export interface PagesDeps {
43
43
  engine: EngineClient;
44
44
  /** Read a page file; injectable for tests. */
45
45
  readPage(path: string): Promise<string>;
46
+ /**
47
+ * List the available page ids (the `*.page.json` basenames under `pagesDir`),
48
+ * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
49
+ * Optional; when absent the index is empty. Injectable for tests.
50
+ */
51
+ listPages?(): Promise<string[]>;
46
52
  }
47
53
 
48
54
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
@@ -64,7 +70,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
64
70
  const rowLimitRaw = opts.rowLimit ?? 200;
65
71
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
66
72
  const sourceName = opts.sourceName ?? "app";
67
- const { db, engine, readPage } = deps;
73
+ const { db, engine, readPage, listPages } = deps;
68
74
 
69
75
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
70
76
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -113,6 +119,34 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
113
119
  handler: () => javascript(RENDERER_JS),
114
120
  });
115
121
 
122
+ // ── GET /app/pages (index of available pages) ───────────────────────────
123
+ // Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
124
+ // page here, and it lets any client enumerate the app's screens. Each entry
125
+ // carries the page's own `title` (best-effort — an unreadable/!JSON page still
126
+ // appears by id) and a `home` flag. Registered as an exact path so it never
127
+ // shadows the `/app/pages/<id>` prefix route below.
128
+ routes.push({
129
+ method: "GET",
130
+ path: "/app/pages",
131
+ source: "surface:pages",
132
+ handler: async () => {
133
+ const ids = listPages ? await listPages().catch(() => []) : [];
134
+ const pages: { id: string; title: string; home: boolean }[] = [];
135
+ for (const id of ids) {
136
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) continue;
137
+ let title = id;
138
+ try {
139
+ const doc = JSON.parse(await readPage(`${pagesDir}/${id}.page.json`));
140
+ if (isRecord(doc) && typeof doc.title === "string" && doc.title) title = doc.title;
141
+ } catch {
142
+ // Unreadable or non-JSON page: still list it by id.
143
+ }
144
+ pages.push({ id, title, home: id === homePage });
145
+ }
146
+ return json({ pages, home: homePage });
147
+ },
148
+ });
149
+
116
150
  // ── GET /app/pages/<id> ─────────────────────────────────────────────────
117
151
  routes.push({
118
152
  method: "GET",
@@ -324,6 +358,13 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
324
358
  db: app.data.open(sourceName),
325
359
  engine: app.engine,
326
360
  readPage: (p) => ctx.host.readTextFile(p),
361
+ listPages: async () => {
362
+ const dir = opts.pagesDir ?? "pages";
363
+ const names = await ctx.host.listDir(dir).catch(() => []);
364
+ return names
365
+ .filter((n) => n.endsWith(".page.json"))
366
+ .map((n) => n.slice(0, -".page.json".length));
367
+ },
327
368
  });
328
369
  ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
329
370
  return {
@@ -430,6 +471,22 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
430
471
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
431
472
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
432
473
  .pc-card-body[hidden] { display:none; }
474
+ /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
475
+ .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
476
+ .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
477
+ .pc-main-col { flex:1 1 auto; min-width:0; }
478
+ .pc-nav-title { font-weight:650; }
479
+ .pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
480
+ .pc-bar .pc-nav-title { margin-right:.5rem; }
481
+ .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
482
+ .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
483
+ .pc-rail .pc-nav-title { margin-bottom:.25rem; padding:0 .7rem; }
484
+ .pc-rail .pc-nav-items { display:flex; flex-direction:column; gap:.15rem; }
485
+ .pc-nav-link { display:flex; align-items:center; gap:.4rem; padding:.4rem .7rem; border-radius:.4rem; color:var(--nano-text-muted); text-decoration:none; font-size:.9rem; }
486
+ .pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
487
+ .pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
488
+ .pc-nav-icon { line-height:1; }
489
+ .pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
433
490
  `;
434
491
 
435
492
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
@@ -440,6 +497,37 @@ const RENDERER_JS = String.raw`
440
497
  const root = document.getElementById("page");
441
498
  const HOME = root.dataset.home || "home";
442
499
 
500
+ // ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
501
+ // Pages are selected by the URL fragment (#/<page>) so navigation never hits
502
+ // the server and works identically at the origin root and under the Nano
503
+ // console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
504
+ // sent upstream). An empty/invalid hash falls back to the home page. Only a
505
+ // safe id charset is accepted so the fragment can't smuggle a path/URL.
506
+ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
507
+ function safePageId(value) {
508
+ return typeof value === "string" && PAGE_ID.test(value);
509
+ }
510
+ function currentPage() {
511
+ try {
512
+ const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
513
+ return safePageId(raw) ? raw : HOME;
514
+ } catch (e) {
515
+ return HOME;
516
+ }
517
+ }
518
+ let CURRENT = currentPage();
519
+
520
+ // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
521
+ // listener here; navigating to another page runs every disposer before the new
522
+ // page renders, so a switched-away grid stops polling (otherwise every visited
523
+ // page would leave a live setInterval fetching forever).
524
+ const disposers = [];
525
+ function teardown() {
526
+ while (disposers.length) {
527
+ try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
528
+ }
529
+ }
530
+
443
531
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
444
532
  // same-origin iframe, so the console's --nano-* custom properties and its
445
533
  // data-appearance never cascade in. Instead the console postMessages its
@@ -888,15 +976,71 @@ function renderDataGrid(node) {
888
976
  }
889
977
  }
890
978
  document.addEventListener("pc:refresh", refresh);
979
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
891
980
  // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
892
981
  // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
893
- // answer and the detail input keeps focus across ticks.
894
- if (p.refreshMs && p.refreshMs > 0) setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
982
+ // answer and the detail input keeps focus across ticks. The interval is registered as a
983
+ // disposer so a page switch (hashchange → renderPage → teardown) stops it.
984
+ if (p.refreshMs && p.refreshMs > 0) {
985
+ const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
986
+ disposers.push(() => clearInterval(timer));
987
+ }
895
988
  refresh();
896
989
  return card;
897
990
  }
898
991
 
899
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
992
+ // A navigation node — a horizontal menu bar (variant "bar", default) or a
993
+ // vertical side rail (variant "rail"). Each item links either to another page
994
+ // ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
995
+ // current page) or to an external URL ({ label, href } -> a hardened new-tab
996
+ // link; only http(s) is honoured). With items:"auto" (or omitted) the nav
997
+ // lists every page from the /app/pages index, using each page's title.
998
+ function navLink(item) {
999
+ const isPage = safePageId(item.page);
1000
+ const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
1001
+ if (!isPage && !isExt) return null;
1002
+ const attrs = { class: "pc-nav-link" };
1003
+ if (isPage) {
1004
+ attrs.href = "#/" + encodeURIComponent(item.page);
1005
+ if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
1006
+ } else {
1007
+ attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
1008
+ }
1009
+ const kids = [];
1010
+ if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
1011
+ kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
1012
+ return el("a", attrs, ...kids);
1013
+ }
1014
+ function fillNav(list, items) {
1015
+ list.replaceChildren();
1016
+ let n = 0;
1017
+ for (const item of items || []) {
1018
+ if (!item || typeof item !== "object") continue;
1019
+ const a = navLink(item);
1020
+ if (a) { list.append(a); n++; }
1021
+ }
1022
+ if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
1023
+ }
1024
+ function renderNav(node) {
1025
+ const p = node.props || {};
1026
+ const rail = p.variant === "rail";
1027
+ const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
1028
+ if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1029
+ const list = el("div", { class: "pc-nav-items" });
1030
+ nav.append(list);
1031
+ const items = p.items;
1032
+ if (Array.isArray(items)) {
1033
+ fillNav(list, items);
1034
+ } else {
1035
+ // "auto" (or unspecified): enumerate every page and link to each.
1036
+ getJSON("/app/pages")
1037
+ .then((res) => fillNav(list, (res.pages || []).map((pg) => ({ label: pg.title || pg.id, page: pg.id }))))
1038
+ .catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
1039
+ }
1040
+ return nav;
1041
+ }
1042
+
1043
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
900
1044
 
901
1045
  // Durable per-node UI state. localStorage is keyed by the home page id + node
902
1046
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -951,7 +1095,7 @@ function makeCollapsible(node, card) {
951
1095
  chevron,
952
1096
  el("span", { class: "pc-collapse-title" }, titleText),
953
1097
  );
954
- const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
1098
+ const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
955
1099
  let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
956
1100
  function apply() {
957
1101
  body.hidden = collapsed;
@@ -968,16 +1112,30 @@ function makeCollapsible(node, card) {
968
1112
  return container;
969
1113
  }
970
1114
 
971
- async function main() {
1115
+ // Render the page named by the current hash route. A rail nav is lifted into a
1116
+ // left <aside> and the rest of the nodes flow into a content column; otherwise
1117
+ // (bar nav or none) nodes render in document order. Re-runs on every hashchange,
1118
+ // tearing down the previous page's grid polls first.
1119
+ async function renderPage() {
1120
+ teardown();
1121
+ CURRENT = currentPage();
972
1122
  try {
973
- const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
974
- if (doc.title) document.title = doc.title;
975
- root.replaceChildren(
976
- ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
977
- );
1123
+ const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1124
+ document.title = (doc && doc.title) || "Urban App";
1125
+ const nodes = (doc && doc.nodes) || [];
1126
+ const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
1127
+ const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
1128
+ if (rail) {
1129
+ const col = el("div", { class: "pc-main-col" });
1130
+ for (const b of built) if (b !== rail) col.append(b.node);
1131
+ root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
1132
+ } else {
1133
+ root.replaceChildren(...built.map((b) => b.node));
1134
+ }
978
1135
  } catch (e) {
979
- root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
1136
+ root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
980
1137
  }
981
1138
  }
982
- main();
1139
+ window.addEventListener("hashchange", renderPage);
1140
+ renderPage();
983
1141
  `;
@@ -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) => 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