@nanobpm/nano-workforce 0.99.1 → 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.
@@ -26,11 +26,6 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
26
26
  await featureRuns(app.data).update(featureKey, {
27
27
  status: "blocked",
28
28
  delivery_label: note ? `operator: ${note}` : "acknowledged",
29
- // The run has left the `feature-blocked` wait, so clear the denormalised completable-task pointer
30
- // the pages gate the "Acknowledge blocked" affordance on. pollFeatureBlocked only sweeps
31
- // `awaiting_operator` runs, so this terminal-ward transition must clear it itself or a stale pointer
32
- // would linger on the now-terminal row.
33
- blocked_user_task_key: null,
34
29
  updated_at: ts,
35
30
  });
36
31
  app.log.info("record-blocked-ack", { featureKey, note: note ?? null });
@@ -54,12 +54,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
54
54
  status: rowStatus,
55
55
  pr_key: prKey,
56
56
  outcome: summary ?? null,
57
- // The run has left the `feature-escalation` wait (answered → abandon, SLA auto-abandon, or the
58
- // agent finished without escalating), so clear the denormalised escalation pointer the pages gate
59
- // on. pollFeatureEscalations only sweeps `running`/`escalated` runs, so a terminal-ward transition
60
- // through here must clear it itself or the stale question would linger on the row.
61
- escalation_question: null,
62
- escalation_user_task_key: null,
63
57
  updated_at: ts,
64
58
  });
65
59
  app.log.info("record-feature", { featureKey, featureStatus, rowStatus, prKey });
@@ -1,9 +1,10 @@
1
1
  // Unit coverage for pr.record-feature-escalation — a feature run parked on the native
2
2
  // `feature-escalation` user task (issue #210). Runs on the `escalated` arm, before the user task
3
- // exists, and must flip the row to the non-terminal `escalated` status and persist the agent's
4
- // `question` so the nwf UI can surface it (the poller can't read task-local vars, so this is the
5
- // question's source of truth). It clears the completable-task pointer to NULL (the task does not
6
- // exist yet, so any non-null value is stale); the poller fills the real key once it is observable.
3
+ // exists, and must flip the row to the non-terminal `escalated` status and append the agent's
4
+ // `question` to the canonical `feature_escalations` audit log so the nwf UI can surface it (the poller
5
+ // can't read task-local vars, so this is the question's source of truth). Issue #332 dropped the
6
+ // denormalised `feature_runs.escalation_question` / `escalation_user_task_key` columns, so this worker
7
+ // now only flips the status and appends the audit row.
7
8
  import { test } from "node:test";
8
9
  import { assertEquals } from "#test-assert";
9
10
  import { noopLog } from "../../test/log.ts";
@@ -43,8 +44,8 @@ function fakeApp(rows: Record<string, unknown>[]): any {
43
44
  };
44
45
  }
45
46
 
46
- test("record-feature-escalation: flips the run to escalated and persists the question", async () => {
47
- const rows = [{ feature_key: "owner/repo#7", status: "running", escalation_question: null, escalation_user_task_key: null }];
47
+ test("record-feature-escalation: flips the run to escalated and appends the question to the audit log", async () => {
48
+ const rows = [{ feature_key: "owner/repo#7", status: "running" }];
48
49
  const app = fakeApp(rows);
49
50
  const out = await handler(
50
51
  { jobKey: "job-1", variables: { featureKey: "owner/repo#7", question: "Which API should I use?" } } as never,
@@ -52,11 +53,8 @@ test("record-feature-escalation: flips the run to escalated and persists the que
52
53
  );
53
54
  assertEquals(out, {});
54
55
  assertEquals(rows[0].status, "escalated");
55
- assertEquals(rows[0].escalation_question, "Which API should I use?");
56
- // The user task does not exist yet the pointer is cleared to NULL here (poller fills the real key).
57
- assertEquals(rows[0].escalation_user_task_key, null);
58
- // Dual-write (issue #305): the question is ALSO appended to the canonical `feature_escalations`
59
- // audit log so `pollUserTasks` can source it from a surviving table once the denormalised column drops.
56
+ // Issue #305/#332: the question is the sole responsibility of the canonical `feature_escalations`
57
+ // audit log so `pollUserTasks` can source it from a surviving table (the denormalised column is gone).
60
58
  assertEquals(app.stores.feature_escalations.length, 1);
61
59
  assertEquals(app.stores.feature_escalations[0].feature_key, "owner/repo#7");
62
60
  assertEquals(app.stores.feature_escalations[0].question, "Which API should I use?");
@@ -68,7 +66,7 @@ test("record-feature-escalation: a retried job (same jobKey) reuses its audit ro
68
66
  // before job completion re-runs with the SAME jobKey. The `job_key` idempotency guard must reuse the
69
67
  // existing `feature_escalations` row rather than append a duplicate (which would bloat the append-only
70
68
  // log and could skew "latest question" selection). Mirrors `record-plan-review`'s `plan_reviews` guard.
71
- const rows = [{ feature_key: "owner/repo#7", status: "running", escalation_question: null, escalation_user_task_key: null }];
69
+ const rows = [{ feature_key: "owner/repo#7", status: "running" }];
72
70
  const app = fakeApp(rows);
73
71
  const job = { jobKey: "job-retry", variables: { featureKey: "owner/repo#7", question: "Which API?" } } as never;
74
72
  await handler(job, app);
@@ -77,22 +75,11 @@ test("record-feature-escalation: a retried job (same jobKey) reuses its audit ro
77
75
  assertEquals(app.stores.feature_escalations[0].job_key, "job-retry");
78
76
  });
79
77
 
80
- test("record-feature-escalation: clears a stale completable-task pointer at escalation entry", async () => {
81
- // The task does not exist yet here, so a non-null key can only be stale (a prior escalation's key
82
- // left behind, a manual DB repair, etc). Leaving it would bind the pages' answer/abandon affordance
83
- // to the wrong task until the poller overwrites it; clear it so the row reads "key unknown here".
84
- const rows = [{ feature_key: "owner/repo#9", status: "running", escalation_question: null, escalation_user_task_key: "stale-ut" }];
85
- const app = fakeApp(rows);
86
- await handler({ jobKey: "job-9", variables: { featureKey: "owner/repo#9", question: "Q?" } } as never, app);
87
- assertEquals(rows[0].status, "escalated");
88
- assertEquals(rows[0].escalation_question, "Q?");
89
- assertEquals(rows[0].escalation_user_task_key, null);
90
- });
91
-
92
- test("record-feature-escalation: a blank/absent question is persisted as NULL (badge/affordance stay off)", async () => {
93
- const rows = [{ feature_key: "owner/repo#8", status: "running", escalation_question: null }];
78
+ test("record-feature-escalation: a blank/absent question is appended as NULL (badge/affordance stay off)", async () => {
79
+ const rows = [{ feature_key: "owner/repo#8", status: "running" }];
94
80
  const app = fakeApp(rows);
95
81
  await handler({ jobKey: "job-8", variables: { featureKey: "owner/repo#8", question: " " } } as never, app);
96
82
  assertEquals(rows[0].status, "escalated");
97
- assertEquals(rows[0].escalation_question, null);
83
+ assertEquals(app.stores.feature_escalations.length, 1);
84
+ assertEquals(app.stores.feature_escalations[0].question, null);
98
85
  });
@@ -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
- });