@nanobpm/nano-workforce 0.63.0 → 0.65.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 +51 -0
- package/app/agentCompletion.ts +50 -12
- package/app/feature.ts +80 -2
- package/app/featureEscalation.test.ts +180 -0
- package/app/service.ts +45 -1
- package/db/migrations/031_feature_escalation_surface.sql +28 -0
- package/e2e/feature-run.e2e.ts +58 -0
- package/nano.app.json +6 -1
- package/openapi.yaml +52 -0
- package/operations/answerFeatureEscalation.ts +68 -0
- package/package.json +2 -2
- package/pages/feature.page.json +39 -3
- package/pages/overview.page.json +37 -2
- package/resources/processes/convergence-loop.bpmn +10 -0
- package/resources/processes/feature.bpmn +104 -58
- package/resources/processes/merge-loop.bpmn +2 -0
- package/resources/processes/plan-fanout.bpmn +42 -1
- package/resources/processes/retro.bpmn +20 -0
- package/scripts/pages-contract.test.ts +1 -1
- package/workers/answer-escalation/worker.ts +3 -5
- package/workers/arm-merge/worker.ts +3 -3
- package/workers/converge-feature/worker.ts +3 -5
- package/workers/ensure-base-branch/worker.ts +3 -4
- package/workers/finalize/worker.ts +4 -16
- package/workers/mark-merged/worker.ts +3 -3
- package/workers/merge/worker.ts +3 -11
- package/workers/persist-escalation/worker.ts +5 -22
- package/workers/persist-round/worker.ts +6 -16
- package/workers/record-dependency/worker.ts +5 -4
- package/workers/record-feature/worker.ts +9 -6
- package/workers/record-feature-escalation/worker.test.ts +74 -0
- package/workers/record-feature-escalation/worker.ts +42 -0
- package/workers/record-plan/worker.ts +3 -0
- package/workers/record-plan-review/worker.ts +3 -6
- package/workers/record-results/worker.ts +3 -3
- package/workers/record-trial-merge/worker.ts +4 -0
- package/workers/record-wave/worker.ts +3 -0
- package/workers/resolve-trial-attention/worker.ts +3 -5
- package/workers/retro-gather/worker.ts +3 -3
- package/workers/retro-record/worker.ts +4 -8
- package/workers/select-wave/worker.ts +3 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.65.0](https://github.com/nanobpm/nano-workforce/compare/v0.64.0...v0.65.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* surface native feature-run escalations in the nwf UI ([#213](https://github.com/nanobpm/nano-workforce/issues/213)) ([cf14724](https://github.com/nanobpm/nano-workforce/commit/cf1472419e2c68be4902a1fce2dabfcf39f2ae54)), closes [#210](https://github.com/nanobpm/nano-workforce/issues/210) [#210](https://github.com/nanobpm/nano-workforce/issues/210)
|
|
7
|
+
|
|
8
|
+
# [0.64.0](https://github.com/nanobpm/nano-workforce/compare/v0.63.0...v0.64.0) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* type workers off the generated data envelope ([#201](https://github.com/nanobpm/nano-workforce/issues/201)) ([#208](https://github.com/nanobpm/nano-workforce/issues/208)) ([792083f](https://github.com/nanobpm/nano-workforce/commit/792083f153050862a11224288b0e27e769ab06ba)), closes [nano-ide#225](https://github.com/nano-ide/issues/225) [#225](https://github.com/nanobpm/nano-workforce/issues/225) [#228](https://github.com/nanobpm/nano-workforce/issues/228) [#211](https://github.com/nanobpm/nano-workforce/issues/211) [#211](https://github.com/nanobpm/nano-workforce/issues/211)
|
|
14
|
+
|
|
1
15
|
# [0.63.0](https://github.com/nanobpm/nano-workforce/compare/v0.62.0...v0.63.0) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -16,6 +16,7 @@ import { test } from "node:test";
|
|
|
16
16
|
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
17
17
|
import {
|
|
18
18
|
completeEscalationAsAgent,
|
|
19
|
+
completeEscalationAsHuman,
|
|
19
20
|
completeUserTaskAttributed,
|
|
20
21
|
latestCompletion,
|
|
21
22
|
revertAgentCompletion,
|
|
@@ -139,6 +140,56 @@ test("agent completer is a no-op for an unknown userTaskKey", async () => {
|
|
|
139
140
|
assertEquals(completed.length, 0);
|
|
140
141
|
});
|
|
141
142
|
|
|
143
|
+
test("a HUMAN operator completes a feature escalation via the SAME attributed resume path (issue #210)", async () => {
|
|
144
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
145
|
+
const data = memData(stores);
|
|
146
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
|
|
147
|
+
|
|
148
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
149
|
+
userTaskKey: "ut-1",
|
|
150
|
+
operatorId: "alice",
|
|
151
|
+
variables: { resolution: "answer", answer: "use v2" },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assertEquals(r.ok, true);
|
|
155
|
+
assertEquals(r.elementId, "feature-escalation");
|
|
156
|
+
|
|
157
|
+
// Identical resume path to the agent/task-inbox: completeUserTask with the exact typed variables.
|
|
158
|
+
assertEquals(completed.length, 1);
|
|
159
|
+
assertEquals(completed[0].variables, { resolution: "answer", answer: "use v2" });
|
|
160
|
+
|
|
161
|
+
// Attribution recorded as a HUMAN completion — the authority, so NOT reversible.
|
|
162
|
+
const row = stores.task_completions.rows[0] as TaskCompletion;
|
|
163
|
+
assertEquals(row.actor_kind, "human");
|
|
164
|
+
assertEquals(row.actor_id, "alice");
|
|
165
|
+
assertEquals(row.reversible, 0, "a human completion is the authority (not reversible)");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
|
|
169
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
170
|
+
const data = memData(stores);
|
|
171
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-x", elementId: "decide" }]);
|
|
172
|
+
|
|
173
|
+
const notEsc = await completeEscalationAsHuman(data, engine, {
|
|
174
|
+
userTaskKey: "ut-x",
|
|
175
|
+
operatorId: "alice",
|
|
176
|
+
variables: { resolution: "abandon" },
|
|
177
|
+
});
|
|
178
|
+
assertEquals(notEsc.ok, false);
|
|
179
|
+
assertEquals(notEsc.reason, "not an escalation task");
|
|
180
|
+
|
|
181
|
+
const missing = await completeEscalationAsHuman(data, engine, {
|
|
182
|
+
userTaskKey: "ut-missing",
|
|
183
|
+
operatorId: "alice",
|
|
184
|
+
variables: { resolution: "abandon" },
|
|
185
|
+
});
|
|
186
|
+
assertEquals(missing.ok, false);
|
|
187
|
+
assertEquals(missing.reason, "no open escalation task");
|
|
188
|
+
|
|
189
|
+
assertEquals(completed.length, 0, "neither refusal completes a task");
|
|
190
|
+
assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
|
|
191
|
+
});
|
|
192
|
+
|
|
142
193
|
test("a human can revert/override an agent completion (recording who + when + corrective note)", async () => {
|
|
143
194
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
144
195
|
const data = memData(stores);
|
package/app/agentCompletion.ts
CHANGED
|
@@ -148,6 +148,23 @@ export interface AgentCompleteResult {
|
|
|
148
148
|
elementId?: string;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/** Resolve a parked escalation user task by key: return its `elementId` if it is one of the migrated
|
|
152
|
+
* escalation tasks, or a failure reason otherwise. Shared by the agent and human completers so both
|
|
153
|
+
* refuse a non-escalation / missing target the exact same way (a key with no matching open
|
|
154
|
+
* escalation task is a 404-style no-op). */
|
|
155
|
+
async function resolveEscalationTask(
|
|
156
|
+
engine: EngineClient,
|
|
157
|
+
userTaskKey: string,
|
|
158
|
+
): Promise<{ ok: true; elementId: string } | { ok: false; reason: string }> {
|
|
159
|
+
const open = await engine.searchUserTasks();
|
|
160
|
+
const match = open.find((t) => t.userTaskKey === userTaskKey);
|
|
161
|
+
if (!match) return { ok: false, reason: "no open escalation task" };
|
|
162
|
+
if (!match.elementId || !ESCALATION_TASK_ELEMENTS.has(match.elementId)) {
|
|
163
|
+
return { ok: false, reason: "not an escalation task" };
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, elementId: match.elementId };
|
|
166
|
+
}
|
|
167
|
+
|
|
151
168
|
/** Complete an escalation user task AS AN AGENT (ADR 0046). Resolves the parked task by its key,
|
|
152
169
|
* refuses anything that is not one of the migrated escalation tasks, and routes the typed form
|
|
153
170
|
* variables through the shared attributed completer with the agent's identity. Reuses the exact
|
|
@@ -163,24 +180,45 @@ export async function completeEscalationAsAgent(
|
|
|
163
180
|
const agentId = input.agentId.trim();
|
|
164
181
|
if (!agentId) return { ok: false, reason: "agentId is required" };
|
|
165
182
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
if (!match) return { ok: false, reason: "no open escalation task" };
|
|
169
|
-
if (!match.elementId || !ESCALATION_TASK_ELEMENTS.has(match.elementId)) {
|
|
170
|
-
return { ok: false, reason: "not an escalation task" };
|
|
171
|
-
}
|
|
183
|
+
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
184
|
+
if (!resolved.ok) return resolved;
|
|
172
185
|
|
|
173
186
|
const { completionId } = await completeUserTaskAttributed(
|
|
174
187
|
data,
|
|
175
188
|
engine,
|
|
176
|
-
{
|
|
177
|
-
userTaskKey,
|
|
178
|
-
elementId: match.elementId,
|
|
179
|
-
variables: input.variables,
|
|
180
|
-
},
|
|
189
|
+
{ userTaskKey, elementId: resolved.elementId, variables: input.variables },
|
|
181
190
|
{ kind: "agent", id: agentId },
|
|
182
191
|
);
|
|
183
|
-
return { ok: true, completionId, userTaskKey, elementId:
|
|
192
|
+
return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Complete an escalation user task AS A HUMAN operator (issue #210). The exact twin of
|
|
196
|
+
* `completeEscalationAsAgent`, but attributed to a human: it drives the SAME canonical
|
|
197
|
+
* `completeUserTaskAttributed` with the operator's typed form variables, so the nwf UI's answer
|
|
198
|
+
* affordance resumes the process through the one implementation a human uses from the task inbox —
|
|
199
|
+
* no parallel completion path — while recording WHO answered in the `task_completions` ledger. A
|
|
200
|
+
* human completion is the authority (not reversible). A key with no matching open escalation task is
|
|
201
|
+
* a 404-style no-op. */
|
|
202
|
+
export async function completeEscalationAsHuman(
|
|
203
|
+
data: DataLayer,
|
|
204
|
+
engine: EngineClient,
|
|
205
|
+
input: { userTaskKey: string; variables: Record<string, unknown>; operatorId: string },
|
|
206
|
+
): Promise<AgentCompleteResult> {
|
|
207
|
+
const userTaskKey = input.userTaskKey.trim();
|
|
208
|
+
if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
|
|
209
|
+
const operatorId = input.operatorId.trim();
|
|
210
|
+
if (!operatorId) return { ok: false, reason: "operatorId is required" };
|
|
211
|
+
|
|
212
|
+
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
213
|
+
if (!resolved.ok) return resolved;
|
|
214
|
+
|
|
215
|
+
const { completionId } = await completeUserTaskAttributed(
|
|
216
|
+
data,
|
|
217
|
+
engine,
|
|
218
|
+
{ userTaskKey, elementId: resolved.elementId, variables: input.variables },
|
|
219
|
+
{ kind: "human", id: operatorId },
|
|
220
|
+
);
|
|
221
|
+
return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
|
|
184
222
|
}
|
|
185
223
|
|
|
186
224
|
export interface RevertResult {
|
package/app/feature.ts
CHANGED
|
@@ -45,12 +45,25 @@ export interface FeatureRun {
|
|
|
45
45
|
* outcome is written to `status` itself; this carries the sub-state / note (e.g. "merged",
|
|
46
46
|
* "waiting_review", or "operator: <note>"). */
|
|
47
47
|
delivery_label: string | null;
|
|
48
|
+
/** The parked `feature-escalation` user task's `question`, persisted at escalation entry by the
|
|
49
|
+
* `record-feature-escalation` worker (NOT by `pollFeatureEscalations` while parked, which
|
|
50
|
+
* deliberately never writes it) so the pages can show what the agent asked. NULL whenever the run
|
|
51
|
+
* is not parked at an escalation — cleared on the exit paths (`record-feature` / the answer
|
|
52
|
+
* operation), and, as a self-heal, by `pollFeatureEscalations` when a previously-observed task is
|
|
53
|
+
* completed out-of-band (see `deriveFeatureEscalationPatch`). */
|
|
54
|
+
escalation_question: string | null;
|
|
55
|
+
/** The completable native `feature-escalation` user-task key the answer affordance posts to
|
|
56
|
+
* (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
|
|
57
|
+
* `pollFeatureEscalations` while parked; NULL otherwise. */
|
|
58
|
+
escalation_user_task_key: string | null;
|
|
48
59
|
created_at: string;
|
|
49
60
|
updated_at: string;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
export const FEATURE_RUN_STATUSES = [
|
|
53
|
-
"running", // the agent is implementing
|
|
64
|
+
"running", // the agent is implementing
|
|
65
|
+
"escalated", // NON-terminal: the run is parked at the `feature-escalation` operator user task,
|
|
66
|
+
// waiting on a human answer (denormalised from the parked user task by pollFeatureEscalations)
|
|
54
67
|
"opened", // a PR was raised and the run ends here (converge was not requested)
|
|
55
68
|
"converging", // the opened PR was handed to the convergence loop (live state via pr_key → pull_requests)
|
|
56
69
|
"awaiting_operator", // NON-terminal: the run is blocked and parked at the feature-blocked operator user task
|
|
@@ -70,7 +83,9 @@ export type FeatureRunStatus = typeof FEATURE_RUN_STATUSES[number];
|
|
|
70
83
|
* `merged`/`converged`/`abandoned` — those are equally terminal, so redispatch gating is unaffected.
|
|
71
84
|
* `awaiting_operator` is deliberately EXCLUDED (non-terminal): while a blocked run is parked at the
|
|
72
85
|
* feature-blocked operator user task its instance is still alive, so a re-dispatch of the same issue
|
|
73
|
-
* must short-circuit (no orphaned parallel instance) until the operator acknowledges it.
|
|
86
|
+
* must short-circuit (no orphaned parallel instance) until the operator acknowledges it. `escalated`
|
|
87
|
+
* is EXCLUDED for the same reason: a run parked at the `feature-escalation` user task is still alive,
|
|
88
|
+
* so a re-dispatch must short-circuit until the human answers (or the SLA fires). */
|
|
74
89
|
export const FEATURE_TERMINAL_STATUSES: readonly FeatureRunStatus[] = [
|
|
75
90
|
"opened",
|
|
76
91
|
"converging",
|
|
@@ -117,6 +132,65 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
|
|
|
117
132
|
}
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
/** The `feature-escalation` user-task element id (feature.bpmn) — the native operator wait a run
|
|
136
|
+
* parks on when the agent escalates. `pollFeatureEscalations` reconciles it onto the read model. */
|
|
137
|
+
export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
|
|
138
|
+
|
|
139
|
+
/** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
|
|
140
|
+
* `searchUserTasks`: the completable user-task key the pages drive an attributed answer against.
|
|
141
|
+
*
|
|
142
|
+
* The agent's `question` is NOT read from here — the WASM testkit engine does not surface a user
|
|
143
|
+
* task's `zeebe:ioMapping`-mapped local variables through `searchUserTasks`, so relying on it would
|
|
144
|
+
* make the question untestable. Instead the `record-feature-escalation` service task (feature.bpmn)
|
|
145
|
+
* persists `question` onto the row at escalation entry — see `workers/record-feature-escalation`. */
|
|
146
|
+
export interface FeatureEscalationParked {
|
|
147
|
+
userTaskKey: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Pure source of truth for the escalation read-model reconcile (`pollFeatureEscalations`): given a
|
|
151
|
+
* run and whether it is currently parked at `feature-escalation`, return the minimal `feature_runs`
|
|
152
|
+
* patch reconciling the run's LIVENESS (status + completable-task pointer) with the observed park
|
|
153
|
+
* state (or null when nothing changed, so the poller skips the write). Idempotent, and — crucially —
|
|
154
|
+
* self-healing across the brief window between the `record-feature-escalation` service task and the
|
|
155
|
+
* user task actually appearing: a premature "not parked" reset to `running` is re-flipped to
|
|
156
|
+
* `escalated` on the next pass once the task is observed.
|
|
157
|
+
*
|
|
158
|
+
* - parked → flip `status` to `escalated` and denormalise the completable `userTaskKey` so the pages
|
|
159
|
+
* can drive an attributed answer. It never writes `escalation_question` while parked — that is the
|
|
160
|
+
* service task's to own (set) and the exit paths' to clear (record-feature / the answer operation),
|
|
161
|
+
* so the poller can never clobber the persisted question during that self-healing window.
|
|
162
|
+
* - un-parked → clear the completable-task pointer; a run still marked `escalated` has resumed
|
|
163
|
+
* (answered / looped back to implement-task), so it returns to `running`. A run already advanced
|
|
164
|
+
* past `escalated` by a downstream worker keeps that status — only the pointer is cleared. Once the
|
|
165
|
+
* pointer was actually OBSERVED (non-NULL) and the task is now gone, `escalation_question` is also
|
|
166
|
+
* cleared here, self-healing a question left populated when the task was completed out-of-band
|
|
167
|
+
* (bypassing the answer operation). This is gated on the observed pointer precisely so it cannot
|
|
168
|
+
* fire in the pre-observation self-healing window, where the pointer is still NULL. */
|
|
169
|
+
export function deriveFeatureEscalationPatch(
|
|
170
|
+
run: Pick<FeatureRun, "status" | "escalation_user_task_key">,
|
|
171
|
+
parked: FeatureEscalationParked | null,
|
|
172
|
+
): Partial<FeatureRun> | null {
|
|
173
|
+
const patch: Partial<FeatureRun> = {};
|
|
174
|
+
if (parked) {
|
|
175
|
+
if (run.status !== "escalated") patch.status = "escalated";
|
|
176
|
+
if (run.escalation_user_task_key !== parked.userTaskKey) patch.escalation_user_task_key = parked.userTaskKey;
|
|
177
|
+
} else {
|
|
178
|
+
if (run.status === "escalated") patch.status = "running";
|
|
179
|
+
// Un-park cleanup — fires ONLY once the poller has actually OBSERVED the task (pointer non-NULL)
|
|
180
|
+
// and it is now gone. This self-heals a `question` left populated when the task was completed
|
|
181
|
+
// out-of-band (e.g. an external task UI, bypassing the answer operation that normally clears it),
|
|
182
|
+
// which would otherwise keep the UI showing an Escalation on a run that has resumed. Gating on
|
|
183
|
+
// the pointer being non-NULL is what makes it safe: during the brief self-healing window between
|
|
184
|
+
// `record-feature-escalation` (which persists the question but leaves the pointer NULL) and the
|
|
185
|
+
// task appearing, the pointer is NULL, so this never clobbers the freshly-persisted question.
|
|
186
|
+
if (run.escalation_user_task_key !== null) {
|
|
187
|
+
patch.escalation_user_task_key = null;
|
|
188
|
+
patch.escalation_question = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return Object.keys(patch).length > 0 ? patch : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
120
194
|
export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
|
|
121
195
|
|
|
122
196
|
/** The deterministic task id for a single-issue run — the implementation agent branches
|
|
@@ -157,6 +231,8 @@ export async function startFeature(
|
|
|
157
231
|
auto_merge: autoMerge ? 1 : 0,
|
|
158
232
|
outcome: null,
|
|
159
233
|
delivery_label: null,
|
|
234
|
+
escalation_question: null,
|
|
235
|
+
escalation_user_task_key: null,
|
|
160
236
|
updated_at: ts,
|
|
161
237
|
});
|
|
162
238
|
} else {
|
|
@@ -173,6 +249,8 @@ export async function startFeature(
|
|
|
173
249
|
auto_merge: autoMerge ? 1 : 0,
|
|
174
250
|
outcome: null,
|
|
175
251
|
delivery_label: null,
|
|
252
|
+
escalation_question: null,
|
|
253
|
+
escalation_user_task_key: null,
|
|
176
254
|
created_at: ts,
|
|
177
255
|
updated_at: ts,
|
|
178
256
|
});
|
|
@@ -0,0 +1,180 @@
|
|
|
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 fake engine whose open user tasks are keyed by processInstanceKey (the only field
|
|
54
|
+
* pollFeatureEscalations queries on). */
|
|
55
|
+
function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
|
|
56
|
+
return {
|
|
57
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
58
|
+
Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
|
|
59
|
+
} as unknown as EngineClient;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("deriveFeatureEscalationPatch: a running run parked at feature-escalation flips to escalated + records the key", () => {
|
|
63
|
+
const patch = deriveFeatureEscalationPatch(
|
|
64
|
+
{ status: "running", escalation_user_task_key: null },
|
|
65
|
+
{ userTaskKey: "ut-9" },
|
|
66
|
+
);
|
|
67
|
+
assertEquals(patch, { status: "escalated", escalation_user_task_key: "ut-9" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("deriveFeatureEscalationPatch: an already-escalated run with the key recorded yields no patch (idempotent)", () => {
|
|
71
|
+
const patch = deriveFeatureEscalationPatch(
|
|
72
|
+
{ status: "escalated", escalation_user_task_key: "ut-9" },
|
|
73
|
+
{ userTaskKey: "ut-9" },
|
|
74
|
+
);
|
|
75
|
+
assertEquals(patch, null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("deriveFeatureEscalationPatch: an escalated run that un-parked resumes to running, pointer + question cleared", () => {
|
|
79
|
+
const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: "ut-9" }, null);
|
|
80
|
+
assertEquals(patch, { status: "running", escalation_user_task_key: null, escalation_question: null });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("deriveFeatureEscalationPatch: a run past escalated with a stale pointer clears the pointer + question", () => {
|
|
84
|
+
const patch = deriveFeatureEscalationPatch({ status: "awaiting_operator", escalation_user_task_key: "ut-9" }, null);
|
|
85
|
+
assertEquals(patch, { escalation_user_task_key: null, escalation_question: null });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Self-heal: a task completed out-of-band (external UI, bypassing the answer operation) leaves the
|
|
89
|
+
// run un-parked but with the pointer still recording the observed task. The poller clears BOTH the
|
|
90
|
+
// pointer and the now-stale question so the UI stops surfacing an Escalation on a resumed run.
|
|
91
|
+
test("deriveFeatureEscalationPatch: un-park after an out-of-band completion clears the stale question", () => {
|
|
92
|
+
const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: "ut-9" }, null);
|
|
93
|
+
assertEquals(patch?.escalation_question, null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// The pre-observation self-healing window (record-feature-escalation has persisted the question but
|
|
97
|
+
// the task is not yet visible, so the pointer is still NULL): a premature "not parked" pass must NOT
|
|
98
|
+
// clobber the freshly-persisted question — only reset the transient status, re-flipped next pass.
|
|
99
|
+
test("deriveFeatureEscalationPatch: the pre-observation self-healing window never clears the question", () => {
|
|
100
|
+
const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: null }, null);
|
|
101
|
+
assertEquals(patch, { status: "running" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("deriveFeatureEscalationPatch: a clean running run not parked yields no patch", () => {
|
|
105
|
+
const patch = deriveFeatureEscalationPatch({ status: "running", escalation_user_task_key: null }, null);
|
|
106
|
+
assertEquals(patch, null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("pollFeatureEscalations: a parked run is flipped to escalated with the completable key (question left to the service task)", async () => {
|
|
110
|
+
const { data, stores } = memData();
|
|
111
|
+
stores.feature_runs = [
|
|
112
|
+
{ feature_key: "o/r#1", status: "running", process_key: "100", escalation_question: null, escalation_user_task_key: null },
|
|
113
|
+
];
|
|
114
|
+
const engine = fakeEngine({ "100": [{ userTaskKey: "ut-1", elementId: "feature-escalation" }] });
|
|
115
|
+
|
|
116
|
+
await pollFeatureEscalations(data, engine);
|
|
117
|
+
|
|
118
|
+
assertEquals(stores.feature_runs[0].status, "escalated");
|
|
119
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-1");
|
|
120
|
+
// The poller does not synthesise the question — that is the record-feature-escalation service task's.
|
|
121
|
+
assertEquals(stores.feature_runs[0].escalation_question, null);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("pollFeatureEscalations: an escalated run whose task is gone resumes to running, clears the pointer + stale question", async () => {
|
|
125
|
+
const { data, stores } = memData();
|
|
126
|
+
stores.feature_runs = [
|
|
127
|
+
{ feature_key: "o/r#2", status: "escalated", process_key: "200", escalation_question: "Q", escalation_user_task_key: "ut-2" },
|
|
128
|
+
];
|
|
129
|
+
const engine = fakeEngine({ "200": [] });
|
|
130
|
+
|
|
131
|
+
await pollFeatureEscalations(data, engine);
|
|
132
|
+
|
|
133
|
+
assertEquals(stores.feature_runs[0].status, "running");
|
|
134
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
|
|
135
|
+
// The observed task is gone → self-heal the now-stale question so the UI stops surfacing it.
|
|
136
|
+
assertEquals(stores.feature_runs[0].escalation_question, null);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("pollFeatureEscalations: a re-observed parked run keeps its persisted question, only filling the key", async () => {
|
|
140
|
+
const { data, stores } = memData();
|
|
141
|
+
stores.feature_runs = [
|
|
142
|
+
{ feature_key: "o/r#3", status: "escalated", process_key: "300", escalation_question: "kept", escalation_user_task_key: null },
|
|
143
|
+
];
|
|
144
|
+
const engine = fakeEngine({ "300": [{ userTaskKey: "ut-3", elementId: "feature-escalation" }] });
|
|
145
|
+
|
|
146
|
+
await pollFeatureEscalations(data, engine);
|
|
147
|
+
|
|
148
|
+
assertEquals(stores.feature_runs[0].status, "escalated");
|
|
149
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-3");
|
|
150
|
+
assertEquals(stores.feature_runs[0].escalation_question, "kept");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("pollFeatureEscalations: only touches running/escalated runs, and never one without a process_key", async () => {
|
|
154
|
+
const { data, stores } = memData();
|
|
155
|
+
stores.feature_runs = [
|
|
156
|
+
{ feature_key: "o/r#4", status: "opened", process_key: "400", escalation_question: null, escalation_user_task_key: null },
|
|
157
|
+
{ feature_key: "o/r#5", status: "running", process_key: null, escalation_question: null, escalation_user_task_key: null },
|
|
158
|
+
];
|
|
159
|
+
const engine = fakeEngine({ "400": [{ userTaskKey: "ut-4", elementId: "feature-escalation" }] });
|
|
160
|
+
|
|
161
|
+
await pollFeatureEscalations(data, engine);
|
|
162
|
+
|
|
163
|
+
// opened is terminal → not a candidate; running with no process_key → skipped.
|
|
164
|
+
assertEquals(stores.feature_runs[0].status, "opened");
|
|
165
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
|
|
166
|
+
assertEquals(stores.feature_runs[1].status, "running");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("pollFeatureEscalations: a parked non-escalation task (feature-blocked) does not flip the run", async () => {
|
|
170
|
+
const { data, stores } = memData();
|
|
171
|
+
stores.feature_runs = [
|
|
172
|
+
{ feature_key: "o/r#6", status: "running", process_key: "600", escalation_question: null, escalation_user_task_key: null },
|
|
173
|
+
];
|
|
174
|
+
const engine = fakeEngine({ "600": [{ userTaskKey: "ut-6", elementId: "feature-blocked" }] });
|
|
175
|
+
|
|
176
|
+
await pollFeatureEscalations(data, engine);
|
|
177
|
+
|
|
178
|
+
assertEquals(stores.feature_runs[0].status, "running");
|
|
179
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
|
|
180
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
|
|
10
10
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
11
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
12
|
-
import { deriveFeatureDelivery, featureRuns } from "./feature.ts";
|
|
12
|
+
import { deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_ESCALATION_ELEMENT, type FeatureRun, featureRuns } from "./feature.ts";
|
|
13
13
|
import {
|
|
14
14
|
classifyMergeability,
|
|
15
15
|
ensureFreshHeadRun,
|
|
@@ -1283,6 +1283,49 @@ export async function pollFeatureDelivery(data: DataLayer) {
|
|
|
1283
1283
|
}
|
|
1284
1284
|
}
|
|
1285
1285
|
|
|
1286
|
+
/** Reconcile each in-flight FEATURE run against its native `feature-escalation` user task (issue
|
|
1287
|
+
* #210 — feature-run escalations were invisible in the nwf UI). When a feature run escalates it parks
|
|
1288
|
+
* on the `feature-escalation` operator user task (an engine wait); no worker runs, so `feature_runs`
|
|
1289
|
+
* — which the schema-driven pages read — stayed `running` with nothing to show. This is the
|
|
1290
|
+
* `feature_runs` twin of `pollFeatureDelivery`: for each run that can be parked at (or resuming from)
|
|
1291
|
+
* the escalation, read its open user tasks and project the parked task onto the row via the pure
|
|
1292
|
+
* `deriveFeatureEscalationPatch` — flipping `status` to `escalated` and denormalising the escalation's
|
|
1293
|
+
* completable `userTaskKey` so the pages can drive an answer, and flipping back to `running` (clearing
|
|
1294
|
+
* the pointer) once it un-parks. It never writes `escalation_question` — that is persisted by the
|
|
1295
|
+
* `record-feature-escalation` worker at escalation entry and cleared on the exit paths, so the poller
|
|
1296
|
+
* can never clobber the source of truth for the question.
|
|
1297
|
+
*
|
|
1298
|
+
* Candidates are only the runs that could be parked here — `running` (may have just escalated) and
|
|
1299
|
+
* `escalated` (may have just resumed) — queried via the `feature_runs(status)` index, so the pass
|
|
1300
|
+
* stays O(in-flight), not O(total runs). Terminal-ward transitions THROUGH `record-feature` (answer
|
|
1301
|
+
* → abandon, SLA auto-abandon, done) clear the pointer in that worker, so a run that has already left
|
|
1302
|
+
* `escalated` never needs sweeping here. Best-effort + idempotent — per-run failures are isolated. */
|
|
1303
|
+
export async function pollFeatureEscalations(data: DataLayer, engine: EngineClient) {
|
|
1304
|
+
const seen = new Set<string>();
|
|
1305
|
+
const candidates: FeatureRun[] = [];
|
|
1306
|
+
for (const status of ["running", "escalated"] as const) {
|
|
1307
|
+
for (const run of await featureRuns(data).find({ status })) {
|
|
1308
|
+
if (seen.has(run.feature_key)) continue;
|
|
1309
|
+
seen.add(run.feature_key);
|
|
1310
|
+
candidates.push(run);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
for (const run of candidates) {
|
|
1314
|
+
if (!run.process_key) continue;
|
|
1315
|
+
try {
|
|
1316
|
+
const tasks = await engine.searchUserTasks({ processInstanceKey: run.process_key });
|
|
1317
|
+
const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
|
|
1318
|
+
const parked = task ? { userTaskKey: task.userTaskKey } : null;
|
|
1319
|
+
const patch = deriveFeatureEscalationPatch(run, parked);
|
|
1320
|
+
if (patch) {
|
|
1321
|
+
await featureRuns(data).update(run.feature_key, { ...patch, updated_at: now() });
|
|
1322
|
+
}
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
console.error(`[poller] feature escalation ${run.feature_key}: ${err}`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1286
1329
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
1287
1330
|
* (when the engine REST endpoint is supplied) the job-activation visibility pass and the
|
|
1288
1331
|
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
|
|
@@ -1297,6 +1340,7 @@ export async function pollOnce(
|
|
|
1297
1340
|
await pollWaveGates(data, engine, token);
|
|
1298
1341
|
await pollDelivery(data);
|
|
1299
1342
|
await pollFeatureDelivery(data);
|
|
1343
|
+
await pollFeatureEscalations(data, engine);
|
|
1300
1344
|
if (engineRest) {
|
|
1301
1345
|
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
1302
1346
|
await pollIncidents(data, engineRest.restAddress, engineRest.token);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Surface native feature-run escalations in the nwf UI (issue #210).
|
|
2
|
+
--
|
|
3
|
+
-- When a feature run (`feature.bpmn`) escalates to a human it parks on the native
|
|
4
|
+
-- `feature-escalation` user task (`candidateGroups=operators`). That wait was
|
|
5
|
+
-- invisible in the nwf UI: the schema-driven pages read `feature_runs`, but the
|
|
6
|
+
-- open user task (the actual wait) is engine state, not a row here, so a run could
|
|
7
|
+
-- sit blocked on a human decision indefinitely with no visible signal, and
|
|
8
|
+
-- `feature_runs.status` stayed `running`.
|
|
9
|
+
--
|
|
10
|
+
-- The `record-feature-escalation` service task (feature.bpmn) runs on the
|
|
11
|
+
-- `escalated` arm, before the user task is created, and persists the escalation
|
|
12
|
+
-- onto the row: it flips `status` to `escalated` and denormalises the agent's
|
|
13
|
+
-- `question` here (it reads the process variable while it is still in scope — the
|
|
14
|
+
-- poller can't, as the WASM engine does not surface task-local user-task variables
|
|
15
|
+
-- via `searchUserTasks`). The poller (`pollFeatureEscalations` in app/service.ts)
|
|
16
|
+
-- then fills the parked `userTaskKey` in once the user task is observable, resets
|
|
17
|
+
-- `status` back to `running` when the run un-parks, and `record-feature` / the
|
|
18
|
+
-- answer operation clear both columns again on exit. Both columns are NULL
|
|
19
|
+
-- whenever the run is not parked at `feature-escalation`.
|
|
20
|
+
--
|
|
21
|
+
-- `escalation_user_task_key` is the completable native user-task key the answer
|
|
22
|
+
-- affordance posts to (`completeUserTaskAttributed`); it also gates the answer
|
|
23
|
+
-- controls in the pages (`showWhenField`, which is JS-truthy, so NULL correctly
|
|
24
|
+
-- hides them). Forward-only, additive (expand): both columns are nullable with no
|
|
25
|
+
-- default. Numbered after the current highest prefix (030); the runner wraps each
|
|
26
|
+
-- file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
27
|
+
ALTER TABLE feature_runs ADD COLUMN escalation_question TEXT;
|
|
28
|
+
ALTER TABLE feature_runs ADD COLUMN escalation_user_task_key TEXT;
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
23
23
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
24
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
|
+
import { pollFeatureEscalations } from "../app/service.ts";
|
|
25
26
|
|
|
26
27
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
28
|
|
|
@@ -56,6 +57,8 @@ interface FeatureRow {
|
|
|
56
57
|
status: string;
|
|
57
58
|
pr_key: string | null;
|
|
58
59
|
delivery_label: string | null;
|
|
60
|
+
escalation_question: string | null;
|
|
61
|
+
escalation_user_task_key: string | null;
|
|
59
62
|
}
|
|
60
63
|
interface PrRow {
|
|
61
64
|
pr_key: string;
|
|
@@ -253,4 +256,59 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
253
256
|
},
|
|
254
257
|
);
|
|
255
258
|
});
|
|
259
|
+
|
|
260
|
+
test("escalate → the poller surfaces it on the read model, and the operator answer resolves it (issue #210)", async () => {
|
|
261
|
+
let calls = 0;
|
|
262
|
+
await withApp(
|
|
263
|
+
{
|
|
264
|
+
"senior:feature": () => {
|
|
265
|
+
calls += 1;
|
|
266
|
+
return calls === 1
|
|
267
|
+
? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
|
|
268
|
+
: { status: "opened", pr: "owner/repo#210", summary: "resumed and opened" };
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{ baseBranch: "epic/e2e" },
|
|
272
|
+
async ({ app, featureKey }) => {
|
|
273
|
+
// The `record-feature-escalation` service task runs on the escalated arm (before the user task),
|
|
274
|
+
// so the row already carries the flipped status + the agent's question when the run parks — the
|
|
275
|
+
// read model the pages read is no longer blind to the native user-task wait (the #210 bug).
|
|
276
|
+
const parked = await featureRow(app, featureKey);
|
|
277
|
+
assert.equal(parked.status, "escalated", "the escalated status is surfaced on the read model");
|
|
278
|
+
assert.equal(parked.escalation_question, "Which API should I use?", "the agent's question is surfaced");
|
|
279
|
+
|
|
280
|
+
// The poller fills in the completable user-task key (which the service task can't know — the
|
|
281
|
+
// task doesn't exist yet when it runs) so the UI can drive an attributed answer.
|
|
282
|
+
await pollFeatureEscalations(app.db, app.engine);
|
|
283
|
+
const escalated = await featureRow(app, featureKey);
|
|
284
|
+
assert.ok(escalated.escalation_user_task_key, "the poller denormalised the completable user-task key");
|
|
285
|
+
assert.equal(escalated.status, "escalated", "the run stays escalated while parked");
|
|
286
|
+
|
|
287
|
+
// Answer through the app's OWN operation (the nwf UI's answer affordance) — the attributed
|
|
288
|
+
// completer resumes the SAME implement task a human would from the task inbox.
|
|
289
|
+
const answered = await app.api?.call("answerFeatureEscalation", {
|
|
290
|
+
body: { userTaskKey: escalated.escalation_user_task_key, resolution: "answer", answer: "use v2" },
|
|
291
|
+
});
|
|
292
|
+
assert.equal(answered?.status, 200, "the operator answer completed the escalation task");
|
|
293
|
+
await app.settle();
|
|
294
|
+
|
|
295
|
+
const flows = takenFlows(app);
|
|
296
|
+
assert.ok(
|
|
297
|
+
flows.includes("w_gw_answer->implement-task"),
|
|
298
|
+
`the answer re-dispatched the same implement task (flows: ${flows.join(", ")})`,
|
|
299
|
+
);
|
|
300
|
+
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
301
|
+
|
|
302
|
+
// The run opened its PR; the escalation pointer + question were cleared once resolved.
|
|
303
|
+
const settled = await featureRow(app, featureKey);
|
|
304
|
+
assert.equal(settled.status, "opened", "the resumed run opened its PR");
|
|
305
|
+
assert.equal(settled.escalation_user_task_key, null, "the escalation pointer was cleared once resolved");
|
|
306
|
+
assert.equal(settled.escalation_question, null, "the surfaced question was cleared once resolved");
|
|
307
|
+
|
|
308
|
+
// A further poll pass is an idempotent no-op — a terminal run is not a candidate.
|
|
309
|
+
await pollFeatureEscalations(app.db, app.engine);
|
|
310
|
+
assert.equal((await featureRow(app, featureKey)).status, "opened");
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
});
|
|
256
314
|
});
|