@nanobpm/nano-workforce 0.99.0 → 0.100.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 (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/agentCompletion.test.ts +92 -6
  3. package/app/agentCompletion.ts +86 -58
  4. package/app/convergeGate.test.ts +8 -4
  5. package/app/convergenceEscalationGuard.test.ts +106 -0
  6. package/app/feature.ts +15 -166
  7. package/app/featureGateway.test.ts +6 -57
  8. package/app/persist-escalation.test.ts +32 -0
  9. package/app/pollUserTasks.test.ts +39 -13
  10. package/app/roundProgress.test.ts +7 -4
  11. package/app/service.ts +60 -132
  12. package/app/stage.test.ts +7 -53
  13. package/app/stage.ts +5 -37
  14. package/app/userTasks.test.ts +1 -1
  15. package/app/userTasks.ts +3 -3
  16. package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
  17. package/e2e/feature-run.e2e.ts +52 -41
  18. package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
  19. package/openapi.yaml +7 -108
  20. package/operations/agentCompleteEscalation.ts +2 -2
  21. package/operations/completeUserTask.test.ts +25 -6
  22. package/operations/completeUserTask.ts +12 -10
  23. package/package.json +2 -2
  24. package/pages/feature.page.json +1 -37
  25. package/pages/overview.page.json +1 -39
  26. package/pages/tasks.page.json +78 -93
  27. package/resources/forms/feature-escalation.form +3 -0
  28. package/resources/processes/convergence-loop.bpmn +114 -118
  29. package/workers/persist-escalation/worker.ts +7 -4
  30. package/workers/record-blocked-ack/worker.test.ts +1 -4
  31. package/workers/record-blocked-ack/worker.ts +0 -5
  32. package/workers/record-feature/worker.ts +0 -6
  33. package/workers/record-feature-escalation/worker.test.ts +14 -27
  34. package/workers/record-feature-escalation/worker.ts +16 -22
  35. package/app/featureBlocked.test.ts +0 -182
  36. package/app/featureEscalation.test.ts +0 -235
  37. package/operations/acknowledgeBlocked.test.ts +0 -111
  38. package/operations/acknowledgeBlocked.ts +0 -62
  39. package/operations/answerFeatureEscalation.ts +0 -68
@@ -1,17 +1,17 @@
1
1
  // pr.record-feature-escalation — a feature run has parked on the native `feature-escalation`
2
2
  // user task (the agent reported `status:"escalated"` with a non-blank `question`). This service
3
- // task runs on the `escalated` arm, immediately BEFORE the user task is created, and persists the
4
- // escalation onto the `feature_runs` row so the nwf UI can surface it (issue #210):
5
- // flips `status` to the non-terminal `escalated` so status-based views and counts flag it, and
6
- // • denormalises the agent's `question` for the Escalation column + the answer affordance.
3
+ // task runs on the `escalated` arm, immediately BEFORE the user task is created, and:
4
+ // flips `feature_runs.status` to the non-terminal `escalated` so status-based views and counts
5
+ // flag it (and a re-dispatch of the same issue short-circuits while it is parked), and
6
+ // • appends the agent's `question` to the append-only `feature_escalations` audit log (issue #305),
7
+ // the canonical, poller-readable source for a parked run's question.
7
8
  //
8
- // It deliberately does NOT record a REAL completable `userTaskKey` — the task does not exist yet at
9
- // this point and clears any stale pointer so the row reads "key unknown until observed".
10
- // `pollFeatureEscalations` (app/service.ts) fills that pointer in once the user task is
11
- // observable via `searchUserTasks`, which is also the reason the question is captured HERE rather
12
- // than by the poller: the WASM testkit engine does not surface a user task's ioMapping-mapped local
13
- // variables through `searchUserTasks`, so the process variable must be persisted while it is still
14
- // in scope on the job.
9
+ // The completable `userTaskKey` is NOT recorded here — the task does not exist yet at this point.
10
+ // `pollUserTasks` (app/service.ts) reads the engine's open `feature-escalation` task directly once it
11
+ // is observable and projects it onto the `user_tasks` Tasks inbox, pairing it with the audit log's
12
+ // question. Capturing the question HERE (not in the poller) is required because the WASM testkit
13
+ // engine does not surface a user task's ioMapping-mapped local variables through the user-task query,
14
+ // so the process variable must be persisted while it is still in scope on the job.
15
15
  import type { AppJobHandler } from "@nanobpm/urban";
16
16
  import { featureRuns, recordFeatureEscalation } from "../../app/feature.ts";
17
17
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
@@ -27,19 +27,13 @@ const handler: AppJobHandler<In> = async (job, app) => {
27
27
  const question = nonBlank(job.variables.question);
28
28
  await featureRuns(app.data).update(featureKey, {
29
29
  status: "escalated",
30
- escalation_question: question,
31
- // The user task does not exist yet, so any non-null pointer here can only be stale (a prior
32
- // escalation's key, a manual DB repair). Clear it so the row reads "key unknown until observed"
33
- // and the pages don't bind the answer/abandon affordance to a dead task; the poller re-fills the
34
- // real key (it can always re-derive it from `searchUserTasks`), so this clear is never lossy.
35
- escalation_user_task_key: null,
36
30
  updated_at: new Date().toISOString(),
37
31
  });
38
- // Append the question to the canonical `feature_escalations` audit log (issue #305) so the poller
39
- // can source it from a SURVIVING table once the denormalised `feature_runs.escalation_question`
40
- // column is dropped in the contract phase — the feature analogue of `record-plan-review` writing
41
- // `plan_reviews`. Dual-write for now (the column above still feeds the legacy page reads); the log
42
- // is authoritative for `pollUserTasks`.
32
+ // Append the question to the canonical `feature_escalations` audit log (issue #305) the SURVIVING
33
+ // table `pollUserTasks` reads to enrich the parked `feature-escalation` task's question on the Tasks
34
+ // inbox (the feature analogue of `record-plan-review` writing `plan_reviews`). The denormalised
35
+ // `feature_runs.escalation_question` column it used to dual-write was dropped in the contract phase
36
+ // (issue #332), so this log is now the sole source of the question text.
43
37
  await recordFeatureEscalation(app.data, { featureKey, question, jobKey: job.jobKey });
44
38
  app.log.info("record-feature-escalation", { featureKey, hasQuestion: question !== null });
45
39
  return {};
@@ -1,182 +0,0 @@
1
- // Read-model derivation test for the FEATURE-run BLOCKED reconcile (issue #220 — a blocked feature run
2
- // parked at `feature-blocked` had no completion affordance in nwf). When a run reaches a `blocked`
3
- // outcome `record-feature` holds the row at the non-terminal `awaiting_operator` status and it parks on
4
- // the native `feature-blocked` operator user task; `feature_runs` (which the pages read) had a status
5
- // but NO completable-task pointer, so the pages could not drive an acknowledge action. The blocked twin
6
- // of `deriveFeatureEscalationPatch` — the pure source of truth tested here — reconciles ONLY that
7
- // completable-task pointer (never the status, which `record-feature`/`record-blocked-ack` own), which
8
- // `pollFeatureBlocked` projects onto the row.
9
- import { test } from "node:test";
10
- import { assertEquals } from "#test-assert";
11
- import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
- import { deriveFeatureBlockedPatch } from "./feature.ts";
13
- import { pollFeatureBlocked } from "./service.ts";
14
-
15
- // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory table double, mirrors featureEscalation.test.ts
16
- function memData(): { data: DataLayer; stores: Record<string, any[]> } {
17
- // biome-ignore lint/suspicious/noExplicitAny: see above
18
- const stores: Record<string, any[]> = {};
19
- function tbl(name: string, pk = "id") {
20
- // biome-ignore lint/suspicious/noExplicitAny: see above
21
- const rows = (stores[name] ??= [] as any[]);
22
- // biome-ignore lint/suspicious/noExplicitAny: see above
23
- const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
24
- return {
25
- async all() {
26
- return rows.slice();
27
- },
28
- // biome-ignore lint/suspicious/noExplicitAny: see above
29
- async get(id: any) {
30
- return rows.find((r) => r[pk] === id);
31
- },
32
- // biome-ignore lint/suspicious/noExplicitAny: see above
33
- async find(where: any = {}) {
34
- return rows.filter((r) => match(r, where));
35
- },
36
- // biome-ignore lint/suspicious/noExplicitAny: see above
37
- async insert(row: any) {
38
- rows.push({ ...row });
39
- return row[pk];
40
- },
41
- // biome-ignore lint/suspicious/noExplicitAny: see above
42
- async update(id: any, patch: any) {
43
- const r = rows.find((row) => row[pk] === id);
44
- if (r) Object.assign(r, patch);
45
- },
46
- };
47
- }
48
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
49
- return { data, stores };
50
- }
51
-
52
- /** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
53
- * defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
54
- * A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
55
- type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
56
-
57
- /** A fake engine whose user tasks are keyed by processInstanceKey (the only field
58
- * pollFeatureBlocked queries on). It models the real engine's two accessors from ONE fixture so a test
59
- * genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
60
- * (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
61
- function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
62
- const all = (filter?: { processInstanceKey?: string }) =>
63
- filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
64
- return {
65
- searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
66
- openUserTasks: (filter?: { processInstanceKey?: string }) =>
67
- Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
68
- } as unknown as EngineClient;
69
- }
70
-
71
- test("deriveFeatureBlockedPatch: a run parked at feature-blocked records the completable key (status untouched)", () => {
72
- const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: null }, { userTaskKey: "ut-9" });
73
- assertEquals(patch, { blocked_user_task_key: "ut-9" });
74
- });
75
-
76
- test("deriveFeatureBlockedPatch: an already-recorded parked run yields no patch (idempotent)", () => {
77
- const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: "ut-9" }, { userTaskKey: "ut-9" });
78
- assertEquals(patch, null);
79
- });
80
-
81
- test("deriveFeatureBlockedPatch: an observed run whose task is gone clears the stale pointer", () => {
82
- const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: "ut-9" }, null);
83
- assertEquals(patch, { blocked_user_task_key: null });
84
- });
85
-
86
- // The pre-observation self-healing window (record-feature has persisted `awaiting_operator` but the
87
- // user task is not yet visible, so the pointer is still NULL): a premature "not parked" pass must NOT
88
- // write anything — the pointer is filled in on the next pass once the task is observable.
89
- test("deriveFeatureBlockedPatch: the pre-observation self-healing window yields no patch", () => {
90
- const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: null }, null);
91
- assertEquals(patch, null);
92
- });
93
-
94
- test("pollFeatureBlocked: a parked awaiting_operator run is denormalised with the completable key", async () => {
95
- const { data, stores } = memData();
96
- stores.feature_runs = [
97
- { feature_key: "o/r#1", status: "awaiting_operator", process_key: "100", blocked_user_task_key: null },
98
- ];
99
- const engine = fakeEngine({ "100": [{ userTaskKey: "ut-1", elementId: "feature-blocked" }] });
100
-
101
- await pollFeatureBlocked(data, engine);
102
-
103
- // The poller never flips status — record-feature owns `awaiting_operator`, record-blocked-ack the terminal.
104
- assertEquals(stores.feature_runs[0].status, "awaiting_operator");
105
- assertEquals(stores.feature_runs[0].blocked_user_task_key, "ut-1");
106
- });
107
-
108
- test("pollFeatureBlocked: an observed run whose task is gone (out-of-band completion) clears the pointer", async () => {
109
- const { data, stores } = memData();
110
- stores.feature_runs = [
111
- { feature_key: "o/r#2", status: "awaiting_operator", process_key: "200", blocked_user_task_key: "ut-2" },
112
- ];
113
- const engine = fakeEngine({ "200": [] });
114
-
115
- await pollFeatureBlocked(data, engine);
116
-
117
- assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
118
- });
119
-
120
- test("pollFeatureBlocked: only touches awaiting_operator runs, and never one without a process_key", async () => {
121
- const { data, stores } = memData();
122
- stores.feature_runs = [
123
- { feature_key: "o/r#3", status: "blocked", process_key: "300", blocked_user_task_key: null },
124
- { feature_key: "o/r#4", status: "awaiting_operator", process_key: null, blocked_user_task_key: null },
125
- ];
126
- const engine = fakeEngine({ "300": [{ userTaskKey: "ut-3", elementId: "feature-blocked" }] });
127
-
128
- await pollFeatureBlocked(data, engine);
129
-
130
- // blocked is terminal → not a candidate; awaiting_operator with no process_key → skipped.
131
- assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
132
- assertEquals(stores.feature_runs[1].blocked_user_task_key, null);
133
- });
134
-
135
- test("pollFeatureBlocked: a parked non-blocked task (feature-escalation) does not record a pointer", async () => {
136
- const { data, stores } = memData();
137
- stores.feature_runs = [
138
- { feature_key: "o/r#5", status: "awaiting_operator", process_key: "500", blocked_user_task_key: null },
139
- ];
140
- const engine = fakeEngine({ "500": [{ userTaskKey: "ut-5", elementId: "feature-escalation" }] });
141
-
142
- await pollFeatureBlocked(data, engine);
143
-
144
- assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
145
- });
146
-
147
- // ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-blocked tasks ────────────
148
- // The blocked wait sits on a blocked→ack loop, so a re-blocked run holds a COMPLETED feature-blocked
149
- // task from a prior round alongside the live CREATED one; the engine returns the COMPLETED task first.
150
- // Scoping the query to open (CREATED) tasks records the live key, never the terminal one.
151
- test("pollFeatureBlocked: a looping run records the CREATED task, never the COMPLETED one", async () => {
152
- const { data, stores } = memData();
153
- stores.feature_runs = [
154
- { feature_key: "o/r#6", status: "awaiting_operator", process_key: "600", blocked_user_task_key: null },
155
- ];
156
- const engine = fakeEngine({
157
- "600": [
158
- { userTaskKey: "ut-completed", elementId: "feature-blocked", state: "COMPLETED" },
159
- { userTaskKey: "ut-live", elementId: "feature-blocked", state: "CREATED" },
160
- ],
161
- });
162
-
163
- await pollFeatureBlocked(data, engine);
164
-
165
- assertEquals(stores.feature_runs[0].blocked_user_task_key, "ut-live");
166
- });
167
-
168
- // Self-heal reached: a run past the blocked wait whose only feature-blocked task is COMPLETED must
169
- // clear the stale pointer (open-task query returns [] → `parked=null`), not latch it on the dead key.
170
- test("pollFeatureBlocked: a run whose only feature-blocked task is COMPLETED clears the stale pointer", async () => {
171
- const { data, stores } = memData();
172
- stores.feature_runs = [
173
- { feature_key: "o/r#7", status: "awaiting_operator", process_key: "700", blocked_user_task_key: "ut-7" },
174
- ];
175
- const engine = fakeEngine({
176
- "700": [{ userTaskKey: "ut-7", elementId: "feature-blocked", state: "COMPLETED" }],
177
- });
178
-
179
- await pollFeatureBlocked(data, engine);
180
-
181
- assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
182
- });
@@ -1,235 +0,0 @@
1
- // Read-model derivation test for the FEATURE-run escalation reconcile (issue #210 — feature-run
2
- // escalations were invisible in the nwf UI). When a feature run escalates it parks on the native
3
- // `feature-escalation` user task; `feature_runs` (which the pages read) stayed `running` with
4
- // nothing to show. Two collaborators fix that: the `record-feature-escalation` service task persists
5
- // the `status`-flip's companion `question` at escalation entry (it can read the process variable
6
- // while it is still in scope), and `deriveFeatureEscalationPatch` — the pure source of truth tested
7
- // here — reconciles the run's LIVENESS (status + completable-task pointer) that `pollFeatureEscalations`
8
- // projects onto the row. The poller never touches `escalation_question`, so it can never clobber the
9
- // service task's write during the self-healing window before the user task is observable.
10
- import { test } from "node:test";
11
- import { assertEquals } from "#test-assert";
12
- import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
- import { deriveFeatureEscalationPatch } from "./feature.ts";
14
- import { pollFeatureEscalations } from "./service.ts";
15
-
16
- // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory table double, mirrors featureDelivery.test.ts
17
- function memData(): { data: DataLayer; stores: Record<string, any[]> } {
18
- // biome-ignore lint/suspicious/noExplicitAny: see above
19
- const stores: Record<string, any[]> = {};
20
- function tbl(name: string, pk = "id") {
21
- // biome-ignore lint/suspicious/noExplicitAny: see above
22
- const rows = (stores[name] ??= [] as any[]);
23
- // biome-ignore lint/suspicious/noExplicitAny: see above
24
- const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
25
- return {
26
- async all() {
27
- return rows.slice();
28
- },
29
- // biome-ignore lint/suspicious/noExplicitAny: see above
30
- async get(id: any) {
31
- return rows.find((r) => r[pk] === id);
32
- },
33
- // biome-ignore lint/suspicious/noExplicitAny: see above
34
- async find(where: any = {}) {
35
- return rows.filter((r) => match(r, where));
36
- },
37
- // biome-ignore lint/suspicious/noExplicitAny: see above
38
- async insert(row: any) {
39
- rows.push({ ...row });
40
- return row[pk];
41
- },
42
- // biome-ignore lint/suspicious/noExplicitAny: see above
43
- async update(id: any, patch: any) {
44
- const r = rows.find((row) => row[pk] === id);
45
- if (r) Object.assign(r, patch);
46
- },
47
- };
48
- }
49
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
50
- return { data, stores };
51
- }
52
-
53
- /** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
54
- * defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
55
- * A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
56
- type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
57
-
58
- /** A fake engine whose user tasks are keyed by processInstanceKey (the only field
59
- * pollFeatureEscalations queries on). It models the real engine's two accessors from ONE fixture so a
60
- * test genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
61
- * (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
62
- function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
63
- const all = (filter?: { processInstanceKey?: string }) =>
64
- filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
65
- return {
66
- searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
67
- openUserTasks: (filter?: { processInstanceKey?: string }) =>
68
- Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
69
- } as unknown as EngineClient;
70
- }
71
-
72
- test("deriveFeatureEscalationPatch: a running run parked at feature-escalation flips to escalated + records the key", () => {
73
- const patch = deriveFeatureEscalationPatch(
74
- { status: "running", escalation_user_task_key: null },
75
- { userTaskKey: "ut-9" },
76
- );
77
- assertEquals(patch, { status: "escalated", escalation_user_task_key: "ut-9" });
78
- });
79
-
80
- test("deriveFeatureEscalationPatch: an already-escalated run with the key recorded yields no patch (idempotent)", () => {
81
- const patch = deriveFeatureEscalationPatch(
82
- { status: "escalated", escalation_user_task_key: "ut-9" },
83
- { userTaskKey: "ut-9" },
84
- );
85
- assertEquals(patch, null);
86
- });
87
-
88
- test("deriveFeatureEscalationPatch: an escalated run that un-parked resumes to running, pointer + question cleared", () => {
89
- const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: "ut-9" }, null);
90
- assertEquals(patch, { status: "running", escalation_user_task_key: null, escalation_question: null });
91
- });
92
-
93
- test("deriveFeatureEscalationPatch: a run past escalated with a stale pointer clears the pointer + question", () => {
94
- const patch = deriveFeatureEscalationPatch({ status: "awaiting_operator", escalation_user_task_key: "ut-9" }, null);
95
- assertEquals(patch, { escalation_user_task_key: null, escalation_question: null });
96
- });
97
-
98
- // Self-heal: a task completed out-of-band (external UI, bypassing the answer operation) leaves the
99
- // run un-parked but with the pointer still recording the observed task. The poller clears BOTH the
100
- // pointer and the now-stale question so the UI stops surfacing an Escalation on a resumed run.
101
- test("deriveFeatureEscalationPatch: un-park after an out-of-band completion clears the stale question", () => {
102
- const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: "ut-9" }, null);
103
- assertEquals(patch?.escalation_question, null);
104
- });
105
-
106
- // The pre-observation self-healing window (record-feature-escalation has persisted the question but
107
- // the task is not yet visible, so the pointer is still NULL): a premature "not parked" pass must NOT
108
- // clobber the freshly-persisted question — only reset the transient status, re-flipped next pass.
109
- test("deriveFeatureEscalationPatch: the pre-observation self-healing window never clears the question", () => {
110
- const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: null }, null);
111
- assertEquals(patch, { status: "running" });
112
- });
113
-
114
- test("deriveFeatureEscalationPatch: a clean running run not parked yields no patch", () => {
115
- const patch = deriveFeatureEscalationPatch({ status: "running", escalation_user_task_key: null }, null);
116
- assertEquals(patch, null);
117
- });
118
-
119
- test("pollFeatureEscalations: a parked run is flipped to escalated with the completable key (question left to the service task)", async () => {
120
- const { data, stores } = memData();
121
- stores.feature_runs = [
122
- { feature_key: "o/r#1", status: "running", process_key: "100", escalation_question: null, escalation_user_task_key: null },
123
- ];
124
- const engine = fakeEngine({ "100": [{ userTaskKey: "ut-1", elementId: "feature-escalation" }] });
125
-
126
- await pollFeatureEscalations(data, engine);
127
-
128
- assertEquals(stores.feature_runs[0].status, "escalated");
129
- assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-1");
130
- // The poller does not synthesise the question — that is the record-feature-escalation service task's.
131
- assertEquals(stores.feature_runs[0].escalation_question, null);
132
- });
133
-
134
- test("pollFeatureEscalations: an escalated run whose task is gone resumes to running, clears the pointer + stale question", async () => {
135
- const { data, stores } = memData();
136
- stores.feature_runs = [
137
- { feature_key: "o/r#2", status: "escalated", process_key: "200", escalation_question: "Q", escalation_user_task_key: "ut-2" },
138
- ];
139
- const engine = fakeEngine({ "200": [] });
140
-
141
- await pollFeatureEscalations(data, engine);
142
-
143
- assertEquals(stores.feature_runs[0].status, "running");
144
- assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
145
- // The observed task is gone → self-heal the now-stale question so the UI stops surfacing it.
146
- assertEquals(stores.feature_runs[0].escalation_question, null);
147
- });
148
-
149
- test("pollFeatureEscalations: a re-observed parked run keeps its persisted question, only filling the key", async () => {
150
- const { data, stores } = memData();
151
- stores.feature_runs = [
152
- { feature_key: "o/r#3", status: "escalated", process_key: "300", escalation_question: "kept", escalation_user_task_key: null },
153
- ];
154
- const engine = fakeEngine({ "300": [{ userTaskKey: "ut-3", elementId: "feature-escalation" }] });
155
-
156
- await pollFeatureEscalations(data, engine);
157
-
158
- assertEquals(stores.feature_runs[0].status, "escalated");
159
- assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-3");
160
- assertEquals(stores.feature_runs[0].escalation_question, "kept");
161
- });
162
-
163
- test("pollFeatureEscalations: only touches running/escalated runs, and never one without a process_key", async () => {
164
- const { data, stores } = memData();
165
- stores.feature_runs = [
166
- { feature_key: "o/r#4", status: "opened", process_key: "400", escalation_question: null, escalation_user_task_key: null },
167
- { feature_key: "o/r#5", status: "running", process_key: null, escalation_question: null, escalation_user_task_key: null },
168
- ];
169
- const engine = fakeEngine({ "400": [{ userTaskKey: "ut-4", elementId: "feature-escalation" }] });
170
-
171
- await pollFeatureEscalations(data, engine);
172
-
173
- // opened is terminal → not a candidate; running with no process_key → skipped.
174
- assertEquals(stores.feature_runs[0].status, "opened");
175
- assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
176
- assertEquals(stores.feature_runs[1].status, "running");
177
- });
178
-
179
- test("pollFeatureEscalations: a parked non-escalation task (feature-blocked) does not flip the run", async () => {
180
- const { data, stores } = memData();
181
- stores.feature_runs = [
182
- { feature_key: "o/r#6", status: "running", process_key: "600", escalation_question: null, escalation_user_task_key: null },
183
- ];
184
- const engine = fakeEngine({ "600": [{ userTaskKey: "ut-6", elementId: "feature-blocked" }] });
185
-
186
- await pollFeatureEscalations(data, engine);
187
-
188
- assertEquals(stores.feature_runs[0].status, "running");
189
- assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
190
- });
191
-
192
- // ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-escalation tasks ─────────
193
- // A run in an escalate→answer→implement→re-escalate loop holds a COMPLETED feature-escalation task
194
- // from a prior round alongside the live CREATED one. The engine returns the COMPLETED task first, so a
195
- // poller that reads UNFILTERED tasks and `.find`s by element latches onto the terminal key — pinning
196
- // the pointer at a dead task. Scoping the query to open (CREATED) tasks resolves the live one.
197
- test("pollFeatureEscalations: a looping run resolves the CREATED task, never the COMPLETED one", async () => {
198
- const { data, stores } = memData();
199
- stores.feature_runs = [
200
- { feature_key: "o/r#7", status: "running", process_key: "700", escalation_question: null, escalation_user_task_key: null },
201
- ];
202
- // Engine returns the COMPLETED (prior-round) task FIRST, then the live CREATED one (issue #294).
203
- const engine = fakeEngine({
204
- "700": [
205
- { userTaskKey: "ut-completed", elementId: "feature-escalation", state: "COMPLETED" },
206
- { userTaskKey: "ut-live", elementId: "feature-escalation", state: "CREATED" },
207
- ],
208
- });
209
-
210
- await pollFeatureEscalations(data, engine);
211
-
212
- assertEquals(stores.feature_runs[0].status, "escalated");
213
- // Must be the live CREATED task, not the COMPLETED prior-round one.
214
- assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-live");
215
- });
216
-
217
- // Self-heal reached: once a run finally exits escalation for good, only a lingering COMPLETED
218
- // feature-escalation task remains. The open-task query returns [], so `parked=null` and the run
219
- // resets escalated→running — instead of a false-positive pointer built from the terminal task pinning
220
- // `status='escalated'` forever.
221
- test("pollFeatureEscalations: a run whose only feature-escalation task is COMPLETED self-heals to running", async () => {
222
- const { data, stores } = memData();
223
- stores.feature_runs = [
224
- { feature_key: "o/r#8", status: "escalated", process_key: "800", escalation_question: "Q", escalation_user_task_key: "ut-8" },
225
- ];
226
- const engine = fakeEngine({
227
- "800": [{ userTaskKey: "ut-8", elementId: "feature-escalation", state: "COMPLETED" }],
228
- });
229
-
230
- await pollFeatureEscalations(data, engine);
231
-
232
- assertEquals(stores.feature_runs[0].status, "running");
233
- assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
234
- assertEquals(stores.feature_runs[0].escalation_question, null);
235
- });
@@ -1,111 +0,0 @@
1
- // Tests for the POST /app/api/actions/acknowledge-blocked operation `acknowledgeBlocked` (issue #220).
2
- // The nwf UI's completion affordance for a blocked feature run parked at `feature-blocked`: it routes
3
- // through the canonical attributed completer (completeBlockedAsHuman → completeUserTaskAttributed),
4
- // resuming the process (→ pr.record-blocked-ack) exactly as the task inbox would, and immediately
5
- // clears the denormalised completable-task pointer so the affordance stops rendering. Mirrors the
6
- // escalation twin (operations/answerFeatureEscalation.ts).
7
- import { test } from "node:test";
8
- import { assertEquals } from "#test-assert";
9
- import type { AppApi } from "@nanobpm/urban";
10
- import { noopLog } from "../test/log.ts";
11
- import handler from "./acknowledgeBlocked.ts";
12
-
13
- function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
14
- app: AppApi;
15
- stores: Record<string, any[]>;
16
- completed: { userTaskKey: string; variables: Record<string, unknown> }[];
17
- } {
18
- const stores: Record<string, any[]> = {};
19
- const completed: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
20
- function tbl(name: string, pk: string) {
21
- const rows = (stores[name] ??= [] as any[]);
22
- return {
23
- async insert(row: any) {
24
- rows.push({ ...row });
25
- return rows.length;
26
- },
27
- async get(id: any) {
28
- return rows.find((r) => r[pk] === id);
29
- },
30
- async find(where: any = {}) {
31
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
32
- },
33
- async delete(id: any) {
34
- const i = rows.findIndex((r) => r[pk] === id);
35
- if (i >= 0) rows.splice(i, 1);
36
- },
37
- async update(id: any, patch: any) {
38
- const r = rows.find((row) => row[pk] === id);
39
- if (r) Object.assign(r, patch);
40
- },
41
- };
42
- }
43
- const engine = {
44
- searchUserTasks: async () => openTasks,
45
- completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
46
- completed.push({ userTaskKey, variables });
47
- },
48
- };
49
- const app = {
50
- data: { table: (n: string, pk: string) => tbl(n, pk) },
51
- engine,
52
- log: noopLog(),
53
- } as any as AppApi;
54
- return { app, stores, completed };
55
- }
56
-
57
- async function call(app: AppApi, body: unknown) {
58
- return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
59
- }
60
-
61
- test("acknowledge-blocked: completes the feature-blocked task, records the note, clears the pointer", async () => {
62
- const { app, stores, completed } = memApp([{ userTaskKey: "ut-1", elementId: "feature-blocked" }]);
63
- stores.feature_runs = [{ feature_key: "o/r#1", status: "awaiting_operator", blocked_user_task_key: "ut-1" }];
64
-
65
- const res = await call(app, { userTaskKey: "ut-1", note: "reassigned to a human", operator: "alice" });
66
-
67
- assertEquals(res.status, 200);
68
- assertEquals(res.body.ok, true);
69
- // The task is completed with the typed `note` variable the record-blocked-ack ioMapping reads.
70
- assertEquals(completed.length, 1);
71
- assertEquals(completed[0].userTaskKey, "ut-1");
72
- assertEquals(completed[0].variables, { note: "reassigned to a human" });
73
- // The attribution ledger records WHO acknowledged (a human — the authority, not reversible).
74
- assertEquals(stores.task_completions.length, 1);
75
- assertEquals(stores.task_completions[0].actor_kind, "human");
76
- assertEquals(stores.task_completions[0].actor_id, "alice");
77
- assertEquals(stores.task_completions[0].reversible, 0);
78
- // The operation clears its own action's pointer immediately (status left to record-blocked-ack).
79
- assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
80
- });
81
-
82
- test("acknowledge-blocked: a blank note omits the variable so the ioMapping fallback fires", async () => {
83
- const { app, stores, completed } = memApp([{ userTaskKey: "ut-2", elementId: "feature-blocked" }]);
84
- stores.feature_runs = [{ feature_key: "o/r#2", status: "awaiting_operator", blocked_user_task_key: "ut-2" }];
85
-
86
- const res = await call(app, { userTaskKey: "ut-2", note: " " });
87
-
88
- assertEquals(res.status, 200);
89
- assertEquals(completed[0].variables, {});
90
- });
91
-
92
- test("acknowledge-blocked: a missing userTaskKey → 400", async () => {
93
- const { app } = memApp([]);
94
- const res = await call(app, { note: "x" });
95
- assertEquals(res.status, 400);
96
- assertEquals(res.body.ok, false);
97
- });
98
-
99
- test("acknowledge-blocked: no matching open task → 404", async () => {
100
- const { app } = memApp([]);
101
- const res = await call(app, { userTaskKey: "ut-gone" });
102
- assertEquals(res.status, 404);
103
- assertEquals(res.body.ok, false);
104
- });
105
-
106
- test("acknowledge-blocked: refuses a non-blocked task (an escalation) → 400", async () => {
107
- const { app } = memApp([{ userTaskKey: "ut-esc", elementId: "feature-escalation" }]);
108
- const res = await call(app, { userTaskKey: "ut-esc" });
109
- assertEquals(res.status, 400);
110
- assertEquals(res.body.ok, false);
111
- });
@@ -1,62 +0,0 @@
1
- // POST /app/api/actions/acknowledge-blocked → operationId `acknowledgeBlocked` (issue #220).
2
- // The nwf UI's completion affordance for a BLOCKED single-issue feature run: an operator acknowledges
3
- // the parked `feature-blocked` user task (with an optional disposition note) directly from the Feature /
4
- // Overview pages, instead of the run sitting parked forever with no control (the escalation path got
5
- // this in issue #210; the blocked path did not).
6
- //
7
- // It routes through the ONE canonical attributed completer (`completeBlockedAsHuman` →
8
- // `completeUserTaskAttributed`), so the completion uses the exact same typed `.form` variable (`note`)
9
- // and engine resume path a human drives from the task inbox — no parallel completion — while recording
10
- // WHO acknowledged in the `task_completions` ledger. Completing the task fires `pr.record-blocked-ack`,
11
- // which settles the row to the terminal `blocked` status with the operator's note. The poller then
12
- // reconciles the completable-task pointer off the row (pollFeatureBlocked) once the task is gone.
13
- //
14
- // The runtime validates the body against openapi.yaml (`userTaskKey` required); this delegate narrows
15
- // the validated shape and builds the typed completion variables the `feature-blocked` form + the
16
- // `record-blocked-ack` ioMapping expect.
17
-
18
- import { completeBlockedAsHuman } from "../app/agentCompletion.ts";
19
- import { featureRuns } from "../app/feature.ts";
20
- import { defineOperation } from "../nano-generated/operations.ts";
21
-
22
- const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
23
-
24
- export default defineOperation("acknowledgeBlocked", async ({ body }, app) => {
25
- if (!body || typeof body !== "object") {
26
- app.log.warn("acknowledge-blocked rejected: missing request body");
27
- return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
28
- }
29
-
30
- const userTaskKey = str(body.userTaskKey);
31
- if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
32
-
33
- // The `feature-blocked` form completes with an optional `note`; the `record-blocked-ack` ioMapping
34
- // reads it (`if is defined(note) then note else null`) into `delivery_label`. An absent/blank note is
35
- // recorded as an "acknowledged" label rather than an empty string — omit the variable entirely so the
36
- // ioMapping's `is defined` fallback fires.
37
- const note = str(body.note);
38
- const variables: Record<string, unknown> = note ? { note } : {};
39
-
40
- // The completing operator, for the attribution ledger. Optional — the UI has no per-operator auth, so
41
- // default to a generic handle rather than blocking the acknowledgement.
42
- const operatorId = str(body.operator) || "operator";
43
-
44
- const r = await completeBlockedAsHuman(app.data, app.engine, { userTaskKey, operatorId, variables });
45
- if (r.ok) {
46
- // Reconcile this operation's OWN action immediately: clear the denormalised blocked pointer so the
47
- // pages stop offering an acknowledge affordance for a task that is now completed. Leave `status` to
48
- // `record-blocked-ack` (which settles it to terminal `blocked`), so we never overwrite the status the
49
- // resumed run has advanced to.
50
- for (const run of await featureRuns(app.data).find({ blocked_user_task_key: userTaskKey })) {
51
- await featureRuns(app.data).update(run.feature_key, {
52
- blocked_user_task_key: null,
53
- updated_at: new Date().toISOString(),
54
- });
55
- }
56
- app.log.info("operator acknowledged blocked feature run", { userTaskKey, elementId: r.elementId });
57
- return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
58
- }
59
- const status = r.reason === "no open blocked task" ? 404 : 400;
60
- app.log.warn("acknowledge-blocked: not completed", { userTaskKey, reason: r.reason });
61
- return { status, body: { ok: false, error: r.reason } };
62
- });