@nanobpm/nano-workforce 0.206.4 → 0.207.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/openapi.yaml CHANGED
@@ -93,6 +93,7 @@ components:
93
93
  - leaseUntil
94
94
  - autoResolveEscalations
95
95
  - merge
96
+ - incident
96
97
  properties:
97
98
  prKey:
98
99
  type: string
@@ -107,6 +108,13 @@ components:
107
108
  nullable: true
108
109
  status:
109
110
  type: string
111
+ description: >-
112
+ The run's workflow status — `converging`, `waiting_review`, `escalated`, `waiting_deps`,
113
+ `waiting_merge`, `queued`, `merging` — OVERLAID with `incident` (issue #931) while the
114
+ run's instance is parked on an ACTIVE engine incident, so a dead-in-the-water loop can
115
+ never again read as a healthy `converging`. The overlay is derived at read time from the
116
+ mirrored `incident` block; the base workflow status resumes the moment the incident
117
+ clears.
110
118
  round:
111
119
  type: integer
112
120
  processKey:
@@ -189,6 +197,37 @@ components:
189
197
  nullable: true
190
198
  enum: ["env", "human-approval", null]
191
199
  description: Why a requested merge will not proceed; null when effective or converge-only.
200
+ incident:
201
+ type: object
202
+ nullable: true
203
+ additionalProperties: false
204
+ description: >-
205
+ The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the
206
+ poller's incident pass, else null. While set, `status` reads `incident`. `key` is the
207
+ engine incidentKey; `type` the errorType (e.g. `JOB_NO_RETRIES`); `message` the
208
+ errorMessage; `since` the incident's creationTime. The operator remedy is the
209
+ `retryIncident` action (the Convergence/Home grids' "Retry incident" row action).
210
+ required:
211
+ - key
212
+ - type
213
+ - message
214
+ - since
215
+ properties:
216
+ key:
217
+ type: string
218
+ description: The engine incidentKey of the ACTIVE incident.
219
+ type:
220
+ type: string
221
+ nullable: true
222
+ description: The incident's errorType (e.g. JOB_NO_RETRIES).
223
+ message:
224
+ type: string
225
+ nullable: true
226
+ description: The incident's errorMessage.
227
+ since:
228
+ type: string
229
+ nullable: true
230
+ description: The incident's creationTime (ISO).
192
231
  ActivePrList:
193
232
  type: object
194
233
  required:
@@ -243,6 +282,7 @@ components:
243
282
  - openEscalation
244
283
  - updatedAt
245
284
  - autoResolveEscalations
285
+ - incident
246
286
  properties:
247
287
  runKey:
248
288
  type: string
@@ -251,6 +291,10 @@ components:
251
291
  nullable: true
252
292
  status:
253
293
  type: string
294
+ description: >-
295
+ The run's workflow status (`running`, …) OVERLAID with `incident` (issue #931) while the
296
+ run's instance is parked on an ACTIVE engine incident — the same read-time overlay the
297
+ PR rows carry.
254
298
  phase:
255
299
  type: string
256
300
  nullable: true
@@ -285,6 +329,35 @@ components:
285
329
  Whether this delivery run opted into automatic escalation resolution by the escalation
286
330
  steward (issue #889). Derived from the durable `auto_resolve_escalations` flag, never a
287
331
  clobbered process variable.
332
+ incident:
333
+ type: object
334
+ nullable: true
335
+ additionalProperties: false
336
+ description: >-
337
+ The ACTIVE engine incident parking this run's instance (issue #931), mirrored by the
338
+ poller's incident pass, else null. While set, `status` reads `incident`. Same shape as
339
+ the PR rows' `incident` block.
340
+ required:
341
+ - key
342
+ - type
343
+ - message
344
+ - since
345
+ properties:
346
+ key:
347
+ type: string
348
+ description: The engine incidentKey of the ACTIVE incident.
349
+ type:
350
+ type: string
351
+ nullable: true
352
+ description: The incident's errorType (e.g. JOB_NO_RETRIES).
353
+ message:
354
+ type: string
355
+ nullable: true
356
+ description: The incident's errorMessage.
357
+ since:
358
+ type: string
359
+ nullable: true
360
+ description: The incident's creationTime (ISO).
288
361
  Escalation:
289
362
  type: object
290
363
  description: >-
@@ -351,6 +424,59 @@ components:
351
424
  The denormalised decision/form context the Tasks inbox renders for this task (the same
352
425
  question + subject the deployed form is seeded with). The typed answer fields an agent
353
426
  submits depend on `kind` (e.g. a PR `{ answer }`, a plan-review `{ directive, notes }`).
427
+ IncidentList:
428
+ type: object
429
+ additionalProperties: false
430
+ required: [count, incidents]
431
+ properties:
432
+ count:
433
+ type: integer
434
+ incidents:
435
+ type: array
436
+ items:
437
+ $ref: "#/components/schemas/IncidentEntry"
438
+ IncidentEntry:
439
+ type: object
440
+ additionalProperties: false
441
+ required: [kind, subjectKey, processInstanceKey, incidentKey, type, message, since, retry]
442
+ properties:
443
+ kind:
444
+ type: string
445
+ enum: [pr, delivery]
446
+ subjectKey:
447
+ type: string
448
+ description: The PR key (owner/repo#N) or delivery-graph run key.
449
+ processInstanceKey:
450
+ type: string
451
+ nullable: true
452
+ incidentKey:
453
+ type: string
454
+ type:
455
+ type: string
456
+ nullable: true
457
+ description: The engine errorType (e.g. JOB_NO_RETRIES).
458
+ message:
459
+ type: string
460
+ nullable: true
461
+ since:
462
+ type: string
463
+ nullable: true
464
+ description: The incident's creationTime.
465
+ retry:
466
+ type: object
467
+ additionalProperties: false
468
+ required: [path, body]
469
+ properties:
470
+ path:
471
+ type: string
472
+ body:
473
+ type: object
474
+ additionalProperties: false
475
+ required: [processInstanceKey]
476
+ properties:
477
+ processInstanceKey:
478
+ type: string
479
+ nullable: true
354
480
  EscalationList:
355
481
  type: object
356
482
  additionalProperties: false
@@ -4099,6 +4225,38 @@ components:
4099
4225
  error:
4100
4226
  type: string
4101
4227
  description: Present on a non-terminal failure — the reason the cancel did not take, the tracked record was not reconciled, or no tracked record owns the key.
4228
+ RetryIncidentIntent:
4229
+ type: object
4230
+ additionalProperties: false
4231
+ required:
4232
+ - processInstanceKey
4233
+ properties:
4234
+ processInstanceKey:
4235
+ type: string
4236
+ description: The engine process-instance key of the incident-parked run to retry (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.
4237
+ RetryIncidentResult:
4238
+ type: object
4239
+ additionalProperties: false
4240
+ required:
4241
+ - ok
4242
+ - processInstanceKey
4243
+ properties:
4244
+ ok:
4245
+ type: boolean
4246
+ description: True when the engine confirmed the incident resolution (the failed job was given a retry first, so a JOB_NO_RETRIES incident does not re-incident instantly) and the record row's mirrored incident columns were cleared. False ⇒ a 4xx/5xx body carrying the reason in `error`.
4247
+ processInstanceKey:
4248
+ type: string
4249
+ description: The instance key, echoed back.
4250
+ incidentKey:
4251
+ type: string
4252
+ description: The engine incidentKey that was resolved (the oldest ACTIVE incident on the instance — the same one the poller mirrors onto the record row).
4253
+ jobKey:
4254
+ type: string
4255
+ nullable: true
4256
+ description: The failed job whose retries were bumped to 1 before the resolution; null when the incident carried no job handle (a non-job fault — resolution alone unsticks it).
4257
+ error:
4258
+ type: string
4259
+ description: Present on a failure — the reason the retry did not take (no tracked record owns the key, no active incident on the instance, or the engine refused the retries bump / resolution).
4102
4260
  # ─────────────────────────────────────────────────────────────────────────────────────────────
4103
4261
  # MCP tool-schema convention (epic nano-workforce#605, S0 — the shared invariant every later slice
4104
4262
  # inherits). The Urban runtime projects THIS document into MCP tools (ADR 0067 — zero MCP server
@@ -4199,6 +4357,61 @@ paths:
4199
4357
  application/json:
4200
4358
  schema:
4201
4359
  $ref: "#/components/schemas/ErrorBody"
4360
+ /actions/incident/retry:
4361
+ post:
4362
+ operationId: retryIncident
4363
+ # x-mcp exclusion (ADR 0067 §2 / nano-ide#488): operator-only remediation door. Retrying an
4364
+ # incident-parked run is the human unstick action on the Convergence/Home grids (the incident
4365
+ # row's "Retry incident" button); agents diagnose through the read surface and escalate, they
4366
+ # do not force engine retries.
4367
+ x-mcp:
4368
+ exclude: true
4369
+ summary: "Retry an incident-parked run the app-owned way (issue #931): bump the failed job's retries AND resolve the ACTIVE engine incident on the run's instance, then clear the incident mirror off the record row. This is the operator remedy for a loop that shows `status: \"incident\"` — previously a hand-run engine-REST sequence (`PATCH /v2/jobs/<k> {changeset:{retries:1}}` + `POST /v2/incidents/<k>/resolution`)."
4370
+ # Intentionally NO hookSecret security: the declarative "Retry incident" page action cannot
4371
+ # attach x-hook-secret (see operations/retryIncident.ts).
4372
+ requestBody:
4373
+ required: true
4374
+ content:
4375
+ application/json:
4376
+ schema:
4377
+ $ref: "#/components/schemas/RetryIncidentIntent"
4378
+ responses:
4379
+ "200":
4380
+ description: The incident was resolved (the failed job was given a retry first) and the record row's incident mirror cleared — the `/status` row leaves `incident` immediately.
4381
+ content:
4382
+ application/json:
4383
+ schema:
4384
+ $ref: "#/components/schemas/RetryIncidentResult"
4385
+ "400":
4386
+ description: Missing/invalid `processInstanceKey`.
4387
+ content:
4388
+ application/json:
4389
+ schema:
4390
+ $ref: "#/components/schemas/ErrorBody"
4391
+ "404":
4392
+ description: No tracked record owns the key (a clean no-op — the engine is never touched).
4393
+ content:
4394
+ application/json:
4395
+ schema:
4396
+ $ref: "#/components/schemas/RetryIncidentResult"
4397
+ "409":
4398
+ description: The instance carries no ACTIVE incident — nothing to retry (it may already be resolved).
4399
+ content:
4400
+ application/json:
4401
+ schema:
4402
+ $ref: "#/components/schemas/RetryIncidentResult"
4403
+ "502":
4404
+ description: "The remedy did not fully take: the engine's incident search was unavailable, the job retries bump failed (the incident is left ACTIVE — never a half-applied remedy), or the resolution itself failed."
4405
+ content:
4406
+ application/json:
4407
+ schema:
4408
+ $ref: "#/components/schemas/RetryIncidentResult"
4409
+ "503":
4410
+ description: The app has no data source configured, so the record cannot be resolved.
4411
+ content:
4412
+ application/json:
4413
+ schema:
4414
+ $ref: "#/components/schemas/ErrorBody"
4202
4415
  /auto-resolutions:
4203
4416
  get:
4204
4417
  operationId: listAutoResolutions
@@ -4302,6 +4515,31 @@ paths:
4302
4515
  application/json:
4303
4516
  schema:
4304
4517
  $ref: "#/components/schemas/ErrorBody"
4518
+ /incidents:
4519
+ get:
4520
+ operationId: listIncidents
4521
+ summary: List every tracked run parked on an ACTIVE engine incident, oldest first.
4522
+ description: >-
4523
+ Sibling of `/escalations` for technical liveness faults (issue #931). Read-only projection
4524
+ over the incident mirror the poller writes onto PR and delivery-graph rows (the same source
4525
+ as the `/status` `incident` overlay). Each entry carries the operator remedy: POST
4526
+ `retry.path` (the `retryIncident` action) with `retry.body`.
4527
+ security:
4528
+ - hookSecret: []
4529
+ - {}
4530
+ responses:
4531
+ "200":
4532
+ description: The active incidents.
4533
+ content:
4534
+ application/json:
4535
+ schema:
4536
+ $ref: "#/components/schemas/IncidentList"
4537
+ "401":
4538
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
4539
+ content:
4540
+ application/json:
4541
+ schema:
4542
+ $ref: "#/components/schemas/ErrorBody"
4305
4543
  /escalations:
4306
4544
  get:
4307
4545
  operationId: listEscalations
@@ -0,0 +1,70 @@
1
+ // Tests for GET /app/api/incidents operation `listIncidents` (issue #931): the incident projection
2
+ // lists every mirrored ACTIVE incident (PR + delivery-graph rows) with its retry remedy, oldest first.
3
+ import { test } from "node:test";
4
+ import { assertEquals } from "#test-assert";
5
+ import type { AppApi } from "@nanobpm/urban";
6
+ import { noopLog } from "../test/log.ts";
7
+ import handler from "./listIncidents.ts";
8
+
9
+ function memApp(stores: Record<string, Record<string, unknown>[]>): AppApi {
10
+ const app = {
11
+ data: { table: (n: string) => ({ all: async () => [...(stores[n] ?? [])] }) },
12
+ log: noopLog(),
13
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
14
+ } as any as AppApi;
15
+ return app;
16
+ }
17
+
18
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
19
+ async function call(app: AppApi): Promise<any> {
20
+ // biome-ignore lint/suspicious/noExplicitAny: see above
21
+ return (await handler({ req: { headers: new Headers() } as any, params: {}, query: {}, body: undefined } as any, app)) as any;
22
+ }
23
+
24
+ test("listIncidents: lists PR + delivery incidents oldest-first with the retry remedy; skips healthy rows", async () => {
25
+ const app = memApp({
26
+ pull_requests: [
27
+ { pr_key: "o/r#1", process_key: "11", incident_key: null },
28
+ {
29
+ pr_key: "o/r#2",
30
+ process_key: "22",
31
+ incident_key: "i2",
32
+ incident_type: "JOB_NO_RETRIES",
33
+ incident_message: "idle",
34
+ incident_since: "2026-11-09T10:00:00Z",
35
+ },
36
+ ],
37
+ delivery_graph_runs: [
38
+ {
39
+ run_key: "run-a",
40
+ process_key: "33",
41
+ incident_key: "i3",
42
+ incident_type: "IO_MAPPING_ERROR",
43
+ incident_message: "boom",
44
+ incident_since: "2026-11-09T09:00:00Z",
45
+ },
46
+ ],
47
+ });
48
+ const res = await call(app);
49
+ assertEquals(res.status, 200);
50
+ assertEquals(res.body.count, 2);
51
+ assertEquals(
52
+ res.body.incidents.map((i: { kind: string; subjectKey: string }) => `${i.kind}:${i.subjectKey}`),
53
+ ["delivery:run-a", "pr:o/r#2"],
54
+ );
55
+ assertEquals(res.body.incidents[1], {
56
+ kind: "pr",
57
+ subjectKey: "o/r#2",
58
+ processInstanceKey: "22",
59
+ incidentKey: "i2",
60
+ type: "JOB_NO_RETRIES",
61
+ message: "idle",
62
+ since: "2026-11-09T10:00:00Z",
63
+ retry: { path: "/app/api/actions/incident/retry", body: { processInstanceKey: "22" } },
64
+ });
65
+ });
66
+
67
+ test("listIncidents: empty when nothing is parked", async () => {
68
+ const res = await call(memApp({ pull_requests: [{ pr_key: "x", incident_key: null }] }));
69
+ assertEquals(res.body, { count: 0, incidents: [] });
70
+ });
@@ -0,0 +1,78 @@
1
+ // GET /app/api/incidents → operationId `listIncidents` (issue #931). The sibling of `/escalations`
2
+ // for TECHNICAL liveness faults: every tracked run (PR convergence/merge loop, delivery-graph run)
3
+ // whose engine instance is parked on an ACTIVE incident, with the `processInstanceKey` the operator
4
+ // remedy `retryIncident` (POST /actions/incident/retry) takes.
5
+ //
6
+ // Read-only projection over the ONE incident mirror the poller's `pollIncidents` pass writes onto
7
+ // the record rows (`incident_key/type/message/since`) — NOT a second engine query, so it can never
8
+ // disagree with the `/status` rows' `incident` overlay. Oldest incident first (longest-dead first).
9
+ //
10
+ // The optional shared-secret guard stays HERE (mirrors `listEscalations`).
11
+ import type { DataLayer } from "@nanobpm/urban";
12
+ import { envVar } from "../app/version.ts";
13
+ import { defineOperation } from "../nano-generated/operations.ts";
14
+
15
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
16
+
17
+ export interface IncidentView {
18
+ kind: "pr" | "delivery";
19
+ subjectKey: string;
20
+ processInstanceKey: string | null;
21
+ incidentKey: string;
22
+ type: string | null;
23
+ message: string | null;
24
+ since: string | null;
25
+ retry: { path: string; body: { processInstanceKey: string | null } };
26
+ }
27
+
28
+ interface MirrorRow {
29
+ process_key?: string | null;
30
+ incident_key?: string | null;
31
+ incident_type?: string | null;
32
+ incident_message?: string | null;
33
+ incident_since?: string | null;
34
+ }
35
+
36
+ const RETRY_PATH = "/app/api/actions/incident/retry";
37
+
38
+ function toView(kind: IncidentView["kind"], subjectKey: string, r: MirrorRow): IncidentView | null {
39
+ if (!r.incident_key) return null;
40
+ const processInstanceKey = r.process_key ?? null;
41
+ return {
42
+ kind,
43
+ subjectKey,
44
+ processInstanceKey,
45
+ incidentKey: r.incident_key,
46
+ type: r.incident_type ?? null,
47
+ message: r.incident_message ?? null,
48
+ since: r.incident_since ?? null,
49
+ retry: { path: RETRY_PATH, body: { processInstanceKey } },
50
+ };
51
+ }
52
+
53
+ /** Every mirrored ACTIVE incident across the incident-watched aggregates, oldest first (a missing
54
+ * `since` sorts last). */
55
+ export async function activeIncidents(data: DataLayer): Promise<IncidentView[]> {
56
+ const out: IncidentView[] = [];
57
+ const prRows = await data.table<MirrorRow & { pr_key: string }>("pull_requests", "pr_key").all();
58
+ for (const r of prRows) {
59
+ const v = toView("pr", r.pr_key, r);
60
+ if (v) out.push(v);
61
+ }
62
+ const runRows = await data.table<MirrorRow & { run_key: string }>("delivery_graph_runs", "run_key").all();
63
+ for (const r of runRows) {
64
+ const v = toView("delivery", r.run_key, r);
65
+ if (v) out.push(v);
66
+ }
67
+ return out.sort((a, b) => (a.since ?? "\uffff").localeCompare(b.since ?? "\uffff"));
68
+ }
69
+
70
+ export default defineOperation("listIncidents", async ({ req }, app) => {
71
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
72
+ app.log.warn("listIncidents rejected: missing/invalid shared secret");
73
+ return { status: 401, body: { error: "unauthorized" } };
74
+ }
75
+ if (!app.data) return { status: 200, body: { count: 0, incidents: [] } };
76
+ const incidents = await activeIncidents(app.data);
77
+ return { status: 200, body: { count: incidents.length, incidents } };
78
+ });