@nanobpm/nano-workforce 0.206.4 → 0.208.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +18 -0
- package/app/deliveryGraphReadModel.test.ts +4 -2
- package/app/deliveryGraphRun.ts +12 -0
- package/app/github.ts +29 -0
- package/app/incidentStatus.test.ts +175 -0
- package/app/mcpExclusions.test.ts +4 -3
- package/app/mcpToolSurface.ts +11 -2
- package/app/mergeLoopBehaviour.test.ts +136 -1
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +266 -0
- package/app/service.ts +311 -96
- 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/nano.app.json +8 -0
- package/openapi.yaml +238 -0
- package/operations/listIncidents.test.ts +70 -0
- package/operations/listIncidents.ts +78 -0
- package/operations/retryIncident.test.ts +280 -0
- package/operations/retryIncident.ts +201 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +16 -3
- package/pages/home.page.json +19 -0
- package/pages/overview.page.json +9 -0
- package/resources/processes/merge-loop.bpmn +401 -213
- package/workers/merge/worker.test.ts +30 -9
- package/workers/merge/worker.ts +23 -12
- package/workers/reconverge/worker.test.ts +110 -0
- package/workers/reconverge/worker.ts +81 -0
- package/workers/request-review/worker.test.ts +54 -0
- package/workers/request-review/worker.ts +68 -0
package/app/service.ts
CHANGED
|
@@ -288,6 +288,14 @@ export const MERGEABLE_REPOLL_INTERVAL = mergeableRepollInterval(
|
|
|
288
288
|
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
289
289
|
export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
|
|
290
290
|
|
|
291
|
+
/** Merge-loop head-current review request (issue #939). When the merge backstop finds no
|
|
292
|
+
* head-current Copilot review (the loop's own rebase/fix push moved the head), the merge loop
|
|
293
|
+
* requests the review itself and waits `REVIEW_REQUEST_WAIT` before re-checking — derived from the
|
|
294
|
+
* SAME nudge window the convergence poller uses, so there is one knob for "how long to give Copilot".
|
|
295
|
+
* It re-requests up to `MAX_REVIEW_REQUESTS` times before escalating (stating what was tried). */
|
|
296
|
+
export const REVIEW_REQUEST_WAIT = `PT${Math.round(REVIEW_NUDGE_MS / 60_000)}M`;
|
|
297
|
+
export const MAX_REVIEW_REQUESTS = 3;
|
|
298
|
+
|
|
291
299
|
/** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
|
|
292
300
|
* writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
|
|
293
301
|
* `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
|
|
@@ -388,13 +396,16 @@ export interface PullRequest {
|
|
|
388
396
|
// running agent curls (GET /app/api/hooks/abandon?token=…) to learn whether this run was cancelled before
|
|
389
397
|
// it performs a side effect. Minted at submit, reused across the convergence + merge instances.
|
|
390
398
|
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
|
-
//
|
|
399
|
+
// Technical-incident surfacing (017_pr_incident.sql, issue #94; enriched 126, issue #931), written
|
|
400
|
+
// by the poller's `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE
|
|
401
|
+
// incident parking this PR's instance, `incident_type` its errorType, `incident_message` its
|
|
402
|
+
// errorMessage and `incident_since` its creationTime; all NULL when the instance has no active
|
|
403
|
+
// incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
|
|
404
|
+
// workflow stage. `/status` derives the `incident` overlay from these columns (see `activePrs`).
|
|
396
405
|
incident_key: string | null;
|
|
406
|
+
incident_type: string | null;
|
|
397
407
|
incident_message: string | null;
|
|
408
|
+
incident_since: string | null;
|
|
398
409
|
// Lineage projection (037_lineage.sql, issue #245): the stable ORIGIN identity (the issue =
|
|
399
410
|
// feature_key / plan_key) threaded onto this PR by `submitPr`, and passed as a `createInstance`
|
|
400
411
|
// variable onto the convergence + merge instances so every descendant carries the root. For a
|
|
@@ -629,9 +640,11 @@ export async function submitPr(
|
|
|
629
640
|
humanApproval = false,
|
|
630
641
|
autoResolveEscalations = false,
|
|
631
642
|
autoMerge?: boolean,
|
|
643
|
+
handoffFrom: string | null = null,
|
|
644
|
+
headShaOverride: string | null = null,
|
|
632
645
|
) {
|
|
633
646
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
634
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge),
|
|
647
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride),
|
|
635
648
|
);
|
|
636
649
|
}
|
|
637
650
|
|
|
@@ -646,6 +659,8 @@ async function submitPrCritical(
|
|
|
646
659
|
humanApproval = false,
|
|
647
660
|
autoResolveEscalations = false,
|
|
648
661
|
autoMerge?: boolean,
|
|
662
|
+
handoffFrom: string | null = null,
|
|
663
|
+
headShaOverride: string | null = null,
|
|
649
664
|
) {
|
|
650
665
|
// Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
|
|
651
666
|
// the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
|
|
@@ -677,7 +692,28 @@ async function submitPrCritical(
|
|
|
677
692
|
// would skip it as non-terminal, never re-enrolling). Treat a keyless non-terminal row as
|
|
678
693
|
// RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
|
|
679
694
|
// treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
|
|
680
|
-
|
|
695
|
+
// Merge-loop → convergence hand-back (issue #939): the merge-loop instance that currently OWNS the
|
|
696
|
+
// row (`process_key`) re-enters convergence when a head-current review reports findings. That
|
|
697
|
+
// instance is live (it is the caller, and ends right after this submit), so it must not read as
|
|
698
|
+
// "already running" — only the exact owning instance may hand back; any other key still dedupes.
|
|
699
|
+
// The bypass additionally requires the row to be LIVE (non-terminal, #941 review): a redelivered
|
|
700
|
+
// `pr.reconverge` can arrive after its successor has already settled the PR (merged/abandoned),
|
|
701
|
+
// and the settled row's `process_key` still names that stale job's instance — an owner match alone
|
|
702
|
+
// would let the dead hand-off restart convergence and clear the settled state. So an internal
|
|
703
|
+
// hand-off against a TERMINAL row is fenced off entirely (below): it dedupes as `alreadyRunning`
|
|
704
|
+
// instead of resubmitting. Only a DELIBERATE PUBLIC submission (no `handoffFrom`) may re-open a
|
|
705
|
+
// settled PR — the internal hand-off channel never may. A partially-failed hand-off's retry
|
|
706
|
+
// against a keyless NON-terminal row still works (that row is not terminal, so it falls through
|
|
707
|
+
// and re-enrolls via the phantom-row path).
|
|
708
|
+
const trackedTerminal = trackedExisting != null && TERMINAL_STATUSES.includes(trackedExisting.derived_status);
|
|
709
|
+
const handingBack =
|
|
710
|
+
handoffFrom != null && existing?.process_key != null && existing.process_key === handoffFrom && !trackedTerminal;
|
|
711
|
+
if (handoffFrom != null && trackedTerminal) {
|
|
712
|
+
// Stale internal hand-off against a settled row: report the existing (terminal) owner without
|
|
713
|
+
// minting a fresh run or touching the settled state. The redelivered job completes idempotently.
|
|
714
|
+
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
715
|
+
}
|
|
716
|
+
if (!handingBack && trackedExisting && existing?.process_key != null && !trackedTerminal) {
|
|
681
717
|
// Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
|
|
682
718
|
// ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
|
|
683
719
|
// Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
|
|
@@ -798,7 +834,14 @@ async function submitPrCritical(
|
|
|
798
834
|
// fleet advertising `durable-resume`: with no participant it stays null, so the round redrives from
|
|
799
835
|
// scratch (graceful degradation). Absent (null) on a first submit, which leaves the envelope
|
|
800
836
|
// unchanged.
|
|
801
|
-
|
|
837
|
+
//
|
|
838
|
+
// `headShaOverride` (#941 round-4 review) WINS over the checkpoint: the checkpoint is written only
|
|
839
|
+
// by the convergence loop's `pr.persist-round`, so a merge-stage rebase/fix-ci push advances the PR
|
|
840
|
+
// head WITHOUT updating it. A merge→convergence hand-off (`pr.reconverge`) that restored the
|
|
841
|
+
// checkpoint would provision the fresh run at the PRE-MERGE tree, not the head whose review raised
|
|
842
|
+
// the findings — so the hand-off passes the live head SHA it read, and a fresh hand-off restores
|
|
843
|
+
// the CURRENT head. `null` (every other caller) keeps the checkpoint resume.
|
|
844
|
+
const worldSha = headShaOverride ?? (await worldRestoreSha(data, parsed.prKey));
|
|
802
845
|
const { processInstanceKey } = await engine.createInstance({
|
|
803
846
|
processDefinitionId: PROCESS_ID,
|
|
804
847
|
variables: {
|
|
@@ -874,6 +917,11 @@ async function submitPrCritical(
|
|
|
874
917
|
// run auto-applies stale decisions, and (crucially) the row stays in its OLD terminal shape until
|
|
875
918
|
// the atomic reopen write lands, so no straggler ever sees a fresh-run row still owned by the old
|
|
876
919
|
// instance.
|
|
920
|
+
let reopened = false;
|
|
921
|
+
// Snapshot BEFORE the reopen write — the row object may be a live reference the update mutates.
|
|
922
|
+
const prior = existing
|
|
923
|
+
? { status: existing.status, current_round: existing.current_round, process_key: existing.process_key }
|
|
924
|
+
: null;
|
|
877
925
|
try {
|
|
878
926
|
if (existing) {
|
|
879
927
|
// ATOMIC REOPEN (Copilot review of #819): reset the row to the fresh run's shape AND publish the
|
|
@@ -928,6 +976,7 @@ async function submitPrCritical(
|
|
|
928
976
|
process_key: processKey,
|
|
929
977
|
updated_at: ts,
|
|
930
978
|
});
|
|
979
|
+
reopened = true;
|
|
931
980
|
} else if (processKey != null) {
|
|
932
981
|
// First submit: the row was INSERTED keyless before `createInstance` (the phantom-row pattern,
|
|
933
982
|
// #704/#497); publish its `process_key` now that the instance exists.
|
|
@@ -946,6 +995,19 @@ async function submitPrCritical(
|
|
|
946
995
|
await resetAdjudications(data, parsed.prKey);
|
|
947
996
|
}
|
|
948
997
|
} catch (err) {
|
|
998
|
+
// Restore the prior row (#941 review): the atomic reopen already advanced `process_key` to the
|
|
999
|
+
// instance we are about to cancel. Leaving that cancelled key on the row would make a dead run its
|
|
1000
|
+
// "owner" — once tracking derives `abandoned`, a retried merge-loop hand-off would be fenced as
|
|
1001
|
+
// stale (settled row) and the merge loop would end with no live convergence owner. Restoring the
|
|
1002
|
+
// pre-reopen shape (owner key + status) keeps the retry the owning hand-off, and a public retry
|
|
1003
|
+
// still sees the old terminal row as resubmittable.
|
|
1004
|
+
if (reopened && prior) {
|
|
1005
|
+
try {
|
|
1006
|
+
await table.update(parsed.prKey, { ...prior, updated_at: now() });
|
|
1007
|
+
} catch (restoreErr) {
|
|
1008
|
+
console.warn(`[submit] ${parsed.prKey} rollback-restore failed: ${restoreErr}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
949
1011
|
if (processInstanceKey != null) {
|
|
950
1012
|
try {
|
|
951
1013
|
await engine.cancelInstance({ processInstanceKey: String(processInstanceKey) });
|
|
@@ -1022,6 +1084,11 @@ export async function startMerge(
|
|
|
1022
1084
|
mergeableRepollInterval: MERGEABLE_REPOLL_INTERVAL,
|
|
1023
1085
|
mergeStallRounds: 0,
|
|
1024
1086
|
mergeStallMax: MAX_MERGE_STALL_ROUNDS,
|
|
1087
|
+
// Head-current review request budget (issue #939): see REVIEW_REQUEST_WAIT.
|
|
1088
|
+
reviewRequestRound: 0,
|
|
1089
|
+
reviewRequestMax: MAX_REVIEW_REQUESTS,
|
|
1090
|
+
reviewRequestWait: REVIEW_REQUEST_WAIT,
|
|
1091
|
+
reviewRequest: null,
|
|
1025
1092
|
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
1026
1093
|
rootRequestKey,
|
|
1027
1094
|
abandonUrl: abUrl,
|
|
@@ -1057,6 +1124,40 @@ export interface MergePolicy {
|
|
|
1057
1124
|
blockedBy: "env" | "human-approval" | null;
|
|
1058
1125
|
}
|
|
1059
1126
|
|
|
1127
|
+
/** The status `/status` reports for a run whose instance is parked on an ACTIVE engine incident
|
|
1128
|
+
* (issue #931). Derived at READ time from the mirrored `incident_*` columns — the base `status`
|
|
1129
|
+
* workflow transient is never overwritten, so the overlay can never wedge the status machine and
|
|
1130
|
+
* lifts the moment the incident clears. */
|
|
1131
|
+
export const INCIDENT_STATUS = "incident";
|
|
1132
|
+
|
|
1133
|
+
/** The incident block every `/status` row carries (issue #931): the ACTIVE engine incident the
|
|
1134
|
+
* poller mirrored onto the record, else null. `key` is the engine incidentKey (the handle the
|
|
1135
|
+
* `retryIncident` action resolves), `type` the errorType (e.g. `JOB_NO_RETRIES`), `message` the
|
|
1136
|
+
* errorMessage, `since` the incident's creationTime. */
|
|
1137
|
+
export interface StatusIncident {
|
|
1138
|
+
key: string;
|
|
1139
|
+
type: string | null;
|
|
1140
|
+
message: string | null;
|
|
1141
|
+
since: string | null;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Derive the `/status` incident block from a row's mirrored incident columns (issue #931). Null
|
|
1145
|
+
* when the row carries no active incident. */
|
|
1146
|
+
function statusIncidentFor(row: {
|
|
1147
|
+
incident_key: string | null;
|
|
1148
|
+
incident_type: string | null;
|
|
1149
|
+
incident_message: string | null;
|
|
1150
|
+
incident_since: string | null;
|
|
1151
|
+
}): StatusIncident | null {
|
|
1152
|
+
if (!row.incident_key) return null;
|
|
1153
|
+
return {
|
|
1154
|
+
key: row.incident_key,
|
|
1155
|
+
type: row.incident_type ?? null,
|
|
1156
|
+
message: row.incident_message ?? null,
|
|
1157
|
+
since: row.incident_since ?? null,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1060
1161
|
/** A PR currently in flight, as reported by the status endpoint. */
|
|
1061
1162
|
export interface ActivePr {
|
|
1062
1163
|
prKey: string;
|
|
@@ -1081,6 +1182,9 @@ export interface ActivePr {
|
|
|
1081
1182
|
/** The effective merge policy (issue #916): what the run intends (`requested`), what the
|
|
1082
1183
|
* deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
|
|
1083
1184
|
merge: MergePolicy;
|
|
1185
|
+
/** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
|
|
1186
|
+
* `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
|
|
1187
|
+
incident: StatusIncident | null;
|
|
1084
1188
|
}
|
|
1085
1189
|
|
|
1086
1190
|
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
@@ -1135,23 +1239,30 @@ export async function activePrs(
|
|
|
1135
1239
|
mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
|
|
1136
1240
|
}),
|
|
1137
1241
|
);
|
|
1138
|
-
return active.map((p) =>
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1242
|
+
return active.map((p) => {
|
|
1243
|
+
// Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
|
|
1244
|
+
// kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
|
|
1245
|
+
// (the base `status` column is never touched) and carry the mirrored incident block.
|
|
1246
|
+
const incident = statusIncidentFor(p);
|
|
1247
|
+
return {
|
|
1248
|
+
prKey: p.pr_key,
|
|
1249
|
+
repo: p.repo,
|
|
1250
|
+
number: p.number,
|
|
1251
|
+
url: p.url,
|
|
1252
|
+
title: p.title ?? null,
|
|
1253
|
+
status: incident ? INCIDENT_STATUS : p.status,
|
|
1254
|
+
round: p.current_round,
|
|
1255
|
+
processKey: p.process_key ?? null,
|
|
1256
|
+
waitingSince: p.waiting_since ?? null,
|
|
1257
|
+
openEscalation: openEscByPr.get(p.pr_key) ?? null,
|
|
1258
|
+
updatedAt: p.updated_at,
|
|
1259
|
+
activeWorker: p.active_worker ?? null,
|
|
1260
|
+
leaseUntil: p.lease_until ?? null,
|
|
1261
|
+
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1262
|
+
merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
|
|
1263
|
+
incident,
|
|
1264
|
+
};
|
|
1265
|
+
});
|
|
1155
1266
|
}
|
|
1156
1267
|
|
|
1157
1268
|
/** The default merge policy when no instance variables are readable (mid-handoff row, engine
|
|
@@ -1225,6 +1336,9 @@ export interface ActiveDelivery {
|
|
|
1225
1336
|
* eligible for steward auto-resolution. Derived from the durable `auto_resolve_escalations` flag,
|
|
1226
1337
|
* never a clobbered process variable. Shown in `/status` per delivery. */
|
|
1227
1338
|
autoResolveEscalations: boolean;
|
|
1339
|
+
/** The ACTIVE engine incident parking this run's instance (issue #931), mirrored by the poller's
|
|
1340
|
+
* `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
|
|
1341
|
+
incident: StatusIncident | null;
|
|
1228
1342
|
}
|
|
1229
1343
|
|
|
1230
1344
|
/** Every delivery-graph run not in a terminal state (done/failed/abandoned), newest-updated first.
|
|
@@ -1248,16 +1362,22 @@ export async function activeDeliveries(data: DataLayer): Promise<ActiveDelivery[
|
|
|
1248
1362
|
if (openEscByRun.has(t.subject_key)) continue;
|
|
1249
1363
|
openEscByRun.set(t.subject_key, toOpenEscalation(t));
|
|
1250
1364
|
}
|
|
1251
|
-
return active.map((r) =>
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1365
|
+
return active.map((r) => {
|
|
1366
|
+
// Issue #931: same incident overlay as the PR rows — a delivery run parked on an engine
|
|
1367
|
+
// incident must not keep reading `running`.
|
|
1368
|
+
const incident = statusIncidentFor(r);
|
|
1369
|
+
return {
|
|
1370
|
+
runKey: r.run_key,
|
|
1371
|
+
title: r.title ?? null,
|
|
1372
|
+
status: incident ? INCIDENT_STATUS : r.status,
|
|
1373
|
+
phase: r.phase ?? null,
|
|
1374
|
+
processKey: r.process_key ?? null,
|
|
1375
|
+
openEscalation: openEscByRun.get(r.run_key) ?? null,
|
|
1376
|
+
updatedAt: r.updated_at,
|
|
1377
|
+
autoResolveEscalations: r.auto_resolve_escalations === 1,
|
|
1378
|
+
incident,
|
|
1379
|
+
};
|
|
1380
|
+
});
|
|
1261
1381
|
}
|
|
1262
1382
|
|
|
1263
1383
|
/** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
|
|
@@ -2351,30 +2471,88 @@ export async function pollJobActivationImpl(
|
|
|
2351
2471
|
}
|
|
2352
2472
|
|
|
2353
2473
|
/** 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
|
-
|
|
2474
|
+
* the unique incident id; `errorType` the machine fault class (e.g. `JOB_NO_RETRIES`);
|
|
2475
|
+
* `errorMessage` is the human-readable fault; `state` is the incident lifecycle (`ACTIVE` while it
|
|
2476
|
+
* parks the token, `RESOLVED` once cleared); `creationTime` orders concurrent incidents and is
|
|
2477
|
+
* surfaced as the incident's `since`. */
|
|
2478
|
+
export interface IncidentSearchItem {
|
|
2358
2479
|
incidentKey?: string;
|
|
2480
|
+
errorType?: string | null;
|
|
2359
2481
|
errorMessage?: string | null;
|
|
2360
2482
|
state?: string;
|
|
2361
2483
|
creationTime?: string | null;
|
|
2484
|
+
/** The job the incident sits on (a `JOB_NO_RETRIES` incident parks until that job carries
|
|
2485
|
+
* retries again) — the handle the `retryIncident` action bumps. */
|
|
2486
|
+
jobKey?: string;
|
|
2362
2487
|
}
|
|
2363
2488
|
|
|
2364
|
-
/**
|
|
2365
|
-
*
|
|
2366
|
-
*
|
|
2367
|
-
|
|
2489
|
+
/** The four mirrored incident columns every incident-watched aggregate carries (PRs since #94/#931,
|
|
2490
|
+
* delivery-graph runs since #931). All-or-nothing: a row with no ACTIVE incident reads NULL on all
|
|
2491
|
+
* four. */
|
|
2492
|
+
interface IncidentMirror {
|
|
2493
|
+
incident_key: string | null;
|
|
2494
|
+
incident_type: string | null;
|
|
2495
|
+
incident_message: string | null;
|
|
2496
|
+
incident_since: string | null;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/** Pick the ACTIVE incident to mirror for one instance: the OLDEST active one (the first thing that
|
|
2500
|
+
* broke — a stable choice if the instance somehow parks more than one). Re-filters on state
|
|
2501
|
+
* defensively in case the wire filter is ignored. An incident with no `creationTime` sorts *last*,
|
|
2502
|
+
* so a missing timestamp can never masquerade as the oldest. Returns null when none is active. */
|
|
2503
|
+
export function oldestActiveIncident(items: IncidentSearchItem[] | undefined): IncidentSearchItem | null {
|
|
2504
|
+
return (
|
|
2505
|
+
(items ?? [])
|
|
2506
|
+
.filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
|
|
2507
|
+
.sort((a, b) => (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff"))[0] ?? null
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/** Query the engine for the OLDEST ACTIVE incident on one process instance. Returns the incident,
|
|
2512
|
+
* `null` when the instance has no active incident, or `undefined` when the transport failed (the
|
|
2513
|
+
* caller keeps the last-known mirror and retries next pass — a failed query must never clear a real
|
|
2514
|
+
* incident nor invent one). */
|
|
2515
|
+
export async function fetchActiveIncident(
|
|
2516
|
+
base: string,
|
|
2517
|
+
headers: Record<string, string>,
|
|
2518
|
+
processInstanceKey: string,
|
|
2519
|
+
): Promise<IncidentSearchItem | null | undefined> {
|
|
2520
|
+
try {
|
|
2521
|
+
const res = await fetch(`${base}/incidents/search`, {
|
|
2522
|
+
method: "POST",
|
|
2523
|
+
headers,
|
|
2524
|
+
body: JSON.stringify({
|
|
2525
|
+
filter: { processInstanceKey, state: "ACTIVE" },
|
|
2526
|
+
page: { limit: 20 },
|
|
2527
|
+
}),
|
|
2528
|
+
});
|
|
2529
|
+
if (!res.ok) return undefined; // engine unhappy → unknown, retry next pass
|
|
2530
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
2531
|
+
const body = (await res.json()) as { items?: IncidentSearchItem[] };
|
|
2532
|
+
return oldestActiveIncident(body.items);
|
|
2533
|
+
} catch {
|
|
2534
|
+
return undefined;
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
/** Incident-surfacing poll pass (issue #94; enriched and extended to delivery-graph runs in issue
|
|
2539
|
+
* #931). A convergence, merge, or delivery-graph process instance can hit a *technical* incident —
|
|
2540
|
+
* an unhandled engine error that parks the token — and nothing on the record row reflected it: the
|
|
2541
|
+
* grid kept showing the last workflow status (`converging`, `merging`, `running`, …) while the run
|
|
2542
|
+
* was actually dead in the water (a PR sat "converging" all day on an incident; on merlin two loops
|
|
2543
|
+
* sat dead for 1.3h/5h before anyone found them via a hand-run engine query).
|
|
2368
2544
|
*
|
|
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
|
-
*
|
|
2545
|
+
* This pass reads the engine's Camunda-8 `/v2/incidents/search` for each tracked record that still
|
|
2546
|
+
* has a live instance (has a `process_key`, non-terminal derived status) and mirrors an ACTIVE
|
|
2547
|
+
* incident onto four orthogonal columns — `incident_key`, `incident_type`, `incident_message`,
|
|
2548
|
+
* `incident_since` — leaving `status` untouched. An incident is a cross-cutting liveness fault, not
|
|
2549
|
+
* a workflow stage, so it must not overload the status machine (`/status` derives the `incident`
|
|
2550
|
+
* overlay at READ time — see `activePrs`/`activeDeliveries`). Clearing is idempotent: when the
|
|
2551
|
+
* instance has no active incident (resolved, or never had one) the columns are nulled, so an
|
|
2552
|
+
* incident raised or resolved out-of-band converges to the truth on the next pass. Best-effort
|
|
2553
|
+
* transport: a failed query leaves the last-known values untouched and the next pass retries.
|
|
2554
|
+
* Updates (and bumps `updated_at`) only on an actual change so a steady state doesn't churn the
|
|
2555
|
+
* grid. */
|
|
2378
2556
|
async function pollIncidents(
|
|
2379
2557
|
data: DataLayer,
|
|
2380
2558
|
restAddress: string,
|
|
@@ -2387,12 +2565,24 @@ async function pollIncidents(
|
|
|
2387
2565
|
}
|
|
2388
2566
|
|
|
2389
2567
|
/** Testable core of {@link pollIncidents}: given the normalised `base` URL and prepared auth
|
|
2390
|
-
* `headers`, reconcile every
|
|
2391
|
-
* exercise the reconciliation with a stubbed
|
|
2568
|
+
* `headers`, reconcile every incident-watched record row (PRs and delivery-graph runs) against the
|
|
2569
|
+
* engine's active incidents. Split out so tests can exercise the reconciliation with a stubbed
|
|
2570
|
+
* `fetch` without re-deriving transport wiring. */
|
|
2392
2571
|
export async function pollIncidentsImpl(
|
|
2393
2572
|
data: DataLayer,
|
|
2394
2573
|
base: string,
|
|
2395
2574
|
headers: Record<string, string>,
|
|
2575
|
+
) {
|
|
2576
|
+
await reconcilePrIncidents(data, base, headers);
|
|
2577
|
+
await reconcileDeliveryIncidents(data, base, headers);
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/** The PR half of {@link pollIncidentsImpl}: mirror the oldest ACTIVE incident of each live PR
|
|
2581
|
+
* instance onto the PR row's four incident columns. */
|
|
2582
|
+
async function reconcilePrIncidents(
|
|
2583
|
+
data: DataLayer,
|
|
2584
|
+
base: string,
|
|
2585
|
+
headers: Record<string, string>,
|
|
2396
2586
|
) {
|
|
2397
2587
|
const all = await prsTracking(data).all();
|
|
2398
2588
|
for (const pr of all) {
|
|
@@ -2404,58 +2594,83 @@ export async function pollIncidentsImpl(
|
|
|
2404
2594
|
// instance. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift from the rest
|
|
2405
2595
|
// of the status machine. Writes stay on the base `prs`.
|
|
2406
2596
|
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
|
-
}
|
|
2597
|
+
await clearIncidentMirror(pr, (patch) => prs(data).update(pr.pr_key, patch));
|
|
2414
2598
|
continue;
|
|
2415
2599
|
}
|
|
2416
2600
|
|
|
2417
|
-
let
|
|
2418
|
-
let incidentMessage: string | null = null;
|
|
2601
|
+
let active: IncidentSearchItem | null | undefined;
|
|
2419
2602
|
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
|
-
}
|
|
2603
|
+
active = await fetchActiveIncident(base, headers, pr.process_key);
|
|
2444
2604
|
} catch (err) {
|
|
2445
2605
|
console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
|
|
2446
2606
|
continue;
|
|
2447
2607
|
}
|
|
2608
|
+
if (active === undefined) continue; // transport unhappy → keep last-known, retry next pass
|
|
2448
2609
|
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2610
|
+
await writeIncidentMirror(pr, active, (patch) => prs(data).update(pr.pr_key, patch));
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
/** The delivery-graph half of {@link pollIncidentsImpl} (issue #931): a delivery RUN's instance
|
|
2615
|
+
* parks on an incident exactly as a convergence loop does, and was just as invisible — `/status`
|
|
2616
|
+
* kept reporting `running`. Mirror onto the run row's four incident columns. */
|
|
2617
|
+
async function reconcileDeliveryIncidents(
|
|
2618
|
+
data: DataLayer,
|
|
2619
|
+
base: string,
|
|
2620
|
+
headers: Record<string, string>,
|
|
2621
|
+
) {
|
|
2622
|
+
const terminal: readonly string[] = DELIVERY_GRAPH_TERMINAL_STATUSES;
|
|
2623
|
+
const all = await deliveryGraphRunsTracking(data).all();
|
|
2624
|
+
for (const run of all) {
|
|
2625
|
+
if (!run.process_key || terminal.includes(run.derived_status)) {
|
|
2626
|
+
await clearIncidentMirror(run, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
|
|
2627
|
+
continue;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
let active: IncidentSearchItem | null | undefined;
|
|
2631
|
+
try {
|
|
2632
|
+
active = await fetchActiveIncident(base, headers, run.process_key);
|
|
2633
|
+
} catch (err) {
|
|
2634
|
+
console.error(`[poller] incidents ${run.run_key}: ${err}`);
|
|
2635
|
+
continue;
|
|
2458
2636
|
}
|
|
2637
|
+
if (active === undefined) continue;
|
|
2638
|
+
|
|
2639
|
+
await writeIncidentMirror(run, active, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/** Null all four incident columns when any is set (a terminal / instance-less record must not keep
|
|
2644
|
+
* a stale mirror). Shared by both reconcile halves so the clearing shape can never drift. */
|
|
2645
|
+
async function clearIncidentMirror(
|
|
2646
|
+
row: IncidentMirror,
|
|
2647
|
+
update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
|
|
2648
|
+
) {
|
|
2649
|
+
if (row.incident_key || row.incident_type || row.incident_message || row.incident_since) {
|
|
2650
|
+
await update({ incident_key: null, incident_type: null, incident_message: null, incident_since: null, updated_at: now() });
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/** Write the mirrored incident onto the row — or clear it when `active` is null — updating (and
|
|
2655
|
+
* bumping `updated_at`) only on an actual change so a steady state doesn't churn the grid. */
|
|
2656
|
+
async function writeIncidentMirror(
|
|
2657
|
+
row: IncidentMirror,
|
|
2658
|
+
active: IncidentSearchItem | null,
|
|
2659
|
+
update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
|
|
2660
|
+
) {
|
|
2661
|
+
const next: IncidentMirror = {
|
|
2662
|
+
incident_key: active?.incidentKey ?? null,
|
|
2663
|
+
incident_type: active?.errorType ?? null,
|
|
2664
|
+
incident_message: active?.errorMessage ?? null,
|
|
2665
|
+
incident_since: active?.creationTime ?? null,
|
|
2666
|
+
};
|
|
2667
|
+
if (
|
|
2668
|
+
next.incident_key !== (row.incident_key ?? null) ||
|
|
2669
|
+
next.incident_type !== (row.incident_type ?? null) ||
|
|
2670
|
+
next.incident_message !== (row.incident_message ?? null) ||
|
|
2671
|
+
next.incident_since !== (row.incident_since ?? null)
|
|
2672
|
+
) {
|
|
2673
|
+
await update({ ...next, updated_at: now() });
|
|
2459
2674
|
}
|
|
2460
2675
|
}
|
|
2461
2676
|
|
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.208.0",
|
|
3
|
+
"gitSha": "0e2d50e8610beb5e939cd3a143d28e62d690f272",
|
|
4
|
+
"gitTag": "v0.208.0",
|
|
5
|
+
"builtAt": "2026-11-10T03:09:52.732Z"
|
|
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
|
+
|