@nanobpm/nano-workforce 0.31.0 → 0.32.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 +7 -0
- package/app/service.test.ts +153 -1
- package/app/service.ts +119 -4
- package/db/migrations/017_pr_incident.sql +14 -0
- package/package.json +1 -1
- package/pages/home.page.json +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.32.0](https://github.com/nanobpm/nano-workforce/compare/v0.31.0...v0.32.0) (2026-08-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **poller:** surface technical incidents on the PR row ([#95](https://github.com/nanobpm/nano-workforce/issues/95)) ([596153c](https://github.com/nanobpm/nano-workforce/commit/596153c0f58a0d511f4a7bac66896dab98a6a5c8)), closes [#94](https://github.com/nanobpm/nano-workforce/issues/94)
|
|
7
|
+
|
|
1
8
|
# [0.31.0](https://github.com/nanobpm/nano-workforce/compare/v0.30.0...v0.31.0) (2026-08-09)
|
|
2
9
|
|
|
3
10
|
|
package/app/service.test.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
// loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { assertEquals } from "jsr:@std/assert@1";
|
|
9
|
-
import { submitPr } from "./service.ts";
|
|
9
|
+
import { pollIncidentsImpl, submitPr } from "./service.ts";
|
|
10
10
|
|
|
11
11
|
// deno-lint-ignore no-explicit-any
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
14
14
|
// deno-lint-ignore no-explicit-any
|
|
15
15
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
16
|
+
all: () => Promise.resolve([...rows]),
|
|
16
17
|
// deno-lint-ignore no-explicit-any
|
|
17
18
|
find: (q: any) =>
|
|
18
19
|
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
@@ -99,3 +100,154 @@ Deno.test("re-submit of a cancelled PR clears stale open escalations + the denor
|
|
|
99
100
|
assertEquals(pr.process_key, "PI-9");
|
|
100
101
|
});
|
|
101
102
|
});
|
|
103
|
+
|
|
104
|
+
// Red/green regression for technical-incident surfacing (issue #94). A convergence/merge instance
|
|
105
|
+
// can hit an engine incident that parks the token; until `pollIncidents` nothing on the PR row
|
|
106
|
+
// reflected it, so the grid kept showing "converging" while the run was dead in the water. This
|
|
107
|
+
// drives the pass's reconciliation core against a stubbed `/v2/incidents/search`:
|
|
108
|
+
// 1. an ACTIVE incident is mirrored onto `incident_key` + `incident_message` (status untouched),
|
|
109
|
+
// 2. once the engine reports no active incident, the columns are cleared idempotently,
|
|
110
|
+
// 3. a PR with no live instance (no process_key / terminal status) is never queried and any
|
|
111
|
+
// stale incident on it is cleared.
|
|
112
|
+
function incidentFetch(byInstance: Record<string, unknown[]>) {
|
|
113
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
114
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
115
|
+
if (!u.endsWith("/incidents/search")) {
|
|
116
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
117
|
+
}
|
|
118
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
119
|
+
filter?: { processInstanceKey?: string };
|
|
120
|
+
};
|
|
121
|
+
const items = byInstance[body.filter?.processInstanceKey ?? ""] ?? [];
|
|
122
|
+
return Promise.resolve(
|
|
123
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it, leaving status untouched", async () => {
|
|
129
|
+
const row = {
|
|
130
|
+
pr_key: "owner/repo#7",
|
|
131
|
+
repo: "owner/repo",
|
|
132
|
+
number: 7,
|
|
133
|
+
status: "converging",
|
|
134
|
+
process_key: "PI-7",
|
|
135
|
+
incident_key: null as string | null,
|
|
136
|
+
incident_message: null as string | null,
|
|
137
|
+
updated_at: "t0",
|
|
138
|
+
};
|
|
139
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
140
|
+
pull_requests: { rows: [row], key: "pr_key" },
|
|
141
|
+
};
|
|
142
|
+
const data = {
|
|
143
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
144
|
+
// deno-lint-ignore no-explicit-any
|
|
145
|
+
} as any;
|
|
146
|
+
const headers = { "content-type": "application/json" };
|
|
147
|
+
|
|
148
|
+
const prevFetch = globalThis.fetch;
|
|
149
|
+
|
|
150
|
+
// Red-ish: with an ACTIVE incident on the instance, the pass must surface it (before this
|
|
151
|
+
// feature the columns stayed null and the incident was invisible).
|
|
152
|
+
globalThis.fetch = incidentFetch({
|
|
153
|
+
"PI-7": [{ incidentKey: "INC-1", errorMessage: "boom: unhandled error", state: "ACTIVE", creationTime: "2024-01-01T00:00:00Z" }],
|
|
154
|
+
}) as typeof fetch;
|
|
155
|
+
try {
|
|
156
|
+
await pollIncidentsImpl(data, "http://engine/v2", headers);
|
|
157
|
+
} finally {
|
|
158
|
+
globalThis.fetch = prevFetch;
|
|
159
|
+
}
|
|
160
|
+
assertEquals(row.incident_key, "INC-1");
|
|
161
|
+
assertEquals(row.incident_message, "boom: unhandled error");
|
|
162
|
+
assertEquals(row.status, "converging"); // orthogonal: status is never touched
|
|
163
|
+
|
|
164
|
+
// Green: once the engine reports no active incident, the columns clear idempotently.
|
|
165
|
+
globalThis.fetch = incidentFetch({ "PI-7": [] }) as typeof fetch;
|
|
166
|
+
try {
|
|
167
|
+
await pollIncidentsImpl(data, "http://engine/v2", headers);
|
|
168
|
+
} finally {
|
|
169
|
+
globalThis.fetch = prevFetch;
|
|
170
|
+
}
|
|
171
|
+
assertEquals(row.incident_key, null);
|
|
172
|
+
assertEquals(row.incident_message, null);
|
|
173
|
+
assertEquals(row.status, "converging");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
Deno.test("pollIncidents never queries a PR with no live instance and clears any stale incident", async () => {
|
|
177
|
+
const noKey = {
|
|
178
|
+
pr_key: "owner/repo#8",
|
|
179
|
+
status: "converging",
|
|
180
|
+
process_key: null as string | null,
|
|
181
|
+
incident_key: "STALE-A",
|
|
182
|
+
incident_message: "left over",
|
|
183
|
+
updated_at: "t0",
|
|
184
|
+
};
|
|
185
|
+
const terminal = {
|
|
186
|
+
pr_key: "owner/repo#9",
|
|
187
|
+
status: "merged",
|
|
188
|
+
process_key: "PI-9",
|
|
189
|
+
incident_key: "STALE-B",
|
|
190
|
+
incident_message: "left over",
|
|
191
|
+
updated_at: "t0",
|
|
192
|
+
};
|
|
193
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
194
|
+
pull_requests: { rows: [noKey, terminal], key: "pr_key" },
|
|
195
|
+
};
|
|
196
|
+
const data = {
|
|
197
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
198
|
+
// deno-lint-ignore no-explicit-any
|
|
199
|
+
} as any;
|
|
200
|
+
const headers = { "content-type": "application/json" };
|
|
201
|
+
|
|
202
|
+
const prevFetch = globalThis.fetch;
|
|
203
|
+
// Any fetch here is a bug — neither PR has a live instance to inspect.
|
|
204
|
+
globalThis.fetch = (() => {
|
|
205
|
+
throw new Error("pollIncidents must not query a PR with no live instance");
|
|
206
|
+
}) as typeof fetch;
|
|
207
|
+
try {
|
|
208
|
+
await pollIncidentsImpl(data, "http://engine/v2", headers);
|
|
209
|
+
} finally {
|
|
210
|
+
globalThis.fetch = prevFetch;
|
|
211
|
+
}
|
|
212
|
+
assertEquals(noKey.incident_key, null);
|
|
213
|
+
assertEquals(noKey.incident_message, null);
|
|
214
|
+
assertEquals(terminal.incident_key, null);
|
|
215
|
+
assertEquals(terminal.incident_message, null);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a missing timestamp last", async () => {
|
|
219
|
+
const row = {
|
|
220
|
+
pr_key: "owner/repo#11",
|
|
221
|
+
status: "converging",
|
|
222
|
+
process_key: "PI-11",
|
|
223
|
+
incident_key: null as string | null,
|
|
224
|
+
incident_message: null as string | null,
|
|
225
|
+
updated_at: "t0",
|
|
226
|
+
};
|
|
227
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
228
|
+
pull_requests: { rows: [row], key: "pr_key" },
|
|
229
|
+
};
|
|
230
|
+
const data = {
|
|
231
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
232
|
+
// deno-lint-ignore no-explicit-any
|
|
233
|
+
} as any;
|
|
234
|
+
const headers = { "content-type": "application/json" };
|
|
235
|
+
|
|
236
|
+
const prevFetch = globalThis.fetch;
|
|
237
|
+
// A no-`creationTime` incident must not masquerade as the oldest (empty-string sort bug): the
|
|
238
|
+
// real earliest ISO timestamp wins even when a timestamp-less incident is returned first.
|
|
239
|
+
globalThis.fetch = incidentFetch({
|
|
240
|
+
"PI-11": [
|
|
241
|
+
{ incidentKey: "INC-NOTS", errorMessage: "no timestamp", state: "ACTIVE" },
|
|
242
|
+
{ incidentKey: "INC-OLD", errorMessage: "the first fault", state: "ACTIVE", creationTime: "2024-01-01T00:00:00Z" },
|
|
243
|
+
{ incidentKey: "INC-NEW", errorMessage: "a later fault", state: "ACTIVE", creationTime: "2024-06-01T00:00:00Z" },
|
|
244
|
+
],
|
|
245
|
+
}) as typeof fetch;
|
|
246
|
+
try {
|
|
247
|
+
await pollIncidentsImpl(data, "http://engine/v2", headers);
|
|
248
|
+
} finally {
|
|
249
|
+
globalThis.fetch = prevFetch;
|
|
250
|
+
}
|
|
251
|
+
assertEquals(row.incident_key, "INC-OLD");
|
|
252
|
+
assertEquals(row.incident_message, "the first fault");
|
|
253
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -130,6 +130,13 @@ interface PullRequest {
|
|
|
130
130
|
// running agent curls (GET /hooks/abandon?token=…) to learn whether this run was cancelled before
|
|
131
131
|
// it performs a side effect. Minted at submit, reused across the convergence + merge instances.
|
|
132
132
|
abandon_token: string | null;
|
|
133
|
+
// Technical-incident surfacing (017_pr_incident.sql, issue #94), written by the poller's
|
|
134
|
+
// `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE incident parking
|
|
135
|
+
// this PR's instance and `incident_message` its errorMessage; both NULL when the instance has no
|
|
136
|
+
// active incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
|
|
137
|
+
// workflow stage.
|
|
138
|
+
incident_key: string | null;
|
|
139
|
+
incident_message: string | null;
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
interface PrDependency {
|
|
@@ -836,6 +843,111 @@ async function pollJobActivation(
|
|
|
836
843
|
}
|
|
837
844
|
}
|
|
838
845
|
|
|
846
|
+
/** The subset of a Camunda-8 `/v2/incidents/search` result item this app reads. `incidentKey` is
|
|
847
|
+
* the unique incident id; `errorMessage` is the human-readable fault; `state` is the incident
|
|
848
|
+
* lifecycle (`ACTIVE` while it parks the token, `RESOLVED` once cleared); `creationTime` orders
|
|
849
|
+
* concurrent incidents. */
|
|
850
|
+
interface IncidentSearchItem {
|
|
851
|
+
incidentKey?: string;
|
|
852
|
+
errorMessage?: string | null;
|
|
853
|
+
state?: string;
|
|
854
|
+
creationTime?: string | null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Incident-surfacing poll pass (issue #94). A convergence or merge process instance can hit a
|
|
858
|
+
* *technical* incident — an unhandled engine error that parks the token — and nothing on the PR
|
|
859
|
+
* row reflected it: the grid kept showing the last workflow status (`converging`, `merging`, …)
|
|
860
|
+
* while the run was actually dead in the water (a PR sat "converging" all day on an incident).
|
|
861
|
+
*
|
|
862
|
+
* This pass reads the engine's Camunda-8 `/v2/incidents/search` for each PR that still has a live
|
|
863
|
+
* instance (has a `process_key`, non-terminal status) and mirrors an ACTIVE incident onto two
|
|
864
|
+
* orthogonal columns — `incident_key` + `incident_message` — leaving `status` untouched. An
|
|
865
|
+
* incident is a cross-cutting liveness fault, not a workflow stage, so it must not overload the
|
|
866
|
+
* status machine. Clearing is idempotent: when the instance has no active incident (resolved, or
|
|
867
|
+
* never had one) the columns are nulled, so an incident raised or resolved out-of-band converges
|
|
868
|
+
* to the truth on the next pass. Best-effort transport: a failed query leaves the last-known
|
|
869
|
+
* values untouched and the next pass retries. Updates (and bumps `updated_at`) only on an actual
|
|
870
|
+
* change so a steady state doesn't churn the grid. */
|
|
871
|
+
async function pollIncidents(
|
|
872
|
+
data: DataLayer,
|
|
873
|
+
restAddress: string,
|
|
874
|
+
engineToken: string | undefined,
|
|
875
|
+
) {
|
|
876
|
+
const base = restAddress.replace(/\/+$/, "");
|
|
877
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
878
|
+
if (engineToken) headers.authorization = `Bearer ${engineToken}`;
|
|
879
|
+
await pollIncidentsImpl(data, base, headers);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Testable core of {@link pollIncidents}: given the normalised `base` URL and prepared auth
|
|
883
|
+
* `headers`, reconcile every PR row against the engine's active incidents. Split out so tests can
|
|
884
|
+
* exercise the reconciliation with a stubbed `fetch` without re-deriving transport wiring. */
|
|
885
|
+
export async function pollIncidentsImpl(
|
|
886
|
+
data: DataLayer,
|
|
887
|
+
base: string,
|
|
888
|
+
headers: Record<string, string>,
|
|
889
|
+
) {
|
|
890
|
+
const all = await prs(data).all();
|
|
891
|
+
for (const pr of all) {
|
|
892
|
+
// No live instance to inspect (never created, mid-transition, or terminal — the run has
|
|
893
|
+
// finished or was given up, so its instance is gone) → make sure no stale incident lingers on
|
|
894
|
+
// the row, then move on. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift
|
|
895
|
+
// from the rest of the status machine.
|
|
896
|
+
if (!pr.process_key || TERMINAL_STATUSES.includes(pr.status)) {
|
|
897
|
+
if (pr.incident_key || pr.incident_message) {
|
|
898
|
+
await prs(data).update(pr.pr_key, {
|
|
899
|
+
incident_key: null,
|
|
900
|
+
incident_message: null,
|
|
901
|
+
updated_at: now(),
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
let incidentKey: string | null = null;
|
|
908
|
+
let incidentMessage: string | null = null;
|
|
909
|
+
try {
|
|
910
|
+
const res = await fetch(`${base}/incidents/search`, {
|
|
911
|
+
method: "POST",
|
|
912
|
+
headers,
|
|
913
|
+
body: JSON.stringify({
|
|
914
|
+
filter: { processInstanceKey: pr.process_key, state: "ACTIVE" },
|
|
915
|
+
page: { limit: 20 },
|
|
916
|
+
}),
|
|
917
|
+
});
|
|
918
|
+
if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
|
|
919
|
+
const body = (await res.json()) as { items?: IncidentSearchItem[] };
|
|
920
|
+
// Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
|
|
921
|
+
// instance somehow parks more than one). Re-filter on state defensively in case the wire
|
|
922
|
+
// filter is ignored. An incident with no `creationTime` sorts *last*, so a missing timestamp
|
|
923
|
+
// can never masquerade as the oldest.
|
|
924
|
+
const active = (body.items ?? [])
|
|
925
|
+
.filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
|
|
926
|
+
.sort((a, b) =>
|
|
927
|
+
(a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff")
|
|
928
|
+
)[0];
|
|
929
|
+
if (active) {
|
|
930
|
+
incidentKey = active.incidentKey ?? null;
|
|
931
|
+
incidentMessage = active.errorMessage ?? null;
|
|
932
|
+
}
|
|
933
|
+
} catch (err) {
|
|
934
|
+
console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (
|
|
939
|
+
incidentKey !== (pr.incident_key ?? null) ||
|
|
940
|
+
incidentMessage !== (pr.incident_message ?? null)
|
|
941
|
+
) {
|
|
942
|
+
await prs(data).update(pr.pr_key, {
|
|
943
|
+
incident_key: incidentKey,
|
|
944
|
+
incident_message: incidentMessage,
|
|
945
|
+
updated_at: now(),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
839
951
|
/** Wave-merge barrier poll pass. After `record-wave` hands off a wave that has a successor, the
|
|
840
952
|
* plan-fanout instance parks at the `wait-wave-merged` catch event and `plans.gate_wave` records
|
|
841
953
|
* that wave's index. Here we check whether every OPENED PR in that wave has MERGED and, if so,
|
|
@@ -879,9 +991,9 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
|
|
|
879
991
|
}
|
|
880
992
|
}
|
|
881
993
|
|
|
882
|
-
/** One full poll pass: advance the review stage, the merge stage,
|
|
883
|
-
* endpoint is supplied) the job-activation visibility pass
|
|
884
|
-
* in `main.ts`. */
|
|
994
|
+
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
995
|
+
* (when the engine REST endpoint is supplied) the job-activation visibility pass and the
|
|
996
|
+
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
|
|
885
997
|
export async function pollOnce(
|
|
886
998
|
data: DataLayer,
|
|
887
999
|
engine: EngineClient,
|
|
@@ -891,5 +1003,8 @@ export async function pollOnce(
|
|
|
891
1003
|
await pollReviews(data, engine, token);
|
|
892
1004
|
await pollMerges(data, engine, token);
|
|
893
1005
|
await pollWaveGates(data, engine, token);
|
|
894
|
-
if (engineRest)
|
|
1006
|
+
if (engineRest) {
|
|
1007
|
+
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
1008
|
+
await pollIncidents(data, engineRest.restAddress, engineRest.token);
|
|
1009
|
+
}
|
|
895
1010
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- Technical-incident surfacing (issue #94). A convergence or merge process instance can hit a
|
|
2
|
+
-- *technical* incident — an unhandled engine error (an expression failure, a job that exhausted
|
|
3
|
+
-- its retries, …) that parks the token — and until now nothing on the PR row reflected it: the
|
|
4
|
+
-- grid kept showing the last workflow status (`converging`, `merging`, …) while the run was
|
|
5
|
+
-- actually stuck. A PR sat "converging" all day while its instance was dead on an incident.
|
|
6
|
+
--
|
|
7
|
+
-- These two orthogonal columns mirror an ACTIVE engine incident onto the PR row, written by the
|
|
8
|
+
-- poller's `pollIncidents` pass from a `/v2/incidents/search` filtered by the PR's `process_key`.
|
|
9
|
+
-- They are deliberately independent of `status`: an incident is a *cross-cutting* liveness fault,
|
|
10
|
+
-- not a workflow stage, so surfacing it must not overload the status machine. NULL means the
|
|
11
|
+
-- instance has no active incident (never had one, or it was resolved) — the poller clears the
|
|
12
|
+
-- columns idempotently, so an incident raised or resolved out-of-band converges on the next pass.
|
|
13
|
+
ALTER TABLE pull_requests ADD COLUMN incident_key TEXT; -- engine incidentKey of the active incident parking this PR's instance; NULL when none
|
|
14
|
+
ALTER TABLE pull_requests ADD COLUMN incident_message TEXT; -- the incident's errorMessage, surfaced on the grid; NULL when none
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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",
|
package/pages/home.page.json
CHANGED
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"columns": [
|
|
85
85
|
{ "field": "pr_key", "header": "PR", "linkField": "url" },
|
|
86
86
|
{ "field": "status", "header": "Status" },
|
|
87
|
+
{ "field": "incident_message", "header": "Incident" },
|
|
87
88
|
{ "field": "current_round", "header": "Round" },
|
|
88
89
|
{ "field": "active_worker", "header": "Agent" },
|
|
89
90
|
{ "field": "updated_at", "header": "Updated" }
|
|
@@ -103,6 +104,8 @@
|
|
|
103
104
|
{ "field": "number", "label": "PR number" },
|
|
104
105
|
{ "field": "active_worker", "label": "Agent (leasing worker)" },
|
|
105
106
|
{ "field": "lease_until", "label": "Activation lease until" },
|
|
107
|
+
{ "field": "incident_message", "label": "Incident" },
|
|
108
|
+
{ "field": "incident_key", "label": "Incident key" },
|
|
106
109
|
{ "field": "merged_at", "label": "Merged at" },
|
|
107
110
|
{ "field": "outcome", "label": "Outcome" }
|
|
108
111
|
],
|