@nanobpm/nano-workforce 0.70.2 → 0.71.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.
@@ -0,0 +1,217 @@
1
+ // Unified "Tasks" inbox read-model (issue #236) — the schema-driven pages' single source for the
2
+ // Tasks page, listing EVERY open native user-task escalation awaiting a human decision so an operator
3
+ // can resolve one in the nwf UI (Urban's `taskInbox` at /tasks is a read-only stub).
4
+ //
5
+ // The four migrated escalations (ADR 0046) are native `userTask`s with linked `.form`s. Their
6
+ // completable keys were only ever denormalised for the FEATURE kinds (migrations 031/032, onto
7
+ // `feature_runs`); the epic/PR kinds (`plan-review-decision`, `trial-merge-decision`, the PR
8
+ // `wait-answer`) had no app-side pointer, so the pages could not drive a completion. This module owns
9
+ // the `user_tasks` read-model row shape and the PURE derivation the `pollUserTasks` reconcile
10
+ // (app/service.ts) projects: `buildUserTaskRow` (one open task → a desired row) and
11
+ // `reconcileUserTasks` (the desired set vs the persisted set → the minimal upserts + deletes). The
12
+ // engine iteration + writes live in the poller; the decisions live here so they are unit-testable
13
+ // without a host, mirroring `deriveFeatureEscalationPatch`.
14
+ //
15
+ // Completion is NOT owned here — the page posts the typed form variables to the ONE canonical human
16
+ // completer (`completeEscalationAsHuman`, app/agentCompletion.ts) / the existing feature answer &
17
+ // acknowledge operations, the exact resume path the task inbox uses. This module only makes the open
18
+ // tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
19
+ // it open.
20
+ import type { DataLayer } from "@nanobpm/urban";
21
+ import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT } from "./feature.ts";
22
+ import type { PlanReview } from "./plan.ts";
23
+ import type { TrialMergeAuditRow } from "./trialMerge.ts";
24
+
25
+ const now = () => new Date().toISOString();
26
+
27
+ /** The plan-review cap escalation user task (plan-fanout.bpmn) — a human directive (proceed/revise)
28
+ * when the adversarial review loop exhausts its budget without approval. */
29
+ export const PLAN_REVIEW_ELEMENT = "plan-review-decision";
30
+
31
+ /** The trial-merge escalation user task (plan-fanout.bpmn) — a human decision (proceed/rebase/abandon)
32
+ * when a wave's trial merge comes back red. */
33
+ export const TRIAL_MERGE_ELEMENT = "trial-merge-decision";
34
+
35
+ /** The PR review-loop escalation user task (convergence-loop.bpmn) — a human answer that resumes the
36
+ * review loop and is handed to the next round. */
37
+ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
38
+
39
+ /** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
40
+ * the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
41
+ * task open; `pollUserTasks` deletes it once the task is gone. */
42
+ export interface UserTaskRow {
43
+ user_task_key: string;
44
+ element_id: string;
45
+ kind_label: string;
46
+ subject_type: string;
47
+ subject_key: string;
48
+ subject_url: string | null;
49
+ question: string | null;
50
+ process_key: string | null;
51
+ created_at: string;
52
+ updated_at: string;
53
+ }
54
+
55
+ export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_tasks", "user_task_key");
56
+
57
+ /** Human-readable label per escalation element. The set of keys is the closed set of user-task
58
+ * elements the Tasks inbox surfaces — an element absent from here is not an escalation and is
59
+ * ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */
60
+ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
61
+ [FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
62
+ [FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
63
+ [PLAN_REVIEW_ELEMENT]: "Plan review",
64
+ [TRIAL_MERGE_ELEMENT]: "Trial merge",
65
+ [PR_WAIT_ANSWER_ELEMENT]: "PR review",
66
+ };
67
+
68
+ /** The denormalised context the poller has resolved for an open escalation user task. */
69
+ export interface UserTaskContext {
70
+ userTaskKey: string;
71
+ elementId: string;
72
+ subjectType: "feature" | "plan" | "pr";
73
+ subjectKey: string;
74
+ subjectUrl?: string | null;
75
+ question?: string | null;
76
+ processKey?: string | null;
77
+ }
78
+
79
+ /** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
80
+ * element is not one of the surfaced escalation kinds (so a non-escalation user task is never listed)
81
+ * or the required keys are blank. `created_at`/`updated_at` default to now for a fresh row; the
82
+ * reconcile preserves the original `created_at` on an update. */
83
+ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
84
+ const userTaskKey = ctx.userTaskKey.trim();
85
+ const subjectKey = ctx.subjectKey.trim();
86
+ const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
87
+ if (!userTaskKey || !subjectKey || !kindLabel) return null;
88
+ const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
89
+ return {
90
+ user_task_key: userTaskKey,
91
+ element_id: ctx.elementId,
92
+ kind_label: kindLabel,
93
+ subject_type: ctx.subjectType,
94
+ subject_key: subjectKey,
95
+ subject_url: ctx.subjectUrl ?? null,
96
+ question,
97
+ process_key: ctx.processKey ?? null,
98
+ created_at: at,
99
+ updated_at: at,
100
+ };
101
+ }
102
+
103
+ /** The minimal write plan a reconcile pass applies: rows to insert, rows to update in place (the
104
+ * task is still open but its denormalised context changed), and keys to delete (the task is gone). */
105
+ export interface UserTaskReconcile {
106
+ inserts: UserTaskRow[];
107
+ updates: UserTaskRow[];
108
+ deletes: string[];
109
+ }
110
+
111
+ /** True when the persisted row already matches the freshly-derived one on every display field, so the
112
+ * reconcile can skip a no-op write (an update touches `updated_at`, which would otherwise churn the
113
+ * row on every pass). `created_at`/`updated_at` are intentionally excluded. */
114
+ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
115
+ return (
116
+ a.element_id === b.element_id &&
117
+ a.kind_label === b.kind_label &&
118
+ a.subject_type === b.subject_type &&
119
+ a.subject_key === b.subject_key &&
120
+ a.subject_url === b.subject_url &&
121
+ a.question === b.question &&
122
+ a.process_key === b.process_key
123
+ );
124
+ }
125
+
126
+ /** Pure source of truth for the `pollUserTasks` reconcile: diff the DESIRED open-task rows (derived
127
+ * from the engine this pass) against the PERSISTED rows, returning the minimal insert/update/delete
128
+ * plan. A desired row not yet persisted is an insert; a persisted row whose task is still desired but
129
+ * whose context drifted is an update (preserving its original `created_at`); a persisted row no longer
130
+ * desired is a delete (its task was completed — here, via the inbox, or out-of-band). Idempotent: a
131
+ * steady state with no drift yields empty lists, so the poller performs zero writes. */
132
+ export function reconcileUserTasks(persisted: UserTaskRow[], desired: UserTaskRow[]): UserTaskReconcile {
133
+ const persistedByKey = new Map(persisted.map((r) => [r.user_task_key, r]));
134
+ const desiredByKey = new Map(desired.map((r) => [r.user_task_key, r]));
135
+ const inserts: UserTaskRow[] = [];
136
+ const updates: UserTaskRow[] = [];
137
+ const deletes: string[] = [];
138
+
139
+ for (const row of desired) {
140
+ const existing = persistedByKey.get(row.user_task_key);
141
+ if (!existing) {
142
+ inserts.push(row);
143
+ } else if (!sameRow(existing, row)) {
144
+ updates.push({ ...row, created_at: existing.created_at });
145
+ }
146
+ }
147
+ for (const row of persisted) {
148
+ if (!desiredByKey.has(row.user_task_key)) deletes.push(row.user_task_key);
149
+ }
150
+ return { inserts, updates, deletes };
151
+ }
152
+
153
+ // ── Audit-table accessors + derivations used by the poller to enrich the question text ────────────
154
+ // These read the question/findings each escalation kind already records in a SURVIVING audit table,
155
+ // keyed by the subject, so the Tasks grid can show WHAT is being decided. They are display-only.
156
+ //
157
+ // NB: the bespoke `plan_escalations` / `plan_review_escalations` mirror tables were retired in
158
+ // migration 027 (the CONTRACT phase of the native-userTask migration), so the enrichment is derived
159
+ // from the canonical append-only audit logs that DID survive — `plan_reviews` (the adversarial
160
+ // plan-review log) and `plan_trial_merges` (the D3 trial-merge gate log) — not from a dropped mirror.
161
+ // The PR-loop `escalations` audit table was deliberately KEPT by 027, so `prEscalations` still reads it.
162
+
163
+ /** Pure: the findings that drove the still-open `plan-review-decision` escalation — the latest
164
+ * adversarial plan-review round's critique. `plan_reviews` is append-only per (epoch, round); the
165
+ * parked escalation is the tail of the current epoch, so the latest round by (epoch, round) carries
166
+ * the rejecting findings the human is being asked to overrule. `null` when there is no round yet. */
167
+ export function latestPlanReviewFindings(reviews: readonly PlanReview[]): string | null {
168
+ let latest: PlanReview | undefined;
169
+ for (const r of reviews) {
170
+ if (!latest || r.epoch > latest.epoch || (r.epoch === latest.epoch && r.round > latest.round)) latest = r;
171
+ }
172
+ return latest?.findings ?? null;
173
+ }
174
+
175
+ /** Pure: the summary that drove the still-open `trial-merge-decision` escalation — the newest
176
+ * UNRESOLVED red trial-merge attempt. `plan_trial_merges` is append-only and supersede-on-insert
177
+ * marks superseded rows `resolved = 1`, so the newest unresolved `suite-failed`/`merge-conflict` row
178
+ * (highest `id`) is the wave awaiting the human decision. `null` when none is open. */
179
+ export function latestTrialMergeQuestion(audits: readonly TrialMergeAuditRow[]): string | null {
180
+ let latest: TrialMergeAuditRow | undefined;
181
+ for (const a of audits) {
182
+ if (a.resolved === 1) continue;
183
+ if (a.result !== "suite-failed" && a.result !== "merge-conflict") continue;
184
+ if (!latest || a.id > latest.id) latest = a;
185
+ }
186
+ return latest?.summary ?? null;
187
+ }
188
+
189
+ /** One PR review-loop escalation (001_init.sql `escalations`). `status` is open | answered; the poller
190
+ * reads the OPEN row's `question`. */
191
+ export interface PrEscalationRow {
192
+ id: number;
193
+ pr_key: string;
194
+ round_no: number;
195
+ kind: string;
196
+ question: string;
197
+ answer: string | null;
198
+ status: string;
199
+ asked_at: string;
200
+ answered_at: string | null;
201
+ }
202
+
203
+ export const prEscalations = (data: DataLayer) =>
204
+ data.table<PrEscalationRow>("escalations", "id");
205
+
206
+ /** Pure: the question for the still-open PR review-loop escalation. `escalations` is append-only and
207
+ * `id` is an AUTOINCREMENT PK, so when a PR has multiple `open` rows the newest (highest `id`) is the
208
+ * live one the human is being asked; a positional `[0]` from an unordered `find` could surface a stale
209
+ * row. `null` when there is no open escalation. */
210
+ export function latestOpenEscalationQuestion(rows: readonly PrEscalationRow[]): string | null {
211
+ let latest: PrEscalationRow | undefined;
212
+ for (const r of rows) {
213
+ if (r.status !== "open") continue;
214
+ if (!latest || r.id > latest.id) latest = r;
215
+ }
216
+ return latest?.question ?? null;
217
+ }
@@ -0,0 +1,51 @@
1
+ -- Unified "Tasks" inbox read-model (issue #236) — surface EVERY open native user-task escalation
2
+ -- awaiting a human decision in the nwf UI, so an operator can resolve one without leaving the app.
3
+ --
4
+ -- The four migrated escalations (ADR 0046) are native BPMN `userTask`s with linked `.form`s:
5
+ -- `feature-escalation` (a stuck single-issue run), `feature-blocked` (a blocked run to acknowledge),
6
+ -- `plan-review-decision` (a plan-review cap escalation), `trial-merge-decision` (a red trial merge),
7
+ -- and the PR review-loop `wait-answer`. The completable user-task keys were only ever denormalised
8
+ -- for the FEATURE kinds (migrations 031/032, onto `feature_runs`); the epic/PR kinds had NO app-side
9
+ -- pointer at all, so the only surface that listed them was Urban's read-only `taskInbox` stub.
10
+ --
11
+ -- This is the schema-driven pages' single source for the Tasks page `dataGrid`s: `pollUserTasks`
12
+ -- (app/service.ts) reconciles one row per currently-open escalation user task from the engine
13
+ -- (`searchUserTasks`), denormalising the completable `user_task_key` plus display context (the
14
+ -- escalation `question`, its `subject_*`, and `element_id`). A row exists iff the user task is open;
15
+ -- the poller deletes a row once its task is gone (answered here, via the task inbox, or out-of-band),
16
+ -- so `showCount` reflects live pending work. The completion affordances post the typed form variables
17
+ -- to the canonical human completer (`completeEscalationAsHuman` / the existing feature answer/ack
18
+ -- operations), the same resume path the task inbox uses — no parallel completion.
19
+ --
20
+ -- Forward-only, additive (expand): a brand-new table, no existing shape touched. Numbered after the
21
+ -- current highest prefix (033); the runner wraps each file in its own transaction, so this file must
22
+ -- NOT contain BEGIN/COMMIT.
23
+ CREATE TABLE user_tasks (
24
+ -- The completable native user-task key (the engine's `userTaskKey`) — the PK, since a user task is
25
+ -- open at most once and every completion affordance posts to it.
26
+ user_task_key TEXT PRIMARY KEY,
27
+ -- The BPMN `elementId` of the parked user task (one of the migrated escalation elements). Drives
28
+ -- which typed decision form / completion operation the page routes the answer through.
29
+ element_id TEXT NOT NULL,
30
+ -- Human-readable kind label for the grid (e.g. "Plan review", "Trial merge").
31
+ kind_label TEXT NOT NULL,
32
+ -- The domain subject the escalation belongs to: `subject_type` is feature | plan | pr; `subject_key`
33
+ -- is its aggregate key (feature_key / plan_key / pr_key); `subject_url` is an optional external link
34
+ -- (the issue/PR URL) shown as a clickable column.
35
+ subject_type TEXT NOT NULL,
36
+ subject_key TEXT NOT NULL,
37
+ subject_url TEXT,
38
+ -- The escalation question / findings the agent (or loop) raised, denormalised for display so the
39
+ -- operator can decide without opening the process. Best-effort; NULL when none was recorded.
40
+ question TEXT,
41
+ -- The owning process instance key, for the process-explorer link. NULL when unknown.
42
+ process_key TEXT,
43
+ created_at TEXT NOT NULL,
44
+ updated_at TEXT NOT NULL
45
+ );
46
+
47
+ -- The pages filter the grid by `element_id` (one grid per kind) and order by recency
48
+ -- (`WHERE element_id IN (...) ORDER BY updated_at DESC`). A single composite index on
49
+ -- `(element_id, updated_at)` serves both the equality filter and the ordered read in one structure —
50
+ -- no extra sort/scan — so the read stays O(matching), not O(all open tasks), as the table grows.
51
+ CREATE INDEX idx_user_tasks_element_updated ON user_tasks(element_id, updated_at);
package/nano.app.json CHANGED
@@ -66,7 +66,8 @@
66
66
  "statusField": "status",
67
67
  "activeStatuses": [
68
68
  "running",
69
- "escalated"
69
+ "escalated",
70
+ "awaiting_operator"
70
71
  ],
71
72
  "onTerminated": {
72
73
  "set": {
package/openapi.yaml CHANGED
@@ -1244,6 +1244,59 @@ paths:
1244
1244
  application/json:
1245
1245
  schema:
1246
1246
  $ref: "#/components/schemas/MessageResult"
1247
+ /actions/complete-user-task:
1248
+ post:
1249
+ operationId: completeUserTask
1250
+ summary: "Complete an open native user-task escalation from the nwf Tasks inbox (issue #236).
1251
+ Submits the parked task's typed `.form` variables (e.g. a plan-review `{ directive, notes }`, a
1252
+ trial-merge `{ action, notes }`, or a PR `{ answer }`) to the canonical human completer
1253
+ (completeEscalationAsHuman → completeUserTaskAttributed) — the same resume path the task inbox
1254
+ uses, recording who answered. The completer refuses any user task that is not one of the
1255
+ migrated escalation elements. The feature-run kinds keep their own operations
1256
+ (answer-escalation / acknowledge-blocked); this door is for the plan-review / trial-merge / PR
1257
+ `wait-answer` kinds whose completion is a straight typed pass-through. On success the answered
1258
+ task's read-model row is dropped so the Tasks grid stops offering a decision for it."
1259
+ requestBody:
1260
+ required: true
1261
+ content:
1262
+ application/json:
1263
+ schema:
1264
+ type: object
1265
+ additionalProperties: false
1266
+ required:
1267
+ - userTaskKey
1268
+ - variables
1269
+ properties:
1270
+ userTaskKey:
1271
+ type: string
1272
+ minLength: 1
1273
+ description: The parked escalation user-task key (user_tasks.user_task_key).
1274
+ variables:
1275
+ type: object
1276
+ additionalProperties: true
1277
+ description: The typed form variables the parked task's `.form` expects (kind-specific).
1278
+ operator:
1279
+ type: string
1280
+ description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
1281
+ responses:
1282
+ "200":
1283
+ description: The escalation user task was completed and the process resumed.
1284
+ content:
1285
+ application/json:
1286
+ schema:
1287
+ $ref: "#/components/schemas/MessageResult"
1288
+ "400":
1289
+ description: A required field was missing/invalid, or the target is not an escalation task.
1290
+ content:
1291
+ application/json:
1292
+ schema:
1293
+ $ref: "#/components/schemas/MessageResult"
1294
+ "404":
1295
+ description: No open escalation user task matches the userTaskKey.
1296
+ content:
1297
+ application/json:
1298
+ schema:
1299
+ $ref: "#/components/schemas/MessageResult"
1247
1300
  /actions/acknowledge-blocked:
1248
1301
  post:
1249
1302
  operationId: acknowledgeBlocked
@@ -0,0 +1,146 @@
1
+ // Tests for the POST /app/api/actions/complete-user-task operation `completeUserTask` (issue #236).
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.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import type { AppApi } from "@nanobpm/urban";
9
+ import { noopLog } from "../test/log.ts";
10
+ import handler from "./completeUserTask.ts";
11
+
12
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors acknowledgeBlocked.test.ts
13
+ function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
14
+ app: AppApi;
15
+ // biome-ignore lint/suspicious/noExplicitAny: see above
16
+ stores: Record<string, any[]>;
17
+ completed: { userTaskKey: string; variables: Record<string, unknown> }[];
18
+ } {
19
+ // biome-ignore lint/suspicious/noExplicitAny: see above
20
+ const stores: Record<string, any[]> = {};
21
+ const completed: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
22
+ function tbl(name: string, pk: string) {
23
+ // biome-ignore lint/suspicious/noExplicitAny: see above
24
+ const rows = (stores[name] ??= [] as any[]);
25
+ return {
26
+ // biome-ignore lint/suspicious/noExplicitAny: see above
27
+ async insert(row: any) {
28
+ rows.push({ ...row });
29
+ return rows.length;
30
+ },
31
+ // biome-ignore lint/suspicious/noExplicitAny: see above
32
+ async get(id: any) {
33
+ return rows.find((r) => r[pk] === id);
34
+ },
35
+ // biome-ignore lint/suspicious/noExplicitAny: see above
36
+ async find(where: any = {}) {
37
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
38
+ },
39
+ // biome-ignore lint/suspicious/noExplicitAny: see above
40
+ async delete(id: any) {
41
+ const i = rows.findIndex((r) => r[pk] === id);
42
+ if (i >= 0) rows.splice(i, 1);
43
+ },
44
+ // biome-ignore lint/suspicious/noExplicitAny: see above
45
+ async update(id: any, patch: any) {
46
+ const r = rows.find((row) => row[pk] === id);
47
+ if (r) Object.assign(r, patch);
48
+ },
49
+ };
50
+ }
51
+ const engine = {
52
+ searchUserTasks: async () => openTasks,
53
+ completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
54
+ completed.push({ userTaskKey, variables });
55
+ },
56
+ };
57
+ const app = {
58
+ data: { table: (n: string, pk: string) => tbl(n, pk) },
59
+ engine,
60
+ log: noopLog(),
61
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
62
+ } as any as AppApi;
63
+ return { app, stores, completed };
64
+ }
65
+
66
+ async function call(app: AppApi, body: unknown) {
67
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
68
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
69
+ }
70
+
71
+ test("complete-user-task: completes a plan-review escalation and drops its read-model row", async () => {
72
+ const { app, stores, completed } = memApp([{ userTaskKey: "ut-1", elementId: "plan-review-decision" }]);
73
+ stores.user_tasks = [{ user_task_key: "ut-1", element_id: "plan-review-decision" }];
74
+
75
+ const res = await call(app, { userTaskKey: "ut-1", variables: { directive: "revise", notes: "narrow scope" } });
76
+
77
+ assertEquals(res.status, 200);
78
+ assertEquals(res.body.ok, true);
79
+ assertEquals(res.body.elementId, "plan-review-decision");
80
+ assertEquals(completed, [{ userTaskKey: "ut-1", variables: { directive: "revise", notes: "narrow scope" } }]);
81
+ assertEquals(stores.user_tasks, []);
82
+ // Attribution recorded as a human completion.
83
+ assertEquals(stores.task_completions.length, 1);
84
+ assertEquals(stores.task_completions[0].actor_kind, "human");
85
+ });
86
+
87
+ test("complete-user-task: completes a trial-merge escalation with the typed action variable", async () => {
88
+ const { app, completed } = memApp([{ userTaskKey: "ut-2", elementId: "trial-merge-decision" }]);
89
+
90
+ const res = await call(app, { userTaskKey: "ut-2", variables: { action: "rebase" } });
91
+
92
+ assertEquals(res.status, 200);
93
+ assertEquals(completed, [{ userTaskKey: "ut-2", variables: { action: "rebase" } }]);
94
+ });
95
+
96
+ test("complete-user-task: a missing userTaskKey is a 400", async () => {
97
+ const { app } = memApp([]);
98
+ const res = await call(app, { variables: { answer: "x" } });
99
+ assertEquals(res.status, 400);
100
+ assertEquals(res.body.ok, false);
101
+ });
102
+
103
+ test("complete-user-task: non-object variables are rejected 400", async () => {
104
+ const { app } = memApp([{ userTaskKey: "ut-3", elementId: "wait-answer" }]);
105
+ const res = await call(app, { userTaskKey: "ut-3", variables: "nope" });
106
+ assertEquals(res.status, 400);
107
+ });
108
+
109
+ test("complete-user-task: an unknown key is a 404 (no open escalation task)", async () => {
110
+ const { app } = memApp([]);
111
+ const res = await call(app, { userTaskKey: "ghost", variables: { answer: "x" } });
112
+ assertEquals(res.status, 404);
113
+ });
114
+
115
+ test("complete-user-task: refuses a non-escalation user task (400)", async () => {
116
+ const { app, completed } = memApp([{ userTaskKey: "ut-4", elementId: "feature-blocked" }]);
117
+ const res = await call(app, { userTaskKey: "ut-4", variables: { note: "x" } });
118
+ assertEquals(res.status, 400);
119
+ assertEquals(completed, []);
120
+ });
121
+
122
+ test("complete-user-task: a read-model cleanup failure does not mask a resumed completion (200)", async () => {
123
+ const { app, completed } = memApp([{ userTaskKey: "ut-5", elementId: "plan-review-decision" }]);
124
+ // The engine has already resumed the process; a transient delete failure on the latency-optimising
125
+ // read-model cleanup must not surface as a 5xx for a task that IS completed (poller reconciles).
126
+ const realTable = app.data.table.bind(app.data);
127
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
128
+ (app.data as any).table = (name: string, pk: string) => {
129
+ const t = realTable(name, pk);
130
+ if (name === "user_tasks") {
131
+ return {
132
+ ...t,
133
+ delete: async () => {
134
+ throw new Error("transient DB error");
135
+ },
136
+ };
137
+ }
138
+ return t;
139
+ };
140
+
141
+ const res = await call(app, { userTaskKey: "ut-5", variables: { directive: "revise", notes: "narrow scope" } });
142
+
143
+ assertEquals(res.status, 200);
144
+ assertEquals(res.body.ok, true);
145
+ assertEquals(completed, [{ userTaskKey: "ut-5", variables: { directive: "revise", notes: "narrow scope" } }]);
146
+ });
@@ -0,0 +1,72 @@
1
+ // POST /app/api/actions/complete-user-task → operationId `completeUserTask` (issue #236).
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.
8
+ //
9
+ // It routes through the ONE canonical human completer (`completeEscalationAsHuman` →
10
+ // `completeUserTaskAttributed`), so the completion uses the exact same typed variables and engine
11
+ // resume path a human drives from the task inbox — no parallel completion — while recording WHO
12
+ // 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.
17
+ //
18
+ // On success it removes the answered task's `user_tasks` read-model row so the Tasks grid stops
19
+ // offering a decision for a task that is now completed, without waiting a poll cycle; the poller
20
+ // (`pollUserTasks`) is the durable source of truth and will re-derive the exact same empty state.
21
+ // That read-model cleanup is best-effort — a transient delete failure is logged and swallowed rather
22
+ // than masking a completion the engine has already resumed with a spurious 5xx.
23
+ //
24
+ // The runtime validates the body against openapi.yaml (`userTaskKey` + `variables` required); this
25
+ // delegate narrows the validated shape and applies the shared human-completion contract.
26
+
27
+ import { completeEscalationAsHuman } from "../app/agentCompletion.ts";
28
+ import { userTasks } from "../app/userTasks.ts";
29
+ import { defineOperation } from "../nano-generated/operations.ts";
30
+
31
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
32
+
33
+ export default defineOperation("completeUserTask", async ({ body }, app) => {
34
+ if (!body || typeof body !== "object") {
35
+ app.log.warn("complete-user-task rejected: missing request body");
36
+ return { status: 400, body: { ok: false, error: "userTaskKey and variables are required" } };
37
+ }
38
+
39
+ const userTaskKey = str(body.userTaskKey);
40
+ if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
41
+
42
+ const variables = body.variables;
43
+ if (!variables || typeof variables !== "object" || Array.isArray(variables)) {
44
+ return { status: 400, body: { ok: false, error: "variables must be an object" } };
45
+ }
46
+
47
+ // The completing operator, for the attribution ledger. Optional — the UI has no per-operator auth,
48
+ // so default to a generic handle rather than blocking the completion.
49
+ const operatorId = str(body.operator) || "operator";
50
+
51
+ const r = await completeEscalationAsHuman(app.data, app.engine, { userTaskKey, operatorId, variables });
52
+ if (r.ok) {
53
+ // Reconcile this operation's OWN action immediately: drop the read-model row so the Tasks page
54
+ // stops offering a decision for a task that is now completed. The poller re-derives the same
55
+ // state, so this is a latency optimisation, not the source of truth — make it best-effort so a
56
+ // transient cleanup failure never masks a completion the engine has already resumed (which would
57
+ // return a spurious 5xx to the UI for a task that IS done). The poller re-derives the empty state.
58
+ try {
59
+ await userTasks(app.data).delete(userTaskKey);
60
+ } catch (err) {
61
+ app.log.warn("complete-user-task: read-model cleanup failed (poller will reconcile)", {
62
+ userTaskKey,
63
+ error: err instanceof Error ? err.message : String(err),
64
+ });
65
+ }
66
+ app.log.info("operator completed user task", { userTaskKey, elementId: r.elementId });
67
+ return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
68
+ }
69
+ const status = r.reason === "no open escalation task" ? 404 : 400;
70
+ app.log.warn("complete-user-task: not completed", { userTaskKey, reason: r.reason });
71
+ return { status, body: { ok: false, error: r.reason } };
72
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.70.2",
3
+ "version": "0.71.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",
@@ -13,6 +13,7 @@
13
13
  { "label": "Convergence", "page": "home" },
14
14
  { "label": "Epics", "page": "epic" },
15
15
  { "label": "Feature", "page": "feature" },
16
+ { "label": "Tasks", "page": "tasks" },
16
17
  { "label": "Cockpit", "page": "cockpit" }
17
18
  ],
18
19
  "sticky": true
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }
@@ -25,6 +25,10 @@
25
25
  "label": "Feature",
26
26
  "page": "feature"
27
27
  },
28
+ {
29
+ "label": "Tasks",
30
+ "page": "tasks"
31
+ },
28
32
  {
29
33
  "label": "Cockpit",
30
34
  "page": "cockpit"
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }