@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
@@ -0,0 +1,345 @@
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 type { Table } from "./gateway.ts";
9
+ import { DataLayer, type ProvisionedSource } from "./datasource.ts";
10
+ import type { AppApi } from "../context.ts";
11
+ import type { EngineClient, ProcessInstanceSnapshot } from "../host.ts";
12
+ import type { InstanceTracking } from "../manifest.ts";
13
+ import {
14
+ DEFAULT_INSTANCE_TRACKING_POLL_MS,
15
+ mountInstanceTracking,
16
+ } from "./instance-tracking.ts";
17
+ import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
18
+
19
+ // A real-timer flush drains the entire pending microtask chain (find → engine → update)
20
+ // each time a fake timer fires — deeper than a fixed number of `await Promise.resolve()`s.
21
+ const realSetTimeout = globalThis.setTimeout;
22
+ const flush = () => new Promise<void>((r) => realSetTimeout(r, 0));
23
+
24
+ // A deterministic timer + clock seam (mirrors triggers.test.ts): `advance` fires every due
25
+ // timer in order, draining microtasks between fires so the async reconcile completes.
26
+ function fakeScheduler(): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
27
+ let clock = 0;
28
+ let seq = 0;
29
+ const timers = new Map<number, { at: number; fn: () => void }>();
30
+ return {
31
+ now: () => clock,
32
+ setTimer: (fn, delayMs) => {
33
+ const id = ++seq;
34
+ timers.set(id, { at: clock + delayMs, fn });
35
+ return id;
36
+ },
37
+ clearTimer: (h) => {
38
+ if (typeof h === "number") timers.delete(h);
39
+ },
40
+ pending: () => timers.size,
41
+ advance: async (ms) => {
42
+ const target = clock + ms;
43
+ // deno-lint-ignore no-constant-condition
44
+ while (true) {
45
+ let nextId = -1;
46
+ let nextAt = Infinity;
47
+ for (const [id, t] of timers) {
48
+ if (t.at <= target && t.at < nextAt) {
49
+ nextAt = t.at;
50
+ nextId = id;
51
+ }
52
+ }
53
+ if (nextId < 0) break;
54
+ const t = timers.get(nextId)!;
55
+ timers.delete(nextId);
56
+ clock = t.at;
57
+ t.fn();
58
+ await flush();
59
+ }
60
+ clock = target;
61
+ },
62
+ };
63
+ }
64
+
65
+ /** An EngineClient whose only live method is searchProcessInstances, driven by a fixed map of
66
+ * processInstanceKey → state. Records the keys asked for on each call. */
67
+ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
68
+ engine: EngineClient;
69
+ queries: string[][];
70
+ } {
71
+ const queries: string[][] = [];
72
+ const notUsed = (m: string) => (): never => {
73
+ throw new Error(`fakeEngine.${m} is not exercised by this test`);
74
+ };
75
+ // Typed as EngineClient (not `as unknown as`) so the compiler enforces the full contract:
76
+ // if EngineClient gains/changes a method, this fake fails to compile instead of drifting.
77
+ const engine: EngineClient = {
78
+ async searchProcessInstances(filter?: {
79
+ processInstanceKeys?: string[];
80
+ state?: ProcessInstanceSnapshot["state"];
81
+ }): Promise<ProcessInstanceSnapshot[]> {
82
+ const keys = filter?.processInstanceKeys ?? [];
83
+ queries.push([...keys]);
84
+ const out: ProcessInstanceSnapshot[] = [];
85
+ for (const key of keys) {
86
+ const state = states[key];
87
+ if (state && (!filter?.state || filter.state === state)) {
88
+ out.push({ processInstanceKey: key, state });
89
+ }
90
+ }
91
+ return out;
92
+ },
93
+ deployResources: notUsed("deployResources"),
94
+ createInstance: notUsed("createInstance"),
95
+ cancelInstance: notUsed("cancelInstance"),
96
+ publishMessage: notUsed("publishMessage"),
97
+ searchUserTasks: notUsed("searchUserTasks"),
98
+ completeUserTask: notUsed("completeUserTask"),
99
+ registerWorker: notUsed("registerWorker"),
100
+ close: notUsed("close"),
101
+ };
102
+ return { engine, queries };
103
+ }
104
+
105
+ interface Harness {
106
+ api: AppApi;
107
+ table: Table<PlanRow>;
108
+ dir: string;
109
+ close: () => Promise<void>;
110
+ logs: { level: string; msg: string }[];
111
+ }
112
+
113
+ // The row shape every test seeds (matches PLANS_DDL); typing the harness table with it lets
114
+ // `table.get(...)` return a typed row instead of the generic `object` constraint that
115
+ // `ReturnType<DataLayer["table"]>` collapses to.
116
+ interface PlanRow {
117
+ plan_key: string;
118
+ process_key: string | null;
119
+ status: string;
120
+ note: string | null;
121
+ }
122
+
123
+ async function withHarness(
124
+ engine: EngineClient,
125
+ ddl: string,
126
+ seed: (t: Table<PlanRow>) => Promise<void>,
127
+ keyField = "process_key",
128
+ table = "plans",
129
+ ): Promise<Harness> {
130
+ const dir = await mkdtemp(join(tmpdir(), "urban-instance-tracking-"));
131
+ const host = createNodeHost({ cwd: dir, log: () => {} });
132
+ const db = host.openSqlite(join(dir, "test.db"));
133
+ db.exec(ddl);
134
+ const source: ProvisionedSource = {
135
+ name: "app",
136
+ driver: "sqlite",
137
+ db,
138
+ source: makeGateway(db),
139
+ migrationsApplied: [],
140
+ close: () => db.close(),
141
+ };
142
+ const data = new DataLayer(new Map([["app", source]]), "app", {});
143
+ const tbl = data.table<PlanRow>(table, keyField);
144
+ await seed(tbl);
145
+ const logs: { level: string; msg: string }[] = [];
146
+ const api: AppApi = {
147
+ manifest: { schemaVersion: 1, id: "app", name: "App" },
148
+ data,
149
+ engine,
150
+ env: () => undefined,
151
+ log: (level, msg) => logs.push({ level, msg }),
152
+ };
153
+ return {
154
+ api,
155
+ table: tbl,
156
+ dir,
157
+ logs,
158
+ close: async () => {
159
+ db.close();
160
+ await rm(dir, { recursive: true, force: true });
161
+ },
162
+ };
163
+ }
164
+
165
+ const PLANS_DDL =
166
+ "CREATE TABLE plans (plan_key TEXT PRIMARY KEY, process_key TEXT, status TEXT NOT NULL, note TEXT)";
167
+
168
+ const planBinding = (over: Partial<InstanceTracking> = {}): InstanceTracking => ({
169
+ table: "plans",
170
+ keyField: "process_key",
171
+ statusField: "status",
172
+ activeStatuses: ["planning", "dispatched"],
173
+ onTerminated: { set: { status: "abandoned", note: null } },
174
+ pollMs: 1000,
175
+ ...over,
176
+ });
177
+
178
+ test("reconciles an active row whose instance is TERMINATED", async () => {
179
+ const { engine } = fakeEngine({ pi1: "TERMINATED" });
180
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
181
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
182
+ });
183
+ const sched = fakeScheduler();
184
+ const handle = mount(h, [planBinding()], sched);
185
+ await sched.advance(1000);
186
+ const row = await h.table.get("pi1");
187
+ assert.equal(row?.status, "abandoned");
188
+ assert.equal(row?.note, null);
189
+ await handle.stop();
190
+ await h.close();
191
+ });
192
+
193
+ test("leaves an active row whose instance is still ACTIVE untouched", async () => {
194
+ const { engine } = fakeEngine({ pi1: "ACTIVE" });
195
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
196
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
197
+ });
198
+ const sched = fakeScheduler();
199
+ const handle = mount(h, [planBinding()], sched);
200
+ await sched.advance(1000);
201
+ const row = await h.table.get("pi1");
202
+ assert.equal(row?.status, "dispatched");
203
+ await handle.stop();
204
+ await h.close();
205
+ });
206
+
207
+ test("does NOT reconcile a COMPLETED instance (a finalize worker owns that row)", async () => {
208
+ const { engine, queries } = fakeEngine({ pi1: "COMPLETED" });
209
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
210
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: "hi" });
211
+ });
212
+ const sched = fakeScheduler();
213
+ const handle = mount(h, [planBinding()], sched);
214
+ await sched.advance(1000);
215
+ const row = await h.table.get("pi1");
216
+ assert.equal(row?.status, "dispatched"); // untouched
217
+ // and it did ask the engine, filtered to TERMINATED
218
+ assert.deepEqual(queries, [["pi1"]]);
219
+ await handle.stop();
220
+ await h.close();
221
+ });
222
+
223
+ test("only polls rows in an active status (skips already-terminal rows)", async () => {
224
+ const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
225
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
226
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
227
+ await t.insert({ plan_key: "p2", process_key: "pi2", status: "abandoned", note: null }); // terminal
228
+ });
229
+ const sched = fakeScheduler();
230
+ const handle = mount(h, [planBinding()], sched);
231
+ await sched.advance(1000);
232
+ assert.deepEqual(queries, [["pi1"]]); // pi2 was already terminal, never polled
233
+ await handle.stop();
234
+ await h.close();
235
+ });
236
+
237
+ test("skips rows whose key column is null/empty", async () => {
238
+ const { engine, queries } = fakeEngine({});
239
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
240
+ await t.insert({ plan_key: "p1", process_key: null, status: "dispatched", note: null });
241
+ await t.insert({ plan_key: "p2", process_key: "", status: "dispatched", note: null });
242
+ });
243
+ const sched = fakeScheduler();
244
+ const handle = mount(h, [planBinding()], sched);
245
+ await sched.advance(1000);
246
+ assert.equal(queries.length, 0); // no keys to poll → no engine call
247
+ await handle.stop();
248
+ await h.close();
249
+ });
250
+
251
+ test("re-arms after each tick and stops cleanly", async () => {
252
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
253
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
254
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
255
+ });
256
+ const sched = fakeScheduler();
257
+ const handle = mount(h, [planBinding()], sched);
258
+ await sched.advance(1000);
259
+ await sched.advance(1000);
260
+ await sched.advance(1000);
261
+ assert.equal(queries.length, 3); // polled once per interval
262
+ assert.equal(sched.pending(), 1); // one timer armed for the next tick
263
+ await handle.stop();
264
+ assert.equal(sched.pending(), 0); // stop() cleared it
265
+ await h.close();
266
+ });
267
+
268
+ test("without statusField/activeStatuses, polls every row", async () => {
269
+ const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
270
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
271
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "abandoned", note: null });
272
+ await t.insert({ plan_key: "p2", process_key: "pi2", status: "dispatched", note: null });
273
+ });
274
+ const sched = fakeScheduler();
275
+ const binding = planBinding({ statusField: undefined, activeStatuses: undefined });
276
+ const handle = mount(h, [binding], sched);
277
+ await sched.advance(1000);
278
+ assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]);
279
+ // both got reconciled (already-abandoned p1 is a no-op change, p2 flips)
280
+ assert.equal((await h.table.get("pi2"))?.status, "abandoned");
281
+ await handle.stop();
282
+ await h.close();
283
+ });
284
+
285
+ test("defaults pollMs when the binding omits it", async () => {
286
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
287
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
288
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
289
+ });
290
+ const sched = fakeScheduler();
291
+ const binding = planBinding({ pollMs: undefined });
292
+ const handle = mount(h, [binding], sched);
293
+ await sched.advance(DEFAULT_INSTANCE_TRACKING_POLL_MS - 1);
294
+ assert.equal(queries.length, 0); // not yet
295
+ await sched.advance(1);
296
+ assert.equal(queries.length, 1); // fired at the default interval
297
+ await handle.stop();
298
+ await h.close();
299
+ });
300
+
301
+ test("clamps a pollMs beyond setTimeout's 32-bit range instead of firing immediately", async () => {
302
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
303
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
304
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
305
+ });
306
+ const sched = fakeScheduler();
307
+ // A pollMs past the 32-bit signed range would overflow setTimeout and fire at ~0 (a hot loop).
308
+ const handle = mount(h, [planBinding({ pollMs: MAX_TIMER_DELAY_MS * 3 })], sched);
309
+ await sched.advance(1000);
310
+ assert.equal(queries.length, 0); // did NOT fire immediately …
311
+ await sched.advance(MAX_TIMER_DELAY_MS);
312
+ assert.equal(queries.length, 1); // … it fired at the clamped delay
313
+ await handle.stop();
314
+ await h.close();
315
+ });
316
+
317
+ // A binding mounted without validation (or with a hand-built binding) must not become a
318
+ // 0-delay hot loop when pollMs is non-positive/NaN — it falls back to the default interval.
319
+ for (const bad of [0, -1000, Number.NaN]) {
320
+ test(`falls back to the default interval for a non-positive/NaN pollMs (${bad})`, async () => {
321
+ const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
322
+ const h = await withHarness(engine, PLANS_DDL, async (t) => {
323
+ await t.insert({ plan_key: "p1", process_key: "pi1", status: "dispatched", note: null });
324
+ });
325
+ const sched = fakeScheduler();
326
+ const handle = mount(h, [planBinding({ pollMs: bad })], sched);
327
+ await sched.advance(DEFAULT_INSTANCE_TRACKING_POLL_MS - 1);
328
+ assert.equal(queries.length, 0); // did NOT hot-loop at ~0 …
329
+ await sched.advance(1);
330
+ assert.equal(queries.length, 1); // … it fired at the default interval instead
331
+ await handle.stop();
332
+ await h.close();
333
+ });
334
+ }
335
+
336
+
337
+ function mount(h: Harness, bindings: InstanceTracking[], sched: SchedulerDeps) {
338
+ h.api.manifest.instanceTracking = bindings;
339
+ return mountInstanceTracking(
340
+ // biome-ignore lint/plugin: RuntimeContext test double — mountInstanceTracking reads only manifest/engine/root; host is unused here.
341
+ { manifest: h.api.manifest, host: {} as never, engine: h.api.engine, root: "." },
342
+ h.api,
343
+ sched,
344
+ );
345
+ }
@@ -0,0 +1,158 @@
1
+ // instance-tracking — the process-instance lifecycle reconciler (manifest `instanceTracking`).
2
+ //
3
+ // The problem it closes: an Urban app derives a row's status from the workers that run
4
+ // along a process's *happy path*. When an instance instead reaches a terminal state with
5
+ // no completion job — an operator/`cancelInstance` termination, or a crash — no worker
6
+ // runs, so the read-model row stays "active" forever and any UI derived from it lies.
7
+ //
8
+ // Each `instanceTracking` binding names a datasource table whose rows track an instance by
9
+ // a key column. This module polls the engine for those rows' instances and, when one is
10
+ // `TERMINATED`, applies the binding's `onTerminated.set` patch to the row. It deliberately
11
+ // reconciles only on `TERMINATED` — the state that by definition runs no completion worker
12
+ // — and never on `COMPLETED`, whose terminal row-write the app's own finalize worker owns
13
+ // (reconciling it here would race that worker and could clobber a legitimately-completed
14
+ // row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
15
+
16
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
17
+ import type { InstanceTracking } from "../manifest.ts";
18
+ import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
19
+
20
+ /** Default poll interval when a binding does not set `pollMs`. */
21
+ export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
22
+
23
+ export interface InstanceTrackingHandle extends Mounted {
24
+ /** `table.keyField` labels of the armed bindings, for `inspect()`. */
25
+ readonly bindings: string[];
26
+ }
27
+
28
+ type Row = Record<string, unknown>;
29
+
30
+ /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
31
+ * instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
32
+ * scanned and how many rows were reconciled this tick. */
33
+ async function reconcileOnce(
34
+ api: AppApi,
35
+ binding: InstanceTracking,
36
+ ): Promise<{ scanned: number; reconciled: number }> {
37
+ const table = api.data.table<Row>(binding.table, binding.keyField);
38
+
39
+ // Select the rows worth polling. With a statusField + activeStatuses, only rows in an
40
+ // active status are candidates, so a row already in a terminal status is skipped; without
41
+ // them every row is polled and a terminal instance's row is idempotently re-patched (the
42
+ // engine query returns only TERMINATED instances and the patch is a no-op once applied).
43
+ let candidates: Row[];
44
+ const statusField = binding.statusField;
45
+ if (statusField && binding.activeStatuses && binding.activeStatuses.length > 0) {
46
+ const perStatus = await Promise.all(
47
+ binding.activeStatuses.map((s): Promise<Row[]> => table.find({ [statusField]: s })),
48
+ );
49
+ candidates = perStatus.flat();
50
+ } else {
51
+ candidates = await table.all();
52
+ }
53
+
54
+ // Map each active row to its tracked instance key. A row with no/empty key can't be
55
+ // reconciled (it never started an instance, or the column is unset) — skip it.
56
+ const keyToRows = new Map<string, Row[]>();
57
+ for (const row of candidates) {
58
+ const raw = row[binding.keyField];
59
+ if (raw == null || raw === "") continue;
60
+ const key = String(raw);
61
+ const list = keyToRows.get(key);
62
+ if (list) list.push(row);
63
+ else keyToRows.set(key, [row]);
64
+ }
65
+ const keys = [...keyToRows.keys()];
66
+ if (keys.length === 0) return { scanned: 0, reconciled: 0 };
67
+
68
+ const snapshots = await api.engine.searchProcessInstances({
69
+ processInstanceKeys: keys,
70
+ state: "TERMINATED",
71
+ });
72
+
73
+ const patch: Partial<Row> = binding.onTerminated.set;
74
+ let reconciled = 0;
75
+ for (const snap of snapshots) {
76
+ if (snap.state !== "TERMINATED") continue; // defensive; we asked for TERMINATED only
77
+ if (!keyToRows.has(snap.processInstanceKey)) continue; // not one we were tracking
78
+ const changed = await table.update(snap.processInstanceKey, patch);
79
+ if (changed > 0) {
80
+ reconciled += changed;
81
+ api.log("info", "instanceTracking: reconciled terminated instance", {
82
+ table: binding.table,
83
+ keyField: binding.keyField,
84
+ processInstanceKey: snap.processInstanceKey,
85
+ rowsChanged: changed,
86
+ });
87
+ }
88
+ }
89
+ return { scanned: keys.length, reconciled };
90
+ }
91
+
92
+ /**
93
+ * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
94
+ * the active rows, asks the engine which of their instances are terminated, and applies the
95
+ * declared patch. `stop()` cancels every armed timer.
96
+ */
97
+ export function mountInstanceTracking(
98
+ ctx: RuntimeContext,
99
+ api: AppApi,
100
+ sched: SchedulerDeps = defaultScheduler(),
101
+ ): InstanceTrackingHandle {
102
+ const bindings = ctx.manifest.instanceTracking ?? [];
103
+ const timers = new Set<unknown>();
104
+ const armed: string[] = [];
105
+ let stopped = false;
106
+
107
+ for (const binding of bindings) {
108
+ // `pollMs` feeds a self-rescheduling timer, so a non-number/NaN/zero/negative value would
109
+ // clamp to a 0-delay hot loop. `??` only guards null/undefined; sanitize to a finite positive
110
+ // number and otherwise fall back to the default. (Manifest validation rejects bad values up
111
+ // front; this is belt-and-suspenders for a binding mounted without validation.)
112
+ const rawPoll = binding.pollMs;
113
+ const pollMs = typeof rawPoll === "number" && Number.isFinite(rawPoll) && rawPoll > 0
114
+ ? rawPoll
115
+ : DEFAULT_INSTANCE_TRACKING_POLL_MS;
116
+ // A single setTimeout delay overflows its 32-bit signed range (~24.8 days) and fires
117
+ // immediately — which for a self-rescheduling poll would become a hot loop. Clamp through
118
+ // the one shared constant the cron trigger loop uses (derive-don't-duplicate).
119
+ const delayMs = Math.min(pollMs, MAX_TIMER_DELAY_MS);
120
+ const label = `${binding.table}.${binding.keyField}`;
121
+ armed.push(label);
122
+
123
+ const arm = (): void => {
124
+ if (stopped) return;
125
+ let handle: unknown;
126
+ handle = sched.setTimer(() => {
127
+ timers.delete(handle);
128
+ if (stopped) return;
129
+ void (async () => {
130
+ try {
131
+ await reconcileOnce(api, binding);
132
+ } catch (err) {
133
+ api.log("error", `instanceTracking "${label}": reconcile failed`, {
134
+ error: String(err),
135
+ });
136
+ } finally {
137
+ arm(); // reschedule regardless of outcome (at-least-once, idempotent)
138
+ }
139
+ })();
140
+ }, delayMs);
141
+ timers.add(handle);
142
+ };
143
+ arm();
144
+ }
145
+
146
+ return {
147
+ name: "instanceTracking",
148
+ bindings: armed,
149
+ async stop(): Promise<void> {
150
+ stopped = true;
151
+ for (const h of timers) sched.clearTimer(h);
152
+ timers.clear();
153
+ },
154
+ describe(): Record<string, unknown> {
155
+ return { bindings: armed };
156
+ },
157
+ };
158
+ }
@@ -41,6 +41,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
41
41
  },
42
42
  async searchUserTasks() { return []; },
43
43
  async completeUserTask() {},
44
+ async searchProcessInstances() { return []; },
44
45
  async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
45
46
  async close() {},
46
47
  };
@@ -65,12 +66,14 @@ function build(dbOverrides: Partial<PagesDataSource> = {}) {
65
66
  const db = fakeDb(dbOverrides);
66
67
  const readPage = async (path: string) => {
67
68
  if (path === "pages/home.page.json") return JSON.stringify({ title: "Home", nodes: [] });
69
+ if (path === "pages/epic.page.json") return JSON.stringify({ title: "Epic Coordination", nodes: [] });
68
70
  throw new Error("not found");
69
71
  };
70
72
  const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
71
73
  db,
72
74
  engine,
73
75
  readPage,
76
+ listPages: async () => ["home", "epic"],
74
77
  });
75
78
  const router = makeRouter(routes);
76
79
  return { router, calls };
@@ -212,8 +215,9 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
212
215
  assert.match(js, /if \(!props\.collapsible\) return card;/);
213
216
  // Seed from defaultCollapsed the first time, then from persisted state.
214
217
  assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
215
- // Namespaced, per-node storage key.
216
- assert.match(js, /"pc:collapsed:" \+ HOME \+ ":" \+ \(node\.id/);
218
+ // Namespaced, per-node storage key — keyed by the *current* page id so two
219
+ // pages that reuse a node id don't share collapse state across navigation.
220
+ assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ \(node\.id/);
217
221
  // Persist on toggle.
218
222
  assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
219
223
  // Every storage access is wrapped so a throwing localStorage can't break the UI.
@@ -239,6 +243,77 @@ test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
239
243
  assert.equal(miss.status, 404);
240
244
  });
241
245
 
246
+ test("GET /app/pages indexes the available pages with titles + home flag", async () => {
247
+ const res = await dispatch("GET", "/app/pages");
248
+ assert.equal(res.status, 200);
249
+ const body = JSON.parse(res.body ?? "{}");
250
+ assert.equal(body.home, "home");
251
+ const byId = Object.fromEntries((body.pages ?? []).map((p: { id: string }) => [p.id, p]));
252
+ assert.deepEqual(byId.home, { id: "home", title: "Home", home: true });
253
+ assert.deepEqual(byId.epic, { id: "epic", title: "Epic Coordination", home: false });
254
+ // The exact index path must not be shadowed by the /app/pages/<id> prefix route.
255
+ assert.equal((await dispatch("GET", "/app/pages/epic")).status, 200);
256
+ });
257
+
258
+ test("GET /app/pages is empty (not an error) when listing is unavailable", async () => {
259
+ // listPages is optional — without it the index is an empty list, not a 500.
260
+ const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
261
+ db: fakeDb(),
262
+ engine: fakeEngine().engine,
263
+ readPage: async () => "{}",
264
+ });
265
+ const res = await makeRouter(routes)(req("GET", "/app/pages"));
266
+ assert.equal(res.status, 200);
267
+ assert.deepEqual(JSON.parse(res.body ?? "{}"), { pages: [], home: "home" });
268
+ });
269
+
270
+ test("the renderer routes between pages by hash and tears down grid polls", async () => {
271
+ const res = await dispatch("GET", "/app/runtime.js");
272
+ const js = res.body ?? "";
273
+ // A page is chosen by the URL fragment (#/<page>), validated to a safe id, and
274
+ // re-rendered on hashchange — proxy-safe because the hash never hits the server.
275
+ assert.match(js, /const PAGE_ID = \/\^\[A-Za-z0-9_-/);
276
+ assert.match(js, /function safePageId\(value\)/);
277
+ assert.match(js, /function currentPage\(\)/);
278
+ assert.match(js, /try \{\s*const raw = decodeURIComponent/s);
279
+ assert.match(js, /catch \(e\) \{\s*return HOME;/s);
280
+ assert.match(js, /location\.hash/);
281
+ assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
282
+ assert.match(js, /async function renderPage\(\)/);
283
+ // Navigation runs every registered disposer first, so a switched-away grid's
284
+ // poll interval + pc:refresh listener are removed rather than leaking forever.
285
+ assert.match(js, /const disposers = \[\]/);
286
+ assert.match(js, /function teardown\(\)/);
287
+ assert.match(js, /disposers\.push\(\(\) => document\.removeEventListener\("pc:refresh", refresh\)\)/);
288
+ assert.match(js, /disposers\.push\(\(\) => clearInterval\(timer\)\)/);
289
+ });
290
+
291
+ test("the renderer ships a nav node (menu bar / rail) with in-app + external links", async () => {
292
+ const res = await dispatch("GET", "/app/runtime.js");
293
+ const js = res.body ?? "";
294
+ // nav is a first-class node type.
295
+ assert.match(js, /nav: renderNav/);
296
+ assert.match(js, /function renderNav\(node\)/);
297
+ // "rail" variant renders a side rail; anything else is a horizontal bar.
298
+ assert.match(js, /p\.variant === "rail"/);
299
+ assert.match(js, /"pc-nav pc-rail"/);
300
+ assert.match(js, /"pc-nav pc-bar"/);
301
+ // A page item becomes an in-app hash link, active-highlighted on the current page.
302
+ assert.match(js, /const isPage = safePageId\(item\.page\)/);
303
+ assert.match(js, /attrs\.href = "#\/" \+ encodeURIComponent\(item\.page\)/);
304
+ assert.match(js, /item\.page === CURRENT/);
305
+ assert.match(js, /aria-current/);
306
+ // An external item is an http(s)-only, hardened new-tab link.
307
+ assert.match(js, /\^https\?:\\\/\\\//);
308
+ assert.match(js, /attrs\.rel = "noopener noreferrer"/);
309
+ // items:"auto" (or omitted) enumerates every page from the index endpoint.
310
+ assert.match(js, /getJSON\("\/app\/pages"\)/);
311
+ // A rail is lifted into a left <aside>; the rest flow into a content column.
312
+ assert.match(js, /pc-rail-wrap/);
313
+ assert.match(js, /pc-layout/);
314
+ assert.match(js, /pc-main-col/);
315
+ });
316
+
242
317
  test("GET /app/data/<source>/<table> returns rows", async () => {
243
318
  const res = await dispatch("GET", "/app/data/app/orders");
244
319
  assert.equal(res.status, 200);