@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
package/app/service.ts
CHANGED
|
@@ -388,13 +388,16 @@ export interface PullRequest {
|
|
|
388
388
|
// running agent curls (GET /app/api/hooks/abandon?token=…) to learn whether this run was cancelled before
|
|
389
389
|
// it performs a side effect. Minted at submit, reused across the convergence + merge instances.
|
|
390
390
|
abandon_token: string | null;
|
|
391
|
-
// Technical-incident surfacing (017_pr_incident.sql, issue #94), written
|
|
392
|
-
// `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE
|
|
393
|
-
// this PR's instance
|
|
394
|
-
//
|
|
395
|
-
//
|
|
391
|
+
// Technical-incident surfacing (017_pr_incident.sql, issue #94; enriched 126, issue #931), written
|
|
392
|
+
// by the poller's `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE
|
|
393
|
+
// incident parking this PR's instance, `incident_type` its errorType, `incident_message` its
|
|
394
|
+
// errorMessage and `incident_since` its creationTime; all NULL when the instance has no active
|
|
395
|
+
// incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
|
|
396
|
+
// workflow stage. `/status` derives the `incident` overlay from these columns (see `activePrs`).
|
|
396
397
|
incident_key: string | null;
|
|
398
|
+
incident_type: string | null;
|
|
397
399
|
incident_message: string | null;
|
|
400
|
+
incident_since: string | null;
|
|
398
401
|
// Lineage projection (037_lineage.sql, issue #245): the stable ORIGIN identity (the issue =
|
|
399
402
|
// feature_key / plan_key) threaded onto this PR by `submitPr`, and passed as a `createInstance`
|
|
400
403
|
// variable onto the convergence + merge instances so every descendant carries the root. For a
|
|
@@ -1057,6 +1060,40 @@ export interface MergePolicy {
|
|
|
1057
1060
|
blockedBy: "env" | "human-approval" | null;
|
|
1058
1061
|
}
|
|
1059
1062
|
|
|
1063
|
+
/** The status `/status` reports for a run whose instance is parked on an ACTIVE engine incident
|
|
1064
|
+
* (issue #931). Derived at READ time from the mirrored `incident_*` columns — the base `status`
|
|
1065
|
+
* workflow transient is never overwritten, so the overlay can never wedge the status machine and
|
|
1066
|
+
* lifts the moment the incident clears. */
|
|
1067
|
+
export const INCIDENT_STATUS = "incident";
|
|
1068
|
+
|
|
1069
|
+
/** The incident block every `/status` row carries (issue #931): the ACTIVE engine incident the
|
|
1070
|
+
* poller mirrored onto the record, else null. `key` is the engine incidentKey (the handle the
|
|
1071
|
+
* `retryIncident` action resolves), `type` the errorType (e.g. `JOB_NO_RETRIES`), `message` the
|
|
1072
|
+
* errorMessage, `since` the incident's creationTime. */
|
|
1073
|
+
export interface StatusIncident {
|
|
1074
|
+
key: string;
|
|
1075
|
+
type: string | null;
|
|
1076
|
+
message: string | null;
|
|
1077
|
+
since: string | null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Derive the `/status` incident block from a row's mirrored incident columns (issue #931). Null
|
|
1081
|
+
* when the row carries no active incident. */
|
|
1082
|
+
function statusIncidentFor(row: {
|
|
1083
|
+
incident_key: string | null;
|
|
1084
|
+
incident_type: string | null;
|
|
1085
|
+
incident_message: string | null;
|
|
1086
|
+
incident_since: string | null;
|
|
1087
|
+
}): StatusIncident | null {
|
|
1088
|
+
if (!row.incident_key) return null;
|
|
1089
|
+
return {
|
|
1090
|
+
key: row.incident_key,
|
|
1091
|
+
type: row.incident_type ?? null,
|
|
1092
|
+
message: row.incident_message ?? null,
|
|
1093
|
+
since: row.incident_since ?? null,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1060
1097
|
/** A PR currently in flight, as reported by the status endpoint. */
|
|
1061
1098
|
export interface ActivePr {
|
|
1062
1099
|
prKey: string;
|
|
@@ -1081,6 +1118,9 @@ export interface ActivePr {
|
|
|
1081
1118
|
/** The effective merge policy (issue #916): what the run intends (`requested`), what the
|
|
1082
1119
|
* deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
|
|
1083
1120
|
merge: MergePolicy;
|
|
1121
|
+
/** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
|
|
1122
|
+
* `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
|
|
1123
|
+
incident: StatusIncident | null;
|
|
1084
1124
|
}
|
|
1085
1125
|
|
|
1086
1126
|
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
@@ -1135,23 +1175,30 @@ export async function activePrs(
|
|
|
1135
1175
|
mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
|
|
1136
1176
|
}),
|
|
1137
1177
|
);
|
|
1138
|
-
return active.map((p) =>
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1178
|
+
return active.map((p) => {
|
|
1179
|
+
// Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
|
|
1180
|
+
// kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
|
|
1181
|
+
// (the base `status` column is never touched) and carry the mirrored incident block.
|
|
1182
|
+
const incident = statusIncidentFor(p);
|
|
1183
|
+
return {
|
|
1184
|
+
prKey: p.pr_key,
|
|
1185
|
+
repo: p.repo,
|
|
1186
|
+
number: p.number,
|
|
1187
|
+
url: p.url,
|
|
1188
|
+
title: p.title ?? null,
|
|
1189
|
+
status: incident ? INCIDENT_STATUS : p.status,
|
|
1190
|
+
round: p.current_round,
|
|
1191
|
+
processKey: p.process_key ?? null,
|
|
1192
|
+
waitingSince: p.waiting_since ?? null,
|
|
1193
|
+
openEscalation: openEscByPr.get(p.pr_key) ?? null,
|
|
1194
|
+
updatedAt: p.updated_at,
|
|
1195
|
+
activeWorker: p.active_worker ?? null,
|
|
1196
|
+
leaseUntil: p.lease_until ?? null,
|
|
1197
|
+
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1198
|
+
merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
|
|
1199
|
+
incident,
|
|
1200
|
+
};
|
|
1201
|
+
});
|
|
1155
1202
|
}
|
|
1156
1203
|
|
|
1157
1204
|
/** The default merge policy when no instance variables are readable (mid-handoff row, engine
|
|
@@ -1225,6 +1272,9 @@ export interface ActiveDelivery {
|
|
|
1225
1272
|
* eligible for steward auto-resolution. Derived from the durable `auto_resolve_escalations` flag,
|
|
1226
1273
|
* never a clobbered process variable. Shown in `/status` per delivery. */
|
|
1227
1274
|
autoResolveEscalations: boolean;
|
|
1275
|
+
/** The ACTIVE engine incident parking this run's instance (issue #931), mirrored by the poller's
|
|
1276
|
+
* `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
|
|
1277
|
+
incident: StatusIncident | null;
|
|
1228
1278
|
}
|
|
1229
1279
|
|
|
1230
1280
|
/** Every delivery-graph run not in a terminal state (done/failed/abandoned), newest-updated first.
|
|
@@ -1248,16 +1298,22 @@ export async function activeDeliveries(data: DataLayer): Promise<ActiveDelivery[
|
|
|
1248
1298
|
if (openEscByRun.has(t.subject_key)) continue;
|
|
1249
1299
|
openEscByRun.set(t.subject_key, toOpenEscalation(t));
|
|
1250
1300
|
}
|
|
1251
|
-
return active.map((r) =>
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1301
|
+
return active.map((r) => {
|
|
1302
|
+
// Issue #931: same incident overlay as the PR rows — a delivery run parked on an engine
|
|
1303
|
+
// incident must not keep reading `running`.
|
|
1304
|
+
const incident = statusIncidentFor(r);
|
|
1305
|
+
return {
|
|
1306
|
+
runKey: r.run_key,
|
|
1307
|
+
title: r.title ?? null,
|
|
1308
|
+
status: incident ? INCIDENT_STATUS : r.status,
|
|
1309
|
+
phase: r.phase ?? null,
|
|
1310
|
+
processKey: r.process_key ?? null,
|
|
1311
|
+
openEscalation: openEscByRun.get(r.run_key) ?? null,
|
|
1312
|
+
updatedAt: r.updated_at,
|
|
1313
|
+
autoResolveEscalations: r.auto_resolve_escalations === 1,
|
|
1314
|
+
incident,
|
|
1315
|
+
};
|
|
1316
|
+
});
|
|
1261
1317
|
}
|
|
1262
1318
|
|
|
1263
1319
|
/** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
|
|
@@ -2351,30 +2407,88 @@ export async function pollJobActivationImpl(
|
|
|
2351
2407
|
}
|
|
2352
2408
|
|
|
2353
2409
|
/** The subset of a Camunda-8 `/v2/incidents/search` result item this app reads. `incidentKey` is
|
|
2354
|
-
* the unique incident id; `
|
|
2355
|
-
*
|
|
2356
|
-
* concurrent incidents
|
|
2357
|
-
|
|
2410
|
+
* the unique incident id; `errorType` the machine fault class (e.g. `JOB_NO_RETRIES`);
|
|
2411
|
+
* `errorMessage` is the human-readable fault; `state` is the incident lifecycle (`ACTIVE` while it
|
|
2412
|
+
* parks the token, `RESOLVED` once cleared); `creationTime` orders concurrent incidents and is
|
|
2413
|
+
* surfaced as the incident's `since`. */
|
|
2414
|
+
export interface IncidentSearchItem {
|
|
2358
2415
|
incidentKey?: string;
|
|
2416
|
+
errorType?: string | null;
|
|
2359
2417
|
errorMessage?: string | null;
|
|
2360
2418
|
state?: string;
|
|
2361
2419
|
creationTime?: string | null;
|
|
2420
|
+
/** The job the incident sits on (a `JOB_NO_RETRIES` incident parks until that job carries
|
|
2421
|
+
* retries again) — the handle the `retryIncident` action bumps. */
|
|
2422
|
+
jobKey?: string;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/** The four mirrored incident columns every incident-watched aggregate carries (PRs since #94/#931,
|
|
2426
|
+
* delivery-graph runs since #931). All-or-nothing: a row with no ACTIVE incident reads NULL on all
|
|
2427
|
+
* four. */
|
|
2428
|
+
interface IncidentMirror {
|
|
2429
|
+
incident_key: string | null;
|
|
2430
|
+
incident_type: string | null;
|
|
2431
|
+
incident_message: string | null;
|
|
2432
|
+
incident_since: string | null;
|
|
2362
2433
|
}
|
|
2363
2434
|
|
|
2364
|
-
/**
|
|
2365
|
-
*
|
|
2366
|
-
*
|
|
2367
|
-
*
|
|
2435
|
+
/** Pick the ACTIVE incident to mirror for one instance: the OLDEST active one (the first thing that
|
|
2436
|
+
* broke — a stable choice if the instance somehow parks more than one). Re-filters on state
|
|
2437
|
+
* defensively in case the wire filter is ignored. An incident with no `creationTime` sorts *last*,
|
|
2438
|
+
* so a missing timestamp can never masquerade as the oldest. Returns null when none is active. */
|
|
2439
|
+
export function oldestActiveIncident(items: IncidentSearchItem[] | undefined): IncidentSearchItem | null {
|
|
2440
|
+
return (
|
|
2441
|
+
(items ?? [])
|
|
2442
|
+
.filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
|
|
2443
|
+
.sort((a, b) => (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff"))[0] ?? null
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/** Query the engine for the OLDEST ACTIVE incident on one process instance. Returns the incident,
|
|
2448
|
+
* `null` when the instance has no active incident, or `undefined` when the transport failed (the
|
|
2449
|
+
* caller keeps the last-known mirror and retries next pass — a failed query must never clear a real
|
|
2450
|
+
* incident nor invent one). */
|
|
2451
|
+
export async function fetchActiveIncident(
|
|
2452
|
+
base: string,
|
|
2453
|
+
headers: Record<string, string>,
|
|
2454
|
+
processInstanceKey: string,
|
|
2455
|
+
): Promise<IncidentSearchItem | null | undefined> {
|
|
2456
|
+
try {
|
|
2457
|
+
const res = await fetch(`${base}/incidents/search`, {
|
|
2458
|
+
method: "POST",
|
|
2459
|
+
headers,
|
|
2460
|
+
body: JSON.stringify({
|
|
2461
|
+
filter: { processInstanceKey, state: "ACTIVE" },
|
|
2462
|
+
page: { limit: 20 },
|
|
2463
|
+
}),
|
|
2464
|
+
});
|
|
2465
|
+
if (!res.ok) return undefined; // engine unhappy → unknown, retry next pass
|
|
2466
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
2467
|
+
const body = (await res.json()) as { items?: IncidentSearchItem[] };
|
|
2468
|
+
return oldestActiveIncident(body.items);
|
|
2469
|
+
} catch {
|
|
2470
|
+
return undefined;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** Incident-surfacing poll pass (issue #94; enriched and extended to delivery-graph runs in issue
|
|
2475
|
+
* #931). A convergence, merge, or delivery-graph process instance can hit a *technical* incident —
|
|
2476
|
+
* an unhandled engine error that parks the token — and nothing on the record row reflected it: the
|
|
2477
|
+
* grid kept showing the last workflow status (`converging`, `merging`, `running`, …) while the run
|
|
2478
|
+
* was actually dead in the water (a PR sat "converging" all day on an incident; on merlin two loops
|
|
2479
|
+
* sat dead for 1.3h/5h before anyone found them via a hand-run engine query).
|
|
2368
2480
|
*
|
|
2369
|
-
* This pass reads the engine's Camunda-8 `/v2/incidents/search` for each
|
|
2370
|
-
* instance (has a `process_key`, non-terminal status) and mirrors an ACTIVE
|
|
2371
|
-
* orthogonal columns — `incident_key`
|
|
2372
|
-
* incident is a cross-cutting liveness fault, not
|
|
2373
|
-
*
|
|
2374
|
-
*
|
|
2375
|
-
*
|
|
2376
|
-
*
|
|
2377
|
-
*
|
|
2481
|
+
* This pass reads the engine's Camunda-8 `/v2/incidents/search` for each tracked record that still
|
|
2482
|
+
* has a live instance (has a `process_key`, non-terminal derived status) and mirrors an ACTIVE
|
|
2483
|
+
* incident onto four orthogonal columns — `incident_key`, `incident_type`, `incident_message`,
|
|
2484
|
+
* `incident_since` — leaving `status` untouched. An incident is a cross-cutting liveness fault, not
|
|
2485
|
+
* a workflow stage, so it must not overload the status machine (`/status` derives the `incident`
|
|
2486
|
+
* overlay at READ time — see `activePrs`/`activeDeliveries`). Clearing is idempotent: when the
|
|
2487
|
+
* instance has no active incident (resolved, or never had one) the columns are nulled, so an
|
|
2488
|
+
* incident raised or resolved out-of-band converges to the truth on the next pass. Best-effort
|
|
2489
|
+
* transport: a failed query leaves the last-known values untouched and the next pass retries.
|
|
2490
|
+
* Updates (and bumps `updated_at`) only on an actual change so a steady state doesn't churn the
|
|
2491
|
+
* grid. */
|
|
2378
2492
|
async function pollIncidents(
|
|
2379
2493
|
data: DataLayer,
|
|
2380
2494
|
restAddress: string,
|
|
@@ -2387,12 +2501,24 @@ async function pollIncidents(
|
|
|
2387
2501
|
}
|
|
2388
2502
|
|
|
2389
2503
|
/** Testable core of {@link pollIncidents}: given the normalised `base` URL and prepared auth
|
|
2390
|
-
* `headers`, reconcile every
|
|
2391
|
-
* exercise the reconciliation with a stubbed
|
|
2504
|
+
* `headers`, reconcile every incident-watched record row (PRs and delivery-graph runs) against the
|
|
2505
|
+
* engine's active incidents. Split out so tests can exercise the reconciliation with a stubbed
|
|
2506
|
+
* `fetch` without re-deriving transport wiring. */
|
|
2392
2507
|
export async function pollIncidentsImpl(
|
|
2393
2508
|
data: DataLayer,
|
|
2394
2509
|
base: string,
|
|
2395
2510
|
headers: Record<string, string>,
|
|
2511
|
+
) {
|
|
2512
|
+
await reconcilePrIncidents(data, base, headers);
|
|
2513
|
+
await reconcileDeliveryIncidents(data, base, headers);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
/** The PR half of {@link pollIncidentsImpl}: mirror the oldest ACTIVE incident of each live PR
|
|
2517
|
+
* instance onto the PR row's four incident columns. */
|
|
2518
|
+
async function reconcilePrIncidents(
|
|
2519
|
+
data: DataLayer,
|
|
2520
|
+
base: string,
|
|
2521
|
+
headers: Record<string, string>,
|
|
2396
2522
|
) {
|
|
2397
2523
|
const all = await prsTracking(data).all();
|
|
2398
2524
|
for (const pr of all) {
|
|
@@ -2404,58 +2530,83 @@ export async function pollIncidentsImpl(
|
|
|
2404
2530
|
// instance. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift from the rest
|
|
2405
2531
|
// of the status machine. Writes stay on the base `prs`.
|
|
2406
2532
|
if (!pr.process_key || TERMINAL_STATUSES.includes(pr.derived_status)) {
|
|
2407
|
-
|
|
2408
|
-
await prs(data).update(pr.pr_key, {
|
|
2409
|
-
incident_key: null,
|
|
2410
|
-
incident_message: null,
|
|
2411
|
-
updated_at: now(),
|
|
2412
|
-
});
|
|
2413
|
-
}
|
|
2533
|
+
await clearIncidentMirror(pr, (patch) => prs(data).update(pr.pr_key, patch));
|
|
2414
2534
|
continue;
|
|
2415
2535
|
}
|
|
2416
2536
|
|
|
2417
|
-
let
|
|
2418
|
-
let incidentMessage: string | null = null;
|
|
2537
|
+
let active: IncidentSearchItem | null | undefined;
|
|
2419
2538
|
try {
|
|
2420
|
-
|
|
2421
|
-
method: "POST",
|
|
2422
|
-
headers,
|
|
2423
|
-
body: JSON.stringify({
|
|
2424
|
-
filter: { processInstanceKey: pr.process_key, state: "ACTIVE" },
|
|
2425
|
-
page: { limit: 20 },
|
|
2426
|
-
}),
|
|
2427
|
-
});
|
|
2428
|
-
if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
|
|
2429
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
2430
|
-
const body = (await res.json()) as { items?: IncidentSearchItem[] };
|
|
2431
|
-
// Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
|
|
2432
|
-
// instance somehow parks more than one). Re-filter on state defensively in case the wire
|
|
2433
|
-
// filter is ignored. An incident with no `creationTime` sorts *last*, so a missing timestamp
|
|
2434
|
-
// can never masquerade as the oldest.
|
|
2435
|
-
const active = (body.items ?? [])
|
|
2436
|
-
.filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
|
|
2437
|
-
.sort((a, b) =>
|
|
2438
|
-
(a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff")
|
|
2439
|
-
)[0];
|
|
2440
|
-
if (active) {
|
|
2441
|
-
incidentKey = active.incidentKey ?? null;
|
|
2442
|
-
incidentMessage = active.errorMessage ?? null;
|
|
2443
|
-
}
|
|
2539
|
+
active = await fetchActiveIncident(base, headers, pr.process_key);
|
|
2444
2540
|
} catch (err) {
|
|
2445
2541
|
console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
|
|
2446
2542
|
continue;
|
|
2447
2543
|
}
|
|
2544
|
+
if (active === undefined) continue; // transport unhappy → keep last-known, retry next pass
|
|
2448
2545
|
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2546
|
+
await writeIncidentMirror(pr, active, (patch) => prs(data).update(pr.pr_key, patch));
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/** The delivery-graph half of {@link pollIncidentsImpl} (issue #931): a delivery RUN's instance
|
|
2551
|
+
* parks on an incident exactly as a convergence loop does, and was just as invisible — `/status`
|
|
2552
|
+
* kept reporting `running`. Mirror onto the run row's four incident columns. */
|
|
2553
|
+
async function reconcileDeliveryIncidents(
|
|
2554
|
+
data: DataLayer,
|
|
2555
|
+
base: string,
|
|
2556
|
+
headers: Record<string, string>,
|
|
2557
|
+
) {
|
|
2558
|
+
const terminal: readonly string[] = DELIVERY_GRAPH_TERMINAL_STATUSES;
|
|
2559
|
+
const all = await deliveryGraphRunsTracking(data).all();
|
|
2560
|
+
for (const run of all) {
|
|
2561
|
+
if (!run.process_key || terminal.includes(run.derived_status)) {
|
|
2562
|
+
await clearIncidentMirror(run, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
|
|
2563
|
+
continue;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
let active: IncidentSearchItem | null | undefined;
|
|
2567
|
+
try {
|
|
2568
|
+
active = await fetchActiveIncident(base, headers, run.process_key);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
console.error(`[poller] incidents ${run.run_key}: ${err}`);
|
|
2571
|
+
continue;
|
|
2458
2572
|
}
|
|
2573
|
+
if (active === undefined) continue;
|
|
2574
|
+
|
|
2575
|
+
await writeIncidentMirror(run, active, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** Null all four incident columns when any is set (a terminal / instance-less record must not keep
|
|
2580
|
+
* a stale mirror). Shared by both reconcile halves so the clearing shape can never drift. */
|
|
2581
|
+
async function clearIncidentMirror(
|
|
2582
|
+
row: IncidentMirror,
|
|
2583
|
+
update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
|
|
2584
|
+
) {
|
|
2585
|
+
if (row.incident_key || row.incident_type || row.incident_message || row.incident_since) {
|
|
2586
|
+
await update({ incident_key: null, incident_type: null, incident_message: null, incident_since: null, updated_at: now() });
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
/** Write the mirrored incident onto the row — or clear it when `active` is null — updating (and
|
|
2591
|
+
* bumping `updated_at`) only on an actual change so a steady state doesn't churn the grid. */
|
|
2592
|
+
async function writeIncidentMirror(
|
|
2593
|
+
row: IncidentMirror,
|
|
2594
|
+
active: IncidentSearchItem | null,
|
|
2595
|
+
update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
|
|
2596
|
+
) {
|
|
2597
|
+
const next: IncidentMirror = {
|
|
2598
|
+
incident_key: active?.incidentKey ?? null,
|
|
2599
|
+
incident_type: active?.errorType ?? null,
|
|
2600
|
+
incident_message: active?.errorMessage ?? null,
|
|
2601
|
+
incident_since: active?.creationTime ?? null,
|
|
2602
|
+
};
|
|
2603
|
+
if (
|
|
2604
|
+
next.incident_key !== (row.incident_key ?? null) ||
|
|
2605
|
+
next.incident_type !== (row.incident_type ?? null) ||
|
|
2606
|
+
next.incident_message !== (row.incident_message ?? null) ||
|
|
2607
|
+
next.incident_since !== (row.incident_since ?? null)
|
|
2608
|
+
) {
|
|
2609
|
+
await update({ ...next, updated_at: now() });
|
|
2459
2610
|
}
|
|
2460
2611
|
}
|
|
2461
2612
|
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.207.0",
|
|
3
|
+
"gitSha": "e3955c8145a966a29806400872b7a9c362d1d992",
|
|
4
|
+
"gitTag": "v0.207.0",
|
|
5
|
+
"builtAt": "2026-11-09T23:52:15.727Z"
|
|
6
6
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Incident enrichment (issue #931). The #94 mirror carried only `incident_key` + `incident_message`
|
|
2
|
+
-- — enough to light a badge, not enough to answer "WHAT broke and SINCE WHEN" from the row, which is
|
|
3
|
+
-- what an operator triaging a parked loop needs (the merlin incidents were `JOB_NO_RETRIES` idle
|
|
4
|
+
-- timeouts; the errorType is the tell). `incident_type` is the engine incident's `errorType`;
|
|
5
|
+
-- `incident_since` its `creationTime` (ISO). Both NULL when the instance has no active incident —
|
|
6
|
+
-- the poller's `pollIncidents` pass clears all four columns together, idempotently.
|
|
7
|
+
ALTER TABLE pull_requests ADD COLUMN incident_type TEXT; -- the active incident's errorType (e.g. JOB_NO_RETRIES); NULL when none
|
|
8
|
+
ALTER TABLE pull_requests ADD COLUMN incident_since TEXT; -- the active incident's creationTime (ISO); NULL when none
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
-- Delivery-graph incident mirroring (issue #931). A delivery-graph RUN's engine instance can park
|
|
2
|
+
-- on a technical incident exactly as a convergence loop can — and was just as invisible: `/status`
|
|
3
|
+
-- kept reporting `running` while nothing moved. These four columns are the delivery-side twin of
|
|
4
|
+
-- the PR incident mirror (017 + 126), written by the SAME `pollIncidents` pass and surfaced on the
|
|
5
|
+
-- `/status` delivery rows. All NULL when the run's instance has no active incident.
|
|
6
|
+
ALTER TABLE delivery_graph_runs ADD COLUMN incident_key TEXT; -- engine incidentKey of the active incident parking this run's instance; NULL when none
|
|
7
|
+
ALTER TABLE delivery_graph_runs ADD COLUMN incident_type TEXT; -- the active incident's errorType; NULL when none
|
|
8
|
+
ALTER TABLE delivery_graph_runs ADD COLUMN incident_message TEXT; -- the active incident's errorMessage; NULL when none
|
|
9
|
+
ALTER TABLE delivery_graph_runs ADD COLUMN incident_since TEXT; -- the active incident's creationTime (ISO); NULL when none
|
|
10
|
+
|
|
11
|
+
-- Mirror the new columns through the `delivery_units` aggregate (ADR 0006) so the
|
|
12
|
+
-- `delivery_graph_runs__units` compat VIEW stays row-for-row with the base table (as 120 did for
|
|
13
|
+
-- `auto_resolve_escalations`): add the columns, then re-create the delivery-graph sync triggers and
|
|
14
|
+
-- the compat VIEW with them.
|
|
15
|
+
ALTER TABLE delivery_units ADD COLUMN incident_key TEXT;
|
|
16
|
+
ALTER TABLE delivery_units ADD COLUMN incident_type TEXT;
|
|
17
|
+
ALTER TABLE delivery_units ADD COLUMN incident_message TEXT;
|
|
18
|
+
ALTER TABLE delivery_units ADD COLUMN incident_since TEXT;
|
|
19
|
+
|
|
20
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
|
|
21
|
+
CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
|
|
22
|
+
BEGIN
|
|
23
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, incident_key, incident_type, incident_message, incident_since, created_at, updated_at)
|
|
24
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.incident_key, NEW.incident_type, NEW.incident_message, NEW.incident_since, NEW.created_at, NEW.updated_at);
|
|
25
|
+
END;
|
|
26
|
+
|
|
27
|
+
DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
|
|
28
|
+
CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
|
|
29
|
+
BEGIN
|
|
30
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, incident_key, incident_type, incident_message, incident_since, created_at, updated_at)
|
|
31
|
+
VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.incident_key, NEW.incident_type, NEW.incident_message, NEW.incident_since, NEW.created_at, NEW.updated_at);
|
|
32
|
+
END;
|
|
33
|
+
|
|
34
|
+
DROP VIEW IF EXISTS delivery_graph_runs__units;
|
|
35
|
+
CREATE VIEW delivery_graph_runs__units AS
|
|
36
|
+
SELECT
|
|
37
|
+
du.legacy_key AS run_key,
|
|
38
|
+
du.process_key AS process_key,
|
|
39
|
+
du.process_definition_id AS process_definition_id,
|
|
40
|
+
du.digest AS digest,
|
|
41
|
+
du.status AS status,
|
|
42
|
+
du.side_effecting AS side_effecting,
|
|
43
|
+
du.node_count AS node_count,
|
|
44
|
+
du.human_node_count AS human_node_count,
|
|
45
|
+
du.side_effect_count AS side_effect_count,
|
|
46
|
+
du.title AS title,
|
|
47
|
+
du.phase AS phase,
|
|
48
|
+
du.phase_node_id AS phase_node_id,
|
|
49
|
+
du.human_labels AS human_labels,
|
|
50
|
+
du.created_at AS created_at,
|
|
51
|
+
du.updated_at AS updated_at,
|
|
52
|
+
du.acknowledged_at AS acknowledged_at,
|
|
53
|
+
du.auto_resolve_escalations AS auto_resolve_escalations,
|
|
54
|
+
du.incident_key AS incident_key,
|
|
55
|
+
du.incident_type AS incident_type,
|
|
56
|
+
du.incident_message AS incident_message,
|
|
57
|
+
du.incident_since AS incident_since
|
|
58
|
+
FROM delivery_units du
|
|
59
|
+
WHERE du.kind = 'delivery-graph';
|
|
60
|
+
|
|
@@ -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;
|
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
|