@nanobpm/nano-workforce 0.206.4 → 0.208.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +18 -0
  3. package/app/deliveryGraphReadModel.test.ts +4 -2
  4. package/app/deliveryGraphRun.ts +12 -0
  5. package/app/github.ts +29 -0
  6. package/app/incidentStatus.test.ts +175 -0
  7. package/app/mcpExclusions.test.ts +4 -3
  8. package/app/mcpToolSurface.ts +11 -2
  9. package/app/mergeLoopBehaviour.test.ts +136 -1
  10. package/app/pullRequestReadModel.test.ts +1 -1
  11. package/app/service.test.ts +266 -0
  12. package/app/service.ts +311 -96
  13. package/build-info.json +4 -4
  14. package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
  15. package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
  16. package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
  17. package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
  18. package/docs/mcp-runbook.md +2 -2
  19. package/nano.app.json +8 -0
  20. package/openapi.yaml +238 -0
  21. package/operations/listIncidents.test.ts +70 -0
  22. package/operations/listIncidents.ts +78 -0
  23. package/operations/retryIncident.test.ts +280 -0
  24. package/operations/retryIncident.ts +201 -0
  25. package/package.json +1 -1
  26. package/pages/delivery-graphs.page.json +16 -3
  27. package/pages/home.page.json +19 -0
  28. package/pages/overview.page.json +9 -0
  29. package/resources/processes/merge-loop.bpmn +401 -213
  30. package/workers/merge/worker.test.ts +30 -9
  31. package/workers/merge/worker.ts +23 -12
  32. package/workers/reconverge/worker.test.ts +110 -0
  33. package/workers/reconverge/worker.ts +81 -0
  34. package/workers/request-review/worker.test.ts +54 -0
  35. package/workers/request-review/worker.ts +68 -0
@@ -0,0 +1,58 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the base column added by
2
+ -- 126_pull_requests_incident_type_since.sql (`incident_type`/`incident_since`). The read-model VIEW must pass
3
+ -- through EVERY base `pull_requests` column (the static pages↔schema contract guard +
4
+ -- app/pullRequestReadModel.test.ts DRIFT GUARD assert it), so adding base columns obliges a fresh
5
+ -- VIEW definition — 094/104/118/122 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`) — the
9
+ -- same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot diverge.
10
+ -- This file is a mechanical re-emission of 122 with the two new base pass-throughs added; the
11
+ -- derived `list_bucket`/`ack_open` expressions are unchanged. The drift guard now points at THIS
12
+ -- migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed `pull_requests__tracking`
15
+ -- re-export of the base table so a terminated PR classifies on ENGINE TRUTH. SQLite does not validate
16
+ -- a view body at CREATE time. The runner wraps each file in its own transaction, so this file must
17
+ -- NOT contain BEGIN/COMMIT. Numbered after 127.
18
+
19
+ DROP VIEW IF EXISTS pull_requests_read_model;
20
+
21
+ CREATE VIEW pull_requests_read_model AS
22
+ SELECT
23
+ pr.pr_key AS pr_key,
24
+ pr.repo AS repo,
25
+ pr.number AS number,
26
+ pr.url AS url,
27
+ pr.title AS title,
28
+ COALESCE(pr.derived_status, pr.status) AS status,
29
+ pr.current_round AS current_round,
30
+ pr.process_key AS process_key,
31
+ pr.waiting_since AS waiting_since,
32
+ pr.last_review_id AS last_review_id,
33
+ pr.outcome AS outcome,
34
+ pr.created_at AS created_at,
35
+ pr.updated_at AS updated_at,
36
+ pr.converged_at AS converged_at,
37
+ pr.merged_at AS merged_at,
38
+ pr.active_worker AS active_worker,
39
+ pr.lease_until AS lease_until,
40
+ pr.last_nudge_at AS last_nudge_at,
41
+ pr.fresh_head_run_head AS fresh_head_run_head,
42
+ pr.abandon_token AS abandon_token,
43
+ pr.incident_key AS incident_key,
44
+ pr.incident_type AS incident_type,
45
+ pr.incident_since AS incident_since,
46
+ pr.incident_message AS incident_message,
47
+ pr.last_round_head AS last_round_head,
48
+ pr.last_progress_job_key AS last_progress_job_key,
49
+ pr.last_progress_result AS last_progress_result,
50
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
51
+ pr.churn_escalated_round AS churn_escalated_round,
52
+ pr.root_request_key AS root_request_key,
53
+ pr.epic_phase_label AS epic_phase_label,
54
+ pr.acknowledged_at AS acknowledged_at,
55
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
56
+ 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,
57
+ 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
58
+ FROM pull_requests__tracking pr;
@@ -0,0 +1,58 @@
1
+ -- Re-create the `delivery_graph_read_model` VIEW so it re-exports the base column added by
2
+ -- 127_delivery_graph_run_incidents.sql (the four `incident_*` mirror columns). The read-model VIEW must pass
3
+ -- through EVERY base `delivery_graph_runs` column (the static pages↔schema contract guard +
4
+ -- app/deliveryGraphReadModel.test.ts DRIFT GUARD assert it), so adding base columns obliges a fresh
5
+ -- VIEW definition — 087/096/121 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/deliveryGraphReadModel.ts (`deliveryGraphReadModel.sqlSelectFor(col, { baseAlias: "dg" })`) —
9
+ -- the same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot
10
+ -- diverge. This file is a mechanical re-emission of 121 with the four new base pass-throughs added;
11
+ -- the derived `stage`/`stage_state`/`list_bucket`/`ack_open`/`park_label` expressions are unchanged.
12
+ -- The drift guard now points at THIS migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed
15
+ -- `delivery_graph_runs__tracking` re-export of the base table so a terminated run classifies on
16
+ -- ENGINE TRUTH. The runner wraps each file in its own transaction, so this file must NOT contain
17
+ -- BEGIN/COMMIT. Numbered after 128.
18
+
19
+ DROP VIEW IF EXISTS delivery_graph_read_model;
20
+ DROP VIEW IF EXISTS delivery_graph_pr_counts;
21
+
22
+ CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
23
+ SELECT
24
+ "__urban_rollup_src"."root_request_key" AS "root_request_key",
25
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
26
+ FROM "pull_requests__tracking" "__urban_rollup_src"
27
+ GROUP BY "__urban_rollup_src"."root_request_key";
28
+
29
+ CREATE VIEW delivery_graph_read_model AS
30
+ SELECT
31
+ dg.run_key AS run_key,
32
+ COALESCE(dg.derived_status, dg.status) AS status,
33
+ dg.process_key AS process_key,
34
+ dg.process_definition_id AS process_definition_id,
35
+ dg.digest AS digest,
36
+ dg.side_effecting AS side_effecting,
37
+ dg.node_count AS node_count,
38
+ dg.human_node_count AS human_node_count,
39
+ dg.side_effect_count AS side_effect_count,
40
+ dg.title AS title,
41
+ dg.phase AS phase,
42
+ dg.phase_node_id AS phase_node_id,
43
+ dg.human_labels AS human_labels,
44
+ dg.created_at AS created_at,
45
+ dg.updated_at AS updated_at,
46
+ dg.acknowledged_at AS acknowledged_at,
47
+ dg.auto_resolve_escalations AS auto_resolve_escalations,
48
+ dg.incident_key AS incident_key,
49
+ dg.incident_type AS incident_type,
50
+ dg.incident_message AS incident_message,
51
+ dg.incident_since AS incident_since,
52
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
53
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
54
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
55
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
56
+ CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
57
+ FROM delivery_graph_runs__tracking dg
58
+ LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
@@ -214,8 +214,8 @@ acceptable. When it **is set**, the guard is not mutation-only: it also covers
214
214
  reads, so read endpoints like `GET /app/api/agent` and `GET /app/api/version`
215
215
  return `401` without the `x-hook-secret` header, just as guarded mutations do. It
216
216
  is not blanket, though — a few doors stay intentionally unguarded even when the
217
- secret is set (e.g. the declarative Save-to-library page action, which
218
- structurally cannot attach the header). Put it in the server entry's
217
+ secret is set (e.g. the declarative Save-to-library and Retry-incident page actions,
218
+ which structurally cannot attach the header). Put it in the server entry's
219
219
  `headers`, never in chat. For a remote fleet,
220
220
  `NANO_WORKFORCE_BASE_URL` reachability rules apply unchanged. The rest of the app's
221
221
  HTTP surface follows the `network.bind` manifest setting, but the runtime-served
package/nano.app.json CHANGED
@@ -144,6 +144,14 @@
144
144
  "taskType": "pr.merge",
145
145
  "handler": "workers/merge/worker.ts"
146
146
  },
147
+ {
148
+ "taskType": "pr.request-review",
149
+ "handler": "workers/request-review/worker.ts"
150
+ },
151
+ {
152
+ "taskType": "pr.reconverge",
153
+ "handler": "workers/reconverge/worker.ts"
154
+ },
147
155
  {
148
156
  "taskType": "pr.mark-merged",
149
157
  "handler": "workers/mark-merged/worker.ts"
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
+ });