@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.100.0](https://github.com/nanobpm/nano-workforce/compare/v0.99.1...v0.100.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* retire interim feature-run escalation/blocked surface ([#339](https://github.com/nanobpm/nano-workforce/issues/339)) ([525d0f7](https://github.com/nanobpm/nano-workforce/commit/525d0f7a7f9661f640899e4bdf1b32052cff4ed9)), closes [#305](https://github.com/nanobpm/nano-workforce/issues/305) [#310](https://github.com/nanobpm/nano-workforce/issues/310) [#332](https://github.com/nanobpm/nano-workforce/issues/332) [#332](https://github.com/nanobpm/nano-workforce/issues/332)
|
|
7
|
+
|
|
1
8
|
## [0.99.1](https://github.com/nanobpm/nano-workforce/compare/v0.99.0...v0.99.1) (2026-08-19)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -62,11 +62,15 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
|
62
62
|
} as any;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** A stub engine recording every `completeUserTask`, with a seeded set of open user tasks.
|
|
65
|
+
/** A stub engine recording every `completeUserTask`, with a seeded set of open user tasks. The
|
|
66
|
+
* completers resolve tasks via `openUserTasks` (CREATED only), so the fixture exposes it; a legacy
|
|
67
|
+
* `searchUserTasks` (ANY state) is also present to prove the completer does NOT reach for it. */
|
|
66
68
|
function fakeEngine(openTasks: Array<{ userTaskKey: string; elementId?: string }>) {
|
|
67
69
|
const completed: Array<{ userTaskKey: string; variables?: Record<string, unknown> }> = [];
|
|
68
70
|
const engine = {
|
|
69
|
-
|
|
71
|
+
openUserTasks: (_filter?: Record<string, unknown>) => Promise.resolve(openTasks),
|
|
72
|
+
searchUserTasks: (_filter?: Record<string, unknown>) =>
|
|
73
|
+
Promise.reject(new Error("completer must resolve via openUserTasks, not searchUserTasks")),
|
|
70
74
|
completeUserTask: (userTaskKey: string, variables?: Record<string, unknown>) => {
|
|
71
75
|
completed.push({ userTaskKey, variables });
|
|
72
76
|
return Promise.resolve();
|
|
@@ -118,7 +122,7 @@ test("agent completer refuses a non-escalation user task (scoped to the migrated
|
|
|
118
122
|
});
|
|
119
123
|
|
|
120
124
|
assertEquals(r.ok, false);
|
|
121
|
-
assertEquals(r.reason, "not
|
|
125
|
+
assertEquals(r.reason, "not a completable task");
|
|
122
126
|
assertEquals(completed.length, 0, "a non-escalation task is never completed");
|
|
123
127
|
assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
|
|
124
128
|
});
|
|
@@ -137,10 +141,40 @@ test("agent completer is a no-op for an unknown userTaskKey", async () => {
|
|
|
137
141
|
});
|
|
138
142
|
|
|
139
143
|
assertEquals(r.ok, false);
|
|
140
|
-
assertEquals(r.reason, "no open
|
|
144
|
+
assertEquals(r.reason, "no open completable task");
|
|
141
145
|
assertEquals(completed.length, 0);
|
|
142
146
|
});
|
|
143
147
|
|
|
148
|
+
test("completer resolves only OPEN tasks — a completed/canceled task's key is a 404-style no-op, not a doomed re-completion", async () => {
|
|
149
|
+
// A looping instance keeps COMPLETED/CANCELED user tasks alongside the live one. If the completer
|
|
150
|
+
// matched by key against ANY-state tasks (`searchUserTasks`), a stale key would drive a re-completion
|
|
151
|
+
// that the engine rejects with a 5xx instead of the intended "no open task" no-op. The completer must
|
|
152
|
+
// query `openUserTasks` (CREATED only), so a key that exists only as a non-open task does not match.
|
|
153
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
154
|
+
const data = memData(stores);
|
|
155
|
+
const completed: Array<{ userTaskKey: string }> = [];
|
|
156
|
+
const engine = {
|
|
157
|
+
// No open tasks…
|
|
158
|
+
openUserTasks: () => Promise.resolve([]),
|
|
159
|
+
// …even though a completed task with this key exists in the full (any-state) search.
|
|
160
|
+
searchUserTasks: () => Promise.resolve([{ userTaskKey: "ut-done", elementId: "feature-escalation" }]),
|
|
161
|
+
completeUserTask: (userTaskKey: string) => {
|
|
162
|
+
completed.push({ userTaskKey });
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
},
|
|
165
|
+
} as any;
|
|
166
|
+
|
|
167
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
168
|
+
userTaskKey: "ut-done",
|
|
169
|
+
operatorId: "alice",
|
|
170
|
+
variables: { resolution: "abandon" },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
assertEquals(r.ok, false);
|
|
174
|
+
assertEquals(r.reason, "no open completable task");
|
|
175
|
+
assertEquals(completed.length, 0, "a non-open key never drives a doomed re-completion");
|
|
176
|
+
});
|
|
177
|
+
|
|
144
178
|
test("a HUMAN operator completes a feature escalation via the SAME attributed resume path (issue #210)", async () => {
|
|
145
179
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
146
180
|
const data = memData(stores);
|
|
@@ -166,6 +200,35 @@ test("a HUMAN operator completes a feature escalation via the SAME attributed re
|
|
|
166
200
|
assertEquals(row.reversible, 0, "a human completion is the authority (not reversible)");
|
|
167
201
|
});
|
|
168
202
|
|
|
203
|
+
test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332)", async () => {
|
|
204
|
+
// Issue #332 folded the bespoke `acknowledge-blocked` door onto the canonical human completer, so a
|
|
205
|
+
// HUMAN operator retires a blocked run through `completeEscalationAsHuman`. It stays OUTSIDE the agent
|
|
206
|
+
// surface (`ESCALATION_TASK_ELEMENTS`) — an agent must never acknowledge a blocked run on a human's
|
|
207
|
+
// behalf — so the agent completer refuses it.
|
|
208
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
209
|
+
const data = memData(stores);
|
|
210
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-b", elementId: "feature-blocked" }]);
|
|
211
|
+
|
|
212
|
+
const asAgent = await completeEscalationAsAgent(data, engine, {
|
|
213
|
+
userTaskKey: "ut-b",
|
|
214
|
+
agentId: "bot",
|
|
215
|
+
variables: { note: "n" },
|
|
216
|
+
});
|
|
217
|
+
assertEquals(asAgent.ok, false, "the agent completer refuses feature-blocked");
|
|
218
|
+
assertEquals(asAgent.reason, "not a completable task");
|
|
219
|
+
assertEquals(completed.length, 0);
|
|
220
|
+
|
|
221
|
+
const asHuman = await completeEscalationAsHuman(data, engine, {
|
|
222
|
+
userTaskKey: "ut-b",
|
|
223
|
+
operatorId: "alice",
|
|
224
|
+
variables: { note: "reassigned to a human" },
|
|
225
|
+
});
|
|
226
|
+
assertEquals(asHuman.ok, true, "the human completer retires feature-blocked");
|
|
227
|
+
assertEquals(asHuman.elementId, "feature-blocked");
|
|
228
|
+
assertEquals(completed.length, 1);
|
|
229
|
+
assertEquals(completed[0].variables, { note: "reassigned to a human" });
|
|
230
|
+
});
|
|
231
|
+
|
|
169
232
|
test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
|
|
170
233
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
171
234
|
const data = memData(stores);
|
|
@@ -177,7 +240,7 @@ test("human completer refuses a non-escalation user task and is a no-op for an u
|
|
|
177
240
|
variables: { resolution: "abandon" },
|
|
178
241
|
});
|
|
179
242
|
assertEquals(notEsc.ok, false);
|
|
180
|
-
assertEquals(notEsc.reason, "not
|
|
243
|
+
assertEquals(notEsc.reason, "not a completable task");
|
|
181
244
|
|
|
182
245
|
const missing = await completeEscalationAsHuman(data, engine, {
|
|
183
246
|
userTaskKey: "ut-missing",
|
|
@@ -185,7 +248,7 @@ test("human completer refuses a non-escalation user task and is a no-op for an u
|
|
|
185
248
|
variables: { resolution: "abandon" },
|
|
186
249
|
});
|
|
187
250
|
assertEquals(missing.ok, false);
|
|
188
|
-
assertEquals(missing.reason, "no open
|
|
251
|
+
assertEquals(missing.reason, "no open completable task");
|
|
189
252
|
|
|
190
253
|
assertEquals(completed.length, 0, "neither refusal completes a task");
|
|
191
254
|
assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
|
|
@@ -455,3 +518,26 @@ test("validateEscalationVariables derives its contract from the canonical .form
|
|
|
455
518
|
// an element with no linked form contract is not enforced
|
|
456
519
|
assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
|
|
457
520
|
});
|
|
521
|
+
|
|
522
|
+
test("feature-escalation demands non-blank answer on the answer path, but not on the hidden abandon path", async () => {
|
|
523
|
+
// resolution=answer shows the conditional `answer` field, which is required → blank/missing rejected.
|
|
524
|
+
assert(
|
|
525
|
+
validateEscalationVariables("feature-escalation", { resolution: "answer" }) !== null,
|
|
526
|
+
"resolution=answer with no guidance is rejected",
|
|
527
|
+
);
|
|
528
|
+
assert(
|
|
529
|
+
validateEscalationVariables("feature-escalation", { resolution: "answer", answer: " " }) !== null,
|
|
530
|
+
"resolution=answer with whitespace-only guidance is rejected",
|
|
531
|
+
);
|
|
532
|
+
assertEquals(
|
|
533
|
+
validateEscalationVariables("feature-escalation", { resolution: "answer", answer: "use v2" }),
|
|
534
|
+
null,
|
|
535
|
+
"resolution=answer with real guidance passes",
|
|
536
|
+
);
|
|
537
|
+
// resolution=abandon HIDES the `answer` field, so its required-ness is not enforced.
|
|
538
|
+
assertEquals(
|
|
539
|
+
validateEscalationVariables("feature-escalation", { resolution: "abandon" }),
|
|
540
|
+
null,
|
|
541
|
+
"the abandon path does not demand the hidden answer field",
|
|
542
|
+
);
|
|
543
|
+
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -71,6 +71,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
71
71
|
"wait-merge-answer", // PR merge-loop escalation (merge-loop.bpmn) — same native user-task path (#256)
|
|
72
72
|
]);
|
|
73
73
|
|
|
74
|
+
/** The `feature-blocked` operator user-task element id (feature.bpmn) — the native wait a run parks on
|
|
75
|
+
* when the agent reports a `blocked` outcome. Unlike an escalation it is NOT agent-answerable (only a
|
|
76
|
+
* human operator retires a blocked run), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` — the agent
|
|
77
|
+
* completer (`completeEscalationAsAgent`) must never touch it — but it IS a human-completable decision
|
|
78
|
+
* on the Tasks inbox, so the HUMAN completer accepts it (issue #332 folded the bespoke
|
|
79
|
+
* `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
|
|
80
|
+
export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
|
|
81
|
+
|
|
82
|
+
/** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
|
|
83
|
+
* `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
|
|
84
|
+
* acknowledgement. The AGENT completer stays scoped to `ESCALATION_TASK_ELEMENTS` (no `feature-blocked`),
|
|
85
|
+
* so widening the human surface never lets an agent retire a blocked run. */
|
|
86
|
+
export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
87
|
+
...ESCALATION_TASK_ELEMENTS,
|
|
88
|
+
FEATURE_BLOCKED_TASK_ELEMENT,
|
|
89
|
+
]);
|
|
90
|
+
|
|
74
91
|
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
75
92
|
* BPMN `zeebe:formDefinition formId`). Kept beside `ESCALATION_TASK_ELEMENTS` so the completer
|
|
76
93
|
* validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
|
|
@@ -80,36 +97,74 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
80
97
|
"trial-merge-decision": "trial-merge-decision",
|
|
81
98
|
"wait-answer": "pr-escalation",
|
|
82
99
|
"wait-merge-answer": "pr-escalation",
|
|
100
|
+
"feature-blocked": "feature-blocked",
|
|
83
101
|
};
|
|
84
102
|
|
|
103
|
+
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
|
104
|
+
* (`=<ref> != "<value>"` / `=<ref> == "<value>"`). A required field is only enforced when it is
|
|
105
|
+
* actually shown, so an "answer only when resolution=answer" field is not demanded on the abandon
|
|
106
|
+
* path. */
|
|
107
|
+
interface HideRule {
|
|
108
|
+
ref: string;
|
|
109
|
+
op: "==" | "!=";
|
|
110
|
+
value: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
85
113
|
interface FormContract {
|
|
86
114
|
/** Field keys marked `validate.required` in the `.form`. */
|
|
87
115
|
required: string[];
|
|
88
116
|
/** `select` field key → its allowed `values`. */
|
|
89
117
|
allowed: Record<string, string[]>;
|
|
118
|
+
/** Field key → its `conditional.hide` rule (only for fields whose visibility is conditional). */
|
|
119
|
+
hideWhen: Record<string, HideRule>;
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
const formContractCache = new Map<string, FormContract>();
|
|
93
123
|
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
124
|
+
/** Parse the `.form` FEEL subset used by `conditional.hide` — `=<ref> (!=|==) "<value>"` — into a
|
|
125
|
+
* structured rule, or `null` for anything outside that subset (treated as "always shown", so a
|
|
126
|
+
* required field is never silently skipped by an unrecognised expression). */
|
|
127
|
+
function parseHide(expr: string | undefined): HideRule | null {
|
|
128
|
+
if (!expr) return null;
|
|
129
|
+
const m = /^=\s*([A-Za-z_$][\w$]*)\s*(==|!=)\s*"([^"]*)"\s*$/.exec(expr);
|
|
130
|
+
if (!m) return null;
|
|
131
|
+
const op = m[2] === "==" ? "==" : "!=";
|
|
132
|
+
return { ref: m[1], op, value: m[3] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a `conditional.hide` rule hides its field given the submitted `variables`. */
|
|
136
|
+
function isHidden(rule: HideRule, variables: Record<string, unknown>): boolean {
|
|
137
|
+
const actual = String(variables[rule.ref] ?? "");
|
|
138
|
+
return rule.op === "!=" ? actual !== rule.value : actual === rule.value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Derive a `.form`'s required-field + select allowed-value + conditional-visibility contract, cached
|
|
142
|
+
* per formId. The `.form` files (`resources/forms/*.form`, deployed via nano.app.json) are the
|
|
143
|
+
* CANONICAL contract, so this reads them rather than re-encoding the field lists — no drift surface. */
|
|
97
144
|
function formContract(formId: string): FormContract {
|
|
98
145
|
const cached = formContractCache.get(formId);
|
|
99
146
|
if (cached) return cached;
|
|
100
147
|
const raw: {
|
|
101
|
-
components?: {
|
|
148
|
+
components?: {
|
|
149
|
+
key?: string;
|
|
150
|
+
validate?: { required?: boolean };
|
|
151
|
+
values?: { value?: string }[];
|
|
152
|
+
conditional?: { hide?: string };
|
|
153
|
+
}[];
|
|
102
154
|
} = JSON.parse(readFileSync(new URL(`../resources/forms/${formId}.form`, import.meta.url), "utf8"));
|
|
103
155
|
const required: string[] = [];
|
|
104
156
|
const allowed: Record<string, string[]> = {};
|
|
157
|
+
const hideWhen: Record<string, HideRule> = {};
|
|
105
158
|
for (const c of raw.components ?? []) {
|
|
106
159
|
if (!c.key) continue;
|
|
107
160
|
if (c.validate?.required) required.push(c.key);
|
|
108
161
|
if (c.values?.length) {
|
|
109
162
|
allowed[c.key] = c.values.map((v) => v.value ?? "").filter((v) => v !== "");
|
|
110
163
|
}
|
|
164
|
+
const hide = parseHide(c.conditional?.hide);
|
|
165
|
+
if (hide) hideWhen[c.key] = hide;
|
|
111
166
|
}
|
|
112
|
-
const contract: FormContract = { required, allowed };
|
|
167
|
+
const contract: FormContract = { required, allowed, hideWhen };
|
|
113
168
|
formContractCache.set(formId, contract);
|
|
114
169
|
return contract;
|
|
115
170
|
}
|
|
@@ -117,18 +172,23 @@ function formContract(formId: string): FormContract {
|
|
|
117
172
|
/** Validate completion `variables` against the escalation's `.form` contract (required fields present
|
|
118
173
|
* + `select` values within the allowed set), so a completion can never resume the process with a
|
|
119
174
|
* missing/invalid decision (e.g. a `wait-answer` with no `answer`, or a `trial-merge-decision` with
|
|
120
|
-
* an `action` outside proceed/rebase/abandon).
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
175
|
+
* an `action` outside proceed/rebase/abandon). A conditionally-shown required field is only enforced
|
|
176
|
+
* when its `conditional.hide` rule leaves it visible — so a `feature-escalation` with
|
|
177
|
+
* `resolution="answer"` demands a non-blank `answer` (re-dispatch guidance), but the `abandon` path,
|
|
178
|
+
* which hides `answer`, does not. Returns a human-readable reason on violation, or `null` when the
|
|
179
|
+
* variables satisfy the contract. Derived from the canonical `.form` — the same contract the task
|
|
180
|
+
* inbox renders — so both the agent and human completers reject invalid input the exact same way,
|
|
181
|
+
* with one implementation. An element with no linked form contract is not enforced. */
|
|
124
182
|
export function validateEscalationVariables(
|
|
125
183
|
elementId: string,
|
|
126
184
|
variables: Record<string, unknown>,
|
|
127
185
|
): string | null {
|
|
128
186
|
const formId = ESCALATION_FORM_BY_ELEMENT[elementId];
|
|
129
187
|
if (!formId) return null;
|
|
130
|
-
const { required, allowed } = formContract(formId);
|
|
188
|
+
const { required, allowed, hideWhen } = formContract(formId);
|
|
131
189
|
for (const key of required) {
|
|
190
|
+
const hide = hideWhen[key];
|
|
191
|
+
if (hide && isHidden(hide, variables)) continue;
|
|
132
192
|
const v = variables[key];
|
|
133
193
|
if (v === undefined || v === null || (typeof v === "string" && v.trim() === "")) {
|
|
134
194
|
return `${elementId}: "${key}" is required`;
|
|
@@ -222,19 +282,24 @@ export interface AgentCompleteResult {
|
|
|
222
282
|
elementId?: string;
|
|
223
283
|
}
|
|
224
284
|
|
|
225
|
-
/** Resolve a parked escalation user task by key: return its `elementId` if it is one of the
|
|
226
|
-
*
|
|
227
|
-
* refuse a non-
|
|
228
|
-
*
|
|
285
|
+
/** Resolve a parked escalation user task by key: return its `elementId` if it is one of the `allowed`
|
|
286
|
+
* completable tasks, or a failure reason otherwise. Shared by the agent and human completers so both
|
|
287
|
+
* refuse a non-completable / missing target the exact same way (a key with no matching open task is a
|
|
288
|
+
* 404-style no-op). The AGENT completer passes the default `ESCALATION_TASK_ELEMENTS`; the HUMAN
|
|
289
|
+
* completer passes the wider `HUMAN_COMPLETABLE_ELEMENTS` (which also admits `feature-blocked`).
|
|
290
|
+
* Queries `openUserTasks` (lifecycle-state `CREATED` only), NOT `searchUserTasks` (which returns
|
|
291
|
+
* tasks in ANY state) — a looping instance keeps COMPLETED/CANCELED tasks whose key could otherwise
|
|
292
|
+
* match and drive a doomed re-completion (a thrown 5xx) instead of the intended 404-style no-op. */
|
|
229
293
|
async function resolveEscalationTask(
|
|
230
294
|
engine: EngineClient,
|
|
231
295
|
userTaskKey: string,
|
|
296
|
+
allowed: ReadonlySet<string> = ESCALATION_TASK_ELEMENTS,
|
|
232
297
|
): Promise<{ ok: true; elementId: string } | { ok: false; reason: string }> {
|
|
233
|
-
const open = await engine.
|
|
298
|
+
const open = await engine.openUserTasks();
|
|
234
299
|
const match = open.find((t) => t.userTaskKey === userTaskKey);
|
|
235
|
-
if (!match) return { ok: false, reason: "no open
|
|
236
|
-
if (!match.elementId || !
|
|
237
|
-
return { ok: false, reason: "not
|
|
300
|
+
if (!match) return { ok: false, reason: "no open completable task" };
|
|
301
|
+
if (!match.elementId || !allowed.has(match.elementId)) {
|
|
302
|
+
return { ok: false, reason: "not a completable task" };
|
|
238
303
|
}
|
|
239
304
|
return { ok: true, elementId: match.elementId };
|
|
240
305
|
}
|
|
@@ -275,7 +340,8 @@ export async function completeEscalationAsAgent(
|
|
|
275
340
|
* affordance resumes the process through the one implementation a human uses from the task inbox —
|
|
276
341
|
* no parallel completion path — while recording WHO answered in the `task_completions` ledger. A
|
|
277
342
|
* human completion is the authority (not reversible). A key with no matching open escalation task is
|
|
278
|
-
* a 404-style no-op.
|
|
343
|
+
* a 404-style no-op. The human surface is the wider `HUMAN_COMPLETABLE_ELEMENTS`, so it also retires a
|
|
344
|
+
* `feature-blocked` acknowledgement (issue #332 folded the bespoke `acknowledge-blocked` door here). */
|
|
279
345
|
export async function completeEscalationAsHuman(
|
|
280
346
|
data: DataLayer,
|
|
281
347
|
engine: EngineClient,
|
|
@@ -286,7 +352,7 @@ export async function completeEscalationAsHuman(
|
|
|
286
352
|
const operatorId = input.operatorId.trim();
|
|
287
353
|
if (!operatorId) return { ok: false, reason: "operatorId is required" };
|
|
288
354
|
|
|
289
|
-
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
355
|
+
const resolved = await resolveEscalationTask(engine, userTaskKey, HUMAN_COMPLETABLE_ELEMENTS);
|
|
290
356
|
if (!resolved.ok) return resolved;
|
|
291
357
|
|
|
292
358
|
const invalid = validateEscalationVariables(resolved.elementId, input.variables);
|
|
@@ -301,44 +367,6 @@ export async function completeEscalationAsHuman(
|
|
|
301
367
|
return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
|
|
302
368
|
}
|
|
303
369
|
|
|
304
|
-
/** The `feature-blocked` operator user-task element id (feature.bpmn). Unlike an escalation this is not
|
|
305
|
-
* an agent-answerable task — it is a blocked-run acknowledgement only a human operator retires — so it
|
|
306
|
-
* lives outside `ESCALATION_TASK_ELEMENTS` (the agent completer must never touch it) and has its own
|
|
307
|
-
* human-only completer below. */
|
|
308
|
-
export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
|
|
309
|
-
|
|
310
|
-
/** Complete the `feature-blocked` operator user task AS A HUMAN (issue #220). The blocked twin of
|
|
311
|
-
* `completeEscalationAsHuman`: it resolves the parked task by key, refuses anything that is not the
|
|
312
|
-
* `feature-blocked` task, and routes the operator's typed form variables (an optional `note`) through
|
|
313
|
-
* the SAME canonical `completeUserTaskAttributed` — so the nwf "Acknowledge blocked" affordance resumes
|
|
314
|
-
* the process (→ `pr.record-blocked-ack`, which settles the row to terminal `blocked`) through the one
|
|
315
|
-
* completion a human drives from the task inbox, recording WHO acknowledged in the `task_completions`
|
|
316
|
-
* ledger. A human completion is the authority (not reversible). A key with no matching open
|
|
317
|
-
* `feature-blocked` task is a 404-style no-op. */
|
|
318
|
-
export async function completeBlockedAsHuman(
|
|
319
|
-
data: DataLayer,
|
|
320
|
-
engine: EngineClient,
|
|
321
|
-
input: { userTaskKey: string; variables: Record<string, unknown>; operatorId: string },
|
|
322
|
-
): Promise<AgentCompleteResult> {
|
|
323
|
-
const userTaskKey = input.userTaskKey.trim();
|
|
324
|
-
if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
|
|
325
|
-
const operatorId = input.operatorId.trim();
|
|
326
|
-
if (!operatorId) return { ok: false, reason: "operatorId is required" };
|
|
327
|
-
|
|
328
|
-
const open = await engine.searchUserTasks();
|
|
329
|
-
const match = open.find((t) => t.userTaskKey === userTaskKey);
|
|
330
|
-
if (!match) return { ok: false, reason: "no open blocked task" };
|
|
331
|
-
if (match.elementId !== FEATURE_BLOCKED_TASK_ELEMENT) return { ok: false, reason: "not a blocked task" };
|
|
332
|
-
|
|
333
|
-
const { completionId } = await completeUserTaskAttributed(
|
|
334
|
-
data,
|
|
335
|
-
engine,
|
|
336
|
-
{ userTaskKey, elementId: match.elementId, variables: input.variables },
|
|
337
|
-
{ kind: "human", id: operatorId },
|
|
338
|
-
);
|
|
339
|
-
return { ok: true, completionId, userTaskKey, elementId: match.elementId };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
370
|
export interface RevertResult {
|
|
343
371
|
ok: boolean;
|
|
344
372
|
reason?: string;
|