@nanobpm/urban 0.80.0 → 0.82.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 (37) hide show
  1. package/dist/runtime/core/manifest.d.ts +44 -11
  2. package/dist/runtime/core/modules/cancel.d.ts +13 -8
  3. package/dist/runtime/core/modules/cancel.js +40 -21
  4. package/dist/runtime/core/modules/datasource.js +7 -4
  5. package/dist/runtime/core/modules/gateway.js +5 -4
  6. package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
  7. package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
  8. package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
  9. package/dist/runtime/core/modules/instance-tracking.js +451 -123
  10. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
  11. package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
  12. package/dist/runtime/core/modules/pages.js +6 -4
  13. package/dist/runtime/core/modules/workers.js +25 -1
  14. package/dist/runtime/core/read-model.d.ts +296 -5
  15. package/dist/runtime/core/read-model.js +1077 -39
  16. package/dist/runtime/core/validate.js +192 -0
  17. package/dist/runtime/index.d.ts +4 -3
  18. package/dist/runtime/index.js +11 -1
  19. package/package.json +1 -1
  20. package/src/runtime/core/manifest.ts +44 -11
  21. package/src/runtime/core/modules/cancel.test.ts +112 -38
  22. package/src/runtime/core/modules/cancel.ts +42 -22
  23. package/src/runtime/core/modules/datasource.ts +7 -4
  24. package/src/runtime/core/modules/gateway.ts +7 -4
  25. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  26. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  27. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  28. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  29. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  30. package/src/runtime/core/modules/pages.ts +6 -5
  31. package/src/runtime/core/modules/workers.ts +24 -1
  32. package/src/runtime/core/read-model.test.ts +94 -0
  33. package/src/runtime/core/read-model.ts +1356 -45
  34. package/src/runtime/core/rollup.test.ts +818 -0
  35. package/src/runtime/core/validate.test.ts +341 -0
  36. package/src/runtime/core/validate.ts +188 -0
  37. package/src/runtime/index.ts +58 -0
@@ -2,35 +2,42 @@
2
2
  //
3
3
  // The problem it closes: an Urban app derives a row's status from the workers that run
4
4
  // along a process's *happy path*. When an instance instead reaches an engine-truth state with
5
- // no worker to record it, the read-model row lies. Two such edges are reconciled declaratively:
5
+ // no worker to record it, the read-model row lies. Two such edges are derived from engine truth:
6
6
  //
7
7
  // - The **terminal** edge (`onTerminated`): each binding's poll asks the engine which of its
8
- // active instances are `TERMINATED` and applies `onTerminated.set` (→ e.g. `abandoned`). A
9
- // terminated instance runs no completion worker, so nothing else would ever close the row.
8
+ // active instances are `TERMINATED`. A terminated instance runs no completion worker, so nothing
9
+ // else would ever close the row.
10
10
  //
11
11
  // - The **wait-on-human** edge (`onWaitingHuman`, issue #355): the symmetric primitive for
12
12
  // "this instance is parked waiting on a human." That state is authoritatively knowable — an
13
13
  // instance is waiting on a human *iff* it has an open user task (`openUserTasks` =
14
- // `searchUserTasks({state:"CREATED"})`) — for every process, forever, with no per-element
15
- // registry. On each poll, an active, non-terminated row whose instance has any open user task
16
- // has `onWaitingHuman.set` applied (typically `status = "awaiting_operator"`). This retires the
17
- // per-subject imperative pattern (workers writing the status on escalate/answer plus a bespoke
18
- // self-heal poller): the parked status becomes a derived view over the one user-task
19
- // projection, so there is no second surface to drift (No Drift Surfaces).
14
+ // `searchUserTasks({state:"CREATED"})`) — for every process, forever, with no per-element registry.
20
15
  //
21
- // Precedence per active row, all derived from engine truth (never a worker write):
22
- // terminated -> onTerminated.set (e.g. abandoned) // terminal edge
23
- // else has open user task -> onWaitingHuman.set (e.g. awaiting_operator) // wait-on-human edge
24
- // else -> leave the worker-owned transient status (running / converging / …)
16
+ // SOURCE, NOT WRITER (ADR 0065, surface #1; #439-L1 / #318). This reconciler used to *write* those
17
+ // derived edges — `reconcileTerminatedKey` / `reconcileWaitingHumanKey` applied `onTerminated.set` /
18
+ // `onWaitingHuman.set` as `table.update` patches on the app's base row. A stored derived value tears
19
+ // from its inputs (nano-workforce#422: an answered escalation still showed a stale ⚠). The inversion
20
+ // makes the reconciler a SOURCE: each poll FEEDS engine truth into the canonical projections
21
+ // (`urban_instance_state` gets the `TERMINATED` fact; `urban_open_user_tasks` gets the instance's
22
+ // current open-task set), and the two edges are DERIVED — recomputed on every read — by a
23
+ // `defineReadModel` VIEW over those projections (see `instance-status-read-model.ts`). Because the
24
+ // wait-on-human edge is an `EXISTS` over the live open-task projection, retiring an answered task via
25
+ // `OpenUserTasksStore.syncInstance` makes the edge go false on the next read by construction, so the
26
+ // #422 staleness cannot recur.
25
27
  //
26
- // It deliberately reconciles the terminal edge only on `TERMINATED` — the state that by
27
- // definition runs no completion worker — and never on `COMPLETED`, whose terminal row-write the
28
- // app's own finalize worker owns (reconciling it here would race that worker and could clobber a
29
- // legitimately-completed row). The wait-on-human edge reads *open* user tasks via `openUserTasks`
30
- // (never a bare `searchUserTasks`, which lags a completion and would latch `awaiting_operator`
31
- // onto a just-answered task). The poll is driven through the shared `SchedulerDeps` seam so tests
32
- // are deterministic.
28
+ // Precedence (terminated wins over waiting-human) is preserved in the derivation, not in a write
29
+ // order. It deliberately records the terminal source only on `TERMINATED` — the state that by
30
+ // definition runs no completion worker — and never on `COMPLETED`, whose terminal row-write the app's
31
+ // own finalize worker owns. The wait-on-human edge reads *open* user tasks via `openUserTasks` (never
32
+ // a bare `searchUserTasks`, which lags a completion). Real business-outcome writes owned by app workers
33
+ // (e.g. a finalize worker's terminal outcome) are untouched — only the derivable edges are inverted.
34
+ // The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
33
35
  import { isConfiguredStatusSelector } from "../manifest.js";
36
+ import { assertSqlIdentifier, lookupColumn, readModelRegistry } from "../read-model.js";
37
+ import { registerCanonicalProjections } from "./canonical-projections.js";
38
+ import { defineInstanceTrackingReadModel, instanceTrackingReadModelTarget, TERMINATED_STATE } from "./instance-status-read-model.js";
39
+ import { INSTANCE_STATE_PROJECTION, InstanceStateStore } from "./instance-state-store.js";
40
+ import { OpenUserTasksStore } from "./open-user-tasks-store.js";
34
41
  import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
35
42
  /** Default poll interval when a binding does not set `pollMs`. */
36
43
  export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
@@ -40,89 +47,130 @@ export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
40
47
  * Probing in bounded-parallel batches makes wall-clock time scale with max latency rather than
41
48
  * N×latency, while the cap keeps in-flight load on the engine bounded. */
42
49
  export const WAITING_HUMAN_PROBE_CONCURRENCY = 8;
43
- /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
44
- * Only the binding whose table owns that key has a matching row, so callers can fan this over
45
- * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
46
- * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
47
- * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
48
- * instance) route through here, so the two can never drift on the patch or the log. */
49
- export async function reconcileTerminatedKey(api, bindings, processInstanceKey) {
50
- const key = String(processInstanceKey);
51
- let reconciled = 0;
52
- for (const binding of bindings) {
53
- const table = api.data.table(binding.table, binding.keyField);
54
- const changed = await table.update(key, binding.onTerminated.set);
55
- if (changed > 0) {
56
- reconciled += changed;
57
- api.log("info", "instanceTracking: reconciled terminated instance", {
58
- table: binding.table,
59
- keyField: binding.keyField,
60
- processInstanceKey: key,
61
- rowsChanged: changed,
62
- });
63
- }
50
+ /**
51
+ * Construct the canonical projection stores over the app's default data source and ensure their schema
52
+ * (idempotent), exactly the per-source boot path `tryLineageStore` / `tryProvisionCanonicalProjections`
53
+ * use. Absent-safe: with no default source there is nothing to feed, so return `undefined` at `debug`
54
+ * (the expected sidecar-absent case). A genuine provisioning fault (missing named source, schema error)
55
+ * degrades to `undefined` at `warn` rather than breaking the mount — a projection feed must never break
56
+ * a job.
57
+ */
58
+ export function tryInstanceProjections(api) {
59
+ if (!api.data.hasDefaultSource()) {
60
+ api.log("debug", "instanceTracking: no default data source; projection feed disabled");
61
+ return undefined;
62
+ }
63
+ try {
64
+ const { db } = api.data.source();
65
+ const instanceState = new InstanceStateStore(db);
66
+ const openUserTasks = new OpenUserTasksStore(db);
67
+ instanceState.ensureSchema();
68
+ openUserTasks.ensureSchema();
69
+ return { instanceState, openUserTasks };
70
+ }
71
+ catch (err) {
72
+ api.log("warn", "instanceTracking: failed to provision projection stores; feed disabled", {
73
+ error: String(err),
74
+ });
75
+ return undefined;
64
76
  }
65
- return reconciled;
66
77
  }
67
- /** Whether `row` already satisfies `patch` on every column it sets — used to make the
68
- * wait-on-human writer quiet-idempotent. Unlike a terminated row (which flips to a terminal
69
- * status and then drops out of the active set), a row parked at a user task stays active and is
70
- * re-polled every tick, so re-writing/re-logging the same `awaiting_operator` patch on every poll
71
- * would be noise; skip the write when it would be a no-op. */
72
- function patchAlreadyApplied(row, set) {
73
- return Object.entries(set).every(([col, value]) => row[col] === value);
78
+ /** Record engine-truth that an instance is TERMINATED into the canonical `urban_instance_state`
79
+ * projection and retire its open-user-task rows — the writer→source replacement for the old
80
+ * `onTerminated.set` `table.update` patch (ADR 0065; #439-L1 / #318). The derived terminal status edge
81
+ * (a VIEW / function over the projection) reflects this on the next read, so there is no stored-derived
82
+ * column to tear from its inputs (nano-workforce#422). This is the SINGLE place that records the terminal
83
+ * source: both the poll reconciler (`reconcileOnce`, once per TERMINATED key it discovers) and the cancel
84
+ * primitive (`cancel.ts`, immediately when it terminates an instance) route through it, so the two can
85
+ * never drift. Absent-safe (no projections / blank key ⇒ 0) and never throws through — a projection write
86
+ * must never break a job or a cancel. Returns 1 when it recorded the terminal source, else 0. */
87
+ export function reconcileTerminatedKey(api, projections, processInstanceKey) {
88
+ const key = String(processInstanceKey ?? "");
89
+ if (!projections || !key)
90
+ return 0;
91
+ try {
92
+ // Retire the open tasks BEFORE recording TERMINATED so no reader can observe a terminated instance
93
+ // that still looks parked on a human (terminated wins over waiting-human, at the source as in the
94
+ // derivation). Both writes are idempotent.
95
+ projections.openUserTasks.clearInstance(key);
96
+ // `recordState` returns false when the state is unchanged (an already-recorded TERMINATED re-fed on a
97
+ // later tick or by the cancel path). Only log/count a REAL transition: without this, every repeated
98
+ // terminal poll of a settled instance emits an info log and inflates `reconciled` as if it wrote,
99
+ // producing one spurious "recorded terminated instance" line per settled instance per tick.
100
+ const changed = projections.instanceState.recordState(key, TERMINATED_STATE, false);
101
+ if (!changed)
102
+ return 0;
103
+ api.log("info", "instanceTracking: recorded terminated instance", {
104
+ projection: INSTANCE_STATE_PROJECTION,
105
+ processInstanceKey: key,
106
+ });
107
+ return 1;
108
+ }
109
+ catch (err) {
110
+ api.log("warn", "instanceTracking: failed to record terminated instance", {
111
+ processInstanceKey: key,
112
+ error: String(err),
113
+ });
114
+ return 0;
115
+ }
74
116
  }
75
- /** Apply a binding's `onWaitingHuman.set` patch to the single row keyed by `processInstanceKey`
76
- * — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). The caller establishes
77
- * the precondition (the instance is non-terminated and has an open user task); this is the single
78
- * place that writes `onWaitingHuman.set` and logs the reconcile. Bindings without an
79
- * `onWaitingHuman` edge are no-ops, so callers can fan this over every binding. It skips the write
80
- * when the row already carries the patch (quiet-idempotent — a long-parked instance is re-polled
81
- * every tick and must not re-log). Returns rows changed. */
82
- export async function reconcileWaitingHumanKey(api, bindings, processInstanceKey) {
83
- const key = String(processInstanceKey);
84
- let reconciled = 0;
85
- for (const binding of bindings) {
86
- const onWaitingHuman = binding.onWaitingHuman;
87
- if (!onWaitingHuman)
88
- continue;
89
- const table = api.data.table(binding.table, binding.keyField);
90
- // Quiet-idempotence must consider EVERY row sharing this key, not just one. `keyField` need not
91
- // be a unique column (e.g. a read model keyed on a non-PK `process_key`), and `table.update`
92
- // patches ALL matching rows — so a `get()` (LIMIT 1) that happens to hit one already-patched row
93
- // would skip the write and leave the OTHER matching rows unreconciled. Skip only when every
94
- // matching row already carries the patch; otherwise fall through and re-patch them all.
95
- const current = await table.find({ [binding.keyField]: key });
96
- if (current.length > 0 && current.every((row) => patchAlreadyApplied(row, onWaitingHuman.set))) {
97
- continue;
98
- }
99
- const changed = await table.update(key, onWaitingHuman.set);
100
- if (changed > 0) {
101
- reconciled += changed;
102
- api.log("info", "instanceTracking: reconciled instance awaiting operator", {
103
- table: binding.table,
104
- keyField: binding.keyField,
105
- processInstanceKey: key,
106
- rowsChanged: changed,
107
- });
117
+ /** Feed an instance's CURRENT open-user-task set into the canonical `urban_open_user_tasks` projection
118
+ * — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355). `syncInstance` is
119
+ * authoritative-per-instance: it inserts the still-open tasks and RETIRES any previously-projected task
120
+ * that is no longer open, so an answered/closed escalation makes the derived wait-on-human edge go false
121
+ * on the next read by construction (nano-workforce#422 — no stored column to leave stale). Passing an
122
+ * EMPTY `openTasks` clears the instance, which is exactly the answered-escalation transition; callers
123
+ * MUST therefore feed every probed non-terminated key, including those with no open task, or a retired
124
+ * task would linger and the edge would tear. Absent-safe (no projections / blank key ⇒ 0) and never
125
+ * throws through. Returns 1 when it fed the projection, else 0. */
126
+ export function reconcileWaitingHumanKey(api, projections, processInstanceKey, openTasks) {
127
+ const key = String(processInstanceKey ?? "");
128
+ if (!projections || !key)
129
+ return 0;
130
+ try {
131
+ // Re-check the terminal source IMMEDIATELY before the sync. `openTasks` was probed with an `await`
132
+ // that yields the event loop, so a concurrent cancel (or another binding's poll) can record this
133
+ // instance TERMINATED and retire its tasks WHILE the probe is in flight. Terminated wins, so
134
+ // re-inserting the (now stale) open tasks here would strand a row `reconcileTerminatedKey` already
135
+ // cleared — the key is then classified settled and never re-probed, leaving a permanent stale
136
+ // projection row. If it went terminal, ensure the tasks stay retired and skip the sync.
137
+ if (projections.instanceState.getState(key)?.state === TERMINATED_STATE) {
138
+ projections.openUserTasks.clearInstance(key);
139
+ return 0;
108
140
  }
141
+ projections.openUserTasks.syncInstance(key, openTasks);
142
+ return 1;
143
+ }
144
+ catch (err) {
145
+ api.log("warn", "instanceTracking: failed to record open user tasks", {
146
+ processInstanceKey: key,
147
+ error: String(err),
148
+ });
149
+ return 0;
109
150
  }
110
- return reconciled;
111
151
  }
112
- /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
113
- * instances are TERMINATED, and applies `onTerminated.set` to each; then, for the binding's
114
- * wait-on-human edge (`onWaitingHuman`, issue #355), applies `onWaitingHuman.set` to every
115
- * non-terminated active row whose instance has an open user task. Returns how many keys were
116
- * scanned and how many rows were reconciled this tick. */
117
- async function reconcileOnce(api, binding) {
152
+ /** Poll one binding's instances once. Selects the active rows, asks the engine which of their instances
153
+ * are TERMINATED, and FEEDS that terminal fact into the canonical `urban_instance_state` projection;
154
+ * then, for the binding's wait-on-human edge (`onWaitingHuman`, issue #355), FEEDS each non-terminated
155
+ * active row's current open-user-task set into `urban_open_user_tasks`. It writes NO derivable status
156
+ * column on the base row — the two edges are DERIVED from the projections by the managed read-model VIEW
157
+ * (ADR 0065). Returns how many keys were scanned and how many projection sources were fed this tick. */
158
+ async function reconcileOnce(api, binding, projections) {
159
+ // A binding mounted without a default data source (e.g. `mount.data: false`) has no table to poll and
160
+ // no VIEW to derive: `provisionInstanceTrackingViews`/`tryInstanceProjections` already degraded to
161
+ // no-ops at mount. Return the honest zero-count no-op here rather than falling through to
162
+ // `api.data.table(...)`, which THROWS with no default source and would log `reconcile failed` on every
163
+ // interval (the timer is still armed) — noise masquerading as a real failure.
164
+ if (!api.data.hasDefaultSource())
165
+ return { scanned: 0, reconciled: 0 };
118
166
  const table = api.data.table(binding.table, binding.keyField);
119
167
  // Select the rows worth polling. Three modes, in precedence order:
120
168
  // - `terminalStatuses` (fail-open exclusion): poll every row whose `statusField` is NOT
121
169
  // a terminal value. A newly-added non-terminal status is polled by default, so it can't
122
170
  // silently fall out of reconciliation; only already-terminal rows are excluded (bounded).
123
171
  // - `activeStatuses` (fail-closed allow-list): poll only rows in an enumerated active status.
124
- // - neither: poll every row; a terminal instance's row is idempotently re-patched (the engine
125
- // query returns only TERMINATED instances and the patch is a no-op once applied).
172
+ // - neither: poll every row; the engine query returns only TERMINATED instances and the projection
173
+ // feed is idempotent, so re-feeding a settled row is a harmless no-op.
126
174
  // `terminalStatuses` and `activeStatuses` are mutually exclusive per binding (validation rejects
127
175
  // both); if both are somehow present, `terminalStatuses` wins (the fail-open selector). Each
128
176
  // selector is gated through the shared `isConfiguredStatusSelector` predicate (a non-empty array
@@ -134,7 +182,7 @@ async function reconcileOnce(api, binding) {
134
182
  if (statusField && isConfiguredStatusSelector(binding.terminalStatuses)) {
135
183
  const terminal = new Set(binding.terminalStatuses);
136
184
  const all = await table.all();
137
- candidates = all.filter((row) => !terminal.has(String(row[statusField])));
185
+ candidates = all.filter((row) => !terminal.has(String(lookupColumn(row, statusField))));
138
186
  }
139
187
  else if (statusField && isConfiguredStatusSelector(binding.activeStatuses)) {
140
188
  const perStatus = await Promise.all(binding.activeStatuses.map((s) => table.find({ [statusField]: s })));
@@ -144,10 +192,14 @@ async function reconcileOnce(api, binding) {
144
192
  candidates = await table.all();
145
193
  }
146
194
  // Map each active row to its tracked instance key. A row with no/empty key can't be
147
- // reconciled (it never started an instance, or the column is unset) — skip it.
195
+ // reconciled (it never started an instance, or the column is unset) — skip it. Resolve `keyField`
196
+ // with the SAME case-insensitive column lookup the read-model VIEW compiler uses (SQLite folds
197
+ // identifier case, so a binding `keyField: "Process_Key"` over a `process_key` column correlates in
198
+ // the derived VIEW): a plain case-sensitive `row[binding.keyField]` would read `undefined` here and
199
+ // feed NO keys into either projection, so neither terminal nor waiting-human status could derive.
148
200
  const keyToRows = new Map();
149
201
  for (const row of candidates) {
150
- const raw = row[binding.keyField];
202
+ const raw = lookupColumn(row, binding.keyField);
151
203
  if (raw == null || raw === "")
152
204
  continue;
153
205
  const key = String(raw);
@@ -158,15 +210,38 @@ async function reconcileOnce(api, binding) {
158
210
  keyToRows.set(key, [row]);
159
211
  }
160
212
  const keys = [...keyToRows.keys()];
161
- if (keys.length === 0)
162
- return { scanned: 0, reconciled: 0 };
163
- const snapshots = await api.engine.searchProcessInstances({
164
- processInstanceKeys: keys,
165
- state: "TERMINATED",
166
- });
167
- // Apply the terminal patch through the shared `reconcileTerminatedKey` — the one place that
168
- // writes `onTerminated.set` and logs the reconcile — so the poll path and the cancel primitive
169
- // can never drift on the patch or its logging (No Drift Surfaces).
213
+ // NB: do NOT early-return on an empty active set. A key that LEFT the active selector (its task was
214
+ // answered and its worker flipped it to a non-active status) still has a stale open-task projection to
215
+ // retire below — returning early here would strand it, re-tearing the derived edge (ADR 0065). The
216
+ // engine query and human probe both degrade to no-ops on an empty set, so falling through is cheap.
217
+ // Skip keys already recorded TERMINATED in the canonical projection: a settled instance never leaves
218
+ // TERMINATED, and the base row keeps its (unchanged) `statusField`, so with `activeStatuses`/no-selector
219
+ // it would pass the selector on every tick and be re-queried + re-fed forever (the old `table.update`
220
+ // moved it out of the active set; the inversion deliberately does not). The projection is the
221
+ // authoritative settled-set, so exclude those keys from both the engine query and the wait-on-human
222
+ // probe below — a pure efficiency win (the re-feed was already an idempotent no-op).
223
+ // `settled` drives a projection `getState` — a real SQL round trip (SELECT … WHERE
224
+ // process_instance_key = ?) — and is consulted for the same key up to three times per pass (the
225
+ // live-key filter here, then the wait-on-human `humanKeySet` build and its projected-open scan). The
226
+ // settled set cannot change mid-pass except for keys THIS pass terminates, and those are already
227
+ // excluded downstream via `terminatedKeys`, so caching the first result per key is behaviour-preserving
228
+ // and caps the projection at one query per distinct key (bounding a large-backlog pass's SQL fan-out).
229
+ const settledCache = new Map();
230
+ const settled = (key) => {
231
+ const cached = settledCache.get(key);
232
+ if (cached !== undefined)
233
+ return cached;
234
+ const value = projections?.instanceState.getState(key)?.state === TERMINATED_STATE;
235
+ settledCache.set(key, value);
236
+ return value;
237
+ };
238
+ const liveKeys = keys.filter((key) => !settled(key));
239
+ const snapshots = liveKeys.length > 0
240
+ ? await api.engine.searchProcessInstances({ processInstanceKeys: liveKeys, state: "TERMINATED" })
241
+ : [];
242
+ // Feed the terminal fact through the shared `reconcileTerminatedKey` — the one place that records the
243
+ // terminal source into `urban_instance_state` — so the poll path and the cancel primitive can never
244
+ // drift (No Drift Surfaces). No base-row write: the terminal status edge is DERIVED from the projection.
170
245
  let reconciled = 0;
171
246
  const terminatedKeys = new Set();
172
247
  for (const snap of snapshots) {
@@ -175,27 +250,54 @@ async function reconcileOnce(api, binding) {
175
250
  if (!keyToRows.has(snap.processInstanceKey))
176
251
  continue; // not one we were tracking
177
252
  terminatedKeys.add(snap.processInstanceKey);
178
- reconciled += await reconcileTerminatedKey(api, [binding], snap.processInstanceKey);
253
+ reconciled += reconcileTerminatedKey(api, projections, snap.processInstanceKey);
179
254
  }
180
- // Wait-on-human edge (issue #355), symmetric to onTerminated. Precedence: terminated wins, so a
181
- // key reconciled as terminated above is excluded here. An instance is "waiting on a human" iff
182
- // it has an open user task — the authoritative engine truth — so read `openUserTasks` (never a
183
- // bare `searchUserTasks`, which lags a completion and would latch `awaiting_operator` onto a
184
- // just-answered task; issue #355 "instance B"). When there is no open user task the row is left
185
- // untouched, so a worker-owned transient status (running / converging / merging) survives and a
186
- // stale `awaiting_operator` is not re-latched. Because the condition is derived every tick, a
187
- // re-escalation after an answer re-flips to `awaiting_operator` on the next poll by construction
188
- // (issue #355 "instance A"). Probe in bounded-parallel batches (`WAITING_HUMAN_PROBE_CONCURRENCY`)
189
- // so a pass over a large active backlog scales with max latency rather than N×latency.
255
+ // Wait-on-human edge (issue #355), symmetric to onTerminated. Precedence: terminated wins, so a key
256
+ // recorded as terminated above (or already settled) is excluded here (its open tasks were retired). An
257
+ // instance is "waiting on a human" iff it has an open user task — the authoritative engine truth — so
258
+ // read `openUserTasks` (never a bare `searchUserTasks`, which lags a completion and would latch a closed
259
+ // task; issue #355 "instance B") and FEED that current open set into the projection via
260
+ // `reconcileWaitingHumanKey`. Feed EVERY probed key, including those with NO open task: an empty set
261
+ // RETIRES a just-answered task so the derived edge re-flips to non-parked by construction — this is the
262
+ // nano-workforce#422 fix (a stored write would have left `awaiting_operator` stale). Probe in
263
+ // bounded-parallel batches (`WAITING_HUMAN_PROBE_CONCURRENCY`) so a pass over a large active backlog
264
+ // scales with max latency rather than N×latency.
190
265
  if (binding.onWaitingHuman) {
191
- const humanKeys = keys.filter((key) => !terminatedKeys.has(key)); // terminated already won
266
+ const humanKeySet = new Set(keys.filter((key) => !terminatedKeys.has(key) && !settled(key)));
267
+ // Retire stale projections INDEPENDENTLY of the active-status selector. With `activeStatuses`, a key
268
+ // can be projected while its task is open and then move to a non-active worker status when the task is
269
+ // answered — it then never passes the selector again, so its `urban_open_user_tasks` row would linger
270
+ // and the VIEW keep deriving `awaiting_operator` (a fresh #422 tear). Re-probe every key STILL
271
+ // projected as open so an answered/closed one is retired (`syncInstance([])`). The projection is
272
+ // app-wide, but re-syncing a key another binding owns is a harmless idempotent no-op against engine
273
+ // truth, so probing the whole projected set is always safe.
274
+ if (projections) {
275
+ for (const key of projections.openUserTasks.instanceKeys()) {
276
+ if (!terminatedKeys.has(key) && !settled(key))
277
+ humanKeySet.add(key);
278
+ }
279
+ }
280
+ const humanKeys = [...humanKeySet];
192
281
  for (let i = 0; i < humanKeys.length; i += WAITING_HUMAN_PROBE_CONCURRENCY) {
193
282
  const batch = humanKeys.slice(i, i + WAITING_HUMAN_PROBE_CONCURRENCY);
194
283
  const perKey = await Promise.all(batch.map(async (key) => {
195
- const openTasks = await api.engine.openUserTasks({ processInstanceKey: key });
196
- if (openTasks.length === 0)
197
- return 0; // not parked on a human ⇒ leave the transient status
198
- return reconcileWaitingHumanKey(api, [binding], key);
284
+ // Isolate each key's probe: one engine `openUserTasks` rejection must NOT reject the whole
285
+ // `Promise.all` batch. Batches are awaited sequentially, so a single permanently-failing key in
286
+ // an early batch would otherwise starve every key in later batches on every tick, letting their
287
+ // stale open-task projections persist indefinitely. On a per-key failure, log and skip (count 0)
288
+ // — the projection is left as-is and re-probed next pass — so one bad engine response cannot
289
+ // stall the rest of the backlog.
290
+ try {
291
+ const openTasks = await api.engine.openUserTasks({ processInstanceKey: key });
292
+ return reconcileWaitingHumanKey(api, projections, key, openTasks);
293
+ }
294
+ catch (err) {
295
+ api.log("warn", "instanceTracking: openUserTasks probe failed; key skipped this pass", {
296
+ key,
297
+ error: String(err),
298
+ });
299
+ return 0;
300
+ }
199
301
  }));
200
302
  for (const n of perKey)
201
303
  reconciled += n;
@@ -203,16 +305,242 @@ async function reconcileOnce(api, binding) {
203
305
  }
204
306
  return { scanned: keys.length, reconciled };
205
307
  }
308
+ /** Is there a NON-view object (a real table) named `name` in the database? SQLite shares one namespace
309
+ * for tables and views, so a real table shadows a managed VIEW: `DROP VIEW IF EXISTS` won't remove it
310
+ * and `CREATE VIEW IF NOT EXISTS` then silently no-ops. Provisioning must refuse rather than appear to
311
+ * succeed while a page reads the wrong object. The name match is `COLLATE NOCASE`: SQLite resolves
312
+ * object names case-insensitively, so a real table `plans__tracking` shadows a configured view
313
+ * `Plans__tracking` at DROP/CREATE time — a binary `=` here would miss it and defeat this guard. */
314
+ function nonViewObjectExists(db, name) {
315
+ const rows = db.all(`SELECT type FROM sqlite_master WHERE name = ? COLLATE NOCASE AND type <> 'view'`, [name]);
316
+ return rows.length > 0;
317
+ }
318
+ /** Retire (DROP) a previously-managed tracking VIEW by name. Used on every skip path (a binding with no
319
+ * `statusField`, a base-column collision) so a manifest change or a schema drift cannot leave a stale,
320
+ * still-readable derived surface behind. Only ever drops a VIEW (never a real table of the same name),
321
+ * and never throws through — a retire failure must not break the mount. */
322
+ function retireManagedTrackingView(api, db, viewName, table) {
323
+ try {
324
+ db.exec(`DROP VIEW IF EXISTS main."${viewName}";`);
325
+ }
326
+ catch (err) {
327
+ api.log("warn", `instanceTracking: failed to retire stale derived VIEW "${viewName}"`, {
328
+ table,
329
+ error: String(err),
330
+ });
331
+ }
332
+ }
333
+ /** Retire (DROP) every managed tracking VIEW for these bindings, used when the projection feed is
334
+ * unavailable (`tryInstanceProjections` returned undefined because projection-store provisioning
335
+ * failed). Without this, a VIEW an earlier successful boot published is left readable and keeps
336
+ * deriving a value over projection tables this mount has no coherent source for — a silent stale
337
+ * surface. Retiring turns that into an honest "no such object" instead. Absent- and error-safe: with
338
+ * no default data source there is no db and nothing managed to retire, and a retire failure never
339
+ * breaks the mount. Deduplicates by folded VIEW name (a status-bearing + status-less binding on one
340
+ * table share the default name) so we DROP each once. */
341
+ function retireInstanceTrackingViews(api, bindings) {
342
+ if (bindings.length === 0)
343
+ return;
344
+ if (!api.data.hasDefaultSource())
345
+ return;
346
+ let db;
347
+ try {
348
+ db = api.data.source().db;
349
+ }
350
+ catch (err) {
351
+ api.log("warn", "instanceTracking: failed to resolve default source; stale derived VIEWs not retired", {
352
+ error: String(err),
353
+ });
354
+ return;
355
+ }
356
+ const retired = new Set();
357
+ for (const binding of bindings) {
358
+ let viewName;
359
+ try {
360
+ viewName = assertSqlIdentifier("instanceTracking.readModel.view", instanceTrackingReadModelTarget(binding).view);
361
+ }
362
+ catch {
363
+ // A non-identifier view name was never provisionable, so there is nothing managed to retire.
364
+ continue;
365
+ }
366
+ const folded = viewName.toLowerCase();
367
+ if (retired.has(folded))
368
+ continue;
369
+ retired.add(folded);
370
+ retireManagedTrackingView(api, db, viewName, binding.table);
371
+ }
372
+ }
373
+ /** Provision each binding's DERIVED read-model VIEW on the app's default data source (ADR 0065, the
374
+ * writer→source inversion). The VIEW re-exports `base.*` plus a derived effective-status column computed
375
+ * by a `defineReadModel` derivation over the canonical projections, so the operator page reads the
376
+ * derived status with no stored write. Registers the canonical projection NAME→physical-table mapping
377
+ * first (idempotent) so the VIEW's `EXISTS` resolves to the `_urban_*` tables even if the worker mount
378
+ * did not run. Truly MANAGED — DROP+CREATE — so a changed derivation always replaces a stale VIEW body.
379
+ * Absent- and error-safe like lineage: no default source provisions nothing, and a VIEW failure never
380
+ * breaks the mount. A binding with no `statusField` has no derivable status, so no VIEW is provisioned
381
+ * (its projections are still fed). */
382
+ function provisionInstanceTrackingViews(api, bindings) {
383
+ if (bindings.length === 0)
384
+ return;
385
+ // Make the DSL projection names (`urban_instance_state` / `urban_open_user_tasks`) resolve to their
386
+ // physical `_urban_*` tables. Idempotent; safe even when the worker mount already registered them.
387
+ registerCanonicalProjections();
388
+ if (!api.data.hasDefaultSource()) {
389
+ api.log("debug", "instanceTracking: no default data source; derived VIEW provisioning skipped");
390
+ return;
391
+ }
392
+ let db;
393
+ try {
394
+ db = api.data.source().db;
395
+ }
396
+ catch (err) {
397
+ api.log("warn", "instanceTracking: failed to resolve default source; derived VIEWs not provisioned", {
398
+ error: String(err),
399
+ });
400
+ return;
401
+ }
402
+ // View names claimed by a CURRENT status-bearing binding (folded — SQLite folds identifiers). A
403
+ // status-less binding shares its base table's default `<table>__tracking` name with a status-bearing
404
+ // binding on the same table, and its retire path (below) would otherwise DROP the view that binding
405
+ // just created (iteration-order dependent). Validation guarantees no two status-bearing bindings share
406
+ // a view name, so this set is unambiguous. Retirement consults it so it only drops UNCLAIMED names.
407
+ const claimedByStatusful = new Set();
408
+ for (const b of bindings) {
409
+ if (typeof b.statusField !== "string" || b.statusField.length === 0)
410
+ continue;
411
+ claimedByStatusful.add(instanceTrackingReadModelTarget(b).view.toLowerCase());
412
+ }
413
+ for (const binding of bindings) {
414
+ // Resolve the managed VIEW name (config override or `<table>__tracking`) up front — we need it even
415
+ // on the skip paths below to RETIRE a previously-managed VIEW, so a manifest change (statusField
416
+ // dropped) or a collision cannot leave a stale, still-readable derived surface behind.
417
+ const { view: targetView, statusColumn } = instanceTrackingReadModelTarget(binding);
418
+ let viewName;
419
+ try {
420
+ viewName = assertSqlIdentifier("instanceTracking.readModel.view", targetView);
421
+ }
422
+ catch (err) {
423
+ api.log("warn", "instanceTracking: invalid read-model view name; derived VIEW skipped", {
424
+ table: binding.table,
425
+ error: String(err),
426
+ });
427
+ continue;
428
+ }
429
+ let model;
430
+ try {
431
+ model = defineInstanceTrackingReadModel(binding);
432
+ }
433
+ catch (err) {
434
+ api.log("warn", "instanceTracking: invalid read-model config; derived VIEW skipped", {
435
+ table: binding.table,
436
+ error: String(err),
437
+ });
438
+ continue;
439
+ }
440
+ if (!model) {
441
+ // No statusField ⇒ nothing derivable to project. Retire any VIEW an earlier boot managed under
442
+ // this name — UNLESS a current status-bearing binding claims the same name (a status-bearing +
443
+ // status-less binding on one table share the default view name; retiring here would drop the view
444
+ // the owner provisions). Without the retire, dropping `statusField` from a binding would otherwise
445
+ // leave the old derived surface discoverable/readable while the runtime believes it provisioned
446
+ // nothing.
447
+ if (!claimedByStatusful.has(viewName.toLowerCase())) {
448
+ retireManagedTrackingView(api, db, viewName, binding.table);
449
+ }
450
+ continue;
451
+ }
452
+ // Base-column collision guard (ADR 0065): the VIEW selects `base.*` and appends the derived status as
453
+ // `${statusColumn} AS ...`. If the base table ALREADY has a column of that name (the default
454
+ // `derived_status`, or an override that happens to match a real column), SQLite keeps the base column
455
+ // under that name and auto-renames the derived one — provisioning "succeeds" but a page querying
456
+ // `statusColumn` reads the STALE stored value. Detect it and skip (loudly) rather than publish a VIEW
457
+ // that silently defeats the derivation. `readModel.statusColumn === statusField` is already rejected at
458
+ // manifest validation; this catches collisions with ANY other base column, which needs the live schema.
459
+ try {
460
+ const cols = db.all(`PRAGMA table_info("${assertSqlIdentifier("instanceTracking.table", binding.table)}")`);
461
+ const folded = statusColumn.toLowerCase();
462
+ if (cols.some((c) => typeof c.name === "string" && c.name.toLowerCase() === folded)) {
463
+ api.log("warn", `instanceTracking: derived status column "${statusColumn}" collides with an existing "${binding.table}" column; derived VIEW skipped (set readModel.statusColumn to a non-colliding name)`, { table: binding.table, statusColumn });
464
+ // Retire any stale VIEW managed under this name — a collision means we can't publish a CORRECT
465
+ // derivation, and leaving an earlier boot's VIEW in place would keep serving a stale surface.
466
+ retireManagedTrackingView(api, db, viewName, binding.table);
467
+ continue;
468
+ }
469
+ }
470
+ catch (err) {
471
+ api.log("warn", "instanceTracking: failed to introspect base columns; derived VIEW skipped", {
472
+ table: binding.table,
473
+ error: String(err),
474
+ });
475
+ continue;
476
+ }
477
+ // A real (non-view) object of this name shares SQLite's table/view namespace: `DROP VIEW IF EXISTS`
478
+ // won't remove it and `CREATE VIEW IF NOT EXISTS` then silently no-ops, so a page configured for this
479
+ // view would read the wrong object with no derived column while the mount looks successful. Refuse
480
+ // explicitly rather than pretend to provision. (Cross-binding name/table collisions are rejected at
481
+ // manifest validation; this catches an undeclared app table that happens to share the name.)
482
+ try {
483
+ if (nonViewObjectExists(db, viewName)) {
484
+ api.log("warn", `instanceTracking: managed VIEW name "${viewName}" is taken by an existing non-view object; derived VIEW skipped (choose a distinct readModel.view)`, { table: binding.table, view: viewName });
485
+ continue;
486
+ }
487
+ }
488
+ catch (err) {
489
+ api.log("warn", "instanceTracking: failed to inspect existing objects; derived VIEW skipped", {
490
+ table: binding.table,
491
+ error: String(err),
492
+ });
493
+ continue;
494
+ }
495
+ // A registered read model (seam (a): `readModelRegistry`) already OWNS a VIEW of this name —
496
+ // `mountWorkers` provisioned it before this mount. Our unconditional `DROP VIEW … ; CREATE VIEW …`
497
+ // below would silently clobber that registered derivation (and a later worker mount could clobber
498
+ // ours back), with neither side detecting the conflict the registry itself rejects for two registered
499
+ // models. Refuse rather than fight over the name, mirroring the `nonViewObjectExists` guard above.
500
+ if (readModelRegistry.get(viewName)) {
501
+ api.log("warn", `instanceTracking: managed VIEW name "${viewName}" collides with a registered read model; derived VIEW skipped (choose a distinct readModel.view)`, { table: binding.table, view: viewName });
502
+ continue;
503
+ }
504
+ try {
505
+ // Qualify the DROP to `main.` for the same reason `ReadModelRegistry.ensureViews` does: an
506
+ // unqualified DROP resolves a stray TEMP view of the same name first (e.g. leaked from a parity
507
+ // run), leaving the managed `main` view stale.
508
+ db.exec(`DROP VIEW IF EXISTS main."${viewName}";`);
509
+ db.exec(model.viewDdl());
510
+ }
511
+ catch (err) {
512
+ api.log("warn", `instanceTracking: failed to provision derived VIEW "${model.decl.name}"`, {
513
+ error: String(err),
514
+ });
515
+ }
516
+ }
517
+ }
206
518
  /**
207
- * Mount the `instanceTracking` reconciler: arm one poll loop per binding. Each loop selects
208
- * the active rows, asks the engine which of their instances are terminated, and applies the
209
- * declared patch. `stop()` cancels every armed timer.
519
+ * Mount the `instanceTracking` reconciler: provision each binding's derived read-model VIEW, construct
520
+ * the canonical projection stores once, and arm one poll loop per binding. Each loop selects the active
521
+ * rows, asks the engine which of their instances are terminated / parked on a human, and FEEDS that
522
+ * engine truth into the canonical projections (never a base-row write — the status edges are DERIVED;
523
+ * ADR 0065). `stop()` cancels every armed timer.
210
524
  */
211
525
  export function mountInstanceTracking(ctx, api, sched = defaultScheduler()) {
212
526
  const bindings = ctx.manifest.instanceTracking ?? [];
213
527
  const timers = new Set();
214
528
  const armed = [];
215
529
  let stopped = false;
530
+ // Ensure the canonical projection tables (`_urban_*`) exist BEFORE creating the derived VIEWs that
531
+ // SELECT over them: a VIEW built on missing sidecar tables is created fine (SQLite does not validate a
532
+ // view body) but then reads `no such table`, so the mount would look successful while the page fails.
533
+ // Both are absent-safe: with no default data source `projections` is undefined and no VIEW is
534
+ // provisioned, so each poll degrades to a pure engine query that feeds nothing (never throwing). When
535
+ // projection setup FAILS after an earlier boot succeeded (undefined despite a default source), there is
536
+ // nothing coherent to derive over, so instead of leaving a prior boot's VIEW readable over an
537
+ // unavailable/stale projection we RETIRE the managed VIEWs — an honest missing surface, not a silent
538
+ // stale one.
539
+ const projections = tryInstanceProjections(api);
540
+ if (projections)
541
+ provisionInstanceTrackingViews(api, bindings);
542
+ else
543
+ retireInstanceTrackingViews(api, bindings);
216
544
  for (const binding of bindings) {
217
545
  // `pollMs` feeds a self-rescheduling timer, so a non-number/NaN/zero/negative value would
218
546
  // clamp to a 0-delay hot loop. `??` only guards null/undefined; sanitize to a finite positive
@@ -238,7 +566,7 @@ export function mountInstanceTracking(ctx, api, sched = defaultScheduler()) {
238
566
  return;
239
567
  void (async () => {
240
568
  try {
241
- await reconcileOnce(api, binding);
569
+ await reconcileOnce(api, binding, projections);
242
570
  }
243
571
  catch (err) {
244
572
  api.log("error", `instanceTracking "${label}": reconcile failed`, {