@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.
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.test.ts +92 -6
- package/app/agentCompletion.ts +86 -58
- package/app/convergeGate.test.ts +8 -4
- package/app/convergenceEscalationGuard.test.ts +106 -0
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/persist-escalation.test.ts +32 -0
- package/app/pollUserTasks.test.ts +39 -13
- package/app/roundProgress.test.ts +7 -4
- 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/resources/processes/convergence-loop.bpmn +114 -118
- package/workers/persist-escalation/worker.ts +7 -4
- 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
|
@@ -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
|
-
});
|
|
@@ -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
|
-
});
|