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