@nanobpm/nano-workforce 0.206.3 → 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/CHANGELOG.md +12 -0
- package/SPEC.md +3 -1
- package/app/deliveryGraph.test.ts +1 -1
- package/app/deliveryGraph.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +49 -2
- package/app/deliveryGraphCompiler.ts +12 -1
- package/app/deliveryGraphReadModel.test.ts +4 -2
- package/app/deliveryGraphRun.ts +12 -0
- package/app/deliveryRunner.test.ts +31 -0
- package/app/deliveryRunner.ts +18 -1
- package/app/incidentStatus.test.ts +175 -0
- package/app/mcpExclusions.test.ts +4 -3
- package/app/mcpToolSurface.ts +11 -2
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +117 -0
- package/app/service.ts +244 -93
- package/build-info.json +4 -4
- package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
- package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
- package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
- package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
- package/docs/agent-guide.md +4 -1
- package/docs/mcp-runbook.md +2 -2
- package/e2e/delivery-graph.e2e.ts +60 -0
- package/openapi.yaml +238 -0
- package/operations/listIncidents.test.ts +70 -0
- package/operations/listIncidents.ts +78 -0
- package/operations/retryIncident.test.ts +280 -0
- package/operations/retryIncident.ts +201 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +16 -3
- package/pages/home.page.json +19 -0
- package/pages/overview.page.json +9 -0
package/docs/mcp-runbook.md
CHANGED
|
@@ -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
|
|
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
|
|
@@ -373,6 +373,66 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
373
373
|
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the satisfied producer's result reaches End");
|
|
374
374
|
});
|
|
375
375
|
|
|
376
|
+
test("#932 producer contract: an agent reporting status=completed with all required emits routes onward and NEVER reaches a human escalation", async () => {
|
|
377
|
+
const app = track(await boot(freshDir()));
|
|
378
|
+
|
|
379
|
+
// Issue #932's observed failure (nano-bpm#1337 → PR #1464 escalation 584376; nano-bpm#1463 →
|
|
380
|
+
// PR #1476 escalation 599960): the agent FULLY delivered — required `pr` emit present, work done,
|
|
381
|
+
// PR later merged — but self-reported the affirmative-completion alias `status: "completed"`,
|
|
382
|
+
// which the #731 gate (accepting only `done`/`opened`/`skipped`) treated as a contract violation
|
|
383
|
+
// and parked on a human "Continue" escalation. With the synonym allowlisted in the ONE canonical
|
|
384
|
+
// vocabulary, the same result must sail through the gate: no __contract task, the downstream
|
|
385
|
+
// connector fires, the graph reaches End.
|
|
386
|
+
let agentFired = 0;
|
|
387
|
+
await app.engine.registerWorker("senior:demo", async () => {
|
|
388
|
+
agentFired++;
|
|
389
|
+
return { status: "completed", pr: "owner/repo#100", summary: "Fully delivered; PR opened and green." };
|
|
390
|
+
});
|
|
391
|
+
let connectorFired = 0;
|
|
392
|
+
await app.engine.registerWorker(
|
|
393
|
+
"pr.delivery-connector",
|
|
394
|
+
async (job) => {
|
|
395
|
+
connectorFired++;
|
|
396
|
+
const vars = job.variables as Record<string, unknown>;
|
|
397
|
+
const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
|
|
398
|
+
const dedupeKey = connectorDedupeKey({
|
|
399
|
+
dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
|
|
400
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
401
|
+
elementId: job.elementId ?? null,
|
|
402
|
+
});
|
|
403
|
+
if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
|
|
404
|
+
return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
|
|
405
|
+
},
|
|
406
|
+
{ fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const graph: DeliveryGraph = {
|
|
410
|
+
name: "e2e producer gate completed synonym",
|
|
411
|
+
nodes: [
|
|
412
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
|
|
413
|
+
{ id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-932" } },
|
|
414
|
+
],
|
|
415
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const run = await runDeliveryGraph(app.engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
|
|
419
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
420
|
+
await app.settle();
|
|
421
|
+
|
|
422
|
+
// The `completed` report + all required emits is a terminal success: it routes ONWARD — no
|
|
423
|
+
// producer-contract escalation (and no escalation of any kind), the connector fired once on the
|
|
424
|
+
// real PR, and the graph reached End. A human is never in the loop.
|
|
425
|
+
assert.equal(agentFired, 1, "the agent node's job fired and completed");
|
|
426
|
+
const createdTasks = await app.engine.searchUserTasks({ state: "CREATED" });
|
|
427
|
+
assert.deepEqual(
|
|
428
|
+
createdTasks.map((t) => t.elementId),
|
|
429
|
+
[],
|
|
430
|
+
`a fully-delivered "completed" result must NEVER park a human task, got ${JSON.stringify(createdTasks.map((t) => t.elementId))}`,
|
|
431
|
+
);
|
|
432
|
+
assert.equal(connectorFired, 1, "the satisfied producer threads its result to the downstream connector");
|
|
433
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the completed producer's result reaches End");
|
|
434
|
+
});
|
|
435
|
+
|
|
376
436
|
test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
|
|
377
437
|
const app = track(await boot(freshDir()));
|
|
378
438
|
// The connector fired once above's-style; here prove the idempotency directly against the ledger a
|
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
|
+
});
|