@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/CHANGELOG.md +6 -0
- package/app/deliveryGraphReadModel.test.ts +4 -2
- package/app/deliveryGraphRun.ts +12 -0
- 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/mcp-runbook.md +2 -2
- 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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Tests for POST /app/api/actions/incident/retry — operationId `retryIncident` (issue #931).
|
|
2
|
+
//
|
|
3
|
+
// The app-owned, RECORD-AWARE incident-retry door. Before it, unsticking an incident-parked loop
|
|
4
|
+
// meant hand-calling engine REST — `PATCH /v2/jobs/<k> {changeset:{retries:1}}` + `POST
|
|
5
|
+
// /v2/incidents/<k>/resolution` — with no app door at all (the two observed incidents on merlin sat
|
|
6
|
+
// dead for 1.3h / 5h partly because nothing surfaced them and nothing offered the retry). This
|
|
7
|
+
// delegate wraps exactly that two-call remedy in one operator action:
|
|
8
|
+
// 1. resolve the ACTIVE incident on the tracked record's instance (404 when no tracked record
|
|
9
|
+
// owns the key — we never retry an instance we don't track; 409 when the instance carries no
|
|
10
|
+
// active incident — nothing to retry),
|
|
11
|
+
// 2. bump the failed job's retries (a JOB_NO_RETRIES incident parks until the job has retries
|
|
12
|
+
// again) and resolve the incident,
|
|
13
|
+
// 3. clear the mirrored incident columns off the record row immediately, so the `/status` row
|
|
14
|
+
// leaves `incident` on the action's return instead of waiting out a poll tick.
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { assert, assertEquals } from "#test-assert";
|
|
17
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
18
|
+
import { noopLog } from "../test/log.ts";
|
|
19
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
20
|
+
|
|
21
|
+
/** The engine base every test here targets — set BEFORE the delegate module is (lazily) imported,
|
|
22
|
+
* since the engine REST address is resolved at module load. */
|
|
23
|
+
const ENGINE = "http://engine.test/v2";
|
|
24
|
+
process.env["CAMUNDA_REST_ADDRESS"] = ENGINE;
|
|
25
|
+
/** A shared secret IS configured for every test here: the door is reached by the declarative
|
|
26
|
+
* "Retry incident" page action, which cannot attach `x-hook-secret`, so the door must stay
|
|
27
|
+
* reachable without it (PR #938 review) — every header-less call below proves it does. */
|
|
28
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cret";
|
|
29
|
+
|
|
30
|
+
const { default: handler } = await import("./retryIncident.ts");
|
|
31
|
+
|
|
32
|
+
interface EngineCall {
|
|
33
|
+
method: string;
|
|
34
|
+
url: string;
|
|
35
|
+
body?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A fetch stub serving the engine's Camunda-8 v2 REST surface for one instance: the ACTIVE
|
|
39
|
+
* incident search, the job retries PATCH, and the incident resolution POST. */
|
|
40
|
+
function engineFetch(
|
|
41
|
+
byInstance: Record<string, unknown[]>,
|
|
42
|
+
calls: EngineCall[],
|
|
43
|
+
opts: { failPatch?: boolean; failResolve?: boolean } = {},
|
|
44
|
+
) {
|
|
45
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
46
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
47
|
+
const json = (body: unknown, status = 200) =>
|
|
48
|
+
status === 204
|
|
49
|
+
? new Response(null, { status: 204 })
|
|
50
|
+
: new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
51
|
+
if (u.endsWith("/incidents/search")) {
|
|
52
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { filter?: { processInstanceKey?: string } };
|
|
53
|
+
calls.push({ method: "POST", url: u, body });
|
|
54
|
+
return Promise.resolve(json({ items: byInstance[body.filter?.processInstanceKey ?? ""] ?? [] }));
|
|
55
|
+
}
|
|
56
|
+
const jobPatch = u.match(/\/jobs\/([^/]+)$/);
|
|
57
|
+
if (jobPatch && init?.method === "PATCH") {
|
|
58
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
59
|
+
calls.push({ method: "PATCH", url: u, body });
|
|
60
|
+
return Promise.resolve(opts.failPatch ? json({ detail: "no such job" }, 404) : json({}, 204));
|
|
61
|
+
}
|
|
62
|
+
const resolution = u.match(/\/incidents\/([^/]+)\/resolution$/);
|
|
63
|
+
if (resolution) {
|
|
64
|
+
calls.push({ method: "POST", url: u });
|
|
65
|
+
return Promise.resolve(opts.failResolve ? json({ detail: "engine unhappy" }, 500) : json({}, 204));
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`unexpected fetch: ${init?.method ?? "GET"} ${u}`);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function memTable(rows: any[], key: string) {
|
|
72
|
+
return {
|
|
73
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
74
|
+
all: () => Promise.resolve([...rows]),
|
|
75
|
+
find: (q: any) =>
|
|
76
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
77
|
+
findOne: (q: any) =>
|
|
78
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
79
|
+
insert: (r: any) => {
|
|
80
|
+
rows.push(r);
|
|
81
|
+
return Promise.resolve(r);
|
|
82
|
+
},
|
|
83
|
+
update: (k: any, patch: any) => {
|
|
84
|
+
const r = rows.find((x) => x[key] === k);
|
|
85
|
+
if (r) Object.assign(r, patch);
|
|
86
|
+
return Promise.resolve(r);
|
|
87
|
+
},
|
|
88
|
+
delete: (k: any) => {
|
|
89
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function prRow(over: Record<string, unknown> = {}) {
|
|
96
|
+
return {
|
|
97
|
+
pr_key: "owner/repo#1",
|
|
98
|
+
repo: "owner/repo",
|
|
99
|
+
number: 1,
|
|
100
|
+
url: "https://github.com/owner/repo/pull/1",
|
|
101
|
+
title: "t",
|
|
102
|
+
status: "converging",
|
|
103
|
+
current_round: 1,
|
|
104
|
+
process_key: "PI-1",
|
|
105
|
+
incident_key: "598235",
|
|
106
|
+
incident_type: "JOB_NO_RETRIES",
|
|
107
|
+
incident_message: 'agent "nano-coder" failed: idle',
|
|
108
|
+
incident_since: "2026-11-09T01:00:00.000Z",
|
|
109
|
+
updated_at: "2024-01-01",
|
|
110
|
+
...over,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function memApp(stores: Record<string, { rows: any[]; key: string }>): AppApi {
|
|
115
|
+
return {
|
|
116
|
+
data: {
|
|
117
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
118
|
+
hasDefaultSource: () => true,
|
|
119
|
+
},
|
|
120
|
+
log: noopLog(),
|
|
121
|
+
} as unknown as AppApi;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only invocation shim for the delegate.
|
|
125
|
+
async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}): Promise<any> {
|
|
126
|
+
const req = { method: "POST", path: "/app/api/actions/incident/retry", headers: new Headers(headers) };
|
|
127
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only invocation shim for the delegate.
|
|
128
|
+
return await handler({ req: req as any, params: {}, query: {}, body } as any, app);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ACTIVE_INCIDENT = {
|
|
132
|
+
incidentKey: "598235",
|
|
133
|
+
errorType: "JOB_NO_RETRIES",
|
|
134
|
+
errorMessage: 'agent "nano-coder" failed: idle',
|
|
135
|
+
state: "ACTIVE",
|
|
136
|
+
creationTime: "2026-11-09T01:00:00.000Z",
|
|
137
|
+
jobKey: "595995",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
test("a missing/non-string processInstanceKey → 400 and never touches the engine", async () => {
|
|
141
|
+
const calls: EngineCall[] = [];
|
|
142
|
+
const prevFetch = globalThis.fetch;
|
|
143
|
+
globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls) as typeof fetch;
|
|
144
|
+
const app = memApp({ pull_requests: { rows: [prRow()], key: "pr_key" } });
|
|
145
|
+
try {
|
|
146
|
+
assertEquals((await call(app, {})).status, 400);
|
|
147
|
+
assertEquals((await call(app, { processInstanceKey: " " })).status, 400);
|
|
148
|
+
assertEquals((await call(app, { processInstanceKey: 2985 })).status, 400);
|
|
149
|
+
assertEquals((await call(app, undefined)).status, 400);
|
|
150
|
+
} finally {
|
|
151
|
+
globalThis.fetch = prevFetch;
|
|
152
|
+
}
|
|
153
|
+
assertEquals(calls.length, 0, "no engine call without a valid key");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a key no tracked record owns → 404 and never touches the engine", async () => {
|
|
157
|
+
const calls: EngineCall[] = [];
|
|
158
|
+
const prevFetch = globalThis.fetch;
|
|
159
|
+
globalThis.fetch = engineFetch({}, calls) as typeof fetch;
|
|
160
|
+
const app = memApp({ pull_requests: { rows: [prRow()], key: "pr_key" } });
|
|
161
|
+
try {
|
|
162
|
+
const res = await call(app, { processInstanceKey: "PI-UNKNOWN" });
|
|
163
|
+
assertEquals(res.status, 404);
|
|
164
|
+
assertEquals(res.body.ok, false);
|
|
165
|
+
} finally {
|
|
166
|
+
globalThis.fetch = prevFetch;
|
|
167
|
+
}
|
|
168
|
+
assertEquals(calls.length, 0, "an untracked key never reaches the engine");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("an instance with no ACTIVE incident → 409 (nothing to retry), no mutation", async () => {
|
|
172
|
+
const calls: EngineCall[] = [];
|
|
173
|
+
const prevFetch = globalThis.fetch;
|
|
174
|
+
globalThis.fetch = engineFetch({ "PI-1": [] }, calls) as typeof fetch;
|
|
175
|
+
const row = prRow({ incident_key: null, incident_type: null, incident_message: null, incident_since: null });
|
|
176
|
+
const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
|
|
177
|
+
try {
|
|
178
|
+
const res = await call(app, { processInstanceKey: "PI-1" });
|
|
179
|
+
assertEquals(res.status, 409);
|
|
180
|
+
assertEquals(res.body.ok, false);
|
|
181
|
+
} finally {
|
|
182
|
+
globalThis.fetch = prevFetch;
|
|
183
|
+
}
|
|
184
|
+
assertEquals(calls.length, 1, "only the incident search ran");
|
|
185
|
+
assertEquals(calls[0].url.endsWith("/incidents/search"), true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("the happy path: bumps the failed job's retries, resolves the incident, and clears the mirrored row", async () => {
|
|
189
|
+
const calls: EngineCall[] = [];
|
|
190
|
+
const prevFetch = globalThis.fetch;
|
|
191
|
+
globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls) as typeof fetch;
|
|
192
|
+
const row = prRow();
|
|
193
|
+
const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
|
|
194
|
+
try {
|
|
195
|
+
const res = await call(app, { processInstanceKey: "PI-1" });
|
|
196
|
+
assertEquals(res.status, 200);
|
|
197
|
+
assertEquals(res.body.ok, true);
|
|
198
|
+
assertEquals(res.body.processInstanceKey, "PI-1");
|
|
199
|
+
assertEquals(res.body.incidentKey, "598235");
|
|
200
|
+
assertEquals(res.body.jobKey, "595995");
|
|
201
|
+
} finally {
|
|
202
|
+
globalThis.fetch = prevFetch;
|
|
203
|
+
}
|
|
204
|
+
// Exactly the manual remedy, in order: search → PATCH retries → POST resolution.
|
|
205
|
+
assertEquals(calls.map((c) => `${c.method} ${c.url}`), [
|
|
206
|
+
"POST http://engine.test/v2/incidents/search",
|
|
207
|
+
"PATCH http://engine.test/v2/jobs/595995",
|
|
208
|
+
"POST http://engine.test/v2/incidents/598235/resolution",
|
|
209
|
+
]);
|
|
210
|
+
assertEquals(calls[1].body, { changeset: { retries: 1 } });
|
|
211
|
+
// The mirrored row columns clear immediately — the /status row leaves `incident` on return.
|
|
212
|
+
assertEquals(row.incident_key, null);
|
|
213
|
+
assertEquals(row.incident_type, null);
|
|
214
|
+
assertEquals(row.incident_message, null);
|
|
215
|
+
assertEquals(row.incident_since, null);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a retries-bump failure → 502 and the incident is NOT resolved (no half-applied remedy)", async () => {
|
|
219
|
+
const calls: EngineCall[] = [];
|
|
220
|
+
const prevFetch = globalThis.fetch;
|
|
221
|
+
globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls, { failPatch: true }) as typeof fetch;
|
|
222
|
+
const row = prRow();
|
|
223
|
+
const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
|
|
224
|
+
try {
|
|
225
|
+
const res = await call(app, { processInstanceKey: "PI-1" });
|
|
226
|
+
assertEquals(res.status, 502);
|
|
227
|
+
assertEquals(res.body.ok, false);
|
|
228
|
+
} finally {
|
|
229
|
+
globalThis.fetch = prevFetch;
|
|
230
|
+
}
|
|
231
|
+
assertEquals(
|
|
232
|
+
calls.some((c) => c.url.endsWith("/resolution")),
|
|
233
|
+
false,
|
|
234
|
+
"the incident is left ACTIVE when the job could not be given retries",
|
|
235
|
+
);
|
|
236
|
+
// The row keeps its incident mirror — the incident is still real.
|
|
237
|
+
assertEquals(row.incident_key, "598235");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("a resolution failure after a successful retries bump → 502, row keeps the mirror (the next poll reconciles)", async () => {
|
|
241
|
+
const calls: EngineCall[] = [];
|
|
242
|
+
const prevFetch = globalThis.fetch;
|
|
243
|
+
globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls, { failResolve: true }) as typeof fetch;
|
|
244
|
+
const row = prRow();
|
|
245
|
+
const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
|
|
246
|
+
try {
|
|
247
|
+
const res = await call(app, { processInstanceKey: "PI-1" });
|
|
248
|
+
assertEquals(res.status, 502);
|
|
249
|
+
assertEquals(res.body.ok, false);
|
|
250
|
+
} finally {
|
|
251
|
+
globalThis.fetch = prevFetch;
|
|
252
|
+
}
|
|
253
|
+
assertEquals(row.incident_key, "598235", "the mirror stays until the engine confirms resolution");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("a delivery-graph run's incident retries through the same door", async () => {
|
|
257
|
+
const calls: EngineCall[] = [];
|
|
258
|
+
const prevFetch = globalThis.fetch;
|
|
259
|
+
globalThis.fetch = engineFetch({ "PI-DG-1": [ACTIVE_INCIDENT] }, calls) as typeof fetch;
|
|
260
|
+
const run = {
|
|
261
|
+
run_key: "dg-1",
|
|
262
|
+
status: "running",
|
|
263
|
+
process_key: "PI-DG-1",
|
|
264
|
+
incident_key: "598235",
|
|
265
|
+
incident_type: "JOB_NO_RETRIES",
|
|
266
|
+
incident_message: "delivery node job exhausted retries",
|
|
267
|
+
incident_since: "2026-11-09T03:00:00.000Z",
|
|
268
|
+
updated_at: "t0",
|
|
269
|
+
};
|
|
270
|
+
const app = memApp({ delivery_graph_runs: { rows: [run], key: "run_key" } });
|
|
271
|
+
try {
|
|
272
|
+
const res = await call(app, { processInstanceKey: "PI-DG-1" });
|
|
273
|
+
assertEquals(res.status, 200);
|
|
274
|
+
assertEquals(res.body.ok, true);
|
|
275
|
+
} finally {
|
|
276
|
+
globalThis.fetch = prevFetch;
|
|
277
|
+
}
|
|
278
|
+
assertEquals(run.incident_key, null);
|
|
279
|
+
assertEquals(run.incident_since, null);
|
|
280
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// POST /app/api/actions/incident/retry → operationId `retryIncident` (issue #931).
|
|
2
|
+
//
|
|
3
|
+
// The app-owned, RECORD-AWARE incident-retry door. A convergence/merge/delivery instance parked on
|
|
4
|
+
// an engine incident used to need a hand-run engine-REST remedy — `PATCH /v2/jobs/<k>
|
|
5
|
+
// {changeset:{retries:1}}` to give the failed job a retry, then `POST /v2/incidents/<k>/resolution`
|
|
6
|
+
// — which is exactly what the two merlin incidents (nano-bpm#1452 loop 576515, nano-workforce#930
|
|
7
|
+
// loop 594789) sat waiting on for hours. This delegate wraps that two-call remedy in ONE operator
|
|
8
|
+
// action the UI's per-row "Retry incident" button and an operator/agent can call:
|
|
9
|
+
//
|
|
10
|
+
// 1. Resolve the tracked record owning `processInstanceKey` (the SAME `resolveTrackedInstance`
|
|
11
|
+
// the cancel door uses, issue #705) — a key no tracked record owns is a clean 404 no-op; we
|
|
12
|
+
// never retry an instance we don't track.
|
|
13
|
+
// 2. Read the instance's OLDEST ACTIVE incident off the engine (`fetchActiveIncident`, the SAME
|
|
14
|
+
// reader the poller's `pollIncidents` pass mirrors from, so the door and the mirror can never
|
|
15
|
+
// disagree about WHICH incident is being retried). No active incident → 409 (nothing to
|
|
16
|
+
// retry — it may already be resolved).
|
|
17
|
+
// 3. Bump the failed job's retries (a `JOB_NO_RETRIES` incident parks until the job can be
|
|
18
|
+
// retried) and resolve the incident. A failed bump aborts BEFORE the resolution — resolving
|
|
19
|
+
// without retries would re-incident instantly, a half-applied remedy.
|
|
20
|
+
// 4. Clear the mirrored `incident_*` columns off the record row immediately, so the `/status`
|
|
21
|
+
// row leaves `incident` on the action's return rather than waiting out a poll tick (the next
|
|
22
|
+
// `pollIncidents` pass re-mirrors if the engine still reports an active incident — the row
|
|
23
|
+
// can never lie for more than a tick).
|
|
24
|
+
//
|
|
25
|
+
// The runtime validates the body against openapi.yaml (`processInstanceKey` required).
|
|
26
|
+
//
|
|
27
|
+
// This door is INTENTIONALLY UNGUARDED by the optional NANO_PR_WEBHOOK_SECRET / x-hook-secret
|
|
28
|
+
// header (PR #938 review). Its primary caller is the declarative per-row "Retry incident" page
|
|
29
|
+
// action (home / overview / delivery-graphs), and the `@nanobpm/urban` page runtime structurally
|
|
30
|
+
// cannot attach a custom header — a guard here would 401 every button press on a secured
|
|
31
|
+
// deployment, making the one-click remedy dead exactly where it is needed (the same reasoning as
|
|
32
|
+
// `saveToLibrary`). The posture matches the runtime's built-in `/app/actions/cancel` page action the
|
|
33
|
+
// same rows already expose, which is a strictly MORE destructive, equally unguarded UI mutation.
|
|
34
|
+
// The blast radius is bounded by construction: only an instance a tracked record owns (404
|
|
35
|
+
// otherwise) with an ACTIVE engine incident (409 otherwise) is touched, and the only effect is the
|
|
36
|
+
// retry the incident is already waiting for. The door remains operator-only: it is excluded from
|
|
37
|
+
// the projected MCP tool surface (`x-mcp`, see app/mcpExclusions.test.ts).
|
|
38
|
+
|
|
39
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
40
|
+
import { resolveEngineAddress } from "../app/enginePreflight.ts";
|
|
41
|
+
import { resolveTrackedInstance } from "../app/instanceTracking.ts";
|
|
42
|
+
import { fetchActiveIncident, type IncidentSearchItem } from "../app/service.ts";
|
|
43
|
+
import { envVar } from "../app/version.ts";
|
|
44
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
45
|
+
|
|
46
|
+
// The engine REST base + auth the remedy calls run against, resolved ONCE at module load exactly as
|
|
47
|
+
// main.ts wires the poller (`resolveEngineAddress` — CAMUNDA_REST_ADDRESS wins, else
|
|
48
|
+
// NANOBPMN_BASE_URL + /v2 — and the CAMUNDA_TOKEN bearer).
|
|
49
|
+
const ENGINE_BASE = resolveEngineAddress().restAddress.replace(/\/+$/, "");
|
|
50
|
+
const ENGINE_HEADERS: Record<string, string> = { "content-type": "application/json" };
|
|
51
|
+
{
|
|
52
|
+
const token = envVar("CAMUNDA_TOKEN");
|
|
53
|
+
if (token) ENGINE_HEADERS.authorization = `Bearer ${token}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The mirrored incident columns cleared off the record row after a confirmed resolution — the
|
|
57
|
+
* same all-four null write `pollIncidents`' clearing path makes, so the two can never drift. */
|
|
58
|
+
const INCIDENT_CLEARED = {
|
|
59
|
+
incident_key: null,
|
|
60
|
+
incident_type: null,
|
|
61
|
+
incident_message: null,
|
|
62
|
+
incident_since: null,
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export default defineOperation("retryIncident", async ({ body }, app) => {
|
|
66
|
+
if (!app.data) {
|
|
67
|
+
app.log.warn("retryIncident: no data source configured — cannot resolve the record");
|
|
68
|
+
return { status: 503, body: { error: "no data source configured" } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Require a STRING key (mirrors cancelInstance): engine keys are 64-bit and a numeric JSON value
|
|
72
|
+
// would already have lost precision before it reached us — reject rather than coerce.
|
|
73
|
+
const raw = body && typeof body === "object" ? body.processInstanceKey : undefined;
|
|
74
|
+
const processInstanceKey = typeof raw === "string" ? raw.trim() : "";
|
|
75
|
+
if (!processInstanceKey) {
|
|
76
|
+
return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Resolve which tracked aggregate (PR / delivery run / …) this key belongs to BEFORE touching the
|
|
80
|
+
// engine. A key that maps to NO tracked record is a clean 404 no-op — we never retry an instance
|
|
81
|
+
// we don't track (mirrors cancelInstance, issue #705).
|
|
82
|
+
const record = await resolveTrackedInstance(app.data, processInstanceKey);
|
|
83
|
+
if (!record) {
|
|
84
|
+
app.log.warn("retryIncident: no tracked record for processInstanceKey — no-op", { processInstanceKey });
|
|
85
|
+
return {
|
|
86
|
+
status: 404,
|
|
87
|
+
body: { ok: false, processInstanceKey, error: "no tracked record for processInstanceKey" },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Read the instance's OLDEST ACTIVE incident off the engine — the SAME reader the poller mirrors
|
|
92
|
+
// from, so the door retries exactly the incident the row shows. `undefined` = transport unhappy →
|
|
93
|
+
// 502 (unknown, retry the action); `null` = no active incident → 409 (nothing to retry).
|
|
94
|
+
let active: IncidentSearchItem | null | undefined;
|
|
95
|
+
try {
|
|
96
|
+
active = await fetchActiveIncident(ENGINE_BASE, ENGINE_HEADERS, processInstanceKey);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
app.log.warn("retryIncident: incident search failed", { processInstanceKey, error: String(err) });
|
|
99
|
+
return { status: 502, body: { ok: false, processInstanceKey, error: `incident search failed: ${String(err)}` } };
|
|
100
|
+
}
|
|
101
|
+
if (active === undefined) {
|
|
102
|
+
return { status: 502, body: { ok: false, processInstanceKey, error: "engine incident search unavailable" } };
|
|
103
|
+
}
|
|
104
|
+
if (!active?.incidentKey) {
|
|
105
|
+
return { status: 409, body: { ok: false, processInstanceKey, error: "no active incident on this instance" } };
|
|
106
|
+
}
|
|
107
|
+
const incidentKey = active.incidentKey;
|
|
108
|
+
const jobKey = active.jobKey ?? null;
|
|
109
|
+
|
|
110
|
+
// The remedy, in order: give the failed job a retry, THEN resolve the incident. A failed bump
|
|
111
|
+
// aborts before the resolution — resolving a JOB_NO_RETRIES incident without retries would
|
|
112
|
+
// re-incident instantly, a half-applied remedy that reads as success.
|
|
113
|
+
if (jobKey) {
|
|
114
|
+
const patch = await fetch(`${ENGINE_BASE}/jobs/${jobKey}`, {
|
|
115
|
+
method: "PATCH",
|
|
116
|
+
headers: ENGINE_HEADERS,
|
|
117
|
+
body: JSON.stringify({ changeset: { retries: 1 } }),
|
|
118
|
+
});
|
|
119
|
+
if (!patch.ok) {
|
|
120
|
+
app.log.warn("retryIncident: job retries bump failed", { processInstanceKey, jobKey, status: patch.status });
|
|
121
|
+
return {
|
|
122
|
+
status: 502,
|
|
123
|
+
body: {
|
|
124
|
+
ok: false,
|
|
125
|
+
processInstanceKey,
|
|
126
|
+
incidentKey,
|
|
127
|
+
jobKey,
|
|
128
|
+
error: `job retries bump failed (engine ${patch.status}) — incident left ACTIVE`,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const resolution = await fetch(`${ENGINE_BASE}/incidents/${incidentKey}/resolution`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: ENGINE_HEADERS,
|
|
136
|
+
});
|
|
137
|
+
if (!resolution.ok) {
|
|
138
|
+
app.log.warn("retryIncident: incident resolution failed", { processInstanceKey, incidentKey, status: resolution.status });
|
|
139
|
+
return {
|
|
140
|
+
status: 502,
|
|
141
|
+
body: {
|
|
142
|
+
ok: false,
|
|
143
|
+
processInstanceKey,
|
|
144
|
+
incidentKey,
|
|
145
|
+
jobKey,
|
|
146
|
+
error: `incident resolution failed (engine ${resolution.status}) — the mirror stays until the engine confirms`,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The engine confirmed the resolution: clear the mirrored columns off the record row NOW so the
|
|
152
|
+
// `/status` row leaves `incident` on this action's return. The next pollIncidents pass re-mirrors
|
|
153
|
+
// if the engine still reports an active incident, so this can never paper over a real one. The
|
|
154
|
+
// record row is the one `resolveTrackedInstance` matched on the tracking VIEW — re-read it off
|
|
155
|
+
// the base table by the SAME instance key, then update by the table's primary key.
|
|
156
|
+
const cleared = await clearMirroredIncident(app.data, record.table, processInstanceKey);
|
|
157
|
+
if (!cleared) {
|
|
158
|
+
app.log.warn("retryIncident: resolved but the mirrored row could not be cleared (the next poll reconciles)", {
|
|
159
|
+
processInstanceKey,
|
|
160
|
+
table: record.table,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
app.log.info("retryIncident: incident resolved and job retried", { processInstanceKey, incidentKey, jobKey });
|
|
165
|
+
return { status: 200, body: { ok: true, processInstanceKey, incidentKey, jobKey } };
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** Clear the four mirrored incident columns off the tracked record owning `processInstanceKey`.
|
|
169
|
+
* Only the incident-watched aggregates carry the columns (PRs since #94/#931, delivery-graph runs
|
|
170
|
+
* since #931); any other tracked table has no mirror to clear and is a no-op. Returns false when
|
|
171
|
+
* the row could not be found/updated (the next poll reconciles regardless). */
|
|
172
|
+
async function clearMirroredIncident(
|
|
173
|
+
data: DataLayer,
|
|
174
|
+
table: string,
|
|
175
|
+
processInstanceKey: string,
|
|
176
|
+
): Promise<boolean> {
|
|
177
|
+
const pkField = incidentMirrorPrimaryKeyFor(table);
|
|
178
|
+
if (!pkField) return true; // not an incident-watched aggregate — nothing to clear
|
|
179
|
+
const rows = await data.table<Record<string, unknown>>(table, pkField).find({ process_key: processInstanceKey });
|
|
180
|
+
const row = rows[0];
|
|
181
|
+
if (!row) return false;
|
|
182
|
+
const pk = row[pkField];
|
|
183
|
+
if (typeof pk !== "string" && typeof pk !== "number") return false;
|
|
184
|
+
await data.table(table, pkField).update(pk, { ...INCIDENT_CLEARED, updated_at: new Date().toISOString() });
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The primary-key field of an incident-watched aggregate, or null for a tracked table that carries
|
|
189
|
+
* no incident mirror (plans, feature_runs, plan_conformance, delivery_units — nothing to clear
|
|
190
|
+
* there). Keyed off the SAME table name `resolveTrackedInstance` resolved, so the write can never
|
|
191
|
+
* drift from the tracked aggregate's identity column. */
|
|
192
|
+
function incidentMirrorPrimaryKeyFor(table: string): string | null {
|
|
193
|
+
switch (table) {
|
|
194
|
+
case "pull_requests":
|
|
195
|
+
return "pr_key";
|
|
196
|
+
case "delivery_graph_runs":
|
|
197
|
+
return "run_key";
|
|
198
|
+
default:
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.207.0",
|
|
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",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
{ "label": "All", "filter": [] }
|
|
150
150
|
],
|
|
151
151
|
"columns": [
|
|
152
|
-
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "
|
|
152
|
+
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "13%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
153
153
|
{ "field": "status", "header": "Status", "truncate": true, "width": "10%", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
154
154
|
{ "field": "process_key", "header": "Agent", "template": "Transcript", "truncate": true, "width": "5%", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
155
155
|
{ "field": "process_key", "header": "Instance", "width": "8%", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
@@ -170,10 +170,11 @@
|
|
|
170
170
|
"stateField": "stage_state",
|
|
171
171
|
"locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
|
|
172
172
|
},
|
|
173
|
-
{ "field": "park_label", "header": "Parked", "truncate": true, "width": "
|
|
173
|
+
{ "field": "park_label", "header": "Parked", "truncate": true, "width": "9%" },
|
|
174
174
|
{ "field": "node_count", "header": "Nodes", "width": "5%" },
|
|
175
175
|
{ "field": "human_node_count", "header": "Human", "width": "5%" },
|
|
176
176
|
{ "field": "side_effect_count", "header": "Effects", "width": "6%" },
|
|
177
|
+
{ "field": "incident_message", "header": "Incident", "truncate": true, "width": "6%", "badge": { "tone": "danger", "label": "1" } },
|
|
177
178
|
{ "field": "created_at", "header": "Dispatched", "width": "7%", "truncate": true, "format": "datetime" },
|
|
178
179
|
{ "field": "updated_at", "header": "Updated", "width": "6%", "truncate": true, "format": "datetime" }
|
|
179
180
|
],
|
|
@@ -181,10 +182,22 @@
|
|
|
181
182
|
"fields": [
|
|
182
183
|
{ "field": "process_definition_id", "label": "Definition" },
|
|
183
184
|
{ "field": "digest", "label": "Digest" },
|
|
184
|
-
{ "field": "phase_node_id", "label": "Parked node" }
|
|
185
|
+
{ "field": "phase_node_id", "label": "Parked node" },
|
|
186
|
+
{ "field": "incident_message", "label": "Incident" },
|
|
187
|
+
{ "field": "incident_type", "label": "Incident type" },
|
|
188
|
+
{ "field": "incident_since", "label": "Incident since" }
|
|
185
189
|
]
|
|
186
190
|
},
|
|
187
191
|
"rowActions": [
|
|
192
|
+
{
|
|
193
|
+
"label": "Retry incident",
|
|
194
|
+
"confirm": "Retry the engine incident parking this delivery run? This gives the failed job a retry and resolves the incident.",
|
|
195
|
+
"showWhenField": "incident_key",
|
|
196
|
+
"action": {
|
|
197
|
+
"path": "/app/api/actions/incident/retry",
|
|
198
|
+
"body": { "processInstanceKey": "{{row.process_key}}" }
|
|
199
|
+
}
|
|
200
|
+
},
|
|
188
201
|
{
|
|
189
202
|
"label": "Save to library",
|
|
190
203
|
"confirm": "Save this dispatched delivery graph to the reusable library? Its stored graph is copied into the Library (source: from-dispatched) so it can be reused later.",
|
package/pages/home.page.json
CHANGED
|
@@ -231,6 +231,17 @@
|
|
|
231
231
|
}
|
|
232
232
|
],
|
|
233
233
|
"rowActions": [
|
|
234
|
+
{
|
|
235
|
+
"label": "Retry incident",
|
|
236
|
+
"confirm": "Retry the engine incident parking this PR's run? This gives the failed job a retry and resolves the incident.",
|
|
237
|
+
"showWhenField": "incident_key",
|
|
238
|
+
"action": {
|
|
239
|
+
"path": "/app/api/actions/incident/retry",
|
|
240
|
+
"body": {
|
|
241
|
+
"processInstanceKey": "{{row.process_key}}"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
},
|
|
234
245
|
{
|
|
235
246
|
"label": "Cancel",
|
|
236
247
|
"confirm": "Cancel this PR's review run?",
|
|
@@ -281,6 +292,14 @@
|
|
|
281
292
|
"field": "incident_key",
|
|
282
293
|
"label": "Incident key"
|
|
283
294
|
},
|
|
295
|
+
{
|
|
296
|
+
"field": "incident_type",
|
|
297
|
+
"label": "Incident type"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"field": "incident_since",
|
|
301
|
+
"label": "Incident since"
|
|
302
|
+
},
|
|
284
303
|
{
|
|
285
304
|
"field": "merged_at",
|
|
286
305
|
"label": "Merged at"
|
package/pages/overview.page.json
CHANGED
|
@@ -135,6 +135,15 @@
|
|
|
135
135
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
136
136
|
],
|
|
137
137
|
"rowActions": [
|
|
138
|
+
{
|
|
139
|
+
"label": "Retry incident",
|
|
140
|
+
"confirm": "Retry the engine incident parking this PR's run? This gives the failed job a retry and resolves the incident.",
|
|
141
|
+
"showWhenField": "incident_key",
|
|
142
|
+
"action": {
|
|
143
|
+
"path": "/app/api/actions/incident/retry",
|
|
144
|
+
"body": { "processInstanceKey": "{{row.process_key}}" }
|
|
145
|
+
}
|
|
146
|
+
},
|
|
138
147
|
{
|
|
139
148
|
"label": "Dismiss",
|
|
140
149
|
"confirm": "Dismiss this finished PR? It acknowledges the terminal pull request and files it under History.",
|