@nanobpm/nano-workforce 0.99.1 → 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,111 +0,0 @@
1
- // Tests for the POST /app/api/actions/acknowledge-blocked operation `acknowledgeBlocked` (issue #220).
2
- // The nwf UI's completion affordance for a blocked feature run parked at `feature-blocked`: it routes
3
- // through the canonical attributed completer (completeBlockedAsHuman → completeUserTaskAttributed),
4
- // resuming the process (→ pr.record-blocked-ack) exactly as the task inbox would, and immediately
5
- // clears the denormalised completable-task pointer so the affordance stops rendering. Mirrors the
6
- // escalation twin (operations/answerFeatureEscalation.ts).
7
- import { test } from "node:test";
8
- import { assertEquals } from "#test-assert";
9
- import type { AppApi } from "@nanobpm/urban";
10
- import { noopLog } from "../test/log.ts";
11
- import handler from "./acknowledgeBlocked.ts";
12
-
13
- function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
14
- app: AppApi;
15
- stores: Record<string, any[]>;
16
- completed: { userTaskKey: string; variables: Record<string, unknown> }[];
17
- } {
18
- const stores: Record<string, any[]> = {};
19
- const completed: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
20
- function tbl(name: string, pk: string) {
21
- const rows = (stores[name] ??= [] as any[]);
22
- return {
23
- async insert(row: any) {
24
- rows.push({ ...row });
25
- return rows.length;
26
- },
27
- async get(id: any) {
28
- return rows.find((r) => r[pk] === id);
29
- },
30
- async find(where: any = {}) {
31
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
32
- },
33
- async delete(id: any) {
34
- const i = rows.findIndex((r) => r[pk] === id);
35
- if (i >= 0) rows.splice(i, 1);
36
- },
37
- async update(id: any, patch: any) {
38
- const r = rows.find((row) => row[pk] === id);
39
- if (r) Object.assign(r, patch);
40
- },
41
- };
42
- }
43
- const engine = {
44
- searchUserTasks: async () => openTasks,
45
- completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
46
- completed.push({ userTaskKey, variables });
47
- },
48
- };
49
- const app = {
50
- data: { table: (n: string, pk: string) => tbl(n, pk) },
51
- engine,
52
- log: noopLog(),
53
- } as any as AppApi;
54
- return { app, stores, completed };
55
- }
56
-
57
- async function call(app: AppApi, body: unknown) {
58
- return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
59
- }
60
-
61
- test("acknowledge-blocked: completes the feature-blocked task, records the note, clears the pointer", async () => {
62
- const { app, stores, completed } = memApp([{ userTaskKey: "ut-1", elementId: "feature-blocked" }]);
63
- stores.feature_runs = [{ feature_key: "o/r#1", status: "awaiting_operator", blocked_user_task_key: "ut-1" }];
64
-
65
- const res = await call(app, { userTaskKey: "ut-1", note: "reassigned to a human", operator: "alice" });
66
-
67
- assertEquals(res.status, 200);
68
- assertEquals(res.body.ok, true);
69
- // The task is completed with the typed `note` variable the record-blocked-ack ioMapping reads.
70
- assertEquals(completed.length, 1);
71
- assertEquals(completed[0].userTaskKey, "ut-1");
72
- assertEquals(completed[0].variables, { note: "reassigned to a human" });
73
- // The attribution ledger records WHO acknowledged (a human — the authority, not reversible).
74
- assertEquals(stores.task_completions.length, 1);
75
- assertEquals(stores.task_completions[0].actor_kind, "human");
76
- assertEquals(stores.task_completions[0].actor_id, "alice");
77
- assertEquals(stores.task_completions[0].reversible, 0);
78
- // The operation clears its own action's pointer immediately (status left to record-blocked-ack).
79
- assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
80
- });
81
-
82
- test("acknowledge-blocked: a blank note omits the variable so the ioMapping fallback fires", async () => {
83
- const { app, stores, completed } = memApp([{ userTaskKey: "ut-2", elementId: "feature-blocked" }]);
84
- stores.feature_runs = [{ feature_key: "o/r#2", status: "awaiting_operator", blocked_user_task_key: "ut-2" }];
85
-
86
- const res = await call(app, { userTaskKey: "ut-2", note: " " });
87
-
88
- assertEquals(res.status, 200);
89
- assertEquals(completed[0].variables, {});
90
- });
91
-
92
- test("acknowledge-blocked: a missing userTaskKey → 400", async () => {
93
- const { app } = memApp([]);
94
- const res = await call(app, { note: "x" });
95
- assertEquals(res.status, 400);
96
- assertEquals(res.body.ok, false);
97
- });
98
-
99
- test("acknowledge-blocked: no matching open task → 404", async () => {
100
- const { app } = memApp([]);
101
- const res = await call(app, { userTaskKey: "ut-gone" });
102
- assertEquals(res.status, 404);
103
- assertEquals(res.body.ok, false);
104
- });
105
-
106
- test("acknowledge-blocked: refuses a non-blocked task (an escalation) → 400", async () => {
107
- const { app } = memApp([{ userTaskKey: "ut-esc", elementId: "feature-escalation" }]);
108
- const res = await call(app, { userTaskKey: "ut-esc" });
109
- assertEquals(res.status, 400);
110
- assertEquals(res.body.ok, false);
111
- });
@@ -1,62 +0,0 @@
1
- // POST /app/api/actions/acknowledge-blocked → operationId `acknowledgeBlocked` (issue #220).
2
- // The nwf UI's completion affordance for a BLOCKED single-issue feature run: an operator acknowledges
3
- // the parked `feature-blocked` user task (with an optional disposition note) directly from the Feature /
4
- // Overview pages, instead of the run sitting parked forever with no control (the escalation path got
5
- // this in issue #210; the blocked path did not).
6
- //
7
- // It routes through the ONE canonical attributed completer (`completeBlockedAsHuman` →
8
- // `completeUserTaskAttributed`), so the completion uses the exact same typed `.form` variable (`note`)
9
- // and engine resume path a human drives from the task inbox — no parallel completion — while recording
10
- // WHO acknowledged in the `task_completions` ledger. Completing the task fires `pr.record-blocked-ack`,
11
- // which settles the row to the terminal `blocked` status with the operator's note. The poller then
12
- // reconciles the completable-task pointer off the row (pollFeatureBlocked) once the task is gone.
13
- //
14
- // The runtime validates the body against openapi.yaml (`userTaskKey` required); this delegate narrows
15
- // the validated shape and builds the typed completion variables the `feature-blocked` form + the
16
- // `record-blocked-ack` ioMapping expect.
17
-
18
- import { completeBlockedAsHuman } from "../app/agentCompletion.ts";
19
- import { featureRuns } from "../app/feature.ts";
20
- import { defineOperation } from "../nano-generated/operations.ts";
21
-
22
- const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
23
-
24
- export default defineOperation("acknowledgeBlocked", async ({ body }, app) => {
25
- if (!body || typeof body !== "object") {
26
- app.log.warn("acknowledge-blocked rejected: missing request body");
27
- return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
28
- }
29
-
30
- const userTaskKey = str(body.userTaskKey);
31
- if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
32
-
33
- // The `feature-blocked` form completes with an optional `note`; the `record-blocked-ack` ioMapping
34
- // reads it (`if is defined(note) then note else null`) into `delivery_label`. An absent/blank note is
35
- // recorded as an "acknowledged" label rather than an empty string — omit the variable entirely so the
36
- // ioMapping's `is defined` fallback fires.
37
- const note = str(body.note);
38
- const variables: Record<string, unknown> = note ? { note } : {};
39
-
40
- // The completing operator, for the attribution ledger. Optional — the UI has no per-operator auth, so
41
- // default to a generic handle rather than blocking the acknowledgement.
42
- const operatorId = str(body.operator) || "operator";
43
-
44
- const r = await completeBlockedAsHuman(app.data, app.engine, { userTaskKey, operatorId, variables });
45
- if (r.ok) {
46
- // Reconcile this operation's OWN action immediately: clear the denormalised blocked pointer so the
47
- // pages stop offering an acknowledge affordance for a task that is now completed. Leave `status` to
48
- // `record-blocked-ack` (which settles it to terminal `blocked`), so we never overwrite the status the
49
- // resumed run has advanced to.
50
- for (const run of await featureRuns(app.data).find({ blocked_user_task_key: userTaskKey })) {
51
- await featureRuns(app.data).update(run.feature_key, {
52
- blocked_user_task_key: null,
53
- updated_at: new Date().toISOString(),
54
- });
55
- }
56
- app.log.info("operator acknowledged blocked feature run", { userTaskKey, elementId: r.elementId });
57
- return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
58
- }
59
- const status = r.reason === "no open blocked task" ? 404 : 400;
60
- app.log.warn("acknowledge-blocked: not completed", { userTaskKey, reason: r.reason });
61
- return { status, body: { ok: false, error: r.reason } };
62
- });
@@ -1,68 +0,0 @@
1
- // POST /app/api/actions/answer-escalation → operationId `answerFeatureEscalation` (issue #210).
2
- // The nwf UI's answer affordance for a native feature-run escalation: an operator answers the parked
3
- // `feature-escalation` user task (resolution=answer/abandon + optional guidance) directly from the
4
- // Feature / Overview pages, instead of only from the generic task inbox.
5
- //
6
- // It routes through the ONE canonical attributed completer (`completeEscalationAsHuman` →
7
- // `completeUserTaskAttributed`), so the completion uses the exact same typed `.form` variables and
8
- // engine resume path a human drives from the task inbox — no parallel completion — while recording
9
- // WHO answered in the `task_completions` ledger. The poller then reconciles the run off `escalated`
10
- // (pollFeatureEscalations) once the task is gone.
11
- //
12
- // The runtime validates the body against openapi.yaml (`userTaskKey` + `resolution` required); this
13
- // delegate narrows the validated shape, enforces the answer/abandon contract, and builds the typed
14
- // completion variables the `feature-escalation` form + the `w_gw_answer` gateway expect.
15
-
16
- import { completeEscalationAsHuman } from "../app/agentCompletion.ts";
17
- import { featureRuns } from "../app/feature.ts";
18
- import { defineOperation } from "../nano-generated/operations.ts";
19
-
20
- const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
21
-
22
- export default defineOperation("answerFeatureEscalation", async ({ body }, app) => {
23
- if (!body || typeof body !== "object") {
24
- app.log.warn("answer-escalation rejected: missing request body");
25
- return { status: 400, body: { ok: false, error: "userTaskKey and resolution are required" } };
26
- }
27
-
28
- const userTaskKey = str(body.userTaskKey);
29
- if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
30
-
31
- const resolution = str(body.resolution);
32
- if (resolution !== "answer" && resolution !== "abandon") {
33
- return { status: 400, body: { ok: false, error: "resolution must be 'answer' or 'abandon'" } };
34
- }
35
-
36
- // The `feature-escalation` form completes with `{ resolution, answer }`; the `w_gw_answer` gateway
37
- // re-dispatches implement-task with `answer` when resolution=answer, else routes to record-feature.
38
- // Guidance is required for an answer (an empty answer re-dispatches the agent with nothing new).
39
- const answer = str(body.answer);
40
- if (resolution === "answer" && !answer) {
41
- return { status: 400, body: { ok: false, error: "answer (guidance) is required when resolution is 'answer'" } };
42
- }
43
- const variables: Record<string, unknown> = resolution === "answer" ? { resolution, answer } : { resolution };
44
-
45
- // The completing operator, for the attribution ledger. Optional — the UI has no per-operator auth,
46
- // so default to a generic handle rather than blocking the answer.
47
- const operatorId = str(body.operator) || "operator";
48
-
49
- const r = await completeEscalationAsHuman(app.data, app.engine, { userTaskKey, operatorId, variables });
50
- if (r.ok) {
51
- // Reconcile this operation's OWN action immediately: clear the denormalised escalation pointer +
52
- // question so the pages stop offering an answer affordance for a task that is now completed. Leave
53
- // `status` to the poller (escalated → running on the answer loop) / record-feature (terminal on
54
- // abandon), so we never overwrite a status the resumed run has already advanced to.
55
- for (const run of await featureRuns(app.data).find({ escalation_user_task_key: userTaskKey })) {
56
- await featureRuns(app.data).update(run.feature_key, {
57
- escalation_question: null,
58
- escalation_user_task_key: null,
59
- updated_at: new Date().toISOString(),
60
- });
61
- }
62
- app.log.info("operator answered feature escalation", { userTaskKey, resolution, elementId: r.elementId });
63
- return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
64
- }
65
- const status = r.reason === "no open escalation task" ? 404 : 400;
66
- app.log.warn("answer-escalation: not completed", { userTaskKey, reason: r.reason });
67
- return { status, body: { ok: false, error: r.reason } };
68
- });