@nanobpm/urban 0.29.0 → 0.30.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 (44) hide show
  1. package/dist/openapi/spec.d.ts +127 -0
  2. package/dist/openapi/spec.js +444 -0
  3. package/dist/runtime/core/modules/api.d.ts +61 -0
  4. package/dist/runtime/core/modules/api.js +359 -0
  5. package/dist/runtime/core/modules/cancel.d.ts +31 -0
  6. package/dist/runtime/core/modules/cancel.js +90 -0
  7. package/dist/runtime/core/modules/datasource.d.ts +5 -2
  8. package/dist/runtime/core/modules/datasource.js +5 -4
  9. package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
  10. package/dist/runtime/core/modules/instance-tracking.js +28 -11
  11. package/dist/runtime/core/modules/pages.d.ts +8 -0
  12. package/dist/runtime/core/modules/pages.js +17 -1
  13. package/dist/runtime/core/modules/surfaces.js +9 -0
  14. package/dist/runtime/engine/nanosdk.js +41 -7
  15. package/dist/runtime/index.d.ts +4 -0
  16. package/dist/runtime/index.js +2 -0
  17. package/dist/toolkit/artifact.d.ts +5 -0
  18. package/dist/toolkit/artifact.js +7 -0
  19. package/dist/toolkit/derivers/api.d.ts +19 -0
  20. package/dist/toolkit/derivers/api.js +224 -0
  21. package/dist/toolkit/gen.js +23 -4
  22. package/dist/toolkit/index.d.ts +5 -0
  23. package/dist/toolkit/index.js +12 -1
  24. package/package.json +1 -1
  25. package/src/openapi/spec.test.ts +292 -0
  26. package/src/openapi/spec.ts +540 -0
  27. package/src/runtime/core/modules/api.test.ts +445 -0
  28. package/src/runtime/core/modules/api.ts +453 -0
  29. package/src/runtime/core/modules/cancel.test.ts +310 -0
  30. package/src/runtime/core/modules/cancel.ts +119 -0
  31. package/src/runtime/core/modules/datasource.ts +5 -4
  32. package/src/runtime/core/modules/instance-tracking.ts +33 -11
  33. package/src/runtime/core/modules/pages.test.ts +46 -2
  34. package/src/runtime/core/modules/pages.ts +23 -1
  35. package/src/runtime/core/modules/surfaces.ts +10 -0
  36. package/src/runtime/engine/nanosdk.test.ts +45 -5
  37. package/src/runtime/engine/nanosdk.ts +39 -7
  38. package/src/runtime/index.ts +12 -0
  39. package/src/toolkit/artifact.ts +8 -0
  40. package/src/toolkit/derivers/api.test.ts +219 -0
  41. package/src/toolkit/derivers/api.ts +255 -0
  42. package/src/toolkit/gen.test.ts +39 -0
  43. package/src/toolkit/gen.ts +24 -4
  44. package/src/toolkit/index.ts +41 -1
@@ -0,0 +1,310 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createNodeHost } from "../../adapters/node.ts";
7
+ import { makeGateway } from "./gateway.ts";
8
+ import type { Table } from "./gateway.ts";
9
+ import { DataLayer, type ProvisionedSource } from "./datasource.ts";
10
+ import type { AppApi } from "../context.ts";
11
+ import type { EngineClient, ProcessInstanceSnapshot } from "../host.ts";
12
+ import type { InstanceTracking } from "../manifest.ts";
13
+ import { cancelInstanceReconciling } from "./cancel.ts";
14
+
15
+ type State = ProcessInstanceSnapshot["state"];
16
+
17
+ const PR_DDL = `CREATE TABLE pull_requests (
18
+ pr_key TEXT PRIMARY KEY,
19
+ process_key TEXT,
20
+ status TEXT NOT NULL
21
+ );`;
22
+
23
+ interface PrRow {
24
+ pr_key: string;
25
+ process_key: string | null;
26
+ status: string;
27
+ }
28
+
29
+ // The pull_requests binding: an ACTIVE row flips to `abandoned` (and drops the process pointer)
30
+ // when its instance terminates — the exact patch the poll reconciler applies.
31
+ const PR_BINDING: InstanceTracking = {
32
+ table: "pull_requests",
33
+ keyField: "process_key",
34
+ statusField: "status",
35
+ activeStatuses: ["converging"],
36
+ onTerminated: { set: { status: "abandoned", process_key: null } },
37
+ };
38
+
39
+ /** An EngineClient double for the cancel primitive. `cancelInstance` runs `onCancel` (which may
40
+ * throw and/or mutate `states`); `searchProcessInstances` reflects the current `states` map, or
41
+ * throws when `verifyThrows` is set (to exercise the unverifiable-read path). */
42
+ function cancelEngine(opts: {
43
+ states: Record<string, State>;
44
+ onCancel: (key: string, states: Record<string, State>) => void;
45
+ verifyThrows?: boolean;
46
+ }): { engine: EngineClient; cancels: string[] } {
47
+ const cancels: string[] = [];
48
+ const notUsed = (m: string) => (): never => {
49
+ throw new Error(`cancelEngine.${m} is not exercised by this test`);
50
+ };
51
+ const engine: EngineClient = {
52
+ async cancelInstance(args: { processInstanceKey: string }): Promise<void> {
53
+ cancels.push(args.processInstanceKey);
54
+ opts.onCancel(args.processInstanceKey, opts.states);
55
+ },
56
+ async searchProcessInstances(filter?: {
57
+ processInstanceKeys?: string[];
58
+ state?: State;
59
+ }): Promise<ProcessInstanceSnapshot[]> {
60
+ if (opts.verifyThrows) throw new Error("read model unavailable");
61
+ const keys = filter?.processInstanceKeys ?? [];
62
+ const out: ProcessInstanceSnapshot[] = [];
63
+ for (const key of keys) {
64
+ const state = opts.states[key];
65
+ if (state && (!filter?.state || filter.state === state)) {
66
+ out.push({ processInstanceKey: key, state });
67
+ }
68
+ }
69
+ return out;
70
+ },
71
+ deployResources: notUsed("deployResources"),
72
+ createInstance: notUsed("createInstance"),
73
+ publishMessage: notUsed("publishMessage"),
74
+ searchUserTasks: notUsed("searchUserTasks"),
75
+ completeUserTask: notUsed("completeUserTask"),
76
+ registerWorker: notUsed("registerWorker"),
77
+ close: notUsed("close"),
78
+ };
79
+ return { engine, cancels };
80
+ }
81
+
82
+ interface Harness {
83
+ api: AppApi;
84
+ table: Table<PrRow>;
85
+ logs: { level: string; msg: string }[];
86
+ close: () => Promise<void>;
87
+ }
88
+
89
+ async function withHarness(engine: EngineClient, seedStatus = "converging"): Promise<Harness> {
90
+ const dir = await mkdtemp(join(tmpdir(), "urban-cancel-"));
91
+ const host = createNodeHost({ cwd: dir, log: () => {} });
92
+ const db = host.openSqlite(join(dir, "test.db"));
93
+ db.exec(PR_DDL);
94
+ const source: ProvisionedSource = {
95
+ name: "app",
96
+ driver: "sqlite",
97
+ db,
98
+ source: makeGateway(db),
99
+ migrationsApplied: [],
100
+ close: () => db.close(),
101
+ };
102
+ const data = new DataLayer(new Map([["app", source]]), "app", {});
103
+ const table = data.table<PrRow>("pull_requests", "process_key");
104
+ await table.insert({ pr_key: "pr-1", process_key: "100", status: seedStatus });
105
+ const logs: { level: string; msg: string }[] = [];
106
+ const api: AppApi = {
107
+ manifest: { schemaVersion: 1, id: "app", name: "App" },
108
+ data,
109
+ engine,
110
+ env: () => undefined,
111
+ log: (level, msg) => logs.push({ level, msg }),
112
+ };
113
+ return {
114
+ api,
115
+ table,
116
+ logs,
117
+ close: async () => {
118
+ db.close();
119
+ await rm(dir, { recursive: true, force: true });
120
+ },
121
+ };
122
+ }
123
+
124
+ test("terminating a tracked instance reconciles its row immediately", async () => {
125
+ // cancelInstance succeeds (no throw) and the engine commits the termination.
126
+ const { engine, cancels } = cancelEngine({
127
+ states: { "100": "ACTIVE" },
128
+ onCancel: (key, states) => {
129
+ states[key] = "TERMINATED";
130
+ },
131
+ });
132
+ const h = await withHarness(engine);
133
+ try {
134
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
135
+ assert.deepEqual(cancels, ["100"]);
136
+ assert.equal(r.ok, true);
137
+ assert.equal(r.state, "TERMINATED");
138
+ assert.equal(r.reconciled, 1);
139
+ const row = await h.table.get("100");
140
+ assert.equal(row, undefined); // process_key was cleared to null, so no row keyed by "100"
141
+ const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
142
+ assert.equal(byPr?.status, "abandoned");
143
+ assert.equal(byPr?.process_key, null);
144
+ } finally {
145
+ await h.close();
146
+ }
147
+ });
148
+
149
+ test("a cancel that throws but reads back TERMINATED is idempotent success + reconciles", async () => {
150
+ // The instance was already terminated; the engine rejects a second cancel, but the verify read
151
+ // confirms it is gone — treat as success and still reconcile the (stale-active) row.
152
+ const { engine } = cancelEngine({
153
+ states: { "100": "TERMINATED" },
154
+ onCancel: () => {
155
+ throw new Error("instance is not active");
156
+ },
157
+ });
158
+ const h = await withHarness(engine);
159
+ try {
160
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
161
+ assert.equal(r.ok, true);
162
+ assert.equal(r.state, "TERMINATED");
163
+ assert.equal(r.reconciled, 1);
164
+ const byPr = (await h.table.all()).find((x) => x.pr_key === "pr-1");
165
+ assert.equal(byPr?.status, "abandoned");
166
+ } finally {
167
+ await h.close();
168
+ }
169
+ });
170
+
171
+ test("a failed cancel with a still-ACTIVE instance is an honest failure and touches no row", async () => {
172
+ const { engine } = cancelEngine({
173
+ states: { "100": "ACTIVE" },
174
+ onCancel: () => {
175
+ throw new Error("permission denied");
176
+ },
177
+ });
178
+ const h = await withHarness(engine);
179
+ try {
180
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
181
+ assert.equal(r.ok, false);
182
+ assert.equal(r.state, "ACTIVE");
183
+ assert.equal(r.reconciled, 0);
184
+ assert.match(r.error ?? "", /permission denied/);
185
+ const row = await h.table.get("100");
186
+ assert.equal(row?.status, "converging"); // untouched — instance still running
187
+ assert.ok(h.logs.some((l) => l.level === "error"));
188
+ } finally {
189
+ await h.close();
190
+ }
191
+ });
192
+
193
+ test("a COMPLETED instance is not reconciled (the app's finalize logic owns that row)", async () => {
194
+ const { engine } = cancelEngine({
195
+ states: { "100": "COMPLETED" },
196
+ onCancel: () => {}, // engine accepts the request; instance had already completed
197
+ });
198
+ const h = await withHarness(engine);
199
+ try {
200
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
201
+ assert.equal(r.ok, true);
202
+ assert.equal(r.state, "COMPLETED");
203
+ assert.equal(r.reconciled, 0);
204
+ const row = await h.table.get("100");
205
+ assert.equal(row?.status, "converging"); // left for the finalize worker, not clobbered
206
+ } finally {
207
+ await h.close();
208
+ }
209
+ });
210
+
211
+ test("a gone/unknown instance is idempotent success with no reconcile", async () => {
212
+ const { engine } = cancelEngine({
213
+ states: {}, // the engine has no record of this key
214
+ onCancel: () => {},
215
+ });
216
+ const h = await withHarness(engine);
217
+ try {
218
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "999999");
219
+ assert.equal(r.ok, true);
220
+ assert.equal(r.state, "gone");
221
+ assert.equal(r.reconciled, 0);
222
+ } finally {
223
+ await h.close();
224
+ }
225
+ });
226
+
227
+ test("a TERMINATED instance with no matching binding row reconciles nothing", async () => {
228
+ const { engine } = cancelEngine({
229
+ states: { "555": "TERMINATED" },
230
+ onCancel: () => {},
231
+ });
232
+ const h = await withHarness(engine);
233
+ try {
234
+ // "555" matches no row (the seeded row tracks "100").
235
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "555");
236
+ assert.equal(r.ok, true);
237
+ assert.equal(r.state, "TERMINATED");
238
+ assert.equal(r.reconciled, 0);
239
+ const row = await h.table.get("100");
240
+ assert.equal(row?.status, "converging");
241
+ } finally {
242
+ await h.close();
243
+ }
244
+ });
245
+
246
+ test("a reconcile write failure does not fail the cancel (the engine already terminated it)", async () => {
247
+ const { engine } = cancelEngine({
248
+ states: { "100": "ACTIVE" },
249
+ onCancel: (key, states) => {
250
+ states[key] = "TERMINATED";
251
+ },
252
+ });
253
+ const h = await withHarness(engine);
254
+ try {
255
+ // A binding pointing at a table that does not exist makes the reconcile UPDATE throw.
256
+ const badBinding: InstanceTracking = {
257
+ table: "does_not_exist",
258
+ keyField: "process_key",
259
+ onTerminated: { set: { status: "abandoned" } },
260
+ };
261
+ const r = await cancelInstanceReconciling(h.api, [badBinding], "100");
262
+ assert.equal(r.ok, true); // engine termination succeeded — the cancel is a success
263
+ assert.equal(r.state, "TERMINATED");
264
+ assert.equal(r.reconciled, 0);
265
+ assert.ok(r.error); // the reconcile failure is surfaced, not swallowed
266
+ assert.ok(h.logs.some((l) => l.level === "error"));
267
+ } finally {
268
+ await h.close();
269
+ }
270
+ });
271
+
272
+ test("an accepted cancel whose verify read fails is trusted (gone) — the 204 committed", async () => {
273
+ const { engine } = cancelEngine({
274
+ states: { "100": "ACTIVE" },
275
+ onCancel: () => {}, // accepted
276
+ verifyThrows: true,
277
+ });
278
+ const h = await withHarness(engine);
279
+ try {
280
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
281
+ assert.equal(r.ok, true);
282
+ assert.equal(r.state, "gone");
283
+ assert.equal(r.reconciled, 0);
284
+ assert.ok(h.logs.some((l) => l.level === "warn"));
285
+ } finally {
286
+ await h.close();
287
+ }
288
+ });
289
+
290
+ test("a thrown cancel whose verify read also fails is reported as an honest failure", async () => {
291
+ const { engine } = cancelEngine({
292
+ states: { "100": "ACTIVE" },
293
+ onCancel: () => {
294
+ throw new Error("transport error");
295
+ },
296
+ verifyThrows: true,
297
+ });
298
+ const h = await withHarness(engine);
299
+ try {
300
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
301
+ assert.equal(r.ok, false);
302
+ assert.equal(r.state, "ACTIVE");
303
+ assert.equal(r.reconciled, 0);
304
+ assert.match(r.error ?? "", /transport error/);
305
+ const row = await h.table.get("100");
306
+ assert.equal(row?.status, "converging");
307
+ } finally {
308
+ await h.close();
309
+ }
310
+ });
@@ -0,0 +1,119 @@
1
+ // The Urban-native "cancel a process instance" primitive.
2
+ //
3
+ // Cancelling a running process from a page action must do two honest things that a bare
4
+ // `engine.cancelInstance` call does not:
5
+ //
6
+ // 1. Report the *real* outcome. A cancel can fail (permissions, the instance already gone,
7
+ // a transport error). Swallowing that and reporting success leaves an instance running in
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.
13
+ //
14
+ // This primitive keys success on the engine's response, then reconciles off the read model: a
15
+ // non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
16
+ // lags at ACTIVE), while a throw whose instance reads back still-ACTIVE is an honest failure. It
17
+ // reuses the instanceTracking bindings' `onTerminated.set` patch, so the immediate reconcile can
18
+ // never drift from the poll reconciler's.
19
+
20
+ import type { AppApi } from "../context.ts";
21
+ import type { InstanceTracking } from "../manifest.ts";
22
+ import { errorMessage } from "../guards.ts";
23
+ import { reconcileTerminatedKey } from "./instance-tracking.ts";
24
+
25
+ export type CancelInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED" | "gone";
26
+
27
+ export interface CancelInstanceResult {
28
+ ok: boolean;
29
+ processInstanceKey: string;
30
+ /** The instance state as read back from the engine after the cancel attempt.
31
+ * `"gone"` means the engine has no record of the key (already cleaned up / never existed). */
32
+ state: CancelInstanceState;
33
+ /** Rows flipped to their terminal status by the immediate instanceTracking reconcile. */
34
+ reconciled: number;
35
+ error?: string;
36
+ }
37
+
38
+ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
39
+
40
+ /**
41
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and immediately
42
+ * reconcile any instanceTracking row bound to its key.
43
+ *
44
+ * Outcome rules:
45
+ * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
46
+ * 204); trust it even if the read model still lags at ACTIVE.
47
+ * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
48
+ * /gone) → the cancel was effectively idempotent; treat as success.
49
+ * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
50
+ * do NOT touch any row (the instance is still running).
51
+ *
52
+ * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
53
+ * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
54
+ */
55
+ export async function cancelInstanceReconciling(
56
+ api: CancelApi,
57
+ bindings: InstanceTracking[],
58
+ processInstanceKey: string,
59
+ ): Promise<CancelInstanceResult> {
60
+ const key = String(processInstanceKey);
61
+
62
+ let threw: unknown;
63
+ try {
64
+ await api.engine.cancelInstance({ processInstanceKey: key });
65
+ } catch (error) {
66
+ threw = error;
67
+ }
68
+
69
+ const state = await readState(api, key, threw);
70
+
71
+ if (threw && state === "ACTIVE") {
72
+ // The cancel genuinely failed and the instance is still running — never flip a row to a
73
+ // terminal status while the engine keeps executing it.
74
+ api.log("error", "cancel: engine termination failed, instance still active", {
75
+ processInstanceKey: key,
76
+ error: errorMessage(threw),
77
+ });
78
+ return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
79
+ }
80
+
81
+ let reconciled = 0;
82
+ if (state === "TERMINATED") {
83
+ try {
84
+ reconciled = await reconcileTerminatedKey(api, bindings, key);
85
+ } catch (error) {
86
+ // The engine termination itself succeeded — a reconcile *write* failure (bad binding,
87
+ // transient DB error) must not turn that into an unhandled 500. Log it and let the
88
+ // instanceTracking poll reconciler catch the row up on its next tick.
89
+ api.log("error", "cancel: instance terminated but row reconcile failed", {
90
+ processInstanceKey: key,
91
+ error: errorMessage(error),
92
+ });
93
+ return { ok: true, processInstanceKey: key, state, reconciled: 0, error: errorMessage(error) };
94
+ }
95
+ }
96
+
97
+ return { ok: true, processInstanceKey: key, state, reconciled };
98
+ }
99
+
100
+ /** Read the instance state back from the engine to verify what actually happened. When the read
101
+ * itself fails we fall back to the cancel call's own result: a thrown cancel with an unverifiable
102
+ * state is reported as still-ACTIVE (honest worst case); an accepted cancel is trusted as gone. */
103
+ async function readState(
104
+ api: CancelApi,
105
+ key: string,
106
+ threw: unknown,
107
+ ): Promise<CancelInstanceState> {
108
+ try {
109
+ const [snapshot] = await api.engine.searchProcessInstances({ processInstanceKeys: [key] });
110
+ if (snapshot) return snapshot.state;
111
+ return "gone";
112
+ } catch (error) {
113
+ api.log("warn", "cancel: could not verify post-cancel state", {
114
+ processInstanceKey: key,
115
+ error: errorMessage(error),
116
+ });
117
+ return threw ? "ACTIVE" : "gone";
118
+ }
119
+ }
@@ -13,6 +13,7 @@ import {
13
13
  type InsertObserver,
14
14
  } from "./gateway.ts";
15
15
  import { currentJobContext } from "../execContext.ts";
16
+ import { isAbsolutePath } from "../../../toolkit/artifact.ts";
16
17
 
17
18
  export function sqlitePathFromUrl(url: string): string {
18
19
  // Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
@@ -25,10 +26,10 @@ export function sqlitePathFromUrl(url: string): string {
25
26
  * that starts with a single backslash ("\data\app.db") and a Windows UNC path
26
27
  * ("\\\\server\\share"). A single leading backslash covers both the drive-root and UNC cases,
27
28
  * matching Node's `path.win32.isAbsolute`. Used by `resolveAppPath` so a caller-supplied absolute
28
- * path is never incorrectly prefixed with the app root. */
29
- export function isAbsolutePath(p: string): boolean {
30
- return /^(\/|\\|[A-Za-z]:[/\\])/.test(p);
31
- }
29
+ * path is never incorrectly prefixed with the app root. Defined once in `toolkit/artifact.ts` (the
30
+ * shared, dependency-free layer) and re-exported here so the toolkit's gen path-join and this
31
+ * runtime resolver apply the identical rule (no gen/runtime drift). */
32
+ export { isAbsolutePath };
32
33
 
33
34
  /** Resolve `p` against the app `root`: an absolute `p` (see `isAbsolutePath`) is returned as-is;
34
35
  * a relative `p` is joined onto `root`. Trims a trailing separator of either kind off `root` so
@@ -27,6 +27,35 @@ export interface InstanceTrackingHandle extends Mounted {
27
27
 
28
28
  type Row = Record<string, unknown>;
29
29
 
30
+ /** Apply a binding's `onTerminated.set` patch to the single row keyed by `processInstanceKey`.
31
+ * Only the binding whose table owns that key has a matching row, so callers can fan this over
32
+ * every binding and let the no-match ones be no-ops. Returns rows changed. The single place that
33
+ * writes the terminal patch (and logs the reconcile): both the poll reconciler below (`reconcileOnce`,
34
+ * once per TERMINATED key it discovers) and the cancel primitive (immediately when it terminates an
35
+ * instance) route through here, so the two can never drift on the patch or the log. */
36
+ export async function reconcileTerminatedKey(
37
+ api: Pick<AppApi, "data" | "log">,
38
+ bindings: InstanceTracking[],
39
+ processInstanceKey: string,
40
+ ): Promise<number> {
41
+ const key = String(processInstanceKey);
42
+ let reconciled = 0;
43
+ for (const binding of bindings) {
44
+ const table = api.data.table<Row>(binding.table, binding.keyField);
45
+ const changed = await table.update(key, binding.onTerminated.set);
46
+ if (changed > 0) {
47
+ reconciled += changed;
48
+ api.log("info", "instanceTracking: reconciled terminated instance", {
49
+ table: binding.table,
50
+ keyField: binding.keyField,
51
+ processInstanceKey: key,
52
+ rowsChanged: changed,
53
+ });
54
+ }
55
+ }
56
+ return reconciled;
57
+ }
58
+
30
59
  /** Poll one binding's instances once. Selects the active rows, asks the engine which of their
31
60
  * instances are TERMINATED, and applies `onTerminated.set` to each. Returns how many keys were
32
61
  * scanned and how many rows were reconciled this tick. */
@@ -70,21 +99,14 @@ async function reconcileOnce(
70
99
  state: "TERMINATED",
71
100
  });
72
101
 
73
- const patch: Partial<Row> = binding.onTerminated.set;
102
+ // Apply the terminal patch through the shared `reconcileTerminatedKey` — the one place that
103
+ // writes `onTerminated.set` and logs the reconcile — so the poll path and the cancel primitive
104
+ // can never drift on the patch or its logging (No Drift Surfaces).
74
105
  let reconciled = 0;
75
106
  for (const snap of snapshots) {
76
107
  if (snap.state !== "TERMINATED") continue; // defensive; we asked for TERMINATED only
77
108
  if (!keyToRows.has(snap.processInstanceKey)) continue; // not one we were tracking
78
- const changed = await table.update(snap.processInstanceKey, patch);
79
- if (changed > 0) {
80
- reconciled += changed;
81
- api.log("info", "instanceTracking: reconciled terminated instance", {
82
- table: binding.table,
83
- keyField: binding.keyField,
84
- processInstanceKey: snap.processInstanceKey,
85
- rowsChanged: changed,
86
- });
87
- }
109
+ reconciled += await reconcileTerminatedKey(api, [binding], snap.processInstanceKey);
88
110
  }
89
111
  return { scanned: keys.length, reconciled };
90
112
  }
@@ -2,7 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
4
4
  import { makeRouter } from "../router.ts";
5
- import { createPagesRoutes, type PagesDataSource } from "./pages.ts";
5
+ import { createPagesRoutes, type PagesDataSource, type PagesDeps } from "./pages.ts";
6
6
 
7
7
  function req(
8
8
  method: string,
@@ -61,7 +61,7 @@ function fakeDb(overrides: Partial<PagesDataSource> = {}): PagesDataSource {
61
61
  };
62
62
  }
63
63
 
64
- function build(dbOverrides: Partial<PagesDataSource> = {}) {
64
+ function build(dbOverrides: Partial<PagesDataSource> = {}, deps: Partial<PagesDeps> = {}) {
65
65
  const { engine, calls } = fakeEngine();
66
66
  const db = fakeDb(dbOverrides);
67
67
  const readPage = async (path: string) => {
@@ -74,6 +74,7 @@ function build(dbOverrides: Partial<PagesDataSource> = {}) {
74
74
  engine,
75
75
  readPage,
76
76
  listPages: async () => ["home", "epic"],
77
+ ...deps,
77
78
  });
78
79
  const router = makeRouter(routes);
79
80
  return { router, calls };
@@ -356,6 +357,49 @@ test("POST /app/actions/cancel cancels, 400 without a key", async () => {
356
357
  assert.equal(bad.status, 400);
357
358
  });
358
359
 
360
+ test("POST /app/actions/cancel uses the injected cancel primitive and returns its result", async () => {
361
+ const seen: string[] = [];
362
+ const cancel: PagesDeps["cancel"] = async (key) => {
363
+ seen.push(key);
364
+ return { ok: true, processInstanceKey: key, state: "TERMINATED", reconciled: 1 };
365
+ };
366
+ const { router } = build({}, { cancel });
367
+ const res = await router(req("POST", "/app/actions/cancel", { body: { processInstanceKey: "pi-7" } }));
368
+ assert.equal(res.status, 200);
369
+ assert.deepEqual(seen, ["pi-7"]);
370
+ assert.deepEqual(JSON.parse(res.body ?? "{}"), {
371
+ ok: true,
372
+ processInstanceKey: "pi-7",
373
+ state: "TERMINATED",
374
+ reconciled: 1,
375
+ });
376
+ });
377
+
378
+ test("POST /app/actions/cancel surfaces a 502 when the cancel primitive reports failure", async () => {
379
+ const cancel: PagesDeps["cancel"] = async (key) => ({
380
+ ok: false,
381
+ processInstanceKey: key,
382
+ state: "ACTIVE",
383
+ reconciled: 0,
384
+ error: "engine rejected the cancellation",
385
+ });
386
+ const { router } = build({}, { cancel });
387
+ const res = await router(req("POST", "/app/actions/cancel", { body: { processInstanceKey: "pi-8" } }));
388
+ assert.equal(res.status, 502);
389
+ assert.equal(JSON.parse(res.body ?? "{}").ok, false);
390
+ assert.match(JSON.parse(res.body ?? "{}").error, /engine rejected/);
391
+ });
392
+
393
+ test("POST /app/actions/cancel maps a throwing cancel primitive to a 502", async () => {
394
+ const cancel: PagesDeps["cancel"] = async () => {
395
+ throw new Error("boom");
396
+ };
397
+ const { router } = build({}, { cancel });
398
+ const res = await router(req("POST", "/app/actions/cancel", { body: { processInstanceKey: "pi-x" } }));
399
+ assert.equal(res.status, 502);
400
+ assert.equal(JSON.parse(res.body ?? "{}").ok, false);
401
+ });
402
+
359
403
  test("POST /app/actions/message publishes, 400 on missing fields", async () => {
360
404
  const { router, calls } = build();
361
405
  const ok = await router(req("POST", "/app/actions/message", {
@@ -19,6 +19,7 @@ import type { AppApi, RuntimeContext } from "../context.ts";
19
19
  import { errorMessage, isRecord } from "../guards.ts";
20
20
  import type { EngineClient } from "../host.ts";
21
21
  import { html, json, type Route } from "../router.ts";
22
+ import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
22
23
  import { quoteIdent } from "./gateway.ts";
23
24
 
24
25
  /** The subset of the datasource gateway the page runtime needs. */
@@ -49,6 +50,13 @@ export interface PagesDeps {
49
50
  * Optional; when absent the index is empty. Injectable for tests.
50
51
  */
51
52
  listPages?(): Promise<string[]>;
53
+ /**
54
+ * Cancel a process instance honestly: terminate it, verify it is actually gone, and reconcile
55
+ * any `instanceTracking` row bound to its key. Injected by `mountPages` (bound to the app's
56
+ * engine, datasource, and instanceTracking bindings). When absent, the `/app/actions/cancel`
57
+ * route falls back to a bare `engine.cancelInstance` (used by tests that inject no cancel dep).
58
+ */
59
+ cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
52
60
  }
53
61
 
54
62
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
@@ -70,7 +78,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
70
78
  const rowLimitRaw = opts.rowLimit ?? 200;
71
79
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
72
80
  const sourceName = opts.sourceName ?? "app";
73
- const { db, engine, readPage, listPages } = deps;
81
+ const { db, engine, readPage, listPages, cancel } = deps;
74
82
 
75
83
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
76
84
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -285,6 +293,18 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
285
293
  return json({ error: "body must be JSON" }, 400);
286
294
  }
287
295
  if (key === undefined || key === "") return json({ error: "processInstanceKey is required" }, 400);
296
+ // The reconcile-aware primitive verifies the real post-cancel state and flips the tracked
297
+ // row immediately; a !ok result means the engine did NOT stop the instance, so surface 502.
298
+ if (cancel) {
299
+ try {
300
+ const result = await cancel(String(key));
301
+ return json(result, result.ok ? 200 : 502);
302
+ } catch (e) {
303
+ // Defensive: the primitive is designed not to throw, but if it ever does, mirror the
304
+ // fallback path's honest 502 rather than letting the request reject as a 500.
305
+ return json({ ok: false, processInstanceKey: String(key), error: errorMessage(e) }, 502);
306
+ }
307
+ }
288
308
  try {
289
309
  await engine.cancelInstance({ processInstanceKey: String(key) });
290
310
  return json({ ok: true });
@@ -354,9 +374,11 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
354
374
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
355
375
  };
356
376
  const sourceName = opts.sourceName ?? "app";
377
+ const bindings = ctx.manifest.instanceTracking ?? [];
357
378
  const routes = createPagesRoutes(opts, {
358
379
  db: app.data.open(sourceName),
359
380
  engine: app.engine,
381
+ cancel: (key) => cancelInstanceReconciling(app, bindings, key),
360
382
  readPage: (p) => ctx.host.readTextFile(p),
361
383
  listPages: async () => {
362
384
  const dir = opts.pagesDir ?? "pages";
@@ -6,6 +6,7 @@
6
6
  import type { AppApi, RuntimeContext } from "../context.ts";
7
7
  import { html, json, normalizeRoutePath, type Route } from "../router.ts";
8
8
  import { mountActions } from "./actions.ts";
9
+ import { mountApi } from "./api.ts";
9
10
  import { mountPages } from "./pages.ts";
10
11
 
11
12
  /** Escape HTML-significant characters before embedding a value in markup. */
@@ -109,6 +110,15 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
109
110
  enabled.push(`actions(${actions.routes.length})`);
110
111
  }
111
112
 
113
+ // The OpenAPI endpoint surface (ADR 0058) mounts AFTER actions (so an exact `actions[]` route
114
+ // still shadows an operation) and BEFORE the generic pages routes. It owns the `api.base`
115
+ // namespace via a regex dispatcher, so it only contributes routes when the app declares `api`.
116
+ const api = mountApi(ctx, app);
117
+ if (api.routes.length > 0) {
118
+ routes.push(...api.routes);
119
+ enabled.push("api");
120
+ }
121
+
112
122
  // The pages surface (the schema-driven page runtime) mounts its own routes.
113
123
  const pages = mountPages(ctx, app);
114
124
  if (pages.routes.length > 0) {