@nanobpm/nano-workforce 0.208.2 → 0.208.4

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.
@@ -0,0 +1,48 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
2
+ -- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
3
+ -- re-emission of 128 with `termination_reason` added; the derived columns are emitted VERBATIM from
4
+ -- app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each file). Numbered after 130.
5
+
6
+ DROP VIEW IF EXISTS pull_requests_read_model;
7
+
8
+ CREATE VIEW pull_requests_read_model AS
9
+ SELECT
10
+ pr.pr_key AS pr_key,
11
+ pr.repo AS repo,
12
+ pr.number AS number,
13
+ pr.url AS url,
14
+ pr.title AS title,
15
+ COALESCE(pr.derived_status, pr.status) AS status,
16
+ pr.current_round AS current_round,
17
+ pr.process_key AS process_key,
18
+ pr.waiting_since AS waiting_since,
19
+ pr.last_review_id AS last_review_id,
20
+ pr.outcome AS outcome,
21
+ pr.created_at AS created_at,
22
+ pr.updated_at AS updated_at,
23
+ pr.converged_at AS converged_at,
24
+ pr.merged_at AS merged_at,
25
+ pr.active_worker AS active_worker,
26
+ pr.lease_until AS lease_until,
27
+ pr.last_nudge_at AS last_nudge_at,
28
+ pr.fresh_head_run_head AS fresh_head_run_head,
29
+ pr.abandon_token AS abandon_token,
30
+ pr.incident_key AS incident_key,
31
+ pr.incident_type AS incident_type,
32
+ pr.incident_since AS incident_since,
33
+ pr.incident_message AS incident_message,
34
+ pr.last_round_head AS last_round_head,
35
+ pr.last_progress_job_key AS last_progress_job_key,
36
+ pr.last_progress_result AS last_progress_result,
37
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
38
+ pr.churn_escalated_round AS churn_escalated_round,
39
+ pr.root_request_key AS root_request_key,
40
+ pr.epic_phase_label AS epic_phase_label,
41
+ pr.acknowledged_at AS acknowledged_at,
42
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
43
+ pr.termination_reason AS termination_reason,
44
+ pr.termination_process_key AS termination_process_key,
45
+ pr.retire_pending_key AS retire_pending_key,
46
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
47
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
48
+ FROM pull_requests__tracking pr;
@@ -159,7 +159,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
159
159
  assert.ok(
160
160
  flows.includes("ensure-base-branch->record-feature-implementing") &&
161
161
  flows.includes("record-feature-implementing->implement") &&
162
- flows.includes("Start->implement-task"),
162
+ flows.includes("slice-branch->implement-task"),
163
163
  `the run reaches the implement agent (via the implement cell) only after the gate (flows: ${flows.join(", ")})`,
164
164
  );
165
165
  // A green probe never escalates.
@@ -192,7 +192,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
192
192
  assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
193
193
  assert.ok(
194
194
  flows.includes("record-feature-implementing->implement") &&
195
- flows.includes("Start->implement-task"),
195
+ flows.includes("slice-branch->implement-task"),
196
196
  "an ungated feature reaches the implement agent (via the implement cell)",
197
197
  );
198
198
  } finally {
@@ -21,6 +21,7 @@ import { after, before, describe, test } from "node:test";
21
21
  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
+ import { recoverBranchCreate } from "../app/repoEnvelope.ts";
24
25
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
26
  import { advancePastTimer, settleFully } from "./support/time.ts";
26
27
 
@@ -127,32 +128,127 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
127
128
  const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
128
129
  const approveReview: Stub = () => ({ approved: true, findings: "" });
129
130
 
130
- test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
131
+ test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope AND the deterministic branch.create (#684/#945)", async () => {
131
132
  // The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
132
133
  // agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
133
134
  // rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
134
135
  // The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
135
- // `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
136
- let agentTask: any = null;
136
+ // `feat/<task.id>` differs per MI child, so it can't be named at seed time. Issue #945: the
137
+ // per-instance `slice-branch` worker (first step of each implement-cell) now stamps the deterministic `branch.create` (`feat/<task.id>`)
138
+ // onto the envelope as a FLAT dotted process variable (`io.nanobpm.agentTask.branch.create`), which
139
+ // the c8ctl harness deep-merges with the whole-object seed — so a retry re-derives the SAME branch
140
+ // and the durable WIP checkpoint restore matches (previously every retry cut a random
141
+ // `nano/agent-work/*` fallback and discarded the prior session's work).
142
+ let agentVars: Record<string, unknown> = {};
137
143
  await withApp(
138
144
  {
139
145
  "senior:plan": singleTaskPlan,
140
146
  "senior:plan-review": approveReview,
141
147
  "senior:feature": (job) => {
142
- agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
148
+ agentVars = job.variables as Record<string, unknown>;
143
149
  return { status: "blocked", summary: "n/a" };
144
150
  },
145
151
  },
146
152
  async ({ app }) => {
147
153
  await app.settle();
154
+ const agentTask = agentVars["io.nanobpm.agentTask"] as Record<string, any> | undefined;
148
155
  assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
149
156
  const agentRepo = agentTask.repository;
150
157
  assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
151
158
  assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
152
- // `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
153
- assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
154
159
  assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
155
160
  assert.equal(agentRepo.filter, "blob:none");
161
+ // The whole-object seed still omits `branch` (it can't name a per-child branch)…
162
+ assert.equal("branch" in agentTask, false, "the whole-object seed carries no per-slice branch");
163
+ // …but the per-instance slice-branch worker stamps it as a FLAT dotted variable, which the harness
164
+ // merges onto the SAME envelope. Assert the dotted variable is present and names the
165
+ // deterministic `feat/<task.id>` branch (the single task here is `t1`).
166
+ assert.equal(
167
+ agentVars["io.nanobpm.agentTask.branch.create"],
168
+ "feat/t1",
169
+ "slice-branch stamps the deterministic per-slice branch.create as a flat dotted variable (#945)",
170
+ );
171
+ // Round-trip through the harness normalizer shape: the merged envelope must recover
172
+ // `branch.create` from the top level (proving the dotted variable and the whole-object seed
173
+ // merge into one envelope the harness reads).
174
+ assert.equal(
175
+ recoverBranchCreate({}, agentVars),
176
+ "feat/t1",
177
+ "the harness merges the dotted branch.create with the whole-object seed envelope (#945)",
178
+ );
179
+ },
180
+ );
181
+ });
182
+
183
+ test("sibling isolation: two parallel slices each see their OWN branch.create, never a sibling's (#945)", async () => {
184
+ // A per-slice branch emitted into the SHARED plan-fanout MI body lands on the epic root, so a
185
+ // sibling's emission overwrites it and a slice can inherit `feat/<sibling>` — sending its work and
186
+ // checkpoint to the wrong branch. The emitter runs inside each implement-cell instance instead, so
187
+ // every activation's recovered branch must be `feat/<its own task.id>`.
188
+ const seen: Array<{ id: unknown; branch: string | undefined }> = [];
189
+ await withApp(
190
+ {
191
+ "senior:plan": () => ({
192
+ tasks: [
193
+ { id: "t1", title: "T1", prompt: "do t1" },
194
+ { id: "t2", title: "T2", prompt: "do t2" },
195
+ ],
196
+ }),
197
+ "senior:plan-review": approveReview,
198
+ "senior:feature": (job) => {
199
+ const vars = job.variables as Record<string, any>;
200
+ seen.push({ id: vars.task?.id, branch: recoverBranchCreate({}, vars) });
201
+ return { status: "blocked", summary: "n/a" };
202
+ },
203
+ },
204
+ async ({ app }) => {
205
+ await app.settle();
206
+ assert.deepEqual(
207
+ seen.map((s) => s.id).sort(),
208
+ ["t1", "t2"],
209
+ "both sibling slices activated their implement-task",
210
+ );
211
+ for (const s of seen) {
212
+ assert.equal(s.branch, `feat/${String(s.id)}`, `slice ${String(s.id)} must see its OWN branch.create`);
213
+ }
214
+ },
215
+ );
216
+ });
217
+
218
+ test("retry determinism: a re-dispatched implement-task names the SAME branch.create, so the WIP checkpoint restore matches (#945)", async () => {
219
+ // Issue #945's field failure: every c8ctl retry of an implement-cell slice cut a NEW random
220
+ // `nano/agent-work/*` fallback branch, so the durable WIP checkpoint (keyed on the deterministic
221
+ // `feat/<task.id>`) was NEVER restored — each retry started from the epic base and discarded the
222
+ // prior session's work. The fix makes `branch.create` a pure function of `task.id`, so a retry
223
+ // re-derives the SAME branch and the checkpoint restore matches. Drive the implement-task through
224
+ // TWO activations of the SAME slice (an escalation parks it; answering re-dispatches it — the same
225
+ // re-activation an idle-kill retry produces) and assert both carry the identical recovered
226
+ // `branch.create`.
227
+ const seenBranches: Array<string | undefined> = [];
228
+ await withApp(
229
+ {
230
+ "senior:plan": singleTaskPlan,
231
+ "senior:plan-review": approveReview,
232
+ "senior:feature": (job) => {
233
+ seenBranches.push(recoverBranchCreate({}, job.variables as Record<string, unknown>));
234
+ // First activation escalates (parks for a human); the resume (second activation) blocks.
235
+ return seenBranches.length === 1
236
+ ? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
237
+ : { status: "blocked", summary: "resumed after answer" };
238
+ },
239
+ },
240
+ async ({ app, processKey }) => {
241
+ // First activation parked on the human-escalation task; answer it to re-dispatch the SAME slice.
242
+ const task = await openTask(app, processKey, "escalation");
243
+ await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
244
+ await app.settle();
245
+ assert.equal(seenBranches.length, 2, "the implement-task was activated twice (initial + retry)");
246
+ assert.equal(seenBranches[0], "feat/t1", "the FIRST activation carries the deterministic branch.create");
247
+ assert.equal(
248
+ seenBranches[1],
249
+ seenBranches[0],
250
+ "the RETRY activation re-derives the SAME branch.create — the WIP checkpoint restore matches (#945)",
251
+ );
156
252
  },
157
253
  );
158
254
  });
@@ -532,7 +628,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
532
628
 
533
629
  const flows = takenFlows(app);
534
630
  assert.ok(
535
- flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("Start->implement-task"),
631
+ flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("slice-branch->implement-task"),
536
632
  `caps-resolved released the barrier into the implement cell (flows: ${flows.join(", ")})`,
537
633
  );
538
634
  assert.equal(
@@ -614,7 +710,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
614
710
  assert.ok(
615
711
  answered.includes("w_gw_answer->record-task-implementing") &&
616
712
  answered.includes("record-task-implementing->implement-cell-call") &&
617
- answered.includes("Start->implement-task"),
713
+ answered.includes("slice-branch->implement-task"),
618
714
  `answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${answered.join(", ")})`,
619
715
  );
620
716
  assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
@@ -668,7 +764,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
668
764
  assert.ok(
669
765
  entered.includes("w_gw_answer->record-task-implementing") &&
670
766
  entered.includes("record-task-implementing->implement-cell-call") &&
671
- entered.includes("Start->implement-task"),
767
+ entered.includes("slice-branch->implement-task"),
672
768
  `answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${entered.join(", ")})`,
673
769
  );
674
770
 
package/nano.app.json CHANGED
@@ -180,6 +180,10 @@
180
180
  "taskType": "pr.select-wave",
181
181
  "handler": "workers/select-wave/worker.ts"
182
182
  },
183
+ {
184
+ "taskType": "pr.slice-branch",
185
+ "handler": "workers/slice-branch/worker.ts"
186
+ },
183
187
  {
184
188
  "taskType": "pr.caps-prepare",
185
189
  "handler": "workers/caps-prepare/worker.ts"
package/openapi.yaml CHANGED
@@ -94,7 +94,15 @@ components:
94
94
  - autoResolveEscalations
95
95
  - merge
96
96
  - incident
97
+ - terminationReason
97
98
  properties:
99
+ terminationReason:
100
+ type: string
101
+ nullable: true
102
+ description: >-
103
+ Why the app (or an operator) last cancelled this PR's current loop instance (issue #943),
104
+ naming the successor/outcome; null when never. An `orphaned` status means the loop ended
105
+ with no recorded reason, successor, or escalation.
98
106
  prKey:
99
107
  type: string
100
108
  repo:
@@ -4357,6 +4365,51 @@ paths:
4357
4365
  application/json:
4358
4366
  schema:
4359
4367
  $ref: "#/components/schemas/ErrorBody"
4368
+ /actions/cancel-pr:
4369
+ post:
4370
+ operationId: cancelPrRun
4371
+ # x-mcp exclusion: the page-only twin of `cancelInstance` (the per-row Cancel button). Agents use
4372
+ # the guarded `/actions/cancel` door.
4373
+ x-mcp:
4374
+ exclude: true
4375
+ summary: "Page-facing record-consistent cancel (issue #943): identical to `cancelInstance` (shared implementation, records termination provenance) but unguarded, because the declarative page action cannot attach x-hook-secret (see operations/cancelPrRun.ts)."
4376
+ requestBody:
4377
+ required: true
4378
+ content:
4379
+ application/json:
4380
+ schema:
4381
+ $ref: "#/components/schemas/CancelInstanceIntent"
4382
+ responses:
4383
+ "200":
4384
+ description: The instance was terminated; its record derives/flips to `abandoned` and drops out of `listActivePrs`.
4385
+ content:
4386
+ application/json:
4387
+ schema:
4388
+ $ref: "#/components/schemas/CancelInstanceResult"
4389
+ "400":
4390
+ description: Missing/invalid `processInstanceKey`.
4391
+ content:
4392
+ application/json:
4393
+ schema:
4394
+ $ref: "#/components/schemas/ErrorBody"
4395
+ "404":
4396
+ description: "No tracked record owns the key (a clean no-op — the engine is never touched). Body is a CancelInstanceResult with `ok:false`, `state:\"gone\"`, `reconciled:0`."
4397
+ content:
4398
+ application/json:
4399
+ schema:
4400
+ $ref: "#/components/schemas/CancelInstanceResult"
4401
+ "502":
4402
+ description: "The cancel did not fully take: EITHER the engine did NOT stop the instance (the cancel was not committed; the run may still be live), OR the engine stopped it but the tracked record did not settle to a terminal edge (`ok:false`, `reconciled:0`) — a wedged record the caller must not read as done."
4403
+ content:
4404
+ application/json:
4405
+ schema:
4406
+ $ref: "#/components/schemas/CancelInstanceResult"
4407
+ "503":
4408
+ description: The app has no data source configured, so the cancel cannot be reconciled into the record.
4409
+ content:
4410
+ application/json:
4411
+ schema:
4412
+ $ref: "#/components/schemas/ErrorBody"
4360
4413
  /actions/incident/retry:
4361
4414
  post:
4362
4415
  operationId: retryIncident
@@ -293,3 +293,12 @@ test("record routing also covers PR/plan aggregates (a resolved pull_requests ke
293
293
  assertEquals(res.body.ok, true);
294
294
  assertEquals(res.body.reconciled, 1);
295
295
  });
296
+
297
+ test("PR#944: operator-cancel provenance is recorded even when the record fails to reconcile (502)", async () => {
298
+ const rows: any[] = [{ pr_key: "o/r#4", process_key: "400", status: "converging" }];
299
+ const { app } = makeRecordApp({ pull_requests: { rows, key: "pr_key" } }, { readBackState: "TERMINATED" });
300
+ const res = await call(app, { processInstanceKey: "400" });
301
+ assertEquals(res.status, 502, "the unreconciled record still surfaces as 502");
302
+ assertEquals(rows[0].termination_process_key, "400", "provenance is stamped once the engine confirms termination");
303
+ assert(typeof rows[0].termination_reason === "string" && rows[0].termination_reason.length > 0);
304
+ });
@@ -37,9 +37,12 @@
37
37
  // door, when NANO_PR_WEBHOOK_SECRET is set callers must present it via the x-hook-secret header —
38
38
  // mirroring `reconcileEngineState`/`agentCompleteEscalation`.
39
39
 
40
+ import type { AppApi } from "@nanobpm/urban";
40
41
  import { cancelInstanceReconciling } from "@nanobpm/urban";
41
42
  import { engineBackedBindings, resolveTrackedInstance } from "../app/instanceTracking.ts";
43
+ import { recordOperatorCancelProvenance } from "../app/service.ts";
42
44
  import { envVar } from "../app/version.ts";
45
+ import type { CancelInstanceResponse } from "../nano-generated/api-io.d.ts";
43
46
  import { defineOperation } from "../nano-generated/operations.ts";
44
47
 
45
48
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
@@ -49,6 +52,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
49
52
  app.log.warn("cancelInstance rejected: missing/invalid shared secret");
50
53
  return { status: 401, body: { error: "unauthorized" } };
51
54
  }
55
+ return cancelTrackedRun(app, body, "/actions/cancel");
56
+ });
57
+
58
+ /** The ONE record-consistent cancel implementation shared by every cancel door (this guarded API/MCP
59
+ * door and the unguarded page door `/actions/cancel-pr`, operations/cancelPrRun.ts), so both stamp
60
+ * termination provenance (issue #943, PR #944 review) and neither can drift. Auth stays per door. */
61
+ export async function cancelTrackedRun(
62
+ app: AppApi,
63
+ body: unknown,
64
+ door: string,
65
+ ): Promise<{ status: number; body: CancelInstanceResponse }> {
52
66
  if (!app.data) {
53
67
  app.log.warn("cancelInstance: no data source configured — cannot reconcile the record");
54
68
  return { status: 503, body: { error: "no data source configured" } };
@@ -58,7 +72,7 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
58
72
  // so a numeric JSON value would already have lost precision before it reached us — accepting it
59
73
  // (and coercing it back to a string) would silently cancel the wrong instance and also contradicts
60
74
  // the OpenAPI `string` contract. Reject a non-string with a 400 rather than coerce.
61
- const raw = body && typeof body === "object" ? body.processInstanceKey : undefined;
75
+ const raw = body && typeof body === "object" && "processInstanceKey" in body ? body.processInstanceKey : undefined;
62
76
  const processInstanceKey = typeof raw === "string" ? raw.trim() : "";
63
77
  if (!processInstanceKey) {
64
78
  return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
@@ -124,6 +138,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
124
138
  // delta lies. Re-read the resolved record and count it reconciled only when its ADR-0065
125
139
  // `derived_status` has LEFT its `activeStatuses` (become terminal → resubmittable). When we cannot
126
140
  // read the record (no default source), trust the primitive's own count.
141
+ // Termination provenance (issue #943): a deliberate operator cancel of a PR loop is NOT an orphan —
142
+ // stamp the reason (as soon as the ENGINE confirms termination, before
143
+ // the record re-read — a 502 on an unreconciled record must not lose it; PR #944 review) on the owning PR row so `/status` does not flag it `orphaned`. Best-effort.
144
+ if (canResolveRecord) {
145
+ try {
146
+ await recordOperatorCancelProvenance(app.data, processInstanceKey, door);
147
+ } catch (err) {
148
+ app.log.warn("cancelInstance: could not record termination reason", { processInstanceKey, error: String(err) });
149
+ }
150
+ }
151
+
127
152
  let reconciled = result.reconciled;
128
153
  let recordReconciled = true;
129
154
  if (canResolveRecord) {
@@ -163,4 +188,5 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
163
188
  reconciled,
164
189
  });
165
190
  return { status: 200, body: responseBody };
166
- });
191
+ }
192
+
@@ -0,0 +1,16 @@
1
+ // POST /app/api/actions/cancel-pr → operationId `cancelPrRun` (issue #943, PR #944 review).
2
+ //
3
+ // The page-facing twin of `cancelInstance` for the per-row Cancel button. The runtime's built-in
4
+ // `/app/actions/cancel` page action bypassed the app's termination-provenance write, so a deliberate
5
+ // UI cancel left an in-flight base status with no reason and `/status` reported it `orphaned`. Both
6
+ // doors now route through ONE implementation (`cancelTrackedRun`), which stamps provenance.
7
+ //
8
+ // Auth posture is preserved: like the built-in page action it replaces (and `retryIncident`), this
9
+ // door is INTENTIONALLY UNGUARDED by NANO_PR_WEBHOOK_SECRET — the `@nanobpm/urban` page runtime cannot
10
+ // attach x-hook-secret, so a guard would 401 every button press on a secured deployment. The guarded
11
+ // `/actions/cancel` stays the API/MCP door; this one is excluded from MCP (`x-mcp`).
12
+
13
+ import { defineOperation } from "../nano-generated/operations.ts";
14
+ import { cancelTrackedRun } from "./cancelInstance.ts";
15
+
16
+ export default defineOperation("cancelPrRun", async ({ body }, app) => cancelTrackedRun(app, body, "/actions/cancel-pr"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.208.2",
3
+ "version": "0.208.4",
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",
@@ -247,7 +247,7 @@
247
247
  "confirm": "Cancel this PR's review run?",
248
248
  "showWhenField": "process_key",
249
249
  "action": {
250
- "path": "/app/actions/cancel",
250
+ "path": "/app/api/actions/cancel-pr",
251
251
  "body": {
252
252
  "processInstanceKey": "{{row.process_key}}"
253
253
  }