@nanobpm/urban 0.80.0 → 0.81.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.
- package/dist/runtime/core/manifest.d.ts +44 -11
- package/dist/runtime/core/modules/cancel.d.ts +13 -8
- package/dist/runtime/core/modules/cancel.js +40 -21
- package/dist/runtime/core/modules/datasource.js +7 -4
- package/dist/runtime/core/modules/gateway.js +5 -4
- package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
- package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
- package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
- package/dist/runtime/core/modules/instance-tracking.js +451 -123
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
- package/dist/runtime/core/modules/pages.js +6 -4
- package/dist/runtime/core/read-model.d.ts +28 -0
- package/dist/runtime/core/read-model.js +44 -4
- package/dist/runtime/core/validate.js +192 -0
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +44 -11
- package/src/runtime/core/modules/cancel.test.ts +112 -38
- package/src/runtime/core/modules/cancel.ts +42 -22
- package/src/runtime/core/modules/datasource.ts +7 -4
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
- package/src/runtime/core/modules/instance-tracking.ts +469 -126
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
- package/src/runtime/core/modules/pages.ts +6 -5
- package/src/runtime/core/read-model.test.ts +69 -0
- package/src/runtime/core/read-model.ts +43 -4
- package/src/runtime/core/validate.test.ts +341 -0
- package/src/runtime/core/validate.ts +188 -0
- package/src/runtime/index.ts +15 -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
|
|
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
|
|
9
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
/**
|
|
76
|
-
* — the wait-on-human twin of {@link reconcileTerminatedKey} (issue #355).
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
*
|
|
114
|
-
* wait-on-human edge (`onWaitingHuman`, issue #355),
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
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;
|
|
125
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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 +=
|
|
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
|
-
//
|
|
182
|
-
// it has an open user task — the authoritative engine truth — so
|
|
183
|
-
// bare `searchUserTasks`, which lags a completion and would latch
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
208
|
-
* the
|
|
209
|
-
*
|
|
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`, {
|