@nanobpm/urban 0.79.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.
Files changed (52) 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/formjs.gen.d.ts +1 -0
  6. package/dist/runtime/core/modules/formjs.gen.js +5 -0
  7. package/dist/runtime/core/modules/forms.d.ts +22 -0
  8. package/dist/runtime/core/modules/forms.js +59 -0
  9. package/dist/runtime/core/modules/gateway.js +5 -4
  10. package/dist/runtime/core/modules/instance-status-read-model.d.ts +56 -0
  11. package/dist/runtime/core/modules/instance-status-read-model.js +127 -0
  12. package/dist/runtime/core/modules/instance-tracking.d.ts +47 -19
  13. package/dist/runtime/core/modules/instance-tracking.js +451 -123
  14. package/dist/runtime/core/modules/open-user-tasks-store.d.ts +5 -0
  15. package/dist/runtime/core/modules/open-user-tasks-store.js +8 -0
  16. package/dist/runtime/core/modules/pages.js +42 -4
  17. package/dist/runtime/core/modules/runtime.gen.js +1 -1
  18. package/dist/runtime/core/modules/surfaces.js +18 -124
  19. package/dist/runtime/core/read-model.d.ts +28 -0
  20. package/dist/runtime/core/read-model.js +44 -4
  21. package/dist/runtime/core/validate.js +192 -0
  22. package/dist/runtime/index.d.ts +2 -1
  23. package/dist/runtime/index.js +5 -0
  24. package/package.json +1 -1
  25. package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
  26. package/src/runtime/browser/formjs.browser.js +192 -0
  27. package/src/runtime/browser/formjs.browser.test.ts +136 -0
  28. package/src/runtime/browser/runtime.browser.js +101 -1
  29. package/src/runtime/browser/runtime.engineform.test.ts +158 -0
  30. package/src/runtime/core/manifest.ts +44 -11
  31. package/src/runtime/core/modules/cancel.test.ts +112 -38
  32. package/src/runtime/core/modules/cancel.ts +42 -22
  33. package/src/runtime/core/modules/datasource.ts +7 -4
  34. package/src/runtime/core/modules/formjs.gen.ts +6 -0
  35. package/src/runtime/core/modules/forms.test.ts +122 -0
  36. package/src/runtime/core/modules/forms.ts +67 -0
  37. package/src/runtime/core/modules/gateway.ts +7 -4
  38. package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
  39. package/src/runtime/core/modules/instance-status-read-model.ts +156 -0
  40. package/src/runtime/core/modules/instance-tracking.test.ts +589 -60
  41. package/src/runtime/core/modules/instance-tracking.ts +469 -126
  42. package/src/runtime/core/modules/open-user-tasks-store.test.ts +11 -0
  43. package/src/runtime/core/modules/open-user-tasks-store.ts +11 -0
  44. package/src/runtime/core/modules/pages.test.ts +99 -0
  45. package/src/runtime/core/modules/pages.ts +44 -5
  46. package/src/runtime/core/modules/runtime.gen.ts +1 -1
  47. package/src/runtime/core/modules/surfaces.ts +19 -121
  48. package/src/runtime/core/read-model.test.ts +69 -0
  49. package/src/runtime/core/read-model.ts +43 -4
  50. package/src/runtime/core/validate.test.ts +341 -0
  51. package/src/runtime/core/validate.ts +188 -0
  52. package/src/runtime/index.ts +15 -0
@@ -0,0 +1,158 @@
1
+ // Unit tests for buildEngineForm (#457) — the dataGrid detail's ENGINE-declared
2
+ // form path. Where buildDetailForm renders a page-authored `detail.form`, this
3
+ // resolves the row's engine `formKey` via the shared `/app/actions/form` gate and
4
+ // renders the AUTHORITATIVE deployed form-js schema with the shared renderer
5
+ // (globalThis.NanoFormJs), completing the row's user task via
6
+ // `/app/actions/complete`. A row with no formKey degrades to a bare completion,
7
+ // matching the taskInbox no-form fallback.
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { buildEngineForm } from "./runtime.browser.js";
12
+ // Importing the shared renderer registers globalThis.NanoFormJs, which
13
+ // buildEngineForm reads lazily when it renders a resolved schema.
14
+ import "./formjs.browser.js";
15
+ import { type FakeElement, installFakeDom } from "./fake-dom.test-utils.ts";
16
+
17
+ interface FetchCall {
18
+ url: string;
19
+ method: string;
20
+ // Parsed request JSON body (or undefined), typed loosely so a test can assert on
21
+ // arbitrary shapes without ceremony — mirrors the runtime's JSDoc `any` payloads.
22
+ body?: any;
23
+ }
24
+
25
+ /** Install a fake `fetch` that resolves each request from a route→response map. */
26
+ function installFakeFetch(
27
+ handler: (url: string, opts: { method?: string; body?: string }) => { status?: number; json?: unknown },
28
+ calls: FetchCall[],
29
+ ): () => void {
30
+ const prior = Reflect.getOwnPropertyDescriptor(globalThis, "fetch");
31
+ const fake = (input: string | URL, opts: { method?: string; body?: string } = {}) => {
32
+ const url = String(input);
33
+ calls.push({ url, method: opts.method ?? "GET", body: opts.body ? JSON.parse(opts.body) : undefined });
34
+ const res = handler(url, opts);
35
+ const status = res.status ?? 200;
36
+ return Promise.resolve({
37
+ ok: status >= 200 && status < 300,
38
+ status,
39
+ json: () =>
40
+ res.json === undefined ? Promise.reject(new Error("no body")) : Promise.resolve(res.json),
41
+ text: () => Promise.resolve(res.json === undefined ? "" : JSON.stringify(res.json)),
42
+ });
43
+ };
44
+ Reflect.set(globalThis, "fetch", fake);
45
+ return () => {
46
+ if (prior) Reflect.defineProperty(globalThis, "fetch", prior);
47
+ else Reflect.deleteProperty(globalThis, "fetch");
48
+ };
49
+ }
50
+
51
+ const CFG = { formKeyField: "form_key", userTaskKeyField: "user_task_key" };
52
+
53
+ test("buildEngineForm renders nothing when the row has no user-task key", (t) => {
54
+ const created: FakeElement[] = [];
55
+ t.after(installFakeDom(created));
56
+ assert.equal(buildEngineForm(CFG, { form_key: "f1" }, () => {}), null);
57
+ assert.equal(buildEngineForm(null, { user_task_key: "u1" }, () => {}), null);
58
+ });
59
+
60
+ test("buildEngineForm resolves the row's formKey and completes with the entered variables", async (t) => {
61
+ const created: FakeElement[] = [];
62
+ const calls: FetchCall[] = [];
63
+ t.after(installFakeDom(created));
64
+ t.after(
65
+ installFakeFetch((url) => {
66
+ if (url.includes("/app/actions/form")) {
67
+ return { status: 200, json: { schema: { components: [{ type: "textfield", key: "note" }] } } };
68
+ }
69
+ return { status: 200, json: { ok: true } };
70
+ }, calls),
71
+ );
72
+ let refreshed = false;
73
+ const box = buildEngineForm(CFG, { form_key: "form-9", user_task_key: "ut-9" }, () => {
74
+ refreshed = true;
75
+ });
76
+ assert.ok(box);
77
+ // Let the form fetch + render settle.
78
+ await new Promise((r) => setTimeout(r, 0));
79
+ const formReq = calls.find((c) => c.url.includes("/app/actions/form"));
80
+ assert.ok(formReq, "the row's formKey is resolved via the shared gate");
81
+ assert.match(formReq!.url, /formKey=form-9/);
82
+ // The shared renderer built the field; fill it and submit.
83
+ const input = created.find((n) => n.tagName === "INPUT");
84
+ assert.ok(input, "the deployed form-js schema was rendered by the shared renderer");
85
+ input!.value = "done";
86
+ const form = created.find((n) => n.tagName === "FORM");
87
+ await form!.fire("submit");
88
+ await new Promise((r) => setTimeout(r, 0));
89
+ const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
90
+ assert.ok(completeReq, "submitting completes the user task");
91
+ assert.equal(completeReq!.method, "POST");
92
+ assert.deepEqual(completeReq!.body, { userTaskKey: "ut-9", variables: { note: "done" } });
93
+ assert.equal(refreshed, true, "a successful completion invokes onSuccess (grid re-poll)");
94
+ });
95
+
96
+ test("buildEngineForm surfaces a completion failure in the rendered-form path", async (t) => {
97
+ const created: FakeElement[] = [];
98
+ const calls: FetchCall[] = [];
99
+ t.after(installFakeDom(created));
100
+ t.after(
101
+ installFakeFetch((url) => {
102
+ if (url.includes("/app/actions/form")) {
103
+ return { status: 200, json: { schema: { components: [{ type: "textfield", key: "note" }] } } };
104
+ }
105
+ return { status: 500 }; // completion fails
106
+ }, calls),
107
+ );
108
+ const box = buildEngineForm(CFG, { form_key: "form-e", user_task_key: "ut-e" }, () => {});
109
+ assert.ok(box);
110
+ await new Promise((r) => setTimeout(r, 0));
111
+ const form = created.find((n) => n.tagName === "FORM");
112
+ assert.ok(form, "the deployed schema was rendered");
113
+ await form!.fire("submit");
114
+ await new Promise((r) => setTimeout(r, 0));
115
+ const msg = created.find((n) => n.className.includes("njf-msg") && n.className.includes("err"));
116
+ assert.ok(msg, "a failed completion is surfaced to the operator (not silent)");
117
+ assert.notEqual(msg!.textContent, "", "the error message element has text");
118
+ const submit = created.find((n) => n.tagName === "BUTTON" && n.type === "submit");
119
+ assert.equal(submit!.disabled, false, "the submit button is re-enabled so the operator can retry");
120
+ });
121
+
122
+ test("buildEngineForm degrades to bare completion when the row has no formKey", async (t) => {
123
+ const created: FakeElement[] = [];
124
+ const calls: FetchCall[] = [];
125
+ t.after(installFakeDom(created));
126
+ t.after(installFakeFetch(() => ({ status: 200, json: { ok: true } }), calls));
127
+ const box = buildEngineForm(CFG, { user_task_key: "ut-2" }, () => {});
128
+ assert.ok(box);
129
+ // No form fetch — a bare "Complete" button is shown directly.
130
+ assert.equal(calls.length, 0, "no formKey ⇒ no form resolution request");
131
+ const button = created.find((n) => n.tagName === "BUTTON");
132
+ assert.ok(button);
133
+ assert.equal(button!.textContent, "Complete");
134
+ await button!.fire("click");
135
+ await new Promise((r) => setTimeout(r, 0));
136
+ const completeReq = calls.find((c) => c.url.includes("/app/actions/complete"));
137
+ assert.ok(completeReq, "the bare button completes the task");
138
+ assert.equal(completeReq!.body.userTaskKey, "ut-2");
139
+ assert.deepEqual(completeReq!.body.variables, {}, "bare completion sends empty variables");
140
+ });
141
+
142
+ test("buildEngineForm degrades to bare completion when the formKey resolves to no form (204)", async (t) => {
143
+ const created: FakeElement[] = [];
144
+ const calls: FetchCall[] = [];
145
+ t.after(installFakeDom(created));
146
+ t.after(
147
+ installFakeFetch((url) => {
148
+ if (url.includes("/app/actions/form")) return { status: 204 }; // no body
149
+ return { status: 200, json: { ok: true } };
150
+ }, calls),
151
+ );
152
+ const box = buildEngineForm(CFG, { form_key: "missing", user_task_key: "ut-3" }, () => {});
153
+ assert.ok(box);
154
+ await new Promise((r) => setTimeout(r, 0));
155
+ assert.ok(calls.some((c) => c.url.includes("/app/actions/form")), "an unresolved formKey still hits the gate");
156
+ const button = created.find((n) => n.tagName === "BUTTON" && n.textContent === "Complete");
157
+ assert.ok(button, "a 204 (no form) falls back to the bare completion button");
158
+ });
@@ -55,25 +55,58 @@ export type InstanceTracking = SchemaInstanceTracking & {
55
55
  * is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
56
56
  readonly terminalStatuses?: readonly string[];
57
57
  /**
58
- * The reconciliation applied to a row whose instance is **parked waiting on a human** — the
59
- * wait-state twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is
60
- * waiting on a human *iff* it has an open user task, so the runtime derives this edge from
61
- * engine truth (`openUserTasks`) rather than from a written column: on each poll, an active,
62
- * non-terminated row whose instance has any open user task has this patch applied (typically
63
- * `status = "awaiting_operator"`). Terminated wins over waiting-human (the {@link
64
- * SchemaInstanceTracking.onTerminated} edge is applied first and excludes the key from this
65
- * pass); a row with neither a terminated instance nor an open user task is left untouched, so
66
- * the worker-owned transient status (running / converging / merging) survives.
58
+ * The reconciliation for a row whose instance is **parked waiting on a human** — the wait-state
59
+ * twin of {@link SchemaInstanceTracking.onTerminated} (issue #355). An instance is waiting on a
60
+ * human *iff* it has an open user task, so the runtime derives this edge from engine truth
61
+ * (`openUserTasks`); terminated wins over waiting-human, and a row with neither is left carrying
62
+ * its worker-owned transient status (running / converging / merging).
63
+ *
64
+ * Since ADR 0065 (the writer→source inversion, #439-L1 / #318) this is a DERIVED, not a stored,
65
+ * edge: the reconciler no longer writes `set` into the base row with `table.update`. Instead it
66
+ * feeds the canonical `urban_open_user_tasks` projection, and the read model derives the effective
67
+ * status from it via `defineReadModel` — so an answered escalation re-flips by construction and the
68
+ * stored-derived tearing of nano-workforce#422 cannot recur. `set[statusField]` now supplies the
69
+ * VALUE the derived wait-on-human edge resolves to (rather than a patch); any secondary columns in
70
+ * `set` are no longer written (a VIEW cannot reset a stored column). See {@link readModel}.
67
71
  *
68
72
  * Optional and, like `terminalStatuses`, bridged locally until the canonical schema
69
73
  * (`@nanobpm/nano-app-schema`) folds it in; drop this augmentation and re-export once the dep
70
74
  * bump lands (No Drift Surfaces).
71
75
  */
72
76
  readonly onWaitingHuman?: {
73
- /** Column → literal value patch written to a row whose instance has an open user task
74
- * (e.g. set the status to a "waiting on human" value). */
77
+ /** Column → literal patch. Its `statusField` entry supplies the VALUE the derived wait-on-human
78
+ * edge resolves to (an instance with an open user task ⇒ this status); no longer a stored write.
79
+ * Since the inversion the reconciler makes NO base-row write at all, so only the `statusField` entry
80
+ * is consumed — any other key is intentionally IGNORED (an app-owned column is the app worker's job,
81
+ * not the reconciler's; ADR 0065). */
75
82
  readonly set: { readonly [k: string]: string | number | boolean | null };
76
83
  };
84
+ /**
85
+ * Optional overrides for the DERIVED read-model VIEW the runtime provisions per binding (ADR 0065,
86
+ * the writer→source inversion). The reconciler feeds engine truth into the canonical projections
87
+ * (`urban_instance_state`, `urban_open_user_tasks`) and the runtime provisions a managed SQLite VIEW
88
+ * over the binding's base table that re-exports `base.*` plus a derived status column — the terminal
89
+ * (`onTerminated`) and wait-on-human (`onWaitingHuman`) edges computed by `defineReadModel` over the
90
+ * projections, precedence-correct (terminated wins). The operator page reads this derived column; the
91
+ * base `statusField` column now holds only the worker-owned / business-outcome status. Both fields
92
+ * default sensibly (`view` → `<table>__tracking`, `statusColumn` → `derived_status`); override them to
93
+ * avoid a collision (e.g. more than one binding on one base table, or a base column already named
94
+ * `derived_status`). A binding with no `statusField` provisions no VIEW (nothing to derive), but the
95
+ * reconciler still feeds the projections.
96
+ *
97
+ * The VIEW is a first-class readable pages surface, not a separate wiring step: `SqliteGateway.schema()`
98
+ * introspects views (`type IN ('table','view')`) and `/app/data/<source>/<view>` serves `SELECT *`
99
+ * verbatim, so an operator page reads the derived status simply by pointing its page `data.table` at
100
+ * this managed VIEW (which re-exports `base.*`, so it is a drop-in for the base table) and its column at
101
+ * `statusColumn`. This is opt-in by design: the base table stays readable carrying the worker-owned
102
+ * status; the runtime does NOT silently rebind every page off the base table.
103
+ */
104
+ readonly readModel?: {
105
+ /** Managed VIEW name (must be a SQL identifier and differ from the base table). */
106
+ readonly view?: string;
107
+ /** Derived effective-status column name in that VIEW (must be a SQL identifier). */
108
+ readonly statusColumn?: string;
109
+ };
77
110
  };
78
111
 
79
112
  /**
@@ -9,9 +9,17 @@ import { makeGateway } from "./gateway.ts";
9
9
  import type { Table } from "./gateway.ts";
10
10
  import { DataLayer, type ProvisionedSource } from "./datasource.ts";
11
11
  import type { AppApi } from "../context.ts";
12
- import type { EngineClient, ProcessInstanceSnapshot } from "../host.ts";
12
+ import type { EngineClient, ProcessInstanceSnapshot, SqliteDb } from "../host.ts";
13
13
  import type { InstanceTracking } from "../manifest.ts";
14
14
  import { cancelInstanceReconciling } from "./cancel.ts";
15
+ import { INSTANCE_STATE_TABLE } from "./instance-state-store.ts";
16
+
17
+ // ADR 0065 — the instanceTracking writer→source inversion. Cancel no longer WRITES a terminal patch on
18
+ // the base row; it records the terminal fact into the canonical `_urban_instance_state` projection
19
+ // through the SAME `reconcileTerminatedKey` the poll reconciler uses (so cancel and poll cannot drift),
20
+ // and the terminal status is DERIVED from the projection by the read model. These tests therefore assert
21
+ // on the recorded projection state and `reconciled` (1 = terminal source recorded) — never a base-row
22
+ // mutation.
15
23
 
16
24
  type State = ProcessInstanceSnapshot["state"];
17
25
 
@@ -27,8 +35,9 @@ interface PrRow {
27
35
  status: string;
28
36
  }
29
37
 
30
- // The pull_requests binding: an ACTIVE row flips to `abandoned` (and drops the process pointer)
31
- // when its instance terminates — the exact patch the poll reconciler applies.
38
+ // The pull_requests binding: an ACTIVE row derives to `abandoned` when its instance terminates — the
39
+ // value the derived terminal edge resolves to (no longer a stored patch). Cancel feeds the projection;
40
+ // the read model derives the status.
32
41
  const PR_BINDING: InstanceTracking = {
33
42
  table: "pull_requests",
34
43
  keyField: "process_key",
@@ -85,6 +94,7 @@ function cancelEngine(opts: {
85
94
  interface Harness {
86
95
  api: AppApi;
87
96
  table: Table<PrRow>;
97
+ db: SqliteDb;
88
98
  logs: { level: string; msg: string }[];
89
99
  close: () => Promise<void>;
90
100
  }
@@ -120,6 +130,7 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
120
130
  return {
121
131
  api,
122
132
  table,
133
+ db,
123
134
  logs,
124
135
  close: async () => {
125
136
  db.close();
@@ -128,7 +139,22 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
128
139
  };
129
140
  }
130
141
 
131
- test("terminating a tracked instance reconciles its row immediately", async () => {
142
+ /** The recorded engine lifecycle state for a key in the `_urban_instance_state` projection, or
143
+ * undefined when it was never recorded. The projection table is created lazily by cancel's own
144
+ * `ensureSchema`; return undefined if it does not exist yet (nothing was ever recorded). */
145
+ function projectedState(db: SqliteDb, key: string): string | undefined {
146
+ try {
147
+ const rows = db.all<{ state: string }>(
148
+ `SELECT state FROM ${INSTANCE_STATE_TABLE} WHERE process_instance_key = ?`,
149
+ [key],
150
+ );
151
+ return rows[0]?.state;
152
+ } catch {
153
+ return undefined; // projection table not provisioned ⇒ nothing recorded
154
+ }
155
+ }
156
+
157
+ test("terminating a tracked instance records its terminal source immediately (no base write)", async () => {
132
158
  // cancelInstance succeeds (no throw) and the engine commits the termination.
133
159
  const { engine, cancels } = cancelEngine({
134
160
  states: { "100": "ACTIVE" },
@@ -143,19 +169,21 @@ test("terminating a tracked instance reconciles its row immediately", async () =
143
169
  assert.equal(r.ok, true);
144
170
  assert.equal(r.state, "TERMINATED");
145
171
  assert.equal(r.reconciled, 1);
172
+ // The terminal fact was recorded into the canonical projection …
173
+ assert.equal(projectedState(h.db, "100"), "TERMINATED");
174
+ // … and the base row was left UNTOUCHED (source-not-writer); the derived read model surfaces the
175
+ // terminal status without a stored write.
146
176
  const row = await h.table.get("100");
147
- assert.equal(row, undefined); // process_key was cleared to null, so no row keyed by "100"
148
- const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
149
- assert.equal(byPr?.status, "abandoned");
150
- assert.equal(byPr?.process_key, null);
177
+ assert.equal(row?.status, "converging");
178
+ assert.equal(row?.process_key, "100");
151
179
  } finally {
152
180
  await h.close();
153
181
  }
154
182
  });
155
183
 
156
- test("a cancel that throws but reads back TERMINATED is idempotent success + reconciles", async () => {
184
+ test("a cancel that throws but reads back TERMINATED is idempotent success + records the source", async () => {
157
185
  // The instance was already terminated; the engine rejects a second cancel, but the verify read
158
- // confirms it is gone — treat as success and still reconcile the (stale-active) row.
186
+ // confirms it is gone — treat as success and still record the terminal source.
159
187
  const { engine } = cancelEngine({
160
188
  states: { "100": "TERMINATED" },
161
189
  onCancel: () => {
@@ -168,14 +196,13 @@ test("a cancel that throws but reads back TERMINATED is idempotent success + rec
168
196
  assert.equal(r.ok, true);
169
197
  assert.equal(r.state, "TERMINATED");
170
198
  assert.equal(r.reconciled, 1);
171
- const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
172
- assert.equal(byPr?.status, "abandoned");
199
+ assert.equal(projectedState(h.db, "100"), "TERMINATED");
173
200
  } finally {
174
201
  await h.close();
175
202
  }
176
203
  });
177
204
 
178
- test("a failed cancel with a still-ACTIVE instance is an honest failure and touches no row", async () => {
205
+ test("a failed cancel with a still-ACTIVE instance is an honest failure and records nothing", async () => {
179
206
  const { engine } = cancelEngine({
180
207
  states: { "100": "ACTIVE" },
181
208
  onCancel: () => {
@@ -189,15 +216,38 @@ test("a failed cancel with a still-ACTIVE instance is an honest failure and touc
189
216
  assert.equal(r.state, "ACTIVE");
190
217
  assert.equal(r.reconciled, 0);
191
218
  assert.match(r.error ?? "", /permission denied/);
219
+ assert.equal(projectedState(h.db, "100"), undefined); // instance still running — nothing recorded
192
220
  const row = await h.table.get("100");
193
- assert.equal(row?.status, "converging"); // untouched — instance still running
221
+ assert.equal(row?.status, "converging"); // untouched
194
222
  assert.ok(h.logs.some((l) => l.level === "error"));
195
223
  } finally {
196
224
  await h.close();
197
225
  }
198
226
  });
199
227
 
200
- test("a COMPLETED instance is not reconciled (the app's finalize logic owns that row)", async () => {
228
+ test("an accepted cancel that still reads ACTIVE (engine lag) records the terminal source", async () => {
229
+ // The engine accepted the 204 but its query store still lags at ACTIVE. The cancel IS committed, so
230
+ // the terminal source must be recorded now — otherwise the derived view stays on the stored worker
231
+ // status until a poll catches up (and never, if the instance is later cleaned up). accepted-cancel/lag.
232
+ const { engine } = cancelEngine({
233
+ states: { "100": "ACTIVE" },
234
+ onCancel: () => {}, // accepted (204); state deliberately NOT flipped, to model read-model lag
235
+ });
236
+ const h = await withHarness(engine);
237
+ try {
238
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
239
+ assert.equal(r.ok, true);
240
+ assert.equal(r.state, "ACTIVE");
241
+ assert.equal(r.reconciled, 1);
242
+ assert.equal(projectedState(h.db, "100"), "TERMINATED");
243
+ const row = await h.table.get("100");
244
+ assert.equal(row?.status, "converging"); // base row untouched (source-not-writer)
245
+ } finally {
246
+ await h.close();
247
+ }
248
+ });
249
+
250
+ test("a COMPLETED instance is not recorded (the app's finalize logic owns that outcome)", async () => {
201
251
  const { engine } = cancelEngine({
202
252
  states: { "100": "COMPLETED" },
203
253
  onCancel: () => {}, // engine accepts the request; instance had already completed
@@ -208,16 +258,17 @@ test("a COMPLETED instance is not reconciled (the app's finalize logic owns that
208
258
  assert.equal(r.ok, true);
209
259
  assert.equal(r.state, "COMPLETED");
210
260
  assert.equal(r.reconciled, 0);
261
+ assert.equal(projectedState(h.db, "100"), undefined); // COMPLETED never recorded here
211
262
  const row = await h.table.get("100");
212
- assert.equal(row?.status, "converging"); // left for the finalize worker, not clobbered
263
+ assert.equal(row?.status, "converging"); // left for the finalize worker
213
264
  } finally {
214
265
  await h.close();
215
266
  }
216
267
  });
217
268
 
218
- test("a gone/unknown instance is idempotent success with no reconcile", async () => {
269
+ test("an accepted cancel that reads back gone (engine cleanup) records the terminal source", async () => {
219
270
  const { engine } = cancelEngine({
220
- states: {}, // the engine has no record of this key
271
+ states: {}, // the engine no longer exposes the key (already cleaned up)
221
272
  onCancel: () => {},
222
273
  });
223
274
  const h = await withHarness(engine);
@@ -225,13 +276,21 @@ test("a gone/unknown instance is idempotent success with no reconcile", async ()
225
276
  const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "999999");
226
277
  assert.equal(r.ok, true);
227
278
  assert.equal(r.state, "gone");
228
- assert.equal(r.reconciled, 0);
279
+ // The accepted 204 is a committed termination, so record the terminal source even though the read
280
+ // model no longer exposes the instance — otherwise the derived view could never leave the stored
281
+ // worker status (the poll cannot observe a gone instance either). accepted-cancel/read-lag.
282
+ assert.equal(r.reconciled, 1);
283
+ assert.equal(projectedState(h.db, "999999"), "TERMINATED");
229
284
  } finally {
230
285
  await h.close();
231
286
  }
232
287
  });
233
288
 
234
- test("a TERMINATED instance with no matching binding row reconciles nothing", async () => {
289
+ test("records the terminal source even when no base row matches (the projection is binding-independent)", async () => {
290
+ // Post-inversion the terminal fact is recorded into the canonical projection for the cancelled key
291
+ // regardless of which base rows reference it — the projection is the engine-truth store, and the
292
+ // derived read model only surfaces the fact for rows that point at the key (none here). This agrees
293
+ // with the poll path (both record TERMINATED for the same key), so they cannot drift.
235
294
  const { engine } = cancelEngine({
236
295
  states: { "555": "TERMINATED" },
237
296
  onCancel: () => {},
@@ -242,37 +301,47 @@ test("a TERMINATED instance with no matching binding row reconciles nothing", as
242
301
  const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "555");
243
302
  assert.equal(r.ok, true);
244
303
  assert.equal(r.state, "TERMINATED");
245
- assert.equal(r.reconciled, 0);
304
+ assert.equal(r.reconciled, 1);
305
+ assert.equal(projectedState(h.db, "555"), "TERMINATED");
246
306
  const row = await h.table.get("100");
247
- assert.equal(row?.status, "converging");
307
+ assert.equal(row?.status, "converging"); // the unrelated tracked row is untouched
248
308
  } finally {
249
309
  await h.close();
250
310
  }
251
311
  });
252
312
 
253
- test("a reconcile write failure does not fail the cancel (the engine already terminated it)", async () => {
313
+ test("cancel succeeds and records nothing when there is no default data source (absent-safe)", async () => {
314
+ // The projection feed is a sidecar on the app's own data source. With no default source there is
315
+ // nothing to record into — but a successful engine termination must STILL be reported as a success:
316
+ // a projection feed must never break a cancel. `reconcileTerminatedKey` is absent-safe (returns 0).
254
317
  const { engine } = cancelEngine({
255
318
  states: { "100": "ACTIVE" },
256
319
  onCancel: (key, states) => {
257
320
  states[key] = "TERMINATED";
258
321
  },
259
322
  });
260
- const h = await withHarness(engine);
323
+ const dir = await mkdtemp(join(tmpdir(), "urban-cancel-nosrc-"));
324
+ const logs: { level: string; msg: string }[] = [];
325
+ const data = new DataLayer(new Map(), undefined, {}); // no default source configured
326
+ const api: AppApi = {
327
+ manifest: { schemaVersion: 1, id: "app", name: "App" },
328
+ data,
329
+ engine,
330
+ env: () => undefined,
331
+ now: () => 0,
332
+ wait: () => Promise.resolve(),
333
+ log: createLogger((level, msg) => {
334
+ logs.push({ level, msg });
335
+ }),
336
+ };
261
337
  try {
262
- // A binding pointing at a table that does not exist makes the reconcile UPDATE throw.
263
- const badBinding: InstanceTracking = {
264
- table: "does_not_exist",
265
- keyField: "process_key",
266
- onTerminated: { set: { status: "abandoned" } },
267
- };
268
- const r = await cancelInstanceReconciling(h.api, [badBinding], "100");
269
- assert.equal(r.ok, true); // engine termination succeeded — the cancel is a success
338
+ const r = await cancelInstanceReconciling(api, [PR_BINDING], "100");
339
+ assert.equal(r.ok, true); // the engine termination committed — a success
270
340
  assert.equal(r.state, "TERMINATED");
271
- assert.equal(r.reconciled, 0);
272
- assert.ok(r.error); // the reconcile failure is surfaced, not swallowed
273
- assert.ok(h.logs.some((l) => l.level === "error"));
341
+ assert.equal(r.reconciled, 0); // nothing to record into
342
+ assert.equal(r.error, undefined); // absent-safe: not surfaced as an error
274
343
  } finally {
275
- await h.close();
344
+ await rm(dir, { recursive: true, force: true });
276
345
  }
277
346
  });
278
347
 
@@ -287,7 +356,10 @@ test("an accepted cancel whose verify read fails is trusted (gone) — the 204 c
287
356
  const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
288
357
  assert.equal(r.ok, true);
289
358
  assert.equal(r.state, "gone");
290
- assert.equal(r.reconciled, 0);
359
+ // Accepted 204 → committed termination; record the terminal source even though the verify read
360
+ // could not confirm it (read-model lag / cleanup must not strand the derived status).
361
+ assert.equal(r.reconciled, 1);
362
+ assert.equal(projectedState(h.db, "100"), "TERMINATED");
291
363
  assert.ok(h.logs.some((l) => l.level === "warn"));
292
364
  } finally {
293
365
  await h.close();
@@ -309,6 +381,7 @@ test("a thrown cancel whose verify read also fails is reported as an honest fail
309
381
  assert.equal(r.state, "ACTIVE");
310
382
  assert.equal(r.reconciled, 0);
311
383
  assert.match(r.error ?? "", /transport error/);
384
+ assert.equal(projectedState(h.db, "100"), undefined);
312
385
  const row = await h.table.get("100");
313
386
  assert.equal(row?.status, "converging");
314
387
  } finally {
@@ -336,8 +409,9 @@ test("a thrown cancel whose verify read comes back empty (gone) is an unverified
336
409
  assert.equal(r.state, "gone");
337
410
  assert.equal(r.reconciled, 0);
338
411
  assert.match(r.error ?? "", /fetch failed/);
412
+ assert.equal(projectedState(h.db, "100"), undefined); // never record a possibly-live run as terminal
339
413
  const row = await h.table.get("100");
340
- assert.equal(row?.status, "converging"); // never flip a possibly-live run to a terminal status
414
+ assert.equal(row?.status, "converging");
341
415
  assert.ok(h.logs.some((l) => l.level === "error"));
342
416
  } finally {
343
417
  await h.close();
@@ -6,21 +6,23 @@
6
6
  // 1. Report the *real* outcome. A cancel can fail (permissions, the instance already gone,
7
7
  // a transport error). Swallowing that and reporting success leaves an instance running in
8
8
  // the engine while the UI claims it stopped — the worst possible lie for a "cancel" button.
9
- // 2. Reconcile the tracked row immediately. Apps bind process instances to rows via
10
- // `instanceTracking`; the poll reconciler flips a row to its terminal status within a few
11
- // seconds of the instance terminating. When *we* are the one terminating it, we can apply
12
- // that same declared patch right away instead of waiting for the next poll tick.
9
+ // 2. Reconcile the tracked instance immediately. Apps bind process instances to rows via
10
+ // `instanceTracking`; since ADR 0065 the reconciler is a SOURCE, not a writer — its poll feeds
11
+ // engine truth into the canonical `urban_instance_state` projection and the terminal status edge
12
+ // is DERIVED from it (no stored column to tear; nano-workforce#422). When *we* are the one
13
+ // terminating the instance, we can record that same terminal source right away instead of waiting
14
+ // for the next poll tick, and the derived read model reflects it on the next read.
13
15
  //
14
16
  // This primitive keys success on the engine's response, then reconciles off the read model: a
15
17
  // non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
16
18
  // lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
17
- // or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
18
- // `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
19
+ // or absent from the read model) is an honest failure. It records the terminal source through the
20
+ // SAME `reconcileTerminatedKey` the poll reconciler uses, so cancel and poll can never drift.
19
21
 
20
22
  import type { AppApi } from "../context.ts";
21
23
  import type { InstanceTracking } from "../manifest.ts";
22
24
  import { errorMessage } from "../guards.ts";
23
- import { reconcileTerminatedKey } from "./instance-tracking.ts";
25
+ import { reconcileTerminatedKey, tryInstanceProjections } from "./instance-tracking.ts";
24
26
 
25
27
  export type CancelInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED" | "gone";
26
28
 
@@ -30,7 +32,8 @@ export interface CancelInstanceResult {
30
32
  /** The instance state as read back from the engine after the cancel attempt.
31
33
  * `"gone"` means the engine has no record of the key (already cleaned up / never existed). */
32
34
  state: CancelInstanceState;
33
- /** Rows flipped to their terminal status by the immediate instanceTracking reconcile. */
35
+ /** 1 when the immediate reconcile recorded the terminal source into the canonical instance-state
36
+ * projection (so the derived terminal status edge reflects it at once), else 0. */
34
37
  reconciled: number;
35
38
  error?: string;
36
39
  }
@@ -39,22 +42,26 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
39
42
 
40
43
  /**
41
44
  * Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
42
- * reconcile any instanceTracking row bound to its key.
45
+ * record the terminal source for its key into the canonical instance-state projection.
43
46
  *
44
47
  * Outcome rules:
45
48
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
46
- * 204); trust it even if the read model still lags at ACTIVE.
49
+ * 204); trust it even if the read model still lags at ACTIVE or has already cleaned the instance
50
+ * up ("gone"), and record the terminal source for the key so the derived view flips at once.
47
51
  * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
48
- * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
49
- * row here (see below); a COMPLETED read intentionally reconciles nothing.
52
+ * → the cancel was effectively idempotent; treat as success. A TERMINATED read records a terminal
53
+ * source here; a COMPLETED read intentionally records nothing.
50
54
  * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
51
55
  * (absent from the read model, which after an engine restart can transiently lack a live
52
- * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
56
+ * instance) → an honest failure: report it and do NOT record anything (the instance may still be
53
57
  * running). A throw is not a committed cancel, and absence from the read model is not proof of
54
58
  * termination, so success is never inferred from it.
55
59
  *
56
- * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
57
- * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
60
+ * Record when the cancellation is CONFIRMED — an accepted (non-throwing) cancel of any non-COMPLETED
61
+ * readback (ACTIVE lag / gone / TERMINATED), or a throw that reads back TERMINATED. A COMPLETED
62
+ * instance's terminal outcome is owned by the app's own finalize logic, so it records nothing. When
63
+ * the app declares no `instanceTracking` bindings there is nothing to reconcile, so the feed is
64
+ * skipped.
58
65
  */
59
66
  export async function cancelInstanceReconciling(
60
67
  api: CancelApi,
@@ -81,7 +88,7 @@ export async function cancelInstanceReconciling(
81
88
  // before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
82
89
  // Trust neither as success: claiming a cancel that may not have happened (while the run keeps
83
90
  // executing) is the worst possible lie for a cancel button. Report ok:false so the caller
84
- // surfaces it and retries; never flip a tracked row to terminal while the instance may live.
91
+ // surfaces it and retries; never record a terminal source while the instance may live.
85
92
  api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
86
93
  processInstanceKey: key,
87
94
  state,
@@ -91,14 +98,27 @@ export async function cancelInstanceReconciling(
91
98
  }
92
99
 
93
100
  let reconciled = 0;
94
- if (state === "TERMINATED") {
101
+ // Record the terminal source whenever the cancellation is CONFIRMED — not only on a positively
102
+ // TERMINATED readback. An accepted (non-throwing) `cancelInstance` is a committed 204: the instance
103
+ // WILL terminate, even if the read model still lags at ACTIVE or the engine has already cleaned the
104
+ // instance up ("gone"). A throw that nonetheless reads back TERMINATED is idempotently confirmed too.
105
+ // Excluded: COMPLETED (its terminal outcome is owned by the app's own finalize logic) and the honest-
106
+ // failure case (a throw with a non-terminal read), which already returned ok:false above. Without
107
+ // recording on the accepted-but-lagging read, a committed cancel returned ok:true / reconciled:0 and
108
+ // the derived view stayed on the stored worker status until a poll caught up — and NEVER, if the
109
+ // engine no longer exposes the instance for the poll to observe (issue: accepted-cancel/read-lag).
110
+ const cancelConfirmed = !threw || state === "TERMINATED";
111
+ if (cancelConfirmed && state !== "COMPLETED" && bindings.length > 0) {
112
+ // Record through the SAME `reconcileTerminatedKey` the poll path uses, over the app's own
113
+ // projection stores, so cancel and poll can never drift. It is internally absent-safe and never
114
+ // throws through — a projection-write failure logs a warn and returns 0 rather than turning a
115
+ // successful engine termination into a 500; the poll reconciler catches the projection up on its
116
+ // next tick. The extra try/catch is belt-and-suspenders against a store construction fault.
95
117
  try {
96
- reconciled = await reconcileTerminatedKey(api, bindings, key);
118
+ const projections = tryInstanceProjections(api);
119
+ reconciled = reconcileTerminatedKey(api, projections, key);
97
120
  } catch (error) {
98
- // The engine termination itself succeeded — a reconcile *write* failure (bad binding,
99
- // transient DB error) must not turn that into an unhandled 500. Log it and let the
100
- // instanceTracking poll reconciler catch the row up on its next tick.
101
- api.log("error", "cancel: instance terminated but row reconcile failed", {
121
+ api.log("error", "cancel: instance terminated but projection reconcile failed", {
102
122
  processInstanceKey: key,
103
123
  error: errorMessage(error),
104
124
  });