@nanobpm/nano-workforce 0.66.0 → 0.68.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 CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.68.0](https://github.com/nanobpm/nano-workforce/compare/v0.67.0...v0.68.0) (2026-08-14)
2
+
3
+
4
+ ### Features
5
+
6
+ * **feature:** UI completion affordance for blocked feature runs ([#220](https://github.com/nanobpm/nano-workforce/issues/220)) ([#221](https://github.com/nanobpm/nano-workforce/issues/221)) ([5b68ad4](https://github.com/nanobpm/nano-workforce/commit/5b68ad4871e21d59224d69cde0b95f02247884ae)), closes [#210](https://github.com/nanobpm/nano-workforce/issues/210)
7
+
8
+ # [0.67.0](https://github.com/nanobpm/nano-workforce/compare/v0.66.0...v0.67.0) (2026-08-14)
9
+
10
+
11
+ ### Features
12
+
13
+ * **agentic:** local-first visibility — on by default, security opt-in (hub) ([#218](https://github.com/nanobpm/nano-workforce/issues/218)) ([ffef1d9](https://github.com/nanobpm/nano-workforce/commit/ffef1d9819679cb6995ad2d2825bce5c6cabccb5)), closes [jwulf/c8ctl-plugin-nano#38](https://github.com/jwulf/c8ctl-plugin-nano/issues/38)
14
+
1
15
  # [0.66.0](https://github.com/nanobpm/nano-workforce/compare/v0.65.0...v0.66.0) (2026-08-14)
2
16
 
3
17
 
@@ -221,6 +221,44 @@ export async function completeEscalationAsHuman(
221
221
  return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
222
222
  }
223
223
 
224
+ /** The `feature-blocked` operator user-task element id (feature.bpmn). Unlike an escalation this is not
225
+ * an agent-answerable task — it is a blocked-run acknowledgement only a human operator retires — so it
226
+ * lives outside `ESCALATION_TASK_ELEMENTS` (the agent completer must never touch it) and has its own
227
+ * human-only completer below. */
228
+ export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
229
+
230
+ /** Complete the `feature-blocked` operator user task AS A HUMAN (issue #220). The blocked twin of
231
+ * `completeEscalationAsHuman`: it resolves the parked task by key, refuses anything that is not the
232
+ * `feature-blocked` task, and routes the operator's typed form variables (an optional `note`) through
233
+ * the SAME canonical `completeUserTaskAttributed` — so the nwf "Acknowledge blocked" affordance resumes
234
+ * the process (→ `pr.record-blocked-ack`, which settles the row to terminal `blocked`) through the one
235
+ * completion a human drives from the task inbox, recording WHO acknowledged in the `task_completions`
236
+ * ledger. A human completion is the authority (not reversible). A key with no matching open
237
+ * `feature-blocked` task is a 404-style no-op. */
238
+ export async function completeBlockedAsHuman(
239
+ data: DataLayer,
240
+ engine: EngineClient,
241
+ input: { userTaskKey: string; variables: Record<string, unknown>; operatorId: string },
242
+ ): Promise<AgentCompleteResult> {
243
+ const userTaskKey = input.userTaskKey.trim();
244
+ if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
245
+ const operatorId = input.operatorId.trim();
246
+ if (!operatorId) return { ok: false, reason: "operatorId is required" };
247
+
248
+ const open = await engine.searchUserTasks();
249
+ const match = open.find((t) => t.userTaskKey === userTaskKey);
250
+ if (!match) return { ok: false, reason: "no open blocked task" };
251
+ if (match.elementId !== FEATURE_BLOCKED_TASK_ELEMENT) return { ok: false, reason: "not a blocked task" };
252
+
253
+ const { completionId } = await completeUserTaskAttributed(
254
+ data,
255
+ engine,
256
+ { userTaskKey, elementId: match.elementId, variables: input.variables },
257
+ { kind: "human", id: operatorId },
258
+ );
259
+ return { ok: true, completionId, userTaskKey, elementId: match.elementId };
260
+ }
261
+
224
262
  export interface RevertResult {
225
263
  ok: boolean;
226
264
  reason?: string;
@@ -5,10 +5,11 @@
5
5
  // hub is visible via `inspect()`, families mount/tear-down through the seam, and shutdown is clean.
6
6
  import { type AddressInfo, createServer, type Server } from "node:http";
7
7
  import { test } from "node:test";
8
+ import { createLogger } from "@nanobpm/urban/runtime";
8
9
  import { WebSocket } from "ws";
9
10
  import { assert, assertEquals } from "#test-assert";
10
11
  import { noopLog } from "../../test/log.ts";
11
- import { type AgenticChannelHandle, mountAgenticChannel } from "./channel.ts";
12
+ import { type AgenticChannelHandle, LOCAL_AGENTIC_TOKEN, mountAgenticChannel } from "./channel.ts";
12
13
  import { type AgenticContext, AgenticFamilyRegistry } from "./registry.ts";
13
14
 
14
15
  const SECRET = "test-agentic-secret";
@@ -242,3 +243,119 @@ test("a missing secret is refused (never mount an open channel)", async (t) => {
242
243
  assert(threw, "mountAgenticChannel must reject an empty secret");
243
244
  assertEquals(port > 0, true);
244
245
  });
246
+
247
+ test("LOCAL mode (secure:false): the well-known token upgrades with NO credential", async (t) => {
248
+ const { server, port } = await startHttp();
249
+ // Local-first default-on: no secret, no credential — a `nano work` worker appears live with the
250
+ // well-known localhost token alone (security opt-in).
251
+ const channel = await mountAgenticChannel({
252
+ server,
253
+ secret: "",
254
+ secure: false,
255
+ data: undefined,
256
+ log: noopLog(),
257
+ });
258
+ t.after(async () => {
259
+ await channel.teardown();
260
+ await closeServer(server);
261
+ });
262
+
263
+ const ws = await connect(port, `?token=${LOCAL_AGENTIC_TOKEN}`);
264
+ assertEquals(ws.readyState, WebSocket.OPEN);
265
+ assertEquals(channel.hub.connectionCount, 1);
266
+ assertEquals(channel.inspect().mode, "local");
267
+ ws.close();
268
+ });
269
+
270
+ test("LOCAL mode still rejects a wrong token (4401)", async (t) => {
271
+ const { server, port } = await startHttp();
272
+ const channel = await mountAgenticChannel({
273
+ server,
274
+ secret: "",
275
+ secure: false,
276
+ data: undefined,
277
+ log: noopLog(),
278
+ });
279
+ t.after(async () => {
280
+ await channel.teardown();
281
+ await closeServer(server);
282
+ });
283
+
284
+ const closedCode = await rejectionCode(port, "?token=not-the-local-token");
285
+ assertEquals(closedCode, 4401);
286
+ assertEquals(channel.hub.connectionCount, 0);
287
+ });
288
+
289
+ /** A capturing `Logger`: records every `(level, msg)` pair the sink receives. */
290
+ function capturingLog(): { log: ReturnType<typeof noopLog>; records: Array<{ level: string; msg: string }> } {
291
+ const records: Array<{ level: string; msg: string }> = [];
292
+ const log = createLogger((level: string, msg: string) => {
293
+ records.push({ level, msg });
294
+ });
295
+ return { log, records };
296
+ }
297
+
298
+ test("LOCAL mode warns when the server is bound to a non-loopback interface", async (t) => {
299
+ const server = createServer((_req, res) => res.end());
300
+ await new Promise<void>((resolve) => server.listen(0, "0.0.0.0", resolve));
301
+ const { log, records } = capturingLog();
302
+ const channel = await mountAgenticChannel({
303
+ server,
304
+ secret: "",
305
+ secure: false,
306
+ data: undefined,
307
+ log,
308
+ });
309
+ t.after(async () => {
310
+ await channel.teardown();
311
+ await closeServer(server);
312
+ });
313
+
314
+ const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
315
+ assert(warned, "LOCAL mode on a non-loopback bind must warn that the well-known token is exposed");
316
+ });
317
+
318
+ test("LOCAL mode warns when the server bind address is unverifiable (not listening)", async (t) => {
319
+ const { server } = await startHttp();
320
+ // Simulate a server whose bind cannot be verified (e.g. mounted before `listen` resolves):
321
+ // `address()` returns null, so the LOCAL exposure check cannot confirm a loopback-only bind.
322
+ const realAddress = server.address.bind(server);
323
+ server.address = () => null;
324
+ const { log, records } = capturingLog();
325
+ const channel = await mountAgenticChannel({
326
+ server,
327
+ secret: "",
328
+ secure: false,
329
+ data: undefined,
330
+ log,
331
+ });
332
+ t.after(async () => {
333
+ server.address = realAddress;
334
+ await channel.teardown();
335
+ await closeServer(server);
336
+ });
337
+
338
+ const warned = records.some(
339
+ (r) => r.level === "warn" && r.msg.includes("bind address could not be verified"),
340
+ );
341
+ assert(warned, "LOCAL mode on an unbound server must warn that the well-known token is unverifiable");
342
+ });
343
+
344
+ test("LOCAL mode does NOT warn when the server is bound to loopback", async (t) => {
345
+ const { server } = await startHttp();
346
+ const { log, records } = capturingLog();
347
+ const channel = await mountAgenticChannel({
348
+ server,
349
+ secret: "",
350
+ secure: false,
351
+ data: undefined,
352
+ log,
353
+ });
354
+ t.after(async () => {
355
+ await channel.teardown();
356
+ await closeServer(server);
357
+ });
358
+
359
+ const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
360
+ assert(!warned, "a loopback-bound LOCAL channel is the expected safe case and must not warn");
361
+ });
@@ -14,6 +14,7 @@
14
14
  // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
15
15
  // is untouched; advisory semantics preserved (a family never gates a BPMN sequence flow).
16
16
  import type { Server } from "node:http";
17
+ import type { AddressInfo } from "node:net";
17
18
  import {
18
19
  AgenticHub,
19
20
  sharedSecretAuthenticator,
@@ -26,11 +27,47 @@ import { AgenticFamilyRegistry } from "./registry.ts";
26
27
  /** The path the agentic channel is served on, on the app's own port. */
27
28
  export const AGENTIC_PATH = "/agentic";
28
29
 
30
+ /**
31
+ * The well-known identity token used in LOCAL mode (security opt-in). Nano is local-first: on a
32
+ * developer's own machine the visibility channel is on by default with no credential friction, so
33
+ * the hub and the `nano work` worker agree on this constant, well-known localhost token. It is NOT
34
+ * a secret — it only gates same-machine dev traffic. In secure mode (`secure: true` + a real
35
+ * `NANO_AGENTIC_SECRET`) this constant is never used and a real ADR 0028 verifier applies. Keep in
36
+ * lock-step with the worker constant in jwulf/c8ctl-plugin-nano (`c8ctl-plugin.js` LOCAL_AGENTIC_TOKEN).
37
+ */
38
+ export const LOCAL_AGENTIC_TOKEN = "nano-local";
39
+
40
+ /**
41
+ * True if `addr` (from {@link Server.address}) is a loopback / same-machine bind — the safety
42
+ * assumption LOCAL mode relies on. A string address is a UNIX domain socket / named pipe (same-host
43
+ * only) and is treated as safe; a TCP bind is loopback only for `127.0.0.0/8` or `::1`. A wildcard
44
+ * bind (`0.0.0.0` / `::`) or any specific public interface is NOT loopback, so the well-known
45
+ * {@link LOCAL_AGENTIC_TOKEN} would be reachable off-box. `null` (an unbound / not-yet-listening
46
+ * server) is NOT treated as safe — the bind is unverifiable, so callers must handle it explicitly
47
+ * rather than silently skipping the exposure check.
48
+ */
49
+ function isLoopbackBind(addr: string | AddressInfo | null): boolean {
50
+ if (addr === null) return false;
51
+ if (typeof addr === "string") return true;
52
+ const host = addr.address;
53
+ return host === "::1" || host === "::ffff:127.0.0.1" || host.startsWith("127.");
54
+ }
55
+
29
56
  export interface MountAgenticChannelOptions {
30
57
  /** The app's own `node:http` server (share its port; `app.httpServer` narrowed to `Server`). */
31
58
  readonly server: Server;
32
- /** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…`. */
59
+ /** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…`. In LOCAL
60
+ * mode (`secure: false`) this may be empty — the hub substitutes {@link LOCAL_AGENTIC_TOKEN}. */
33
61
  readonly secret: string;
62
+ /**
63
+ * Security mode. Nano is local-first, so this defaults to `true` (strict) at the library level to
64
+ * keep the fail-closed contract for any caller that doesn't opt in — but `main.ts` passes
65
+ * `secure: false` whenever no `NANO_AGENTIC_SECRET` is configured, mounting an on-by-default LOCAL
66
+ * channel: a well-known localhost token ({@link LOCAL_AGENTIC_TOKEN}) and NO required capability
67
+ * credential. Set `secure: true` (with a real secret) to require an ADR 0028 identity token AND a
68
+ * capability credential on every upgrade.
69
+ */
70
+ readonly secure?: boolean;
34
71
  /** The app's SQLite data layer, threaded to family modules (may be absent when data isn't mounted). */
35
72
  readonly data: DataLayer | undefined;
36
73
  /** A structured logger for lifecycle lines. */
@@ -67,21 +104,56 @@ async function discoverRegistry(log: Logger): Promise<AgenticFamilyRegistry> {
67
104
  export async function mountAgenticChannel(
68
105
  opts: MountAgenticChannelOptions,
69
106
  ): Promise<AgenticChannelHandle> {
70
- const { server, secret, data, log } = opts;
71
- if (!secret) throw new Error("mountAgenticChannel requires a non-empty identity secret");
107
+ const { server, data, log } = opts;
108
+ // Local-first: `secure` defaults to true at the library level (fail-closed for callers that don't
109
+ // opt in), but `main.ts` passes `secure: false` for the on-by-default LOCAL channel. In LOCAL mode
110
+ // an empty secret is fine — we substitute the well-known localhost token and drop the credential
111
+ // requirement so a `nano work` worker appears live with zero configuration.
112
+ const secure = opts.secure ?? true;
113
+ const secret = opts.secret || (secure ? "" : LOCAL_AGENTIC_TOKEN);
114
+ if (secure && !secret) {
115
+ throw new Error("mountAgenticChannel (secure mode) requires a non-empty identity secret");
116
+ }
72
117
 
73
118
  const transport = new WebSocketChannelTransport({ server, path: AGENTIC_PATH });
74
119
  const hub = new AgenticHub({
75
120
  transport,
76
- // A valid identity token PLUS a required capability credential upgrades; either missing/invalid
77
- // is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an Authenticator.
78
- authenticator: sharedSecretAuthenticator({ secret, requireCredential: true }),
121
+ // Secure mode: a valid identity token PLUS a required capability credential upgrades; either
122
+ // missing/invalid is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an
123
+ // Authenticator. LOCAL mode: token-only (the well-known localhost token), no credential required.
124
+ authenticator: sharedSecretAuthenticator({ secret, requireCredential: secure }),
79
125
  onError: (err, connectionId) =>
80
126
  log.warn("agentic hub error", { connectionId, err: String(err) }),
81
127
  });
82
128
  // Share the app's port: the transport rode the existing server, so it is already listening.
83
129
  await transport.ready();
84
130
 
131
+ // LOCAL mode gates only on the well-known localhost token, so it is safe ONLY while the server is
132
+ // bound to loopback. The channel rides the app's server and does not own its bind address, so it
133
+ // cannot enforce this — but if the server is exposed on a wildcard/public interface, the token is
134
+ // reachable off-box; warn loudly so an operator either binds to loopback or switches to secure mode.
135
+ // A `null` address (server not listening yet) is unverifiable — warn rather than silently skipping
136
+ // the exposure check, since the bind could later resolve to a public interface.
137
+ if (!secure) {
138
+ const addr = server.address();
139
+ if (addr === null) {
140
+ log.warn(
141
+ "agentic channel is in LOCAL mode but the server bind address could not be verified " +
142
+ "(the server is not listening yet) — the well-known LOCAL_AGENTIC_TOKEN cannot be " +
143
+ "confirmed loopback-only. Mount the channel after the server is listening, set " +
144
+ "NANO_AGENTIC_SECRET for secure mode, or bind the server to 127.0.0.1.",
145
+ { mode: "local", bind: null },
146
+ );
147
+ } else if (!isLoopbackBind(addr)) {
148
+ log.warn(
149
+ "agentic channel is in LOCAL mode but the server is not bound to loopback — the well-known " +
150
+ "LOCAL_AGENTIC_TOKEN is reachable from other hosts. Set NANO_AGENTIC_SECRET for secure " +
151
+ "mode, or bind the server to 127.0.0.1.",
152
+ { mode: "local", bind: typeof addr === "object" ? addr.address : String(addr) },
153
+ );
154
+ }
155
+ }
156
+
85
157
  // If discovery or any family mount throws, the transport + hub are already live: tear down whatever
86
158
  // mounted (in reverse) and close the hub before rethrowing, so a failed boot never strands upgrade
87
159
  // handlers or half-open connections.
@@ -97,6 +169,7 @@ export async function mountAgenticChannel(
97
169
 
98
170
  log.info("agentic channel mounted", {
99
171
  path: AGENTIC_PATH,
172
+ mode: secure ? "secure" : "local",
100
173
  families: registry.names(),
101
174
  });
102
175
 
@@ -108,6 +181,7 @@ export async function mountAgenticChannel(
108
181
  inspect() {
109
182
  return {
110
183
  path: AGENTIC_PATH,
184
+ mode: secure ? "secure" : "local",
111
185
  families: registry.names(),
112
186
  connections: hub.connectionCount,
113
187
  address: hub.address,
package/app/feature.ts CHANGED
@@ -56,6 +56,14 @@ export interface FeatureRun {
56
56
  * (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
57
57
  * `pollFeatureEscalations` while parked; NULL otherwise. */
58
58
  escalation_user_task_key: string | null;
59
+ /** The completable native `feature-blocked` user-task key the "Acknowledge blocked" affordance posts
60
+ * to (`completeUserTaskAttributed`) and the pages gate the acknowledge control on (`showWhenField`).
61
+ * Kept DISTINCT from `escalation_user_task_key` so the two human tasks (an escalation answer vs a
62
+ * blocked-run acknowledgement) are never conflated. Set by `pollFeatureBlocked` while a run is parked
63
+ * at `feature-blocked` (status `awaiting_operator`); NULL otherwise — cleared on the exit paths
64
+ * (`record-blocked-ack` / the acknowledge operation) and, as a self-heal, by `pollFeatureBlocked`
65
+ * when a previously-observed task is completed out-of-band (see `deriveFeatureBlockedPatch`). */
66
+ blocked_user_task_key: string | null;
59
67
  created_at: string;
60
68
  updated_at: string;
61
69
  }
@@ -191,6 +199,52 @@ export function deriveFeatureEscalationPatch(
191
199
  return Object.keys(patch).length > 0 ? patch : null;
192
200
  }
193
201
 
202
+ /** The `feature-blocked` user-task element id (feature.bpmn) — the native operator wait a run parks on
203
+ * when the agent reports a `blocked` outcome (it gave up / the escalation was abandoned or timed out).
204
+ * `pollFeatureBlocked` reconciles it onto the read model. */
205
+ export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
206
+
207
+ /** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `searchUserTasks`:
208
+ * the completable user-task key the pages drive an attributed acknowledgement against. */
209
+ export interface FeatureBlockedParked {
210
+ userTaskKey: string;
211
+ }
212
+
213
+ /** Pure source of truth for the blocked read-model reconcile (`pollFeatureBlocked`), the blocked twin
214
+ * of `deriveFeatureEscalationPatch`: given a run and whether it is currently parked at `feature-blocked`,
215
+ * return the minimal `feature_runs` patch reconciling the completable-task pointer with the observed park
216
+ * state (or null when nothing changed, so the poller skips the write). Idempotent and self-healing.
217
+ *
218
+ * Unlike the escalation reconcile, the STATUS flip is NOT owned here: `record-feature` already persists
219
+ * the row as `awaiting_operator` in the same token path before the `feature-blocked` user task is
220
+ * created, and `record-blocked-ack` settles it to the terminal `blocked` on completion. So this only
221
+ * reconciles the completable-task POINTER — never the status — so it can never overwrite the terminal
222
+ * `blocked` the acknowledgement worker has already written.
223
+ *
224
+ * - parked → denormalise the completable `userTaskKey` so the pages can drive an attributed acknowledge.
225
+ * - un-parked → clear the pointer ONLY once it was actually OBSERVED (non-NULL) and the task is now gone.
226
+ * Gating on the observed pointer is what makes it safe across the brief self-healing window between
227
+ * `record-feature` (which persists `awaiting_operator` but leaves the pointer NULL) and the user task
228
+ * appearing: in that window the pointer is NULL, so this never fires, and the next pass fills it in once
229
+ * the task is observable. Once observed and then gone (e.g. an out-of-band completion), the stale
230
+ * pointer is cleared so the pages stop offering an acknowledge control for a task that no longer exists. */
231
+ export function deriveFeatureBlockedPatch(
232
+ run: Pick<FeatureRun, "blocked_user_task_key">,
233
+ parked: FeatureBlockedParked | null,
234
+ ): Partial<FeatureRun> | null {
235
+ const patch: Partial<FeatureRun> = {};
236
+ if (parked) {
237
+ if (run.blocked_user_task_key !== parked.userTaskKey) patch.blocked_user_task_key = parked.userTaskKey;
238
+ } else {
239
+ // Un-park cleanup — fires ONLY once the poller has actually OBSERVED the task (pointer non-NULL) and
240
+ // it is now gone. Gating on the pointer being non-NULL is what makes it safe: during the brief
241
+ // self-healing window between `record-feature` (which persists `awaiting_operator` but leaves the
242
+ // pointer NULL) and the task appearing, the pointer is NULL, so this never clears prematurely.
243
+ if (run.blocked_user_task_key !== null) patch.blocked_user_task_key = null;
244
+ }
245
+ return Object.keys(patch).length > 0 ? patch : null;
246
+ }
247
+
194
248
  export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
195
249
 
196
250
  /** The deterministic task id for a single-issue run — the implementation agent branches
@@ -233,6 +287,7 @@ export async function startFeature(
233
287
  delivery_label: null,
234
288
  escalation_question: null,
235
289
  escalation_user_task_key: null,
290
+ blocked_user_task_key: null,
236
291
  updated_at: ts,
237
292
  });
238
293
  } else {
@@ -251,6 +306,7 @@ export async function startFeature(
251
306
  delivery_label: null,
252
307
  escalation_question: null,
253
308
  escalation_user_task_key: null,
309
+ blocked_user_task_key: null,
254
310
  created_at: ts,
255
311
  updated_at: ts,
256
312
  });
@@ -0,0 +1,135 @@
1
+ // Read-model derivation test for the FEATURE-run BLOCKED reconcile (issue #220 — a blocked feature run
2
+ // parked at `feature-blocked` had no completion affordance in nwf). When a run reaches a `blocked`
3
+ // outcome `record-feature` holds the row at the non-terminal `awaiting_operator` status and it parks on
4
+ // the native `feature-blocked` operator user task; `feature_runs` (which the pages read) had a status
5
+ // but NO completable-task pointer, so the pages could not drive an acknowledge action. The blocked twin
6
+ // of `deriveFeatureEscalationPatch` — the pure source of truth tested here — reconciles ONLY that
7
+ // completable-task pointer (never the status, which `record-feature`/`record-blocked-ack` own), which
8
+ // `pollFeatureBlocked` projects onto the row.
9
+ import { test } from "node:test";
10
+ import { assertEquals } from "#test-assert";
11
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
+ import { deriveFeatureBlockedPatch } from "./feature.ts";
13
+ import { pollFeatureBlocked } from "./service.ts";
14
+
15
+ // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory table double, mirrors featureEscalation.test.ts
16
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
17
+ // biome-ignore lint/suspicious/noExplicitAny: see above
18
+ const stores: Record<string, any[]> = {};
19
+ function tbl(name: string, pk = "id") {
20
+ // biome-ignore lint/suspicious/noExplicitAny: see above
21
+ const rows = (stores[name] ??= [] as any[]);
22
+ // biome-ignore lint/suspicious/noExplicitAny: see above
23
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
24
+ return {
25
+ async all() {
26
+ return rows.slice();
27
+ },
28
+ // biome-ignore lint/suspicious/noExplicitAny: see above
29
+ async get(id: any) {
30
+ return rows.find((r) => r[pk] === id);
31
+ },
32
+ // biome-ignore lint/suspicious/noExplicitAny: see above
33
+ async find(where: any = {}) {
34
+ return rows.filter((r) => match(r, where));
35
+ },
36
+ // biome-ignore lint/suspicious/noExplicitAny: see above
37
+ async insert(row: any) {
38
+ rows.push({ ...row });
39
+ return row[pk];
40
+ },
41
+ // biome-ignore lint/suspicious/noExplicitAny: see above
42
+ async update(id: any, patch: any) {
43
+ const r = rows.find((row) => row[pk] === id);
44
+ if (r) Object.assign(r, patch);
45
+ },
46
+ };
47
+ }
48
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
49
+ return { data, stores };
50
+ }
51
+
52
+ /** A fake engine whose open user tasks are keyed by processInstanceKey (the only field
53
+ * pollFeatureBlocked queries on). */
54
+ function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
55
+ return {
56
+ searchUserTasks: (filter?: { processInstanceKey?: string }) =>
57
+ Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
58
+ } as unknown as EngineClient;
59
+ }
60
+
61
+ test("deriveFeatureBlockedPatch: a run parked at feature-blocked records the completable key (status untouched)", () => {
62
+ const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: null }, { userTaskKey: "ut-9" });
63
+ assertEquals(patch, { blocked_user_task_key: "ut-9" });
64
+ });
65
+
66
+ test("deriveFeatureBlockedPatch: an already-recorded parked run yields no patch (idempotent)", () => {
67
+ const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: "ut-9" }, { userTaskKey: "ut-9" });
68
+ assertEquals(patch, null);
69
+ });
70
+
71
+ test("deriveFeatureBlockedPatch: an observed run whose task is gone clears the stale pointer", () => {
72
+ const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: "ut-9" }, null);
73
+ assertEquals(patch, { blocked_user_task_key: null });
74
+ });
75
+
76
+ // The pre-observation self-healing window (record-feature has persisted `awaiting_operator` but the
77
+ // user task is not yet visible, so the pointer is still NULL): a premature "not parked" pass must NOT
78
+ // write anything — the pointer is filled in on the next pass once the task is observable.
79
+ test("deriveFeatureBlockedPatch: the pre-observation self-healing window yields no patch", () => {
80
+ const patch = deriveFeatureBlockedPatch({ blocked_user_task_key: null }, null);
81
+ assertEquals(patch, null);
82
+ });
83
+
84
+ test("pollFeatureBlocked: a parked awaiting_operator run is denormalised with the completable key", async () => {
85
+ const { data, stores } = memData();
86
+ stores.feature_runs = [
87
+ { feature_key: "o/r#1", status: "awaiting_operator", process_key: "100", blocked_user_task_key: null },
88
+ ];
89
+ const engine = fakeEngine({ "100": [{ userTaskKey: "ut-1", elementId: "feature-blocked" }] });
90
+
91
+ await pollFeatureBlocked(data, engine);
92
+
93
+ // The poller never flips status — record-feature owns `awaiting_operator`, record-blocked-ack the terminal.
94
+ assertEquals(stores.feature_runs[0].status, "awaiting_operator");
95
+ assertEquals(stores.feature_runs[0].blocked_user_task_key, "ut-1");
96
+ });
97
+
98
+ test("pollFeatureBlocked: an observed run whose task is gone (out-of-band completion) clears the pointer", async () => {
99
+ const { data, stores } = memData();
100
+ stores.feature_runs = [
101
+ { feature_key: "o/r#2", status: "awaiting_operator", process_key: "200", blocked_user_task_key: "ut-2" },
102
+ ];
103
+ const engine = fakeEngine({ "200": [] });
104
+
105
+ await pollFeatureBlocked(data, engine);
106
+
107
+ assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
108
+ });
109
+
110
+ test("pollFeatureBlocked: only touches awaiting_operator runs, and never one without a process_key", async () => {
111
+ const { data, stores } = memData();
112
+ stores.feature_runs = [
113
+ { feature_key: "o/r#3", status: "blocked", process_key: "300", blocked_user_task_key: null },
114
+ { feature_key: "o/r#4", status: "awaiting_operator", process_key: null, blocked_user_task_key: null },
115
+ ];
116
+ const engine = fakeEngine({ "300": [{ userTaskKey: "ut-3", elementId: "feature-blocked" }] });
117
+
118
+ await pollFeatureBlocked(data, engine);
119
+
120
+ // blocked is terminal → not a candidate; awaiting_operator with no process_key → skipped.
121
+ assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
122
+ assertEquals(stores.feature_runs[1].blocked_user_task_key, null);
123
+ });
124
+
125
+ test("pollFeatureBlocked: a parked non-blocked task (feature-escalation) does not record a pointer", async () => {
126
+ const { data, stores } = memData();
127
+ stores.feature_runs = [
128
+ { feature_key: "o/r#5", status: "awaiting_operator", process_key: "500", blocked_user_task_key: null },
129
+ ];
130
+ const engine = fakeEngine({ "500": [{ userTaskKey: "ut-5", elementId: "feature-escalation" }] });
131
+
132
+ await pollFeatureBlocked(data, engine);
133
+
134
+ assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
135
+ });
package/app/service.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
10
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
11
  import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
12
- import { deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_ESCALATION_ELEMENT, type FeatureRun, featureRuns } from "./feature.ts";
12
+ import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureRun, featureRuns } from "./feature.ts";
13
13
  import {
14
14
  classifyMergeability,
15
15
  ensureFreshHeadRun,
@@ -1326,6 +1326,40 @@ export async function pollFeatureEscalations(data: DataLayer, engine: EngineClie
1326
1326
  }
1327
1327
  }
1328
1328
 
1329
+ /** Reconcile each BLOCKED FEATURE run against its native `feature-blocked` user task (issue #220 —
1330
+ * a blocked run parked at `feature-blocked` had no completion affordance in nwf). When a feature run
1331
+ * reaches a `blocked` outcome `record-feature` holds the row at the NON-terminal `awaiting_operator`
1332
+ * status and it parks on the `feature-blocked` operator user task (an engine wait); no worker runs, so
1333
+ * the schema-driven pages — which read `feature_runs` — had a status to show but NO pointer to drive a
1334
+ * completion action, so the run sat parked forever unless completed out-of-band. This is the blocked
1335
+ * twin of `pollFeatureEscalations`: for each run parked at (or resuming from) the blocked wait, read its
1336
+ * open user tasks and project the parked task's completable `userTaskKey` onto the row via the pure
1337
+ * `deriveFeatureBlockedPatch`, so the pages can drive an "Acknowledge blocked" action, and clear the
1338
+ * pointer once it un-parks. It never touches `status` — `record-feature` owns the `awaiting_operator`
1339
+ * flip and `record-blocked-ack` owns the terminal `blocked`, so the poller can never clobber either.
1340
+ *
1341
+ * Candidates are only the runs that could be parked here — `awaiting_operator` (parked at, or just
1342
+ * un-parked from, the blocked wait) — queried via the `feature_runs(status)` index, so the pass stays
1343
+ * O(in-flight), not O(total runs). The terminal-ward transition THROUGH `record-blocked-ack` (and the
1344
+ * acknowledge operation) clears the pointer, so a run that has already settled to `blocked` never needs
1345
+ * sweeping here. Best-effort + idempotent — per-run failures are isolated. */
1346
+ export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient) {
1347
+ for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
1348
+ if (!run.process_key) continue;
1349
+ try {
1350
+ const tasks = await engine.searchUserTasks({ processInstanceKey: run.process_key });
1351
+ const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
1352
+ const parked = task ? { userTaskKey: task.userTaskKey } : null;
1353
+ const patch = deriveFeatureBlockedPatch(run, parked);
1354
+ if (patch) {
1355
+ await featureRuns(data).update(run.feature_key, { ...patch, updated_at: now() });
1356
+ }
1357
+ } catch (err) {
1358
+ console.error(`[poller] feature blocked ${run.feature_key}: ${err}`);
1359
+ }
1360
+ }
1361
+ }
1362
+
1329
1363
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
1330
1364
  * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
1331
1365
  * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
@@ -1341,6 +1375,7 @@ export async function pollOnce(
1341
1375
  await pollDelivery(data);
1342
1376
  await pollFeatureDelivery(data);
1343
1377
  await pollFeatureEscalations(data, engine);
1378
+ await pollFeatureBlocked(data, engine);
1344
1379
  if (engineRest) {
1345
1380
  await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1346
1381
  await pollIncidents(data, engineRest.restAddress, engineRest.token);
@@ -0,0 +1,26 @@
1
+ -- Surface a blocked feature-run's completion affordance in the nwf UI (issue #220).
2
+ --
3
+ -- The escalation path (`feature-escalation`) got the full UI treatment in issue
4
+ -- #210; the BLOCKED path (`feature-blocked`) did not. When a single-issue feature
5
+ -- run reaches a `blocked` outcome it parks on the native `feature-blocked` operator
6
+ -- user task (`candidateGroups=operators`) and `record-feature` holds the row at the
7
+ -- NON-terminal `awaiting_operator` status. That wait was actionable only out-of-band
8
+ -- (a direct `/v2/user-tasks/{key}/completion` call): the schema-driven pages read
9
+ -- `feature_runs`, but nothing denormalised the completable `feature-blocked`
10
+ -- userTaskKey onto the row, so the pages had no pointer to drive a completion action
11
+ -- and the run sat parked forever with no affordance.
12
+ --
13
+ -- This column is the blocked twin of `escalation_user_task_key` (migration 031):
14
+ -- the completable native `feature-blocked` user-task key the "Acknowledge blocked"
15
+ -- affordance posts to (`completeUserTaskAttributed`) and the pages gate the control
16
+ -- on (`showWhenField`, JS-truthy, so NULL correctly hides it). It is kept DISTINCT
17
+ -- from `escalation_user_task_key` so the two human tasks are never conflated. The
18
+ -- poller (`pollFeatureBlocked` in app/service.ts) fills it in once the user task is
19
+ -- observable and clears it when the run un-parks; `record-blocked-ack` / the
20
+ -- acknowledge operation clear it on the terminal-ward exit. It is NULL whenever the
21
+ -- run is not parked at `feature-blocked`.
22
+ --
23
+ -- Forward-only, additive (expand): the column is nullable with no default. Numbered
24
+ -- after the current highest prefix (031); the runner wraps each file in its own
25
+ -- transaction, so this file must NOT contain BEGIN/COMMIT.
26
+ ALTER TABLE feature_runs ADD COLUMN blocked_user_task_key TEXT;
@@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  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
- import { pollFeatureEscalations } from "../app/service.ts";
25
+ import { pollFeatureBlocked, pollFeatureEscalations } from "../app/service.ts";
26
26
 
27
27
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
28
 
@@ -59,6 +59,7 @@ interface FeatureRow {
59
59
  delivery_label: string | null;
60
60
  escalation_question: string | null;
61
61
  escalation_user_task_key: string | null;
62
+ blocked_user_task_key: string | null;
62
63
  }
63
64
  interface PrRow {
64
65
  pr_key: string;
@@ -189,8 +190,20 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
189
190
  const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({});
190
191
  assert.equal(prs.length, 0, "a blocked run never enrolled a PR into the convergence loop");
191
192
 
192
- // Acknowledging the blocked run records the note, settles it terminal `blocked`, and ends.
193
- await app.engine.completeUserTask(task!.userTaskKey, { note: "reassigned to a human" });
193
+ // The poller fills in the completable user-task key (which no service task can know the task
194
+ // doesn't exist yet when record-feature runs) so the pages can drive an attributed acknowledge.
195
+ await pollFeatureBlocked(app.db, app.engine);
196
+ const denorm = await featureRow(app, featureKey);
197
+ assert.ok(denorm.blocked_user_task_key, "the poller denormalised the completable blocked user-task key");
198
+ assert.equal(denorm.status, "awaiting_operator", "the run stays awaiting_operator while parked");
199
+
200
+ // Acknowledge through the app's OWN operation (the nwf UI's affordance) — the attributed
201
+ // completer resumes the SAME record-blocked-ack path a human would from the task inbox, with NO
202
+ // out-of-band /v2/user-tasks/{key}/completion call.
203
+ const acked = await app.api?.call("acknowledgeBlocked", {
204
+ body: { userTaskKey: denorm.blocked_user_task_key, note: "reassigned to a human" },
205
+ });
206
+ assert.equal(acked?.status, 200, "the operator acknowledgement completed the blocked task");
194
207
  await app.settle();
195
208
  const flows2 = takenFlows(app);
196
209
  assert.ok(flows2.includes("feature-blocked->record-blocked-ack"), "ack routes through record-blocked-ack");
@@ -198,6 +211,11 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
198
211
  const settled = await featureRow(app, featureKey);
199
212
  assert.equal(settled.status, "blocked", "the acknowledged run settles at terminal blocked");
200
213
  assert.equal(settled.delivery_label, "operator: reassigned to a human", "the operator note is recorded");
214
+ assert.equal(settled.blocked_user_task_key, null, "the completable-task pointer was cleared on ack");
215
+
216
+ // A further poll pass is an idempotent no-op — a terminal run is not a candidate.
217
+ await pollFeatureBlocked(app.db, app.engine);
218
+ assert.equal((await featureRow(app, featureKey)).status, "blocked");
201
219
  },
202
220
  );
203
221
  });
package/main.ts CHANGED
@@ -46,24 +46,40 @@ const app = await runFromEnv({ engine, host, port: PORT, handleSignals: false })
46
46
  // Agentic visibility channel (ADR 0056, epic #142). Ride the app's OWN HTTP server so the channel
47
47
  // shares the app port (no sidecar). This is the ONLY main.ts wiring for the whole epic — sibling
48
48
  // slices (H1/H3/H4) extend it by dropping a family module under `app/agentic/families/`, never here.
49
- // Mount only when a shared identity secret is configured, so the app never exposes an
50
- // unauthenticated upgrade; `app.httpServer` is a `node:http` Server once started (undefined on hosts
51
- // that don't surface one, e.g. Deno).
49
+ //
50
+ // Local-first (security opt-in): Nano is designed for local use, so the channel is ON BY DEFAULT.
51
+ // - No secret configured -> LOCAL mode: well-known localhost token, no credential required, so a
52
+ // `nano work` worker appears live with zero configuration.
53
+ // - `NANO_AGENTIC_SECRET` (or `NANO_PR_WEBHOOK_SECRET`) set -> SECURE mode: ADR 0028 identity token
54
+ // + capability credential required on every upgrade.
55
+ // - `NANO_AGENTIC=off` (or 0/false/no) -> disabled entirely.
56
+ // `app.httpServer` is a `node:http` Server once started (undefined on hosts that don't surface one,
57
+ // e.g. Deno).
52
58
  let agentic: AgenticChannelHandle | undefined;
53
59
  const agenticSecret = envVar("NANO_AGENTIC_SECRET") ?? envVar("NANO_PR_WEBHOOK_SECRET");
60
+ const agenticDisabled = /^(0|off|false|no)$/i.test(envVar("NANO_AGENTIC") ?? "");
54
61
  const httpServer = app.httpServer;
55
62
  if (httpServer instanceof Server) {
56
- if (agenticSecret) {
63
+ if (agenticDisabled) {
64
+ app.log.info("agentic channel disabled (NANO_AGENTIC=off)");
65
+ } else {
66
+ const secure = Boolean(agenticSecret);
57
67
  agentic = await mountAgenticChannel({
58
68
  server: httpServer,
59
- secret: agenticSecret,
69
+ secret: agenticSecret ?? "",
70
+ secure,
60
71
  data: app.data,
61
72
  log: app.log,
62
73
  });
63
- } else {
64
- app.log.warn("agentic channel not mounted: set NANO_AGENTIC_SECRET (or NANO_PR_WEBHOOK_SECRET)");
74
+ if (!secure) {
75
+ app.log.info(
76
+ "agentic channel mounted in LOCAL mode (on by default, token-only — a well-known localhost " +
77
+ "token, no capability credential). Set NANO_AGENTIC_SECRET for secure mode, or " +
78
+ "NANO_AGENTIC=off to disable.",
79
+ );
80
+ }
65
81
  }
66
- } else if (agenticSecret) {
82
+ } else if (!agenticDisabled) {
67
83
  app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
68
84
  }
69
85
 
package/openapi.yaml CHANGED
@@ -977,6 +977,55 @@ paths:
977
977
  application/json:
978
978
  schema:
979
979
  $ref: "#/components/schemas/MessageResult"
980
+ /actions/acknowledge-blocked:
981
+ post:
982
+ operationId: acknowledgeBlocked
983
+ summary: "Acknowledge a blocked native feature run (issue #220). Completes the parked
984
+ `feature-blocked` operator user task with the operator's optional disposition note, driving the
985
+ canonical attributed completer (completeUserTaskAttributed) — the same resume path the task inbox
986
+ uses, recording who acknowledged. Completing it fires pr.record-blocked-ack, settling the run to
987
+ the terminal `blocked` status with the note, and the poller reconciles the completable-task
988
+ pointer off the row. This is the nwf UI's completion affordance for a feature run parked as
989
+ blocked (the blocked twin of answer-escalation)."
990
+ requestBody:
991
+ required: true
992
+ content:
993
+ application/json:
994
+ schema:
995
+ type: object
996
+ additionalProperties: false
997
+ required:
998
+ - userTaskKey
999
+ properties:
1000
+ userTaskKey:
1001
+ type: string
1002
+ minLength: 1
1003
+ description: The parked `feature-blocked` user-task key (feature_runs.blocked_user_task_key).
1004
+ note:
1005
+ type: string
1006
+ description: Optional operator disposition note, recorded on the run (delivery_label). Blank ⇒ "acknowledged".
1007
+ operator:
1008
+ type: string
1009
+ description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
1010
+ responses:
1011
+ "200":
1012
+ description: The blocked user task was completed and the process resumed.
1013
+ content:
1014
+ application/json:
1015
+ schema:
1016
+ $ref: "#/components/schemas/MessageResult"
1017
+ "400":
1018
+ description: A required field was missing/invalid, or the target is not a blocked task.
1019
+ content:
1020
+ application/json:
1021
+ schema:
1022
+ $ref: "#/components/schemas/MessageResult"
1023
+ "404":
1024
+ description: No open blocked user task matches the userTaskKey.
1025
+ content:
1026
+ application/json:
1027
+ schema:
1028
+ $ref: "#/components/schemas/MessageResult"
980
1029
  /hooks/agent-complete:
981
1030
  post:
982
1031
  operationId: agentCompleteEscalation
@@ -0,0 +1,111 @@
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
+ });
@@ -0,0 +1,62 @@
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.66.0",
3
+ "version": "0.68.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",
@@ -88,6 +88,15 @@
88
88
  "path": "/app/api/actions/answer-escalation",
89
89
  "body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
90
90
  }
91
+ },
92
+ {
93
+ "label": "Acknowledge blocked",
94
+ "confirm": "Acknowledge this blocked run? It settles to terminal blocked (the agent could not open a PR).",
95
+ "showWhenField": "blocked_user_task_key",
96
+ "action": {
97
+ "path": "/app/api/actions/acknowledge-blocked",
98
+ "body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
99
+ }
91
100
  }
92
101
  ],
93
102
  "detail": {
@@ -141,6 +141,15 @@
141
141
  "path": "/app/api/actions/answer-escalation",
142
142
  "body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
143
143
  }
144
+ },
145
+ {
146
+ "label": "Acknowledge blocked",
147
+ "confirm": "Acknowledge this blocked run? It settles to terminal blocked (the agent could not open a PR).",
148
+ "showWhenField": "blocked_user_task_key",
149
+ "action": {
150
+ "path": "/app/api/actions/acknowledge-blocked",
151
+ "body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
152
+ }
144
153
  }
145
154
  ],
146
155
  "detail": {
@@ -32,12 +32,15 @@ function fakeApp(rows: Record<string, unknown>[]) {
32
32
  }
33
33
 
34
34
  test("record-blocked-ack: settles the parked run at terminal blocked and records the operator note", async () => {
35
- const rows = [{ feature_key: "owner/repo#7", status: "awaiting_operator", delivery_label: null }];
35
+ const rows = [{ feature_key: "owner/repo#7", status: "awaiting_operator", delivery_label: null, blocked_user_task_key: "ut-7" }];
36
36
  const app = fakeApp(rows);
37
37
  const out = await handler({ variables: { featureKey: "owner/repo#7", note: "reassigned to a human" } } as any, app);
38
38
  assertEquals(out, {});
39
39
  assertEquals(rows[0].status, "blocked");
40
40
  assertEquals(rows[0].delivery_label, "operator: reassigned to a human");
41
+ // The completable-task pointer is cleared on the terminal-ward transition so the pages stop offering
42
+ // the acknowledge affordance for a now-completed task (pollFeatureBlocked no longer sweeps this row).
43
+ assertEquals(rows[0].blocked_user_task_key, null);
41
44
  });
42
45
 
43
46
  test("record-blocked-ack: a blank note falls back to an 'acknowledged' label", async () => {
@@ -26,6 +26,11 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
26
26
  await featureRuns(app.data).update(featureKey, {
27
27
  status: "blocked",
28
28
  delivery_label: note ? `operator: ${note}` : "acknowledged",
29
+ // The run has left the `feature-blocked` wait, so clear the denormalised completable-task pointer
30
+ // the pages gate the "Acknowledge blocked" affordance on. pollFeatureBlocked only sweeps
31
+ // `awaiting_operator` runs, so this terminal-ward transition must clear it itself or a stale pointer
32
+ // would linger on the now-terminal row.
33
+ blocked_user_task_key: null,
29
34
  updated_at: ts,
30
35
  });
31
36
  app.log.info("record-blocked-ack", { featureKey, note: note ?? null });