@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.
- package/CHANGELOG.md +7 -0
- package/app/agentCompletion.test.ts +92 -6
- package/app/agentCompletion.ts +86 -58
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/pollUserTasks.test.ts +39 -13
- package/app/service.ts +60 -132
- package/app/stage.test.ts +7 -53
- package/app/stage.ts +5 -37
- package/app/userTasks.test.ts +1 -1
- package/app/userTasks.ts +3 -3
- package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
- package/e2e/feature-run.e2e.ts +52 -41
- package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
- package/openapi.yaml +7 -108
- package/operations/agentCompleteEscalation.ts +2 -2
- package/operations/completeUserTask.test.ts +25 -6
- package/operations/completeUserTask.ts +12 -10
- package/package.json +2 -2
- package/pages/feature.page.json +1 -37
- package/pages/overview.page.json +1 -39
- package/pages/tasks.page.json +78 -93
- package/resources/forms/feature-escalation.form +3 -0
- package/workers/record-blocked-ack/worker.test.ts +1 -4
- package/workers/record-blocked-ack/worker.ts +0 -5
- package/workers/record-feature/worker.ts +0 -6
- package/workers/record-feature-escalation/worker.test.ts +14 -27
- package/workers/record-feature-escalation/worker.ts +16 -22
- package/app/featureBlocked.test.ts +0 -182
- package/app/featureEscalation.test.ts +0 -235
- package/operations/acknowledgeBlocked.test.ts +0 -111
- package/operations/acknowledgeBlocked.ts +0 -62
- package/operations/answerFeatureEscalation.ts +0 -68
|
@@ -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
|
|
4
|
-
// `question` so the nwf UI can surface it (the poller
|
|
5
|
-
// question's source of truth).
|
|
6
|
-
//
|
|
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
|
|
47
|
-
const rows = [{ feature_key: "owner/repo#7", status: "running"
|
|
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
|
-
|
|
56
|
-
//
|
|
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"
|
|
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:
|
|
81
|
-
|
|
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(
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// •
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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)
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// `
|
|
42
|
-
// is
|
|
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
|
-
});
|