@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/agentCompletion.test.ts +92 -6
  3. package/app/agentCompletion.ts +86 -58
  4. package/app/contracts.ts +18 -2
  5. package/app/feature.ts +15 -166
  6. package/app/featureGateway.test.ts +6 -57
  7. package/app/migration049.test.ts +112 -0
  8. package/app/pollUserTasks.test.ts +39 -13
  9. package/app/service.test.ts +16 -0
  10. package/app/service.ts +107 -136
  11. package/app/stage.test.ts +7 -53
  12. package/app/stage.ts +5 -37
  13. package/app/userTasks.test.ts +1 -1
  14. package/app/userTasks.ts +3 -3
  15. package/app/world/checkpoint.test.ts +193 -0
  16. package/app/world/checkpoint.ts +142 -0
  17. package/app/world/effect-ledger.test.ts +86 -0
  18. package/app/world/effect-ledger.ts +103 -0
  19. package/app/world/git.ts +53 -0
  20. package/app/world/index.ts +26 -0
  21. package/app/world/store.test.ts +443 -0
  22. package/app/world/store.ts +320 -0
  23. package/app/world-marker.test.ts +79 -0
  24. package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
  25. package/db/migrations/049_world_checkpoint.sql +84 -0
  26. package/e2e/feature-run.e2e.ts +52 -41
  27. package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
  28. package/openapi.yaml +7 -108
  29. package/operations/agentCompleteEscalation.ts +2 -2
  30. package/operations/completeUserTask.test.ts +25 -6
  31. package/operations/completeUserTask.ts +12 -10
  32. package/package.json +2 -2
  33. package/pages/feature.page.json +1 -37
  34. package/pages/overview.page.json +1 -39
  35. package/pages/tasks.page.json +78 -93
  36. package/resources/forms/feature-escalation.form +3 -0
  37. package/test/worldDb.ts +103 -0
  38. package/workers/persist-round/worker.ts +68 -0
  39. package/workers/record-blocked-ack/worker.test.ts +1 -4
  40. package/workers/record-blocked-ack/worker.ts +0 -5
  41. package/workers/record-feature/worker.ts +0 -6
  42. package/workers/record-feature-escalation/worker.test.ts +14 -27
  43. package/workers/record-feature-escalation/worker.ts +16 -22
  44. package/app/featureBlocked.test.ts +0 -182
  45. package/app/featureEscalation.test.ts +0 -235
  46. package/operations/acknowledgeBlocked.test.ts +0 -111
  47. package/operations/acknowledgeBlocked.ts +0 -62
  48. package/operations/answerFeatureEscalation.ts +0 -68
@@ -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 { pollFeatureBlocked, pollFeatureEscalations } from "../app/service.ts";
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 poller fills in the completable user-task key (which no service task can know the task
195
- // doesn't exist yet when record-feature runs) so the pages can drive an attributed acknowledge.
196
- await pollFeatureBlocked(app.db, asEngineClient(app.engine));
197
- const denorm = await featureRow(app, featureKey);
198
- assert.ok(denorm.blocked_user_task_key, "the poller denormalised the completable blocked user-task key");
199
- assert.equal(denorm.status, "awaiting_operator", "the run stays awaiting_operator while parked");
200
-
201
- // Acknowledge through the app's OWN operation (the nwf UI's affordance) — the attributed
202
- // completer resumes the SAME record-blocked-ack path a human would from the task inbox, with NO
203
- // out-of-band /v2/user-tasks/{key}/completion call.
204
- const acked = await app.api?.call("acknowledgeBlocked", {
205
- body: { userTaskKey: denorm.blocked_user_task_key, note: "reassigned to a human" },
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 — a terminal run is not a candidate.
218
- await pollFeatureBlocked(app.db, asEngineClient(app.engine));
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 read model, and the operator answer resolves it (issue #210)", async () => {
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 + the agent's question when the run parks — the
294
- // read model the pages read is no longer blind to the native user-task wait (the #210 bug).
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
- assert.equal(parked.escalation_question, "Which API should I use?", "the agent's question is surfaced");
298
-
299
- // The poller fills in the completable user-task key (which the service task can't know — the
300
- // task doesn't exist yet when it runs) so the UI can drive an attributed answer.
301
- await pollFeatureEscalations(app.db, asEngineClient(app.engine));
302
- const escalated = await featureRow(app, featureKey);
303
- assert.ok(escalated.escalation_user_task_key, "the poller denormalised the completable user-task key");
304
- assert.equal(escalated.status, "escalated", "the run stays escalated while parked");
305
-
306
- // Answer through the app's OWN operation (the nwf UI's answer affordance) — the attributed
307
- // completer resumes the SAME implement task a human would from the task inbox.
308
- const answered = await app.api?.call("answerFeatureEscalation", {
309
- body: { userTaskKey: escalated.escalation_user_task_key, resolution: "answer", answer: "use v2" },
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; the escalation pointer + question were cleared once resolved.
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 is an idempotent no-op a terminal run is not a candidate.
328
- await pollFeatureEscalations(app.db, asEngineClient(app.engine));
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 }`, or a PR `{ answer }`) to the canonical human completer
1969
- (completeEscalationAsHuman completeUserTaskAttributed) the same resume path the task inbox
1970
- uses, recording who answered. The completer refuses any user task that is not one of the
1971
- migrated escalation elements. The feature-run kinds keep their own operations
1972
- (answer-escalation / acknowledge-blocked); this door is for the plan-review / trial-merge / PR
1973
- `wait-answer` kinds whose completion is a straight typed pass-through. On success the answered
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 acknowledge-blocked, but a terminal run is
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-escalation / missing-target is a client error; an unknown key is a 404.
50
- const status = r.reason === "no open escalation task" ? 404 : 400;
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: it routes the typed form variables through the canonical human completer
4
- // (completeEscalationAsHuman completeUserTaskAttributed), resuming the process exactly as the task
5
- // inbox would, and drops the answered task's read-model row so the grid stops offering a decision.
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 acknowledgeBlocked.test.ts
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 escalation task)", async () => {
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: refuses a non-escalation user task (400)", async () => {
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 epic/PR native escalations that had no
4
- // app-side completion path — `plan-review-decision`, `trial-merge-decision`, and the PR review-loop
5
- // `wait-answer`. An operator submits the parked task's typed `.form` variables (e.g. a plan-review
6
- // `{ directive, notes }`, a trial-merge `{ action, notes }`, or a PR `{ answer }`) directly from the
7
- // Tasks inbox instead of only from Urban's read-only task-inbox stub.
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 migrated escalation elements (`ESCALATION_TASK_ELEMENTS`), so this generic door can never
14
- // complete an arbitrary internal user task. The feature-run kinds keep their own reconcile-bearing
15
- // operations (`answer-escalation` / `acknowledge-blocked`); this door is for the escalation kinds
16
- // whose completion is a straight typed pass-through.
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 escalation task" ? 404 : 400;
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.99.1",
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.61.0"
58
+ "@nanobpm/urban": "^0.68.1"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.4.11",
@@ -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
  }
@@ -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
  }