@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.
- package/CHANGELOG.md +7 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -0
- package/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +229 -2
- package/app/trialMerge.ts +5 -0
- package/app/userTasks.test.ts +208 -0
- package/app/userTasks.ts +217 -0
- package/db/migrations/034_user_tasks_inbox.sql +51 -0
- package/nano.app.json +2 -1
- package/openapi.yaml +53 -0
- package/operations/completeUserTask.test.ts +146 -0
- package/operations/completeUserTask.ts +72 -0
- package/package.json +1 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +297 -0
package/app/userTasks.ts
ADDED
|
@@ -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
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.
|
|
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",
|
package/pages/cockpit.page.json
CHANGED
package/pages/epic.page.json
CHANGED
package/pages/feature.page.json
CHANGED
package/pages/home.page.json
CHANGED
package/pages/overview.page.json
CHANGED