@nanobpm/nano-workforce 0.67.0 → 0.69.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 +14 -0
- package/README.md +64 -0
- package/app/agentCompletion.ts +38 -0
- package/app/agentic/channel.test.ts +85 -1
- package/app/agentic/channel.ts +98 -14
- package/app/feature.ts +56 -0
- package/app/featureBlocked.test.ts +135 -0
- package/app/service.ts +36 -1
- package/db/migrations/032_feature_blocked_surface.sql +26 -0
- package/e2e/feature-run.e2e.ts +21 -3
- package/openapi.yaml +49 -0
- package/operations/acknowledgeBlocked.test.ts +111 -0
- package/operations/acknowledgeBlocked.ts +62 -0
- package/package.json +1 -1
- package/pages/feature.page.json +9 -0
- package/pages/overview.page.json +9 -0
- package/workers/record-blocked-ack/worker.test.ts +4 -1
- package/workers/record-blocked-ack/worker.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.69.0](https://github.com/nanobpm/nano-workforce/compare/v0.68.0...v0.69.0) (2026-08-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **agentic:** enable nwf for a remote fleet — loopback-guard the LOCAL token + document network posture ([#224](https://github.com/nanobpm/nano-workforce/issues/224)) ([#228](https://github.com/nanobpm/nano-workforce/issues/228)) ([73f6170](https://github.com/nanobpm/nano-workforce/commit/73f6170ff4c0df0ad6c4eb2a2e6e9b3df5d1e343)), closes [nano-ide#235](https://github.com/nano-ide/issues/235) [nano-ide#235](https://github.com/nano-ide/issues/235)
|
|
7
|
+
|
|
8
|
+
# [0.68.0](https://github.com/nanobpm/nano-workforce/compare/v0.67.0...v0.68.0) (2026-08-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
1
15
|
# [0.67.0](https://github.com/nanobpm/nano-workforce/compare/v0.66.0...v0.67.0) (2026-08-14)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -263,6 +263,70 @@ active epic already targets the same custom base. See
|
|
|
263
263
|
| `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
|
|
264
264
|
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | `PT20M` | ISO-8601 duration the loop waits for a fresh review before escalating a stalled review (timer arm of the `wait-review` gateway) |
|
|
265
265
|
| `NANO_PR_REVIEW_NUDGE_MINUTES` | `5` | cooldown between the poller's automatic reviewer re-request nudges for one waiting PR (clamped 1–1440) |
|
|
266
|
+
| `NANO_PR_PUBLIC_BASE_URL` | `http://localhost:3000` | externally-reachable base URL for the capability hooks (`/app/api/hooks/*`). Must resolve from **wherever the agent runs** — set it to the app's LAN address (or console-proxy URL) for a remote fleet. Falls back to `NANO_PR_BASE_URL`, then `http://localhost:3000`. See [Fleet networking](#fleet-networking-remote-workers) |
|
|
267
|
+
| `NANO_AGENTIC_SECRET` | — | enables **secure mode** for the agentic visibility channel (`/agentic`): requires an ADR 0028 identity token + capability credential from every peer. Required to attach agentic visibility from **off-box** workers; unset = on-by-default **LOCAL mode** (well-known token, loopback peers only). Also accepts `NANO_PR_WEBHOOK_SECRET` |
|
|
268
|
+
|
|
269
|
+
### Fleet networking (remote workers)
|
|
270
|
+
|
|
271
|
+
`nano-workforce` can drive a **distributed worker fleet** — `senior:*` agents running on other LAN
|
|
272
|
+
machines. Two app surfaces must be reachable from those off-box workers:
|
|
273
|
+
|
|
274
|
+
- The **capability hooks** — `/app/api/hooks/abandon` and `/app/api/hooks/blackboard`. Every
|
|
275
|
+
side-effecting agent is handed an unguessable per-run capability URL in its prompt and `curl`s it
|
|
276
|
+
before each irreversible action (an unknown token is a `404`). A remote worker can only reach these
|
|
277
|
+
if (a) the app's HTTP server is bound so off-box hosts can connect, and (b) the base URL baked into
|
|
278
|
+
that prompt resolves from the worker's host — hence **`NANO_PR_PUBLIC_BASE_URL` must be the app's
|
|
279
|
+
LAN address, not `localhost`**.
|
|
280
|
+
- The **agentic visibility channel** — `/agentic` (WebSocket). In on-by-default **LOCAL mode** it is
|
|
281
|
+
gated only by a *well-known, non-secret* localhost token, so it is enforced **loopback-only**: an
|
|
282
|
+
off-box peer is refused — as is a **reverse-proxied** peer (a connection carrying an
|
|
283
|
+
`X-Forwarded-For`/`Forwarded`/`X-Real-IP` header is refused even over loopback, so forwarding
|
|
284
|
+
`/agentic` through the console proxy cannot smuggle the well-known token off-box). To give remote
|
|
285
|
+
workers visibility, run the channel in **secure mode** by setting `NANO_AGENTIC_SECRET`. (Fleet
|
|
286
|
+
coordination itself does not depend on this channel — it is visibility only.)
|
|
287
|
+
|
|
288
|
+
#### Bind the HTTP server
|
|
289
|
+
|
|
290
|
+
The capability hooks are only reachable off-box if the app's HTTP server binds to a routable
|
|
291
|
+
interface. The declarative, per-app control is an **app-manifest** setting (loopback by default,
|
|
292
|
+
opt-in to all interfaces):
|
|
293
|
+
|
|
294
|
+
```jsonc
|
|
295
|
+
// nano.app.json
|
|
296
|
+
{ "network": { "bind": "all" } } // 0.0.0.0 / :: — expose to the LAN for a remote fleet
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
> **Status:** this manifest key is delivered by the Urban runtime in
|
|
300
|
+
> [`nanobpm/nano-ide#235`](https://github.com/nanobpm/nano-ide/issues/235) (add the field to the app
|
|
301
|
+
> schema + plumb the bind host to the node adapter). Until that lands, the runtime binds to all
|
|
302
|
+
> interfaces by default (Node's `listen(port)` default), so a fleet already reaches the hooks — but
|
|
303
|
+
> once the parallel *loopback-by-default* change ships, set `"network": { "bind": "all" }` here to
|
|
304
|
+
> keep nwf reachable. This repo is ready for that flip; nwf already enforces the LOCAL-token
|
|
305
|
+
> loopback-only guard so binding wide never exposes the well-known agentic token off-box.
|
|
306
|
+
|
|
307
|
+
#### Choose a path: direct LAN bind vs console proxy
|
|
308
|
+
|
|
309
|
+
nwf composes with two deployment topologies — pick one and point the fleet at it:
|
|
310
|
+
|
|
311
|
+
| Path | When | Fleet uses |
|
|
312
|
+
|---|---|---|
|
|
313
|
+
| **Direct LAN bind** | nwf run standalone for a fleet | Bind the server wide (above) and set `NANO_PR_PUBLIC_BASE_URL=http://<app-lan-host>:3000`. Workers `curl` the hooks directly on the app's LAN address. |
|
|
314
|
+
| **Console reverse-proxy** | nwf embedded behind the nano console at `/console/app-view/Workforce` | Leave the app bound to loopback and set `NANO_PR_PUBLIC_BASE_URL` to the console's public origin + the app-view prefix, so `/app/api/hooks/*` resolves through the proxy. Workers reach the hooks via the console. |
|
|
315
|
+
|
|
316
|
+
Either way the capability URL in each agent's prompt (`${NANO_PR_PUBLIC_BASE_URL}/app/api/hooks/…`)
|
|
317
|
+
must resolve from the worker's host. **Verify** from a fleet host before relying on it:
|
|
318
|
+
|
|
319
|
+
```sh
|
|
320
|
+
# From a remote LAN worker, against the base URL seeded into agent prompts.
|
|
321
|
+
# A live run returns { "prKey": "...", "status": "...", "abandoned": false }; -f exits non-zero on 404.
|
|
322
|
+
curl -fsS "${NANO_PR_PUBLIC_BASE_URL}/app/api/hooks/abandon?token=<per-run-token>"
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
If the `curl` cannot reach the host, off-box agents will (correctly, per the abort contract) treat the
|
|
326
|
+
run as abandoned and stop — the exact failure behind
|
|
327
|
+
[`jwulf/c8ctl-plugin-nano#76`](https://github.com/jwulf/c8ctl-plugin-nano/issues/76). Fix it by
|
|
328
|
+
binding wide + setting a routable `NANO_PR_PUBLIC_BASE_URL`, or by fronting nwf with the console proxy.
|
|
329
|
+
|
|
266
330
|
|
|
267
331
|
### Purge
|
|
268
332
|
|
package/app/agentCompletion.ts
CHANGED
|
@@ -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,11 +5,19 @@
|
|
|
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 { AUTH_UNAUTHORIZED } from "@nanobpm/agentic/channel";
|
|
8
9
|
import { createLogger } from "@nanobpm/urban/runtime";
|
|
9
10
|
import { WebSocket } from "ws";
|
|
10
11
|
import { assert, assertEquals } from "#test-assert";
|
|
11
12
|
import { noopLog } from "../../test/log.ts";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type AgenticChannelHandle,
|
|
15
|
+
isForwardedConnection,
|
|
16
|
+
isLoopbackRemote,
|
|
17
|
+
LOCAL_AGENTIC_TOKEN,
|
|
18
|
+
loopbackOnly,
|
|
19
|
+
mountAgenticChannel,
|
|
20
|
+
} from "./channel.ts";
|
|
13
21
|
import { type AgenticContext, AgenticFamilyRegistry } from "./registry.ts";
|
|
14
22
|
|
|
15
23
|
const SECRET = "test-agentic-secret";
|
|
@@ -359,3 +367,79 @@ test("LOCAL mode does NOT warn when the server is bound to loopback", async (t)
|
|
|
359
367
|
const warned = records.some((r) => r.level === "warn" && r.msg.includes("not bound to loopback"));
|
|
360
368
|
assert(!warned, "a loopback-bound LOCAL channel is the expected safe case and must not warn");
|
|
361
369
|
});
|
|
370
|
+
|
|
371
|
+
// --- Loopback-only enforcement of the LOCAL well-known token (issue #224 / nano-ide#235) ---
|
|
372
|
+
//
|
|
373
|
+
// The LOCAL token is not a secret, so once the app binds to all interfaces (network.bind: "all") it
|
|
374
|
+
// must never be honoured off-box. `isLoopbackRemote` vets the peer's origin; `loopbackOnly` wraps an
|
|
375
|
+
// authenticator to refuse a non-loopback peer with 4401 while delegating loopback peers to the base.
|
|
376
|
+
|
|
377
|
+
test("isLoopbackRemote accepts same-host peers and rejects everything else", () => {
|
|
378
|
+
for (const ok of ["127.0.0.1", "127.0.0.5", "::1", "::ffff:127.0.0.1", "::ffff:127.1.2.3"]) {
|
|
379
|
+
assert(isLoopbackRemote(ok), `${ok} should be loopback`);
|
|
380
|
+
}
|
|
381
|
+
for (const no of [undefined, "", "10.0.0.4", "192.168.1.20", "::ffff:10.0.0.4", "2001:db8::1", "0.0.0.0"]) {
|
|
382
|
+
assert(!isLoopbackRemote(no), `${String(no)} should NOT be loopback`);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("loopbackOnly refuses a non-loopback peer with 4401 and never calls the base authenticator", () => {
|
|
387
|
+
let baseCalls = 0;
|
|
388
|
+
const base = () => {
|
|
389
|
+
baseCalls++;
|
|
390
|
+
return { ok: true as const, grant: { identity: "peer" } };
|
|
391
|
+
};
|
|
392
|
+
const guarded = loopbackOnly(base);
|
|
393
|
+
|
|
394
|
+
const remote = guarded({ token: LOCAL_AGENTIC_TOKEN, remote: "10.0.0.4" });
|
|
395
|
+
assert(!("then" in remote), "authenticator result is synchronous here");
|
|
396
|
+
assertEquals((remote as { ok: boolean; code?: number }).ok, false);
|
|
397
|
+
assertEquals((remote as { code?: number }).code, AUTH_UNAUTHORIZED);
|
|
398
|
+
assertEquals(baseCalls, 0);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("loopbackOnly delegates a loopback peer to the base authenticator", () => {
|
|
402
|
+
let baseCalls = 0;
|
|
403
|
+
const base = () => {
|
|
404
|
+
baseCalls++;
|
|
405
|
+
return { ok: true as const, grant: { identity: "peer" } };
|
|
406
|
+
};
|
|
407
|
+
const guarded = loopbackOnly(base);
|
|
408
|
+
|
|
409
|
+
const local = guarded({ token: LOCAL_AGENTIC_TOKEN, remote: "127.0.0.1" });
|
|
410
|
+
assertEquals((local as { ok: boolean }).ok, true);
|
|
411
|
+
assertEquals(baseCalls, 1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// A reverse proxy that connects to the app over loopback makes an off-box client appear same-host to
|
|
415
|
+
// `req.remote`. `isForwardedConnection` detects the relay from proxy-forwarding headers, so
|
|
416
|
+
// `loopbackOnly` fails closed on a proxied peer even when `req.remote` itself is loopback.
|
|
417
|
+
|
|
418
|
+
test("isForwardedConnection detects proxy-forwarding headers and ignores absent/empty ones", () => {
|
|
419
|
+
assert(isForwardedConnection({ "x-forwarded-for": "10.0.0.4" }), "x-forwarded-for marks a relay");
|
|
420
|
+
assert(isForwardedConnection({ forwarded: "for=10.0.0.4" }), "forwarded marks a relay");
|
|
421
|
+
assert(isForwardedConnection({ "x-real-ip": "10.0.0.4" }), "x-real-ip marks a relay");
|
|
422
|
+
|
|
423
|
+
assert(!isForwardedConnection(undefined), "no headers is a direct connection");
|
|
424
|
+
assert(!isForwardedConnection({}), "empty headers is a direct connection");
|
|
425
|
+
assert(!isForwardedConnection({ "x-forwarded-for": " " }), "whitespace value is treated as absent");
|
|
426
|
+
assert(!isForwardedConnection({ "content-type": "application/json" }), "unrelated headers are ignored");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("loopbackOnly refuses a reverse-proxied peer (loopback remote + forwarding header) with 4401", () => {
|
|
430
|
+
let baseCalls = 0;
|
|
431
|
+
const base = () => {
|
|
432
|
+
baseCalls++;
|
|
433
|
+
return { ok: true as const, grant: { identity: "peer" } };
|
|
434
|
+
};
|
|
435
|
+
const guarded = loopbackOnly(base);
|
|
436
|
+
|
|
437
|
+
const proxied = guarded({
|
|
438
|
+
token: LOCAL_AGENTIC_TOKEN,
|
|
439
|
+
remote: "127.0.0.1",
|
|
440
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
441
|
+
});
|
|
442
|
+
assertEquals((proxied as { ok: boolean; code?: number }).ok, false);
|
|
443
|
+
assertEquals((proxied as { code?: number }).code, AUTH_UNAUTHORIZED);
|
|
444
|
+
assertEquals(baseCalls, 0);
|
|
445
|
+
});
|
package/app/agentic/channel.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type { Server } from "node:http";
|
|
|
17
17
|
import type { AddressInfo } from "node:net";
|
|
18
18
|
import {
|
|
19
19
|
AgenticHub,
|
|
20
|
+
AUTH_UNAUTHORIZED,
|
|
21
|
+
type Authenticator,
|
|
20
22
|
sharedSecretAuthenticator,
|
|
21
23
|
WebSocketChannelTransport,
|
|
22
24
|
} from "@nanobpm/agentic/channel";
|
|
@@ -53,6 +55,83 @@ function isLoopbackBind(addr: string | AddressInfo | null): boolean {
|
|
|
53
55
|
return host === "::1" || host === "::ffff:127.0.0.1" || host.startsWith("127.");
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* True if a peer's remote address (`req.remote`, i.e. `socket.remoteAddress`) is a same-host /
|
|
60
|
+
* loopback peer. This is the per-connection counterpart to {@link isLoopbackBind}: while that vets
|
|
61
|
+
* the *server's* bind, this vets the *client's* origin, so LOCAL mode can be honoured off a
|
|
62
|
+
* wildcard/all-interfaces bind (`network.bind: "all"`, issue #224) yet still refuse the well-known
|
|
63
|
+
* {@link LOCAL_AGENTIC_TOKEN} to anything but a same-machine peer. Loopback is `127.0.0.0/8`, `::1`,
|
|
64
|
+
* or the IPv6-mapped IPv4 forms Node reports on a dual-stack listener (`::ffff:127.x`). An
|
|
65
|
+
* absent/unparseable remote is NOT provably same-host, so it is treated as non-loopback
|
|
66
|
+
* (fail-closed), matching the `isLoopbackBind(null) === false` posture.
|
|
67
|
+
*/
|
|
68
|
+
export function isLoopbackRemote(remote: string | undefined): boolean {
|
|
69
|
+
if (!remote) return false;
|
|
70
|
+
return (
|
|
71
|
+
remote === "::1" ||
|
|
72
|
+
remote === "::ffff:127.0.0.1" ||
|
|
73
|
+
remote.startsWith("127.") ||
|
|
74
|
+
remote.startsWith("::ffff:127.")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Proxy-forwarding request headers. Their presence means the connection was relayed through a
|
|
80
|
+
* reverse proxy, so `req.remote` is the *proxy's* address (typically loopback for an embedded/console
|
|
81
|
+
* proxy) rather than the true client — a loopback `remote` no longer proves a same-host peer. A
|
|
82
|
+
* genuine same-machine loopback peer connects directly and never carries one of these.
|
|
83
|
+
*/
|
|
84
|
+
const FORWARDING_HEADERS: readonly string[] = ["x-forwarded-for", "forwarded", "x-real-ip"];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True if the handshake carries a proxy-forwarding header — i.e. the connection reached us through a
|
|
88
|
+
* reverse proxy, so `req.remote` is the proxy, not the originating client. Used to fail LOCAL mode
|
|
89
|
+
* closed: a relayed connection can present a loopback `remote` (the proxy) while the real client is
|
|
90
|
+
* off-box, so the well-known token must never be honoured for it (see {@link loopbackOnly}). Headers
|
|
91
|
+
* are lower-cased by the transport ({@link HandshakeRequest.headers}); an empty/whitespace value is
|
|
92
|
+
* treated as absent.
|
|
93
|
+
*/
|
|
94
|
+
export function isForwardedConnection(headers: Readonly<Record<string, string>> | undefined): boolean {
|
|
95
|
+
if (!headers) return false;
|
|
96
|
+
return FORWARDING_HEADERS.some((h) => {
|
|
97
|
+
const value = headers[h];
|
|
98
|
+
return typeof value === "string" && value.trim() !== "";
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Wrap `base` so a peer is admitted ONLY from a direct, same-host loopback connection. LOCAL mode
|
|
104
|
+
* gates purely on the well-known {@link LOCAL_AGENTIC_TOKEN}, which is not a secret — so once the app
|
|
105
|
+
* is exposed on the LAN (`network.bind: "all"`, issue #224) that token must never be honoured
|
|
106
|
+
* off-box. This enforces the invariant per-connection (any other peer is closed `4401`), independent
|
|
107
|
+
* of the server's bind, closing the interplay the bind-to-all setting exposes (nano-ide#235).
|
|
108
|
+
*
|
|
109
|
+
* Two ways a peer can fail to be a same-host loopback client, both refused:
|
|
110
|
+
* - a non-loopback `req.remote` (a direct off-box connection); or
|
|
111
|
+
* - a proxy-forwarding header ({@link isForwardedConnection}) — the connection was relayed, so a
|
|
112
|
+
* loopback `req.remote` is the *proxy*, not the client. Refusing any forwarded connection keeps
|
|
113
|
+
* the guard robust even if `/agentic` is inadvertently reverse-proxied over loopback (the
|
|
114
|
+
* embedded/console-proxy topology), where the off-box client would otherwise appear same-host.
|
|
115
|
+
*
|
|
116
|
+
* Note this guards ONLY the agentic visibility channel: the capability HTTP hooks
|
|
117
|
+
* (`/app/api/hooks/*`) carry their own unguessable per-request tokens and stay reachable off-box
|
|
118
|
+
* (including through the console proxy), which is what a remote fleet needs. To attach agentic
|
|
119
|
+
* visibility from off-box — directly or via a proxy — run the channel in SECURE mode instead.
|
|
120
|
+
*/
|
|
121
|
+
export function loopbackOnly(base: Authenticator): Authenticator {
|
|
122
|
+
return (req) => {
|
|
123
|
+
if (isForwardedConnection(req.headers) || !isLoopbackRemote(req.remote)) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
code: AUTH_UNAUTHORIZED,
|
|
127
|
+
reason:
|
|
128
|
+
"LOCAL-mode agentic channel is loopback-only and refuses reverse-proxied peers; use secure mode (NANO_AGENTIC_SECRET) for off-box or proxied peers",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return base(req);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
56
135
|
export interface MountAgenticChannelOptions {
|
|
57
136
|
/** The app's own `node:http` server (share its port; `app.httpServer` narrowed to `Server`). */
|
|
58
137
|
readonly server: Server;
|
|
@@ -116,39 +195,44 @@ export async function mountAgenticChannel(
|
|
|
116
195
|
}
|
|
117
196
|
|
|
118
197
|
const transport = new WebSocketChannelTransport({ server, path: AGENTIC_PATH });
|
|
198
|
+
// LOCAL mode gates only on the well-known localhost token, so it must be honoured only for a
|
|
199
|
+
// same-machine peer: wrap the authenticator to refuse any non-loopback remote (see loopbackOnly).
|
|
200
|
+
// Secure mode presents a real ADR 0028 identity token + capability credential, so it is safe from
|
|
201
|
+
// any origin and needs no such guard.
|
|
202
|
+
const baseAuthenticator = sharedSecretAuthenticator({ secret, requireCredential: secure });
|
|
119
203
|
const hub = new AgenticHub({
|
|
120
204
|
transport,
|
|
121
205
|
// Secure mode: a valid identity token PLUS a required capability credential upgrades; either
|
|
122
206
|
// 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),
|
|
124
|
-
authenticator:
|
|
207
|
+
// Authenticator. LOCAL mode: token-only (the well-known localhost token), loopback peers only.
|
|
208
|
+
authenticator: secure ? baseAuthenticator : loopbackOnly(baseAuthenticator),
|
|
125
209
|
onError: (err, connectionId) =>
|
|
126
210
|
log.warn("agentic hub error", { connectionId, err: String(err) }),
|
|
127
211
|
});
|
|
128
212
|
// Share the app's port: the transport rode the existing server, so it is already listening.
|
|
129
213
|
await transport.ready();
|
|
130
214
|
|
|
131
|
-
// LOCAL mode
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// A `null` address (server not listening yet) is unverifiable — warn
|
|
136
|
-
// the exposure check, since the bind could later resolve to a public interface.
|
|
215
|
+
// LOCAL mode is now enforced loopback-only per connection (see loopbackOnly), so the well-known
|
|
216
|
+
// token can never be honoured off-box even on a wildcard/all-interfaces bind. A non-loopback bind
|
|
217
|
+
// is still worth surfacing though: it means off-box agentic peers are REFUSED, so a remote worker
|
|
218
|
+
// fleet gets no visibility until the channel runs in secure mode. Warn so the operator makes the
|
|
219
|
+
// deliberate choice. A `null` address (server not listening yet) is unverifiable — warn too.
|
|
137
220
|
if (!secure) {
|
|
138
221
|
const addr = server.address();
|
|
139
222
|
if (addr === null) {
|
|
140
223
|
log.warn(
|
|
141
224
|
"agentic channel is in LOCAL mode but the server bind address could not be verified " +
|
|
142
|
-
"(the server is not listening yet) — the
|
|
143
|
-
"confirmed
|
|
144
|
-
"NANO_AGENTIC_SECRET for secure mode, or bind the server to 127.0.0.1.",
|
|
225
|
+
"(the server is not listening yet) — the loopback-only enforcement for the well-known " +
|
|
226
|
+
"LOCAL_AGENTIC_TOKEN cannot be confirmed. Mount the channel after the server is listening, " +
|
|
227
|
+
"set NANO_AGENTIC_SECRET for secure mode, or bind the server to 127.0.0.1.",
|
|
145
228
|
{ mode: "local", bind: null },
|
|
146
229
|
);
|
|
147
230
|
} else if (!isLoopbackBind(addr)) {
|
|
148
231
|
log.warn(
|
|
149
|
-
"agentic channel is in LOCAL mode but the server is not bound to loopback —
|
|
150
|
-
"
|
|
151
|
-
"
|
|
232
|
+
"agentic channel is in LOCAL mode but the server is not bound to loopback — off-box peers " +
|
|
233
|
+
"are refused the channel (the well-known LOCAL_AGENTIC_TOKEN is enforced loopback-only), " +
|
|
234
|
+
"so a remote worker fleet cannot attach visibility. Set NANO_AGENTIC_SECRET for secure " +
|
|
235
|
+
"mode to serve remote peers, or bind the server to 127.0.0.1.",
|
|
152
236
|
{ mode: "local", bind: typeof addr === "object" ? addr.address : String(addr) },
|
|
153
237
|
);
|
|
154
238
|
}
|
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;
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -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
|
-
//
|
|
193
|
-
|
|
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/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.
|
|
3
|
+
"version": "0.69.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/feature.page.json
CHANGED
|
@@ -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": {
|
package/pages/overview.page.json
CHANGED
|
@@ -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 });
|