@nanobpm/nano-workforce 0.99.1 → 0.101.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/contracts.ts +18 -2
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/migration049.test.ts +112 -0
- package/app/pollUserTasks.test.ts +39 -13
- package/app/service.test.ts +16 -0
- package/app/service.ts +107 -136
- 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/app/world/checkpoint.test.ts +193 -0
- package/app/world/checkpoint.ts +142 -0
- package/app/world/effect-ledger.test.ts +86 -0
- package/app/world/effect-ledger.ts +103 -0
- package/app/world/git.ts +53 -0
- package/app/world/index.ts +26 -0
- package/app/world/store.test.ts +443 -0
- package/app/world/store.ts +320 -0
- package/app/world-marker.test.ts +79 -0
- package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
- package/db/migrations/049_world_checkpoint.sql +84 -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/test/worldDb.ts +103 -0
- package/workers/persist-round/worker.ts +68 -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/e2e/feature-run.e2e.ts
CHANGED
|
@@ -23,7 +23,7 @@ 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
25
|
import { asEngineClient } from "./support/engine-client.ts";
|
|
26
|
-
import {
|
|
26
|
+
import { pollUserTasks } from "../app/service.ts";
|
|
27
27
|
|
|
28
28
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
29
|
|
|
@@ -58,9 +58,6 @@ interface FeatureRow {
|
|
|
58
58
|
status: string;
|
|
59
59
|
pr_key: string | null;
|
|
60
60
|
delivery_label: string | null;
|
|
61
|
-
escalation_question: string | null;
|
|
62
|
-
escalation_user_task_key: string | null;
|
|
63
|
-
blocked_user_task_key: string | null;
|
|
64
61
|
}
|
|
65
62
|
interface PrRow {
|
|
66
63
|
pr_key: string;
|
|
@@ -191,18 +188,23 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
191
188
|
const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({});
|
|
192
189
|
assert.equal(prs.length, 0, "a blocked run never enrolled a PR into the convergence loop");
|
|
193
190
|
|
|
194
|
-
// The
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
191
|
+
// The escalation state now lives on the native user task + the Tasks inbox `user_tasks`
|
|
192
|
+
// read-model (issue #332 dropped the denormalised `feature_runs.blocked_user_task_key` pointer),
|
|
193
|
+
// so `pollUserTasks` projects the parked `feature-blocked` task onto `user_tasks` by reading the
|
|
194
|
+
// engine directly — no per-run column write.
|
|
195
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
196
|
+
const inboxRow = await app.db
|
|
197
|
+
.table<{ user_task_key: string; element_id: string }>("user_tasks", "user_task_key")
|
|
198
|
+
.findOne({ user_task_key: task!.userTaskKey });
|
|
199
|
+
assert.ok(inboxRow, "the poller projected the blocked task onto the Tasks inbox read-model");
|
|
200
|
+
assert.equal(inboxRow!.element_id, "feature-blocked", "the projected row is a feature-blocked task");
|
|
201
|
+
assert.equal(parked.status, "awaiting_operator", "the run stays awaiting_operator while parked");
|
|
202
|
+
|
|
203
|
+
// Acknowledge through the ONE canonical `complete-user-task` door (issue #332 retired the bespoke
|
|
204
|
+
// `acknowledge-blocked` operation) — the attributed human completer resumes the SAME
|
|
205
|
+
// record-blocked-ack path from the task inbox, with NO out-of-band completion call.
|
|
206
|
+
const acked = await app.api?.call("completeUserTask", {
|
|
207
|
+
body: { userTaskKey: task!.userTaskKey, variables: { note: "reassigned to a human" } },
|
|
206
208
|
});
|
|
207
209
|
assert.equal(acked?.status, 200, "the operator acknowledgement completed the blocked task");
|
|
208
210
|
await app.settle();
|
|
@@ -212,10 +214,10 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
212
214
|
const settled = await featureRow(app, featureKey);
|
|
213
215
|
assert.equal(settled.status, "blocked", "the acknowledged run settles at terminal blocked");
|
|
214
216
|
assert.equal(settled.delivery_label, "operator: reassigned to a human", "the operator note is recorded");
|
|
215
|
-
assert.equal(settled.blocked_user_task_key, null, "the completable-task pointer was cleared on ack");
|
|
216
217
|
|
|
217
|
-
// A further poll pass is an idempotent no-op —
|
|
218
|
-
|
|
218
|
+
// A further poll pass is an idempotent no-op — the task is completed, so the read-model row is
|
|
219
|
+
// reconciled away and a terminal run is not a candidate.
|
|
220
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
219
221
|
assert.equal((await featureRow(app, featureKey)).status, "blocked");
|
|
220
222
|
},
|
|
221
223
|
);
|
|
@@ -276,7 +278,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
276
278
|
);
|
|
277
279
|
});
|
|
278
280
|
|
|
279
|
-
test("escalate → the poller surfaces it on the
|
|
281
|
+
test("escalate → the poller surfaces it on the Tasks inbox, and the operator answer resolves it (issue #210/#332)", async () => {
|
|
280
282
|
let calls = 0;
|
|
281
283
|
await withApp(
|
|
282
284
|
{
|
|
@@ -288,25 +290,32 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
288
290
|
},
|
|
289
291
|
},
|
|
290
292
|
{ baseBranch: "epic/e2e" },
|
|
291
|
-
async ({ app, featureKey }) => {
|
|
293
|
+
async ({ app, featureKey, processKey }) => {
|
|
292
294
|
// The `record-feature-escalation` service task runs on the escalated arm (before the user task),
|
|
293
|
-
// so the row already carries the flipped status
|
|
294
|
-
//
|
|
295
|
+
// so the row already carries the flipped status when the run parks, and the agent's question is
|
|
296
|
+
// recorded in the `feature_escalations` audit log the poller reads (issue #332 dropped the
|
|
297
|
+
// denormalised `feature_runs.escalation_question` column).
|
|
295
298
|
const parked = await featureRow(app, featureKey);
|
|
296
299
|
assert.equal(parked.status, "escalated", "the escalated status is surfaced on the read model");
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
300
|
+
|
|
301
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
|
|
302
|
+
const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
|
|
303
|
+
assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
|
|
304
|
+
|
|
305
|
+
// The poller projects the parked task onto the Tasks inbox `user_tasks` read-model by reading
|
|
306
|
+
// the engine directly, sourcing the question from the `feature_escalations` audit log.
|
|
307
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
308
|
+
const inboxRow = await app.db
|
|
309
|
+
.table<{ user_task_key: string; element_id: string; question: string | null }>("user_tasks", "user_task_key")
|
|
310
|
+
.findOne({ user_task_key: task!.userTaskKey });
|
|
311
|
+
assert.ok(inboxRow, "the poller projected the escalation onto the Tasks inbox read-model");
|
|
312
|
+
assert.equal(inboxRow!.question, "Which API should I use?", "the agent's question is surfaced from the audit log");
|
|
313
|
+
|
|
314
|
+
// Answer through the ONE canonical `complete-user-task` door (issue #332 retired the bespoke
|
|
315
|
+
// `answer-escalation` operation) — the attributed human completer resumes the SAME implement task
|
|
316
|
+
// a human would from the task inbox.
|
|
317
|
+
const answered = await app.api?.call("completeUserTask", {
|
|
318
|
+
body: { userTaskKey: task!.userTaskKey, variables: { resolution: "answer", answer: "use v2" } },
|
|
310
319
|
});
|
|
311
320
|
assert.equal(answered?.status, 200, "the operator answer completed the escalation task");
|
|
312
321
|
await app.settle();
|
|
@@ -318,14 +327,16 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
318
327
|
);
|
|
319
328
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
320
329
|
|
|
321
|
-
// The run opened its PR
|
|
330
|
+
// The run opened its PR.
|
|
322
331
|
const settled = await featureRow(app, featureKey);
|
|
323
332
|
assert.equal(settled.status, "opened", "the resumed run opened its PR");
|
|
324
|
-
assert.equal(settled.escalation_user_task_key, null, "the escalation pointer was cleared once resolved");
|
|
325
|
-
assert.equal(settled.escalation_question, null, "the surfaced question was cleared once resolved");
|
|
326
333
|
|
|
327
|
-
// A further poll pass
|
|
328
|
-
await
|
|
334
|
+
// A further poll pass reconciles the completed task's read-model row away.
|
|
335
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
336
|
+
const gone = await app.db
|
|
337
|
+
.table<{ user_task_key: string }>("user_tasks", "user_task_key")
|
|
338
|
+
.findOne({ user_task_key: task!.userTaskKey });
|
|
339
|
+
assert.equal(gone, undefined, "the completed escalation's inbox row was reconciled away");
|
|
329
340
|
assert.equal((await featureRow(app, featureKey)).status, "opened");
|
|
330
341
|
},
|
|
331
342
|
);
|
|
@@ -155,6 +155,32 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
|
|
|
155
155
|
}
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
test("issue #332 contract phase: feature_runs escalation columns + bespoke feature doors are gone", async () => {
|
|
159
|
+
// The destructive contract phase of #305 dropped the denormalised feature-run escalation surface now
|
|
160
|
+
// that escalation state lives on the native `user_tasks` inbox + the `feature_escalations` audit log.
|
|
161
|
+
const featureCols = await columnNames(app, "feature_runs");
|
|
162
|
+
assert.ok(featureCols.length > 0, "feature_runs table still exists");
|
|
163
|
+
for (const dropped of [
|
|
164
|
+
"escalation_question",
|
|
165
|
+
"escalation_user_task_key",
|
|
166
|
+
"blocked_user_task_key",
|
|
167
|
+
"escalation_open",
|
|
168
|
+
]) {
|
|
169
|
+
assert.ok(!featureCols.includes(dropped), `feature_runs.${dropped} column dropped (#332)`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The bespoke feature-run answer doors are gone — the ONE canonical `/actions/complete-user-task`
|
|
173
|
+
// door now completes `feature-escalation` / `feature-blocked` alongside the epic/PR kinds.
|
|
174
|
+
for (const path of ["/app/api/actions/answer-escalation", "/app/api/actions/acknowledge-blocked"]) {
|
|
175
|
+
const res = await app.callRoute({
|
|
176
|
+
method: "POST",
|
|
177
|
+
path,
|
|
178
|
+
body: JSON.stringify({ userTaskKey: "x" }),
|
|
179
|
+
});
|
|
180
|
+
assert.equal(res.status, 404, `retired feature door ${path} is unmounted (404)`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
158
184
|
test("an escalation still round-trips via userTask + inbox with no denormalised pointer or dead form", async () => {
|
|
159
185
|
const api = app.api;
|
|
160
186
|
assert.ok(api, "the OpenAPI driver is available");
|
package/openapi.yaml
CHANGED
|
@@ -1908,69 +1908,17 @@ paths:
|
|
|
1908
1908
|
application/json:
|
|
1909
1909
|
schema:
|
|
1910
1910
|
$ref: "#/components/schemas/MessageResult"
|
|
1911
|
-
/actions/answer-escalation:
|
|
1912
|
-
post:
|
|
1913
|
-
operationId: answerFeatureEscalation
|
|
1914
|
-
summary: "Answer a native feature-run escalation (issue #210). Completes the parked
|
|
1915
|
-
`feature-escalation` user task with the operator's typed form variables (resolution=answer/abandon
|
|
1916
|
-
+ guidance), driving the canonical attributed completer (completeUserTaskAttributed) — the same
|
|
1917
|
-
resume path the task inbox uses, recording who answered. The poller then reconciles the run off
|
|
1918
|
-
`escalated`. This is the nwf UI's answer affordance for a feature run parked on a human decision."
|
|
1919
|
-
requestBody:
|
|
1920
|
-
required: true
|
|
1921
|
-
content:
|
|
1922
|
-
application/json:
|
|
1923
|
-
schema:
|
|
1924
|
-
type: object
|
|
1925
|
-
additionalProperties: false
|
|
1926
|
-
required:
|
|
1927
|
-
- userTaskKey
|
|
1928
|
-
- resolution
|
|
1929
|
-
properties:
|
|
1930
|
-
userTaskKey:
|
|
1931
|
-
type: string
|
|
1932
|
-
minLength: 1
|
|
1933
|
-
description: The parked `feature-escalation` user-task key (feature_runs.escalation_user_task_key).
|
|
1934
|
-
resolution:
|
|
1935
|
-
type: string
|
|
1936
|
-
enum: [answer, abandon]
|
|
1937
|
-
description: answer ⇒ re-dispatch the implement task with the guidance; abandon ⇒ give up on the task.
|
|
1938
|
-
answer:
|
|
1939
|
-
type: string
|
|
1940
|
-
description: Guidance for the implementation agent. Required (non-blank) when resolution is answer.
|
|
1941
|
-
operator:
|
|
1942
|
-
type: string
|
|
1943
|
-
description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
|
|
1944
|
-
responses:
|
|
1945
|
-
"200":
|
|
1946
|
-
description: The escalation user task was completed and the process resumed.
|
|
1947
|
-
content:
|
|
1948
|
-
application/json:
|
|
1949
|
-
schema:
|
|
1950
|
-
$ref: "#/components/schemas/MessageResult"
|
|
1951
|
-
"400":
|
|
1952
|
-
description: A required field was missing/invalid, or the target is not an escalation task.
|
|
1953
|
-
content:
|
|
1954
|
-
application/json:
|
|
1955
|
-
schema:
|
|
1956
|
-
$ref: "#/components/schemas/MessageResult"
|
|
1957
|
-
"404":
|
|
1958
|
-
description: No open escalation user task matches the userTaskKey.
|
|
1959
|
-
content:
|
|
1960
|
-
application/json:
|
|
1961
|
-
schema:
|
|
1962
|
-
$ref: "#/components/schemas/MessageResult"
|
|
1963
1911
|
/actions/complete-user-task:
|
|
1964
1912
|
post:
|
|
1965
1913
|
operationId: completeUserTask
|
|
1966
1914
|
summary: "Complete an open native user-task escalation from the nwf Tasks inbox (issue #236).
|
|
1967
1915
|
Submits the parked task's typed `.form` variables (e.g. a plan-review `{ directive, notes }`, a
|
|
1968
|
-
trial-merge `{ action, notes }`,
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
(answer-escalation / acknowledge-blocked)
|
|
1973
|
-
`
|
|
1916
|
+
trial-merge `{ action, notes }`, a PR `{ answer }`, a feature escalation `{ resolution, answer }`,
|
|
1917
|
+
or a blocked-run `{ note }`) to the canonical human completer (completeEscalationAsHuman →
|
|
1918
|
+
completeUserTaskAttributed) — the same resume path the task inbox uses, recording who answered.
|
|
1919
|
+
The completer refuses any user task that is not one of the human-completable elements. Issue #332
|
|
1920
|
+
retired the bespoke feature-run doors (answer-escalation / acknowledge-blocked) and folded
|
|
1921
|
+
`feature-escalation` / `feature-blocked` onto this one canonical door. On success the answered
|
|
1974
1922
|
task's read-model row is dropped so the Tasks grid stops offering a decision for it."
|
|
1975
1923
|
requestBody:
|
|
1976
1924
|
required: true
|
|
@@ -2013,61 +1961,12 @@ paths:
|
|
|
2013
1961
|
application/json:
|
|
2014
1962
|
schema:
|
|
2015
1963
|
$ref: "#/components/schemas/MessageResult"
|
|
2016
|
-
/actions/acknowledge-blocked:
|
|
2017
|
-
post:
|
|
2018
|
-
operationId: acknowledgeBlocked
|
|
2019
|
-
summary: "Acknowledge a blocked native feature run (issue #220). Completes the parked
|
|
2020
|
-
`feature-blocked` operator user task with the operator's optional disposition note, driving the
|
|
2021
|
-
canonical attributed completer (completeUserTaskAttributed) — the same resume path the task inbox
|
|
2022
|
-
uses, recording who acknowledged. Completing it fires pr.record-blocked-ack, settling the run to
|
|
2023
|
-
the terminal `blocked` status with the note, and the poller reconciles the completable-task
|
|
2024
|
-
pointer off the row. This is the nwf UI's completion affordance for a feature run parked as
|
|
2025
|
-
blocked (the blocked twin of answer-escalation)."
|
|
2026
|
-
requestBody:
|
|
2027
|
-
required: true
|
|
2028
|
-
content:
|
|
2029
|
-
application/json:
|
|
2030
|
-
schema:
|
|
2031
|
-
type: object
|
|
2032
|
-
additionalProperties: false
|
|
2033
|
-
required:
|
|
2034
|
-
- userTaskKey
|
|
2035
|
-
properties:
|
|
2036
|
-
userTaskKey:
|
|
2037
|
-
type: string
|
|
2038
|
-
minLength: 1
|
|
2039
|
-
description: The parked `feature-blocked` user-task key (feature_runs.blocked_user_task_key).
|
|
2040
|
-
note:
|
|
2041
|
-
type: string
|
|
2042
|
-
description: Optional operator disposition note, recorded on the run (delivery_label). Blank ⇒ "acknowledged".
|
|
2043
|
-
operator:
|
|
2044
|
-
type: string
|
|
2045
|
-
description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
|
|
2046
|
-
responses:
|
|
2047
|
-
"200":
|
|
2048
|
-
description: The blocked user task was completed and the process resumed.
|
|
2049
|
-
content:
|
|
2050
|
-
application/json:
|
|
2051
|
-
schema:
|
|
2052
|
-
$ref: "#/components/schemas/MessageResult"
|
|
2053
|
-
"400":
|
|
2054
|
-
description: A required field was missing/invalid, or the target is not a blocked task.
|
|
2055
|
-
content:
|
|
2056
|
-
application/json:
|
|
2057
|
-
schema:
|
|
2058
|
-
$ref: "#/components/schemas/MessageResult"
|
|
2059
|
-
"404":
|
|
2060
|
-
description: No open blocked user task matches the userTaskKey.
|
|
2061
|
-
content:
|
|
2062
|
-
application/json:
|
|
2063
|
-
schema:
|
|
2064
|
-
$ref: "#/components/schemas/MessageResult"
|
|
2065
1964
|
/actions/acknowledge-done:
|
|
2066
1965
|
post:
|
|
2067
1966
|
operationId: acknowledgeDone
|
|
2068
1967
|
summary: "Tick off a TERMINAL feature run (issue #254 §5). Stamps `acknowledged_at` on the run so
|
|
2069
1968
|
the gateway recomputes its `list_bucket` to 'history', dropping the finished run out of the
|
|
2070
|
-
primary Active list into History. The Done twin of
|
|
1969
|
+
primary Active list into History. The Done twin of the blocked acknowledgement, but a terminal run is
|
|
2071
1970
|
not parked at a user task, so this completes no user task and only writes the row. Keyed on the
|
|
2072
1971
|
run's `feature_key`; idempotent-safe (re-acknowledging keeps it in History)."
|
|
2073
1972
|
requestBody:
|
|
@@ -46,8 +46,8 @@ export default defineOperation("agentCompleteEscalation", async ({ req, body },
|
|
|
46
46
|
app.log.info("agent completed escalation", { userTaskKey, agentId, elementId: r.elementId });
|
|
47
47
|
return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
|
|
48
48
|
}
|
|
49
|
-
// A non-
|
|
50
|
-
const status = r.reason === "no open
|
|
49
|
+
// A non-completable / missing-target is a client error; an unknown key is a 404.
|
|
50
|
+
const status = r.reason === "no open completable task" ? 404 : 400;
|
|
51
51
|
app.log.warn("agent-complete: not completed", { userTaskKey, reason: r.reason });
|
|
52
52
|
return { status, body: { ok: false, error: r.reason } };
|
|
53
53
|
});
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// Tests for the POST /app/api/actions/complete-user-task operation `completeUserTask` (issue #236).
|
|
2
2
|
// The nwf Tasks page's decision affordance for the plan-review / trial-merge / PR `wait-answer`
|
|
3
|
-
// escalations
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// escalations plus the feature kinds `feature-escalation` / `feature-blocked` (issue #332 folded the
|
|
4
|
+
// bespoke feature doors onto this one canonical door): it routes the typed form variables through the
|
|
5
|
+
// canonical human completer (completeEscalationAsHuman → completeUserTaskAttributed), resuming the
|
|
6
|
+
// process exactly as the task inbox would, and drops the answered task's read-model row so the grid
|
|
7
|
+
// stops offering a decision.
|
|
6
8
|
import { test } from "node:test";
|
|
7
9
|
import { assertEquals } from "#test-assert";
|
|
8
10
|
import type { AppApi } from "@nanobpm/urban";
|
|
9
11
|
import { noopLog } from "../test/log.ts";
|
|
10
12
|
import handler from "./completeUserTask.ts";
|
|
11
13
|
|
|
12
|
-
// biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors
|
|
14
|
+
// biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors sibling op tests
|
|
13
15
|
function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
|
|
14
16
|
app: AppApi;
|
|
15
17
|
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
@@ -49,6 +51,7 @@ function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
const engine = {
|
|
54
|
+
openUserTasks: async () => openTasks,
|
|
52
55
|
searchUserTasks: async () => openTasks,
|
|
53
56
|
completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
|
|
54
57
|
completed.push({ userTaskKey, variables });
|
|
@@ -106,14 +109,30 @@ test("complete-user-task: non-object variables are rejected 400", async () => {
|
|
|
106
109
|
assertEquals(res.status, 400);
|
|
107
110
|
});
|
|
108
111
|
|
|
109
|
-
test("complete-user-task: an unknown key is a 404 (no open
|
|
112
|
+
test("complete-user-task: an unknown key is a 404 (no open completable task)", async () => {
|
|
110
113
|
const { app } = memApp([]);
|
|
111
114
|
const res = await call(app, { userTaskKey: "ghost", variables: { answer: "x" } });
|
|
112
115
|
assertEquals(res.status, 404);
|
|
113
116
|
});
|
|
114
117
|
|
|
115
|
-
test("complete-user-task:
|
|
118
|
+
test("complete-user-task: completes a feature-blocked acknowledgement with the typed note (issue #332)", async () => {
|
|
116
119
|
const { app, completed } = memApp([{ userTaskKey: "ut-4", elementId: "feature-blocked" }]);
|
|
120
|
+
const res = await call(app, { userTaskKey: "ut-4", variables: { note: "reassigned" } });
|
|
121
|
+
assertEquals(res.status, 200);
|
|
122
|
+
assertEquals(res.body.elementId, "feature-blocked");
|
|
123
|
+
assertEquals(completed, [{ userTaskKey: "ut-4", variables: { note: "reassigned" } }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("complete-user-task: completes a feature-escalation answer (issue #332)", async () => {
|
|
127
|
+
const { app, completed } = memApp([{ userTaskKey: "ut-6", elementId: "feature-escalation" }]);
|
|
128
|
+
const res = await call(app, { userTaskKey: "ut-6", variables: { resolution: "answer", answer: "use v2" } });
|
|
129
|
+
assertEquals(res.status, 200);
|
|
130
|
+
assertEquals(res.body.elementId, "feature-escalation");
|
|
131
|
+
assertEquals(completed, [{ userTaskKey: "ut-6", variables: { resolution: "answer", answer: "use v2" } }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("complete-user-task: refuses a non-completable internal user task (400)", async () => {
|
|
135
|
+
const { app, completed } = memApp([{ userTaskKey: "ut-4", elementId: "some-internal-task" }]);
|
|
117
136
|
const res = await call(app, { userTaskKey: "ut-4", variables: { note: "x" } });
|
|
118
137
|
assertEquals(res.status, 400);
|
|
119
138
|
assertEquals(completed, []);
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
// POST /app/api/actions/complete-user-task → operationId `completeUserTask` (issue #236).
|
|
2
2
|
//
|
|
3
|
-
// The nwf **Tasks** page's decision affordance for the
|
|
4
|
-
// app-side completion path — `plan-review-decision`, `trial-merge-decision`,
|
|
5
|
-
// `wait-answer
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// The nwf **Tasks** page's decision affordance for the native user-task escalations that had no
|
|
4
|
+
// app-side completion path — `plan-review-decision`, `trial-merge-decision`, the PR review-loop
|
|
5
|
+
// `wait-answer`, the feature escalation `feature-escalation`, and the human-only `feature-blocked`
|
|
6
|
+
// acknowledgement. An operator submits the parked task's typed `.form` variables (e.g. a plan-review
|
|
7
|
+
// `{ directive, notes }`, a trial-merge `{ action, notes }`, a PR `{ answer }`, a feature escalation
|
|
8
|
+
// `{ resolution, answer }`, or a blocked-run `{ note }`) directly from the Tasks inbox instead of only
|
|
9
|
+
// from Urban's read-only task-inbox stub.
|
|
8
10
|
//
|
|
9
11
|
// It routes through the ONE canonical human completer (`completeEscalationAsHuman` →
|
|
10
12
|
// `completeUserTaskAttributed`), so the completion uses the exact same typed variables and engine
|
|
11
13
|
// resume path a human drives from the task inbox — no parallel completion — while recording WHO
|
|
12
14
|
// answered in the `task_completions` ledger. That completer refuses any user task that is not one of
|
|
13
|
-
// the
|
|
14
|
-
// complete an arbitrary internal user task.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
15
|
+
// the human-completable elements (`HUMAN_COMPLETABLE_ELEMENTS`), so this generic door can never
|
|
16
|
+
// complete an arbitrary internal user task. Issue #332 retired the bespoke feature-run reconcile doors
|
|
17
|
+
// (`answer-escalation` / `acknowledge-blocked`) and folded `feature-escalation` / `feature-blocked`
|
|
18
|
+
// onto this one canonical door.
|
|
17
19
|
//
|
|
18
20
|
// On success it removes the answered task's `user_tasks` read-model row so the Tasks grid stops
|
|
19
21
|
// offering a decision for a task that is now completed, without waiting a poll cycle; the poller
|
|
@@ -66,7 +68,7 @@ export default defineOperation("completeUserTask", async ({ body }, app) => {
|
|
|
66
68
|
app.log.info("operator completed user task", { userTaskKey, elementId: r.elementId });
|
|
67
69
|
return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
|
|
68
70
|
}
|
|
69
|
-
const status = r.reason === "no open
|
|
71
|
+
const status = r.reason === "no open completable task" ? 404 : 400;
|
|
70
72
|
app.log.warn("complete-user-task: not completed", { userTaskKey, reason: r.reason });
|
|
71
73
|
return { status, body: { ok: false, error: r.reason } };
|
|
72
74
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.101.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@nanobpm/agentic": "^0.1.0",
|
|
58
|
-
"@nanobpm/urban": "^0.
|
|
58
|
+
"@nanobpm/urban": "^0.68.1"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@biomejs/biome": "^2.4.11",
|
package/pages/feature.page.json
CHANGED
|
@@ -126,24 +126,6 @@
|
|
|
126
126
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
127
127
|
],
|
|
128
128
|
"rowActions": [
|
|
129
|
-
{
|
|
130
|
-
"label": "Abandon",
|
|
131
|
-
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
132
|
-
"showWhenField": "escalation_open",
|
|
133
|
-
"action": {
|
|
134
|
-
"path": "/app/api/actions/answer-escalation",
|
|
135
|
-
"body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
"label": "Acknowledge blocked",
|
|
140
|
-
"confirm": "Acknowledge this blocked run? It settles to terminal blocked (the agent could not open a PR).",
|
|
141
|
-
"showWhenField": "blocked_user_task_key",
|
|
142
|
-
"action": {
|
|
143
|
-
"path": "/app/api/actions/acknowledge-blocked",
|
|
144
|
-
"body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
129
|
{
|
|
148
130
|
"label": "Dismiss",
|
|
149
131
|
"confirm": "Tick off this finished run? It acknowledges the run as done and files it under History.",
|
|
@@ -156,28 +138,10 @@
|
|
|
156
138
|
],
|
|
157
139
|
"detail": {
|
|
158
140
|
"fields": [
|
|
159
|
-
{ "field": "escalation_question", "label": "Escalation question" },
|
|
160
141
|
{ "field": "base_branch", "label": "Base branch" },
|
|
161
142
|
{ "field": "outcome", "label": "Outcome" },
|
|
162
143
|
{ "field": "delivery_label", "label": "Delivery" }
|
|
163
|
-
]
|
|
164
|
-
"form": {
|
|
165
|
-
"showWhenField": "escalation_open",
|
|
166
|
-
"title": "Answer escalation",
|
|
167
|
-
"promptField": "escalation_question",
|
|
168
|
-
"inputKey": "answer",
|
|
169
|
-
"inputLabel": "Guidance for the implementation agent",
|
|
170
|
-
"submitLabel": "Answer & re-dispatch",
|
|
171
|
-
"action": {
|
|
172
|
-
"path": "/app/api/actions/answer-escalation",
|
|
173
|
-
"successLabel": "Answered — the agent will resume",
|
|
174
|
-
"body": {
|
|
175
|
-
"userTaskKey": "{{row.escalation_user_task_key}}",
|
|
176
|
-
"resolution": "answer",
|
|
177
|
-
"answer": "{{form.answer}}"
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
144
|
+
]
|
|
181
145
|
}
|
|
182
146
|
}
|
|
183
147
|
}
|
package/pages/overview.page.json
CHANGED
|
@@ -165,48 +165,10 @@
|
|
|
165
165
|
{ "field": "delivery_label", "header": "Delivery" },
|
|
166
166
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
167
167
|
],
|
|
168
|
-
"rowActions": [
|
|
169
|
-
{
|
|
170
|
-
"label": "Abandon",
|
|
171
|
-
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
172
|
-
"showWhenField": "escalation_open",
|
|
173
|
-
"action": {
|
|
174
|
-
"path": "/app/api/actions/answer-escalation",
|
|
175
|
-
"body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
|
|
176
|
-
}
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
"label": "Acknowledge blocked",
|
|
180
|
-
"confirm": "Acknowledge this blocked run? It settles to terminal blocked (the agent could not open a PR).",
|
|
181
|
-
"showWhenField": "blocked_user_task_key",
|
|
182
|
-
"action": {
|
|
183
|
-
"path": "/app/api/actions/acknowledge-blocked",
|
|
184
|
-
"body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
],
|
|
188
168
|
"detail": {
|
|
189
169
|
"fields": [
|
|
190
|
-
{ "field": "escalation_question", "label": "Escalation question" },
|
|
191
170
|
{ "field": "outcome", "label": "Outcome" }
|
|
192
|
-
]
|
|
193
|
-
"form": {
|
|
194
|
-
"showWhenField": "escalation_open",
|
|
195
|
-
"title": "Answer escalation",
|
|
196
|
-
"promptField": "escalation_question",
|
|
197
|
-
"inputKey": "answer",
|
|
198
|
-
"inputLabel": "Guidance for the implementation agent",
|
|
199
|
-
"submitLabel": "Answer & re-dispatch",
|
|
200
|
-
"action": {
|
|
201
|
-
"path": "/app/api/actions/answer-escalation",
|
|
202
|
-
"successLabel": "Answered — the agent will resume",
|
|
203
|
-
"body": {
|
|
204
|
-
"userTaskKey": "{{row.escalation_user_task_key}}",
|
|
205
|
-
"resolution": "answer",
|
|
206
|
-
"answer": "{{form.answer}}"
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
171
|
+
]
|
|
210
172
|
}
|
|
211
173
|
}
|
|
212
174
|
}
|