@nanobpm/nano-workforce 0.170.1 → 0.171.1

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/openapi.yaml CHANGED
@@ -3118,6 +3118,39 @@ components:
3118
3118
  abandoned:
3119
3119
  type: boolean
3120
3120
  description: Derived from pull_requests.status; true ⇒ the run was cancelled and the agent must stop.
3121
+ CancelInstanceIntent:
3122
+ type: object
3123
+ additionalProperties: false
3124
+ required:
3125
+ - processInstanceKey
3126
+ properties:
3127
+ processInstanceKey:
3128
+ type: string
3129
+ description: The engine process-instance key of the run to cancel (the `process_key` column surfaced by `listActivePrs`/the Convergence page). Must be a string — engine keys are 64-bit and a numeric value risks silent JS precision loss.
3130
+ CancelInstanceResult:
3131
+ type: object
3132
+ required:
3133
+ - ok
3134
+ - processInstanceKey
3135
+ - state
3136
+ - reconciled
3137
+ properties:
3138
+ ok:
3139
+ type: boolean
3140
+ description: True when the engine confirmed the instance is terminated (the record is now — or derives as — `abandoned`). False ⇒ the engine did NOT stop the instance (surfaced as a 502).
3141
+ processInstanceKey:
3142
+ type: string
3143
+ description: The cancelled instance key, echoed back.
3144
+ state:
3145
+ type: string
3146
+ enum: [ACTIVE, COMPLETED, TERMINATED, gone]
3147
+ description: The instance state read back from the engine after the cancel attempt. `gone` ⇒ the engine has no record of the key (already cleaned up / never existed).
3148
+ reconciled:
3149
+ type: integer
3150
+ description: 1 when the immediate reconcile recorded the terminal source into the canonical instance-state projection (so the PR's derived tracking status flips to `abandoned` at once), else 0.
3151
+ error:
3152
+ type: string
3153
+ description: Present only on a non-terminal failure — the reason the cancel did not take.
3121
3154
  # ─────────────────────────────────────────────────────────────────────────────────────────────
3122
3155
  # MCP tool-schema convention (epic nano-workforce#605, S0 — the shared invariant every later slice
3123
3156
  # inherits). The Urban runtime projects THIS document into MCP tools (ADR 0067 — zero MCP server
@@ -3159,6 +3192,59 @@ paths:
3159
3192
  application/json:
3160
3193
  schema:
3161
3194
  $ref: "#/components/schemas/ErrorBody"
3195
+ /actions/cancel:
3196
+ post:
3197
+ operationId: cancelInstance
3198
+ summary: "Cancel a wedged run the app-owned, record-consistent way: terminate the engine instance AND flip its PR/plan record to `abandoned` (issue #667). The correct unstick — go through the app so its record state stays consistent, rather than engine-level `urban_debug_cancel_instance`, which cancels out from under the app and leaves the PR row inconsistent."
3199
+ security:
3200
+ - hookSecret: []
3201
+ - {}
3202
+ requestBody:
3203
+ required: true
3204
+ content:
3205
+ application/json:
3206
+ schema:
3207
+ # BEGIN generated:mcp-body source=#/components/schemas/CancelInstanceIntent (scripts/inline-mcp-bodies.ts — do not hand-edit)
3208
+ type: object
3209
+ additionalProperties: false
3210
+ required:
3211
+ - processInstanceKey
3212
+ properties:
3213
+ processInstanceKey:
3214
+ type: string
3215
+ description: The engine process-instance key of the run to cancel (the `process_key` column surfaced by `listActivePrs`/the Convergence page). Must be a string — engine keys are 64-bit and a numeric value risks silent JS precision loss.
3216
+ # END generated:mcp-body
3217
+ responses:
3218
+ "200":
3219
+ description: The instance was terminated; its record derives/flips to `abandoned` and drops out of `listActivePrs`.
3220
+ content:
3221
+ application/json:
3222
+ schema:
3223
+ $ref: "#/components/schemas/CancelInstanceResult"
3224
+ "400":
3225
+ description: Missing/invalid `processInstanceKey`.
3226
+ content:
3227
+ application/json:
3228
+ schema:
3229
+ $ref: "#/components/schemas/ErrorBody"
3230
+ "401":
3231
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3232
+ content:
3233
+ application/json:
3234
+ schema:
3235
+ $ref: "#/components/schemas/ErrorBody"
3236
+ "502":
3237
+ description: The engine did NOT stop the instance (the cancel was not committed); the run may still be live.
3238
+ content:
3239
+ application/json:
3240
+ schema:
3241
+ $ref: "#/components/schemas/CancelInstanceResult"
3242
+ "503":
3243
+ description: The app has no data source configured, so the cancel cannot be reconciled into the record.
3244
+ content:
3245
+ application/json:
3246
+ schema:
3247
+ $ref: "#/components/schemas/ErrorBody"
3162
3248
  /escalations:
3163
3249
  get:
3164
3250
  operationId: listEscalations
@@ -0,0 +1,138 @@
1
+ // Tests for POST /app/api/actions/cancel — operationId `cancelInstance` (issue #667, epic #664).
2
+ //
3
+ // The delegate is a thin, record-consistent door over the SAME primitive the UI's per-row Cancel
4
+ // uses (urban's `cancelInstanceReconciling`). These tests drive the REAL primitive through a fake
5
+ // engine — no second source of truth for the cancel-then-reconcile dance — and pin the delegate's
6
+ // own contract: body validation, string-key enforcement, and the ok→200 / not-committed→502 mapping. The
7
+ // `abandoned` transition itself is DERIVED off the instance-state projection the primitive feeds
8
+ // (ADR 0065), so terminating the instance through this door is exactly what makes the PR drop out of
9
+ // `listActivePrs`; that derivation is the framework's, exercised via the shared primitive here.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import type { AppApi } from "@nanobpm/urban";
13
+ import { noopLog } from "../test/log.ts";
14
+ import handler from "./cancelInstance.ts";
15
+
16
+ interface FakeEngineOpts {
17
+ /** Reject the cancel call (an uncommitted termination). */
18
+ cancelThrows?: boolean;
19
+ /** The state `searchProcessInstances` reads back for the key. `undefined` ⇒ "gone" (empty). */
20
+ readBackState?: "ACTIVE" | "COMPLETED" | "TERMINATED" | undefined;
21
+ }
22
+
23
+ function makeApp(opts: FakeEngineOpts = {}): { app: AppApi; cancelCalls: string[] } {
24
+ const cancelCalls: string[] = [];
25
+ const engine = {
26
+ async cancelInstance({ processInstanceKey }: { processInstanceKey: string }) {
27
+ cancelCalls.push(processInstanceKey);
28
+ if (opts.cancelThrows) throw new Error("engine refused");
29
+ },
30
+ async searchProcessInstances() {
31
+ return opts.readBackState ? [{ state: opts.readBackState }] : [];
32
+ },
33
+ };
34
+ // hasDefaultSource:false makes the primitive's projection feed a clean no-op (no derived-status
35
+ // store to fake here) — the reconcile-through-projection path is the framework's own tested seam.
36
+ const data = { hasDefaultSource: () => false };
37
+ const app = { engine, data, log: noopLog() } as unknown as AppApi;
38
+ return { app, cancelCalls };
39
+ }
40
+
41
+ function req(headers: Record<string, string> = {}) {
42
+ return { method: "POST", path: "/app/api/actions/cancel", headers: new Headers(headers) };
43
+ }
44
+
45
+ // biome-ignore lint/suspicious/noExplicitAny: test-only invocation shim for the delegate.
46
+ async function callHandler(
47
+ h: typeof handler,
48
+ app: AppApi,
49
+ body: unknown,
50
+ headers: Record<string, string> = {},
51
+ // biome-ignore lint/suspicious/noExplicitAny: test-only invocation shim for the delegate.
52
+ ): Promise<any> {
53
+ return await h({ req: req(headers) as any, params: {}, query: {}, body } as any, app);
54
+ }
55
+
56
+ // biome-ignore lint/suspicious/noExplicitAny: test-only invocation shim for the delegate.
57
+ async function call(app: AppApi, body: unknown): Promise<any> {
58
+ return await callHandler(handler, app, body);
59
+ }
60
+
61
+ test("a missing processInstanceKey → 400 and never touches the engine", async () => {
62
+ const { app, cancelCalls } = makeApp();
63
+ assertEquals((await call(app, {})).status, 400);
64
+ assertEquals((await call(app, { processInstanceKey: " " })).status, 400);
65
+ assertEquals((await call(app, undefined)).status, 400);
66
+ assertEquals(cancelCalls.length, 0, "no cancel attempted without a key");
67
+ });
68
+
69
+ test("a committed cancel (engine terminates) → 200 ok, echoes the key, and cancels via the engine", async () => {
70
+ const { app, cancelCalls } = makeApp({ readBackState: "TERMINATED" });
71
+ const res = await call(app, { processInstanceKey: "2985" });
72
+ assertEquals(res.status, 200);
73
+ assertEquals(res.body.ok, true);
74
+ assertEquals(res.body.processInstanceKey, "2985");
75
+ assertEquals(res.body.state, "TERMINATED");
76
+ assertEquals(cancelCalls, ["2985"], "the shared primitive issued the engine cancel for the key");
77
+ });
78
+
79
+ test("an accepted cancel whose read model lags at ACTIVE is still trusted → 200 ok", async () => {
80
+ // A non-throwing cancelInstance is a committed 204; the primitive trusts it even if the read
81
+ // model still reports ACTIVE. The door must surface that as success, not a spurious 502.
82
+ const { app } = makeApp({ readBackState: "ACTIVE" });
83
+ const res = await call(app, { processInstanceKey: "42" });
84
+ assertEquals(res.status, 200);
85
+ assertEquals(res.body.ok, true);
86
+ });
87
+
88
+ test("a numeric processInstanceKey is rejected → 400 and never touches the engine", async () => {
89
+ // Engine keys are 64-bit and can exceed JS's safe-integer range, so a numeric JSON value has
90
+ // already lost precision before we see it — the door requires a string (matching the OpenAPI
91
+ // contract) rather than coercing a possibly-corrupted number.
92
+ const { app, cancelCalls } = makeApp({ readBackState: "TERMINATED" });
93
+ const res = await call(app, { processInstanceKey: 2985 });
94
+ assertEquals(res.status, 400);
95
+ assertEquals(cancelCalls.length, 0, "a non-string key never reaches the engine");
96
+ });
97
+
98
+ test("an uncommitted cancel (engine throws, instance still ACTIVE) → 502 not-ok with the reason", async () => {
99
+ const { app } = makeApp({ cancelThrows: true, readBackState: "ACTIVE" });
100
+ const res = await call(app, { processInstanceKey: "77" });
101
+ assertEquals(res.status, 502, "an unconfirmed termination must NOT be reported as success");
102
+ assertEquals(res.body.ok, false);
103
+ assert(typeof res.body.error === "string" && res.body.error.length > 0, "carries the failure reason");
104
+ });
105
+
106
+ test("no data source configured → 503 and never touches the engine (cannot reconcile the record)", async () => {
107
+ // The record-consistent door refuses to cancel when it has no data source to reconcile the
108
+ // terminal state into — a live cancel with no derived-status update would leave the record lying.
109
+ const { app, cancelCalls } = makeApp();
110
+ // Strip the data source the primitive would reconcile against.
111
+ (app as unknown as { data?: unknown }).data = undefined;
112
+ const res = await call(app, { processInstanceKey: "2985" });
113
+ assertEquals(res.status, 503);
114
+ assert("error" in res.body, "carries an error reason");
115
+ assertEquals(cancelCalls.length, 0, "no cancel attempted without a data source");
116
+ });
117
+
118
+ test("shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, a missing/wrong secret → 401; the right one passes", async () => {
119
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
120
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
121
+ try {
122
+ // SECRET is captured at module load, so import a cache-busted copy to observe the guard.
123
+ const mod = await import(`./cancelInstance.ts?guard=${Date.now()}`);
124
+ const guarded = mod.default as typeof handler;
125
+ const { app, cancelCalls } = makeApp({ readBackState: "TERMINATED" });
126
+ const missing = await callHandler(guarded, app, { processInstanceKey: "2985" });
127
+ assertEquals(missing.status, 401, "no secret header is rejected");
128
+ const wrong = await callHandler(guarded, app, { processInstanceKey: "2985" }, { "x-hook-secret": "nope" });
129
+ assertEquals(wrong.status, 401, "a wrong secret is rejected");
130
+ assertEquals(cancelCalls.length, 0, "a rejected request never reaches the engine");
131
+ const ok = await callHandler(guarded, app, { processInstanceKey: "2985" }, { "x-hook-secret": "s3cr3t" });
132
+ assertEquals(ok.status, 200, "the correct secret is accepted");
133
+ assertEquals(cancelCalls, ["2985"], "only the authorized request issues the engine cancel");
134
+ } finally {
135
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
136
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
137
+ }
138
+ });
@@ -0,0 +1,80 @@
1
+ // POST /app/api/actions/cancel → operationId `cancelInstance` (issue #667, epic #664).
2
+ //
3
+ // The app-owned, RECORD-CONSISTENT unstick for a wedged run. agent-guide §7 documents
4
+ // `POST /actions/cancel { processInstanceKey }` as the CORRECT way to abort a run — "go through the
5
+ // app so its record state stays consistent" — but until now the only cancel projected over MCP was
6
+ // the engine-level `urban_debug_cancel_instance`, which §7 warns against because it terminates the
7
+ // instance out from under the app and leaves the PR/plan row inconsistent. This delegate closes that
8
+ // doc/impl drift by exposing the app door as a projected MCP tool.
9
+ //
10
+ // Derivation over duplication (AGENTS.md): it does NOT re-implement the cancel-then-transition
11
+ // dance. It routes through the EXACT same primitive the UI's per-row Cancel button uses —
12
+ // urban's `cancelInstanceReconciling` (the handler wired to the built-in `/app/actions/cancel`
13
+ // page action) — so there is ONE source of truth for terminating the instance and recording the
14
+ // terminal source into the canonical instance-state projection. The PR's tracking status then
15
+ // derives to `abandoned` (ADR 0065) and the row drops out of `listActivePrs`. The `instanceTracking`
16
+ // bindings come from the app's single accessor (`engineBackedBindings`), so the set can never drift
17
+ // from the reconciler's registry.
18
+ //
19
+ // The runtime validates the body against openapi.yaml (`processInstanceKey` required); the optional
20
+ // shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): as a MUTATING
21
+ // door, when NANO_PR_WEBHOOK_SECRET is set callers must present it via the x-hook-secret header —
22
+ // mirroring `reconcileEngineState`/`agentCompleteEscalation`.
23
+
24
+ import { cancelInstanceReconciling } from "@nanobpm/urban";
25
+ import { engineBackedBindings } from "../app/instanceTracking.ts";
26
+ import { envVar } from "../app/version.ts";
27
+ import { defineOperation } from "../nano-generated/operations.ts";
28
+
29
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
30
+
31
+ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
32
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
33
+ app.log.warn("cancelInstance rejected: missing/invalid shared secret");
34
+ return { status: 401, body: { error: "unauthorized" } };
35
+ }
36
+ if (!app.data) {
37
+ app.log.warn("cancelInstance: no data source configured — cannot reconcile the record");
38
+ return { status: 503, body: { error: "no data source configured" } };
39
+ }
40
+
41
+ // Require a STRING key. Engine instance keys are 64-bit and can exceed JS's safe-integer range,
42
+ // so a numeric JSON value would already have lost precision before it reached us — accepting it
43
+ // (and coercing it back to a string) would silently cancel the wrong instance and also contradicts
44
+ // the OpenAPI `string` contract. Reject a non-string with a 400 rather than coerce.
45
+ const raw = body && typeof body === "object" ? body.processInstanceKey : undefined;
46
+ const processInstanceKey = typeof raw === "string" ? raw.trim() : "";
47
+ if (!processInstanceKey) {
48
+ return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
49
+ }
50
+
51
+ const result = await cancelInstanceReconciling(
52
+ app,
53
+ [...engineBackedBindings()],
54
+ processInstanceKey,
55
+ );
56
+ const responseBody = {
57
+ ok: result.ok,
58
+ processInstanceKey: result.processInstanceKey,
59
+ state: result.state,
60
+ reconciled: result.reconciled,
61
+ ...(result.error !== undefined ? { error: result.error } : {}),
62
+ };
63
+ if (result.ok) {
64
+ app.log.info("cancelInstance: instance terminated", {
65
+ processInstanceKey,
66
+ state: result.state,
67
+ reconciled: result.reconciled,
68
+ });
69
+ return { status: 200, body: responseBody };
70
+ }
71
+ // A !ok result means the engine did NOT stop the instance (the cancel was not committed); the run
72
+ // may still be live, so surface 502 — the same non-committed-cancel signal the built-in page
73
+ // action returns.
74
+ app.log.warn("cancelInstance: engine did not confirm termination", {
75
+ processInstanceKey,
76
+ state: result.state,
77
+ error: result.error,
78
+ });
79
+ return { status: 502, body: responseBody };
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.170.1",
3
+ "version": "0.171.1",
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",
@@ -62,7 +62,7 @@
62
62
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
63
63
  },
64
64
  "dependencies": {
65
- "@nanobpm/agentic": "^0.4.0",
65
+ "@nanobpm/agentic": "^0.10.0",
66
66
  "@nanobpm/urban": "^0.88.1",
67
67
  "bpmn-auto-layout": "^2.0.0-alpha.2"
68
68
  },