@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.
- 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/formjs.gen.d.ts +1 -0
- package/dist/runtime/core/modules/formjs.gen.js +5 -0
- package/dist/runtime/core/modules/forms.d.ts +22 -0
- package/dist/runtime/core/modules/forms.js +59 -0
- 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 +42 -4
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +18 -124
- 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/browser/fake-dom.test-utils.ts +114 -0
- package/src/runtime/browser/formjs.browser.js +192 -0
- package/src/runtime/browser/formjs.browser.test.ts +136 -0
- package/src/runtime/browser/runtime.browser.js +101 -1
- package/src/runtime/browser/runtime.engineform.test.ts +158 -0
- 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/formjs.gen.ts +6 -0
- package/src/runtime/core/modules/forms.test.ts +122 -0
- package/src/runtime/core/modules/forms.ts +67 -0
- package/src/runtime/core/modules/gateway.ts +7 -4
- package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
- 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.test.ts +99 -0
- package/src/runtime/core/modules/pages.ts +44 -5
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.ts +19 -121
- 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
|
@@ -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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* the
|
|
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
|
|
74
|
-
* (
|
|
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
|
|
31
|
-
//
|
|
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
|
-
|
|
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,
|
|
148
|
-
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
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("
|
|
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,
|
|
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("
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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");
|
|
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
|
|
10
|
-
// `instanceTracking`; the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
18
|
-
// `
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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.
|
|
49
|
-
*
|
|
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
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
const projections = tryInstanceProjections(api);
|
|
119
|
+
reconciled = reconcileTerminatedKey(api, projections, key);
|
|
97
120
|
} catch (error) {
|
|
98
|
-
|
|
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
|
});
|