@teamscala/service-boot 0.23.360 → 0.23.362
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/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.362",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@teamscala/auth": "*",
|
|
22
22
|
"@teamscala/cache": "*",
|
|
23
23
|
"@teamscala/channel": "*",
|
|
24
|
+
"@teamscala/chat-completion": "*",
|
|
24
25
|
"@teamscala/db": "*",
|
|
25
26
|
"@teamscala/db-validation": "*",
|
|
26
27
|
"@teamscala/document-signing": "*",
|
|
@@ -229,6 +230,14 @@
|
|
|
229
230
|
"types": "./src/content-page-floor.d.ts",
|
|
230
231
|
"default": "./src/content-page-floor.ts"
|
|
231
232
|
},
|
|
233
|
+
"./csrf-protection": {
|
|
234
|
+
"types": "./src/csrf-protection.d.ts",
|
|
235
|
+
"default": "./src/csrf-protection.ts"
|
|
236
|
+
},
|
|
237
|
+
"./csrf-protection.ts": {
|
|
238
|
+
"types": "./src/csrf-protection.d.ts",
|
|
239
|
+
"default": "./src/csrf-protection.ts"
|
|
240
|
+
},
|
|
232
241
|
"./discover-blocks": {
|
|
233
242
|
"types": "./src/discover-blocks.d.ts",
|
|
234
243
|
"default": "./src/discover-blocks.ts"
|
|
@@ -417,6 +426,22 @@
|
|
|
417
426
|
"types": "./src/handlers/pane-outbox-drain.d.ts",
|
|
418
427
|
"default": "./src/handlers/pane-outbox-drain.ts"
|
|
419
428
|
},
|
|
429
|
+
"./handlers/platform-fault-escalation": {
|
|
430
|
+
"types": "./src/handlers/platform-fault-escalation.d.ts",
|
|
431
|
+
"default": "./src/handlers/platform-fault-escalation.ts"
|
|
432
|
+
},
|
|
433
|
+
"./handlers/platform-fault-escalation.ts": {
|
|
434
|
+
"types": "./src/handlers/platform-fault-escalation.d.ts",
|
|
435
|
+
"default": "./src/handlers/platform-fault-escalation.ts"
|
|
436
|
+
},
|
|
437
|
+
"./handlers/platform-fault-escalation-logic": {
|
|
438
|
+
"types": "./src/handlers/platform-fault-escalation-logic.d.ts",
|
|
439
|
+
"default": "./src/handlers/platform-fault-escalation-logic.ts"
|
|
440
|
+
},
|
|
441
|
+
"./handlers/platform-fault-escalation-logic.ts": {
|
|
442
|
+
"types": "./src/handlers/platform-fault-escalation-logic.d.ts",
|
|
443
|
+
"default": "./src/handlers/platform-fault-escalation-logic.ts"
|
|
444
|
+
},
|
|
420
445
|
"./handlers/projects-digest": {
|
|
421
446
|
"types": "./src/handlers/projects-digest.d.ts",
|
|
422
447
|
"default": "./src/handlers/projects-digest.ts"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Pure decision logic for the platform-fault escalation sweep
|
|
7
|
+
* (`platform-fault-escalation` handler). Split from the I/O shell so the
|
|
8
|
+
* threshold/dedup/episode semantics are unit-testable with zero adapter
|
|
9
|
+
* dependencies (same shape as whatsapp-liveness-logic).
|
|
10
|
+
*
|
|
11
|
+
* The semantics this locks (from the 2026-08-15 "760 errors escalated to
|
|
12
|
+
* nobody" incident brief):
|
|
13
|
+
* - Threshold on DURATION and RECURRENCE, never a single event. A kind
|
|
14
|
+
* qualifies as a sustained fault only when BOTH hold inside the lookback
|
|
15
|
+
* window: at least `min_count` occurrences (recurrence) spread over at
|
|
16
|
+
* least `min_span_minutes` (duration). One failed push is noise; the same
|
|
17
|
+
* kind recurring for half an hour is an outage.
|
|
18
|
+
* - One escalation per distinct fault kind per episode. An episode is open
|
|
19
|
+
* from first qualification until `resolve_after_minutes` of quiet; while
|
|
20
|
+
* open, the fault never re-fires — it only appends throttled updates.
|
|
21
|
+
* - Delivery is verified, not queued: an episode row with `escalated_at`
|
|
22
|
+
* NULL is a CLAIM, not an escalation. The sweep re-attempts delivery for
|
|
23
|
+
* claimed-but-undelivered episodes until the WhatsApp send returns a
|
|
24
|
+
* message id.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface FaultAggregate {
|
|
28
|
+
kind: string;
|
|
29
|
+
count: number;
|
|
30
|
+
firstSeen: Date;
|
|
31
|
+
lastSeen: Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EscalationEpisode {
|
|
35
|
+
kind: string;
|
|
36
|
+
firstSeenAt: Date;
|
|
37
|
+
lastSeenAt: Date;
|
|
38
|
+
eventCount: number;
|
|
39
|
+
escalatedAt: Date | null;
|
|
40
|
+
workItemId: string | null;
|
|
41
|
+
resolvedAt: Date | null;
|
|
42
|
+
lastUpdatedAt: Date | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FaultEscalationPolicy {
|
|
46
|
+
/** Recurrence floor: minimum event count inside the window. */
|
|
47
|
+
minCount: number;
|
|
48
|
+
/** Duration floor: minimum first→last spread inside the window, minutes. */
|
|
49
|
+
minSpanMinutes: number;
|
|
50
|
+
/** Quiet period that closes an episode and re-arms escalation, minutes. */
|
|
51
|
+
resolveAfterMinutes: number;
|
|
52
|
+
/** Minimum spacing between throttled updates on an open episode, minutes. */
|
|
53
|
+
updateAfterMinutes: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-kind threshold overrides — a low-rate-but-persistent kind (guard.fail.open
|
|
57
|
+
* at ~1/hour over 12h) has different noise characteristics than a bursty one, so
|
|
58
|
+
* the recurrence/duration floors are per-kind policy in the config row. */
|
|
59
|
+
export interface KindThresholdOverride {
|
|
60
|
+
minCount?: number;
|
|
61
|
+
minSpanMinutes?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SweepDecision {
|
|
65
|
+
/** Sustained faults needing a FIRST delivery (new episode, or a claim whose send failed). */
|
|
66
|
+
escalate: Array<{ aggregate: FaultAggregate; episode: EscalationEpisode | null }>;
|
|
67
|
+
/** Open, delivered episodes still recurring — append evidence, never re-send. */
|
|
68
|
+
update: Array<{ aggregate: FaultAggregate; episode: EscalationEpisode }>;
|
|
69
|
+
/** Open episodes whose kind went quiet past the resolve window — close them. */
|
|
70
|
+
resolve: Array<{ episode: EscalationEpisode; quietMinutes: number }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function minutesBetween(a: Date, b: Date): number {
|
|
74
|
+
return (b.getTime() - a.getTime()) / 60_000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isSustained(
|
|
78
|
+
aggregate: FaultAggregate,
|
|
79
|
+
policy: FaultEscalationPolicy,
|
|
80
|
+
override: KindThresholdOverride | undefined,
|
|
81
|
+
): boolean {
|
|
82
|
+
const minCount = override?.minCount ?? policy.minCount;
|
|
83
|
+
const minSpan = override?.minSpanMinutes ?? policy.minSpanMinutes;
|
|
84
|
+
return (
|
|
85
|
+
aggregate.count >= minCount &&
|
|
86
|
+
minutesBetween(aggregate.firstSeen, aggregate.lastSeen) >= minSpan
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Classify the current sweep. `aggregates` are the per-kind error aggregates
|
|
92
|
+
* from the lookback window; `episodes` are the existing episode rows for those
|
|
93
|
+
* kinds PLUS every still-open episode (an open episode whose kind no longer
|
|
94
|
+
* appears in the window at all is the strongest resolve candidate).
|
|
95
|
+
*/
|
|
96
|
+
export function decideSweep(
|
|
97
|
+
aggregates: FaultAggregate[],
|
|
98
|
+
episodes: EscalationEpisode[],
|
|
99
|
+
policy: FaultEscalationPolicy,
|
|
100
|
+
now: Date,
|
|
101
|
+
kindThresholds: Record<string, KindThresholdOverride> = {},
|
|
102
|
+
): SweepDecision {
|
|
103
|
+
const byKind = new Map(episodes.map((e) => [e.kind, e]));
|
|
104
|
+
const aggregateByKind = new Map(aggregates.map((a) => [a.kind, a]));
|
|
105
|
+
const decision: SweepDecision = { escalate: [], update: [], resolve: [] };
|
|
106
|
+
|
|
107
|
+
for (const aggregate of aggregates) {
|
|
108
|
+
const episode = byKind.get(aggregate.kind);
|
|
109
|
+
if (!isSustained(aggregate, policy, kindThresholds[aggregate.kind])) continue;
|
|
110
|
+
// No episode, or the previous episode closed (quiet past resolve window):
|
|
111
|
+
// a NEW episode begins and must be delivered.
|
|
112
|
+
if (!episode || episode.resolvedAt) {
|
|
113
|
+
decision.escalate.push({ aggregate, episode: episode ?? null });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// Open episode with no verified delivery — the claim is retried, never
|
|
117
|
+
// counted as escalated (a component may not report a state it has not
|
|
118
|
+
// verified).
|
|
119
|
+
if (!episode.escalatedAt) {
|
|
120
|
+
decision.escalate.push({ aggregate, episode });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Open, delivered, still recurring — throttled evidence update only.
|
|
124
|
+
const lastUpdate = episode.lastUpdatedAt ?? episode.escalatedAt;
|
|
125
|
+
if (minutesBetween(lastUpdate, now) >= policy.updateAfterMinutes) {
|
|
126
|
+
decision.update.push({ aggregate, episode });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const episode of episodes) {
|
|
131
|
+
if (episode.resolvedAt) continue;
|
|
132
|
+
const aggregate = aggregateByKind.get(episode.kind);
|
|
133
|
+
const quietReference = aggregate?.lastSeen ?? episode.lastSeenAt;
|
|
134
|
+
const quietMinutes = minutesBetween(quietReference, now);
|
|
135
|
+
if (quietMinutes >= policy.resolveAfterMinutes) {
|
|
136
|
+
decision.resolve.push({ episode, quietMinutes });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return decision;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Human "N min" span, rounded — for the phone-sized escalation message. */
|
|
144
|
+
export function formatSpanMinutes(minutes: number): string {
|
|
145
|
+
if (minutes < 60) return `${Math.round(minutes)} min`;
|
|
146
|
+
const hours = minutes / 60;
|
|
147
|
+
return `${hours >= 10 ? Math.round(hours) : Math.round(hours * 10) / 10} h`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The WhatsApp message id from a GOWA /send/message result. The canonical
|
|
152
|
+
* shape (from the spec the api-gowa adapter is generated from,
|
|
153
|
+
* SendResponse = { code, message, results: { message_id, status } }) is
|
|
154
|
+
* `results.message_id`; the legacy shape returned the bare id string as
|
|
155
|
+
* `results`. Delivery is only VERIFIED on a real id — `"true"`, a status
|
|
156
|
+
* string, or an empty body is not a message id and must not be recorded as
|
|
157
|
+
* delivered.
|
|
158
|
+
*/
|
|
159
|
+
export function extractGowaMessageId(sendResult: unknown): string | null {
|
|
160
|
+
const results = (sendResult as { results?: unknown } | undefined)?.results;
|
|
161
|
+
if (typeof results === "string") {
|
|
162
|
+
return /^[A-Z0-9]{8,}$/i.test(results) ? results : null;
|
|
163
|
+
}
|
|
164
|
+
if (results && typeof results === "object") {
|
|
165
|
+
const id = (results as { message_id?: unknown }).message_id;
|
|
166
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The escalation message — escalation-routing's three-field contract adapted
|
|
173
|
+
* to an automated detector: what was OBSERVED (evidence), what it MEANS
|
|
174
|
+
* (impact), and the RECOMMENDATION. Short: the channel is a phone.
|
|
175
|
+
*/
|
|
176
|
+
export function composeEscalationMessage(input: {
|
|
177
|
+
kind: string;
|
|
178
|
+
aggregate: FaultAggregate;
|
|
179
|
+
impact: string;
|
|
180
|
+
recommendation: string;
|
|
181
|
+
}): string {
|
|
182
|
+
const span = formatSpanMinutes(
|
|
183
|
+
(input.aggregate.lastSeen.getTime() - input.aggregate.firstSeen.getTime()) / 60_000,
|
|
184
|
+
);
|
|
185
|
+
return [
|
|
186
|
+
`🔴 Platform fault: ${input.kind}`,
|
|
187
|
+
`Observed: ${input.aggregate.count} errors over ${span}, still recurring.`,
|
|
188
|
+
`Impact: ${input.impact}`,
|
|
189
|
+
`Recommendation: ${input.recommendation}`,
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The work-item description carries the same three fields plus the evidence. */
|
|
194
|
+
export function composeEscalationDescription(input: {
|
|
195
|
+
aggregate: FaultAggregate;
|
|
196
|
+
impact: string;
|
|
197
|
+
recommendation: string;
|
|
198
|
+
}): string {
|
|
199
|
+
return [
|
|
200
|
+
"Automated platform-fault escalation (platform-fault-escalation sweep).",
|
|
201
|
+
`Observed: ${input.aggregate.count} '${input.aggregate.kind}' events in event_log from ${input.aggregate.firstSeen.toISOString()} to ${input.aggregate.lastSeen.toISOString()}, still recurring.`,
|
|
202
|
+
`Impact: ${input.impact}`,
|
|
203
|
+
`Recommendation: ${input.recommendation}`,
|
|
204
|
+
"Delivery: WhatsApp via the scala PA. This task closes automatically when the fault stops for the resolve window; reply via the PA to rule otherwise.",
|
|
205
|
+
].join("\n");
|
|
206
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the pure decision core of the platform-fault escalation
|
|
7
|
+
* sweep (`platform-fault-escalation` handler). The handler's DB/WhatsApp I/O
|
|
8
|
+
* is runtime-verified by the scheduled_job firing; this locks the threshold,
|
|
9
|
+
* dedup and episode semantics from the 2026-08-15 incident brief.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
composeEscalationMessage,
|
|
14
|
+
decideSweep,
|
|
15
|
+
extractGowaMessageId,
|
|
16
|
+
type EscalationEpisode,
|
|
17
|
+
type FaultAggregate,
|
|
18
|
+
type FaultEscalationPolicy,
|
|
19
|
+
} from "./platform-fault-escalation-logic.ts";
|
|
20
|
+
|
|
21
|
+
const NOW = new Date("2026-08-15T23:45:00+10:00");
|
|
22
|
+
const POLICY: FaultEscalationPolicy = {
|
|
23
|
+
minCount: 10,
|
|
24
|
+
minSpanMinutes: 25,
|
|
25
|
+
resolveAfterMinutes: 30,
|
|
26
|
+
updateAfterMinutes: 30,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function agg(kind: string, count: number, firstSeenMinutesAgo: number, lastSeenMinutesAgo: number): FaultAggregate {
|
|
30
|
+
return {
|
|
31
|
+
kind,
|
|
32
|
+
count,
|
|
33
|
+
firstSeen: new Date(NOW.getTime() - firstSeenMinutesAgo * 60_000),
|
|
34
|
+
lastSeen: new Date(NOW.getTime() - lastSeenMinutesAgo * 60_000),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function episode(overrides: Partial<EscalationEpisode> & { kind: string }): EscalationEpisode {
|
|
39
|
+
return {
|
|
40
|
+
firstSeenAt: NOW,
|
|
41
|
+
lastSeenAt: NOW,
|
|
42
|
+
eventCount: 10,
|
|
43
|
+
escalatedAt: new Date(NOW.getTime() - 60 * 60_000),
|
|
44
|
+
workItemId: "wi-1",
|
|
45
|
+
resolvedAt: null,
|
|
46
|
+
lastUpdatedAt: null,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("decideSweep — thresholds", () => {
|
|
52
|
+
test("a single event is noise — never escalates", () => {
|
|
53
|
+
const decision = decideSweep([agg("push.error", 1, 2, 2)], [], POLICY, NOW);
|
|
54
|
+
expect(decision.escalate).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("recurrence without duration does not escalate (10 events in 5 min)", () => {
|
|
58
|
+
const decision = decideSweep([agg("burst.error", 10, 5, 0)], [], POLICY, NOW);
|
|
59
|
+
expect(decision.escalate).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("duration without recurrence does not escalate (3 events over 40 min)", () => {
|
|
63
|
+
const decision = decideSweep([agg("sparse.error", 3, 40, 0)], [], POLICY, NOW);
|
|
64
|
+
expect(decision.escalate).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the 490-over-12h shape escalates (duration + recurrence)", () => {
|
|
68
|
+
const sustained = agg("host-command-bus.error", 490, 720, 0);
|
|
69
|
+
const decision = decideSweep([sustained], [], POLICY, NOW);
|
|
70
|
+
expect(decision.escalate).toHaveLength(1);
|
|
71
|
+
expect(decision.escalate[0]!.aggregate.kind).toBe("host-command-bus.error");
|
|
72
|
+
expect(decision.escalate[0]!.episode).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("per-kind threshold override lets a low-rate persistent kind through", () => {
|
|
76
|
+
// guard.fail.open: 8 events over 12h — below the default recurrence floor
|
|
77
|
+
// of 10, but persistent. The override lowers recurrence to 4 over 60 min.
|
|
78
|
+
const drip = agg("guard.fail.open", 8, 720, 0);
|
|
79
|
+
const withoutOverride = decideSweep([drip], [], POLICY, NOW);
|
|
80
|
+
expect(withoutOverride.escalate).toHaveLength(0);
|
|
81
|
+
const withOverride = decideSweep([drip], [], POLICY, NOW, {
|
|
82
|
+
"guard.fail.open": { minCount: 4, minSpanMinutes: 60 },
|
|
83
|
+
});
|
|
84
|
+
expect(withOverride.escalate).toHaveLength(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("override raises a floor too — bursty kinds can be tightened", () => {
|
|
88
|
+
const sustained = agg("llm.provider.error", 40, 60, 0); // passes default
|
|
89
|
+
const decision = decideSweep([sustained], [], POLICY, NOW, {
|
|
90
|
+
"llm.provider.error": { minCount: 100 },
|
|
91
|
+
});
|
|
92
|
+
expect(decision.escalate).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("decideSweep — dedup (one escalation per fault, no re-fire)", () => {
|
|
97
|
+
test("already escalated and unresolved does NOT re-escalate", () => {
|
|
98
|
+
const sustained = agg("host-command-bus.error", 500, 720, 0);
|
|
99
|
+
const open = episode({ kind: "host-command-bus.error", escalatedAt: new Date(NOW.getTime() - 3_600_000) });
|
|
100
|
+
const decision = decideSweep([sustained], [open], POLICY, NOW);
|
|
101
|
+
expect(decision.escalate).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("open delivered episode past the update window gets an UPDATE, not a message", () => {
|
|
105
|
+
const sustained = agg("llm.provider.error", 300, 720, 0);
|
|
106
|
+
const open = episode({
|
|
107
|
+
kind: "llm.provider.error",
|
|
108
|
+
escalatedAt: new Date(NOW.getTime() - 90 * 60_000), // last update long ago
|
|
109
|
+
lastUpdatedAt: new Date(NOW.getTime() - 90 * 60_000),
|
|
110
|
+
});
|
|
111
|
+
const decision = decideSweep([sustained], [open], POLICY, NOW);
|
|
112
|
+
expect(decision.escalate).toHaveLength(0);
|
|
113
|
+
expect(decision.update).toHaveLength(1);
|
|
114
|
+
expect(decision.update[0]!.episode.workItemId).toBe("wi-1");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("open episode inside the update window gets NOTHING (dedup window)", () => {
|
|
118
|
+
const sustained = agg("llm.provider.error", 300, 720, 0);
|
|
119
|
+
const open = episode({
|
|
120
|
+
kind: "llm.provider.error",
|
|
121
|
+
escalatedAt: new Date(NOW.getTime() - 10 * 60_000),
|
|
122
|
+
lastUpdatedAt: new Date(NOW.getTime() - 10 * 60_000),
|
|
123
|
+
});
|
|
124
|
+
const decision = decideSweep([sustained], [open], POLICY, NOW);
|
|
125
|
+
expect(decision.escalate).toHaveLength(0);
|
|
126
|
+
expect(decision.update).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a claimed-but-undelivered episode is RETRIED, not counted as escalated", () => {
|
|
130
|
+
const sustained = agg("guard.fail.open", 40, 720, 0);
|
|
131
|
+
const claim = episode({ kind: "guard.fail.open", escalatedAt: null, workItemId: null });
|
|
132
|
+
const decision = decideSweep([sustained], [claim], POLICY, NOW);
|
|
133
|
+
expect(decision.escalate).toHaveLength(1);
|
|
134
|
+
expect(decision.escalate[0]!.episode).toBe(claim);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("decideSweep — resolution + re-arm", () => {
|
|
139
|
+
test("quiet past the resolve window closes the episode", () => {
|
|
140
|
+
const gone = episode({ kind: "wake.failed", lastSeenAt: new Date(NOW.getTime() - 60 * 60_000) });
|
|
141
|
+
const decision = decideSweep([], [gone], POLICY, NOW);
|
|
142
|
+
expect(decision.resolve).toHaveLength(1);
|
|
143
|
+
expect(decision.resolve[0]!.quietMinutes).toBeGreaterThanOrEqual(30);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("an absent kind resolves by its episode's last_seen, not the sweep clock", () => {
|
|
147
|
+
const gone = episode({ kind: "listen.connect_failed", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) });
|
|
148
|
+
const decision = decideSweep([], [gone], POLICY, NOW);
|
|
149
|
+
expect(decision.resolve).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("after resolution the same fault escalates again (new episode)", () => {
|
|
153
|
+
const recurring = agg("host-command-bus.error", 50, 40, 0);
|
|
154
|
+
const resolved = episode({
|
|
155
|
+
kind: "host-command-bus.error",
|
|
156
|
+
resolvedAt: new Date(NOW.getTime() - 35 * 60_000),
|
|
157
|
+
});
|
|
158
|
+
const decision = decideSweep([recurring], [resolved], POLICY, NOW);
|
|
159
|
+
expect(decision.escalate).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("still-active kind within the resolve window is not resolved even if below threshold", () => {
|
|
163
|
+
const active = agg("slow.error", 3, 20, 0); // below threshold, but recent
|
|
164
|
+
const open = episode({ kind: "slow.error", lastSeenAt: active.lastSeen });
|
|
165
|
+
const decision = decideSweep([active], [open], POLICY, NOW);
|
|
166
|
+
expect(decision.resolve).toHaveLength(0);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("extractGowaMessageId — delivery verification", () => {
|
|
171
|
+
test("the canonical GOWA SendResponse shape yields results.message_id", () => {
|
|
172
|
+
expect(extractGowaMessageId({ code: "SUCCESS", results: { message_id: "3EB089B9D6ADD58153C561", status: "sent" } })).toBe(
|
|
173
|
+
"3EB089B9D6ADD58153C561",
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("the legacy bare-string results shape yields the id", () => {
|
|
178
|
+
expect(extractGowaMessageId({ results: "3EB089B9D6ADD58153C561" })).toBe("3EB089B9D6ADD58153C561");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a 'true' ack is NOT a message id — delivery stays unverified", () => {
|
|
182
|
+
// A boolean-ish ack string must never be recorded as a delivered id:
|
|
183
|
+
// that would mark the episode delivered with an unfollowable handle.
|
|
184
|
+
expect(extractGowaMessageId({ results: "true" })).toBeNull();
|
|
185
|
+
expect(extractGowaMessageId({ code: "SUCCESS", message: "sent", results: { status: "sent" } })).toBeNull();
|
|
186
|
+
expect(extractGowaMessageId({ code: "SUCCESS" })).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("composeEscalationMessage", () => {
|
|
191
|
+
test("carries the three escalation-routing fields and stays phone-sized", () => {
|
|
192
|
+
const message = composeEscalationMessage({
|
|
193
|
+
kind: "host-command-bus.error",
|
|
194
|
+
aggregate: agg("host-command-bus.error", 490, 720, 0),
|
|
195
|
+
impact: "scala-tools verbs are failing host-wide",
|
|
196
|
+
recommendation: "check oxmgr logs for the bus consumer",
|
|
197
|
+
});
|
|
198
|
+
expect(message).toContain("Platform fault: host-command-bus.error");
|
|
199
|
+
expect(message).toContain("Observed: 490 errors over 12 h");
|
|
200
|
+
expect(message).toContain("Impact: scala-tools verbs are failing host-wide");
|
|
201
|
+
expect(message).toContain("Recommendation: check oxmgr logs for the bus consumer");
|
|
202
|
+
expect(message.split("\n")).toHaveLength(4);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `platform-fault-escalation` pg-boss handler — closes the "receiving is
|
|
7
|
+
* decoupled from consequence" gap measured 2026-08-15 (760 error events over
|
|
8
|
+
* 12 hours, zero escalations: host-command-bus.error 490, llm.provider.error
|
|
9
|
+
* 231, guard.fail.open 16, scale-to-zero.wake_failed 12, listen.connect_failed
|
|
10
|
+
* 11). Emission works and observability-is-passive is satisfied; nothing
|
|
11
|
+
* acted. This sweep makes RECEIVING have CONSEQUENCE: sustained platform
|
|
12
|
+
* faults escalate to the OPERATOR over the existing PA path (WhatsApp, the
|
|
13
|
+
* scala PA operator-relay bot — escalation-routing's channel; no second
|
|
14
|
+
* channel invented), with the durable record as a work_items task the PA can
|
|
15
|
+
* read back and the operator can answer on.
|
|
16
|
+
*
|
|
17
|
+
* THIS IS NOT A DRIFT-DETECTOR PATROLLING INSTANCES. It reacts only to
|
|
18
|
+
* components reporting THEIR OWN failure — event_log kinds that already
|
|
19
|
+
* assert a fault about their emitter. Thresholds (duration + recurrence,
|
|
20
|
+
* never a single event), the watched-kind patterns, the resolve window and
|
|
21
|
+
* the per-kind impact/recommendation copy are all in the
|
|
22
|
+
* `config/platform-fault-escalation` registry row — changing policy is a row
|
|
23
|
+
* edit, not a redeploy. Episode dedup lives in the
|
|
24
|
+
* `platform_fault_escalations` table (migration 501): one escalation per
|
|
25
|
+
* distinct fault kind per episode; an already-escalated-and-unresolved fault
|
|
26
|
+
* only appends throttled work-item comments, never a second message.
|
|
27
|
+
*
|
|
28
|
+
* DELIVERY IS VERIFIED, NOT QUEUED. The WhatsApp send goes through the
|
|
29
|
+
* central `/mcp` loopback (`api_gowa_message` sendMessage — the single
|
|
30
|
+
* sealed GOWA-credential surface, same as whatsapp-liveness) and an episode
|
|
31
|
+
* only carries `escalated_at` once the send returned a WhatsApp message id;
|
|
32
|
+
* before that the row is a CLAIM the next sweep retries. Success is recorded
|
|
33
|
+
* only after readback of the delivery facts (message id + work item id).
|
|
34
|
+
*
|
|
35
|
+
* WHY A TICK EXISTS AT ALL, given observability-is-passive and
|
|
36
|
+
* no-polling-for-pushable-state: the trigger is DURATION + RECURRENCE of
|
|
37
|
+
* events already durably logged — a fact about the PATTERN of past events,
|
|
38
|
+
* not a state transition any single event can announce. The sweep reads one
|
|
39
|
+
* server-side aggregate (GROUP BY kind) per run against indexed event_log;
|
|
40
|
+
* while the platform is healthy it returns zero rows and costs nothing
|
|
41
|
+
* beyond the tick (idle-costs-nothing). Same justification
|
|
42
|
+
* lane-attention-sweep carries on its row as `poll_justification`.
|
|
43
|
+
*
|
|
44
|
+
* Registered under slug `@teamscala/service-boot:platform-fault-escalation`.
|
|
45
|
+
*/
|
|
46
|
+
import type { ExecutionContext } from "@teamscala/os/contracts/mcp";
|
|
47
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
48
|
+
import * as v from "valibot";
|
|
49
|
+
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
50
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
51
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
52
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
53
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
54
|
+
import {
|
|
55
|
+
composeEscalationDescription,
|
|
56
|
+
composeEscalationMessage,
|
|
57
|
+
decideSweep,
|
|
58
|
+
extractGowaMessageId,
|
|
59
|
+
type EscalationEpisode,
|
|
60
|
+
type FaultAggregate,
|
|
61
|
+
type KindThresholdOverride,
|
|
62
|
+
} from "./platform-fault-escalation-logic.ts";
|
|
63
|
+
|
|
64
|
+
const CONFIG_SLUG = "platform-fault-escalation";
|
|
65
|
+
|
|
66
|
+
const FaultEscalationConfigSchema = v.object({
|
|
67
|
+
/** Postgres regex patterns selecting watched event_log kinds. */
|
|
68
|
+
watched_kind_patterns: v.array(v.string()),
|
|
69
|
+
/** Lookback window for the recurrence/duration aggregate, minutes. */
|
|
70
|
+
window_minutes: v.number(),
|
|
71
|
+
min_count: v.number(),
|
|
72
|
+
min_span_minutes: v.number(),
|
|
73
|
+
/** Per-kind recurrence/duration overrides — low-rate-but-persistent kinds. */
|
|
74
|
+
kind_thresholds: v.optional(
|
|
75
|
+
v.record(
|
|
76
|
+
v.string(),
|
|
77
|
+
v.object({
|
|
78
|
+
min_count: v.optional(v.number()),
|
|
79
|
+
min_span_minutes: v.optional(v.number()),
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
resolve_after_minutes: v.number(),
|
|
84
|
+
update_after_minutes: v.number(),
|
|
85
|
+
/** The developer-session project escalations are parented to. */
|
|
86
|
+
parent_project_id: v.string(),
|
|
87
|
+
/** Per-kind copy for the message (impact + recommendation). Falls back to default_copy. */
|
|
88
|
+
copy: v.record(v.string(), v.object({ impact: v.string(), recommendation: v.string() })),
|
|
89
|
+
default_copy: v.object({ impact: v.string(), recommendation: v.string() }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
type FaultEscalationConfig = v.InferOutput<typeof FaultEscalationConfigSchema>;
|
|
93
|
+
|
|
94
|
+
interface EventLogAggregateRow {
|
|
95
|
+
kind: string;
|
|
96
|
+
n: bigint;
|
|
97
|
+
first_seen: Date;
|
|
98
|
+
last_seen: Date;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface OperatorRelayBot {
|
|
102
|
+
id: string;
|
|
103
|
+
organisation_id: string;
|
|
104
|
+
external_id: string | null;
|
|
105
|
+
flow_config: { operator_phone?: string } | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
|
|
109
|
+
async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
110
|
+
if (cachedSystemIdentity) return cachedSystemIdentity;
|
|
111
|
+
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
112
|
+
cachedSystemIdentity = {
|
|
113
|
+
userId: row?.system_user_id as string | undefined,
|
|
114
|
+
organisationId: row?.system_org_id as string | undefined,
|
|
115
|
+
};
|
|
116
|
+
return cachedSystemIdentity;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function loadConfig(): Promise<FaultEscalationConfig> {
|
|
120
|
+
return loadRegistryConfig("config", CONFIG_SLUG, FaultEscalationConfigSchema);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function fetchAggregates(patterns: string[], windowMinutes: number): Promise<FaultAggregate[]> {
|
|
124
|
+
const prisma = getPrisma();
|
|
125
|
+
// Patterns come from the valibot-validated registry row and are regex-only
|
|
126
|
+
// by construction below; embedding them as one alternation keeps the query
|
|
127
|
+
// parameter-free on the pattern side while the window is a real parameter.
|
|
128
|
+
const regexClass = /^[A-Za-z0-9_\\^$.*+?()[\]{}|-]+$/;
|
|
129
|
+
for (const pattern of patterns) {
|
|
130
|
+
if (!regexClass.test(pattern)) {
|
|
131
|
+
throw new Error(`config/${CONFIG_SLUG}: watched_kind_pattern is not regex-only: ${pattern}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const combined = `(${patterns.join("|")})`;
|
|
135
|
+
const rows = await prisma.$queryRawUnsafe<EventLogAggregateRow[]>(
|
|
136
|
+
`SELECT kind, count(*)::bigint AS n, min(occurred_at) AS first_seen, max(occurred_at) AS last_seen
|
|
137
|
+
FROM event_log
|
|
138
|
+
WHERE occurred_at > now() - ($1::int * interval '1 minute')
|
|
139
|
+
AND kind ~ $2::text
|
|
140
|
+
GROUP BY kind`,
|
|
141
|
+
windowMinutes,
|
|
142
|
+
combined,
|
|
143
|
+
);
|
|
144
|
+
return rows.map((row) => ({
|
|
145
|
+
kind: row.kind,
|
|
146
|
+
count: Number(row.n),
|
|
147
|
+
firstSeen: new Date(row.first_seen),
|
|
148
|
+
lastSeen: new Date(row.last_seen),
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
|
|
153
|
+
const prisma = getPrisma();
|
|
154
|
+
const bot = await prisma.messaging_bots.findFirst({
|
|
155
|
+
where: {
|
|
156
|
+
is_active: true,
|
|
157
|
+
platform: "whatsapp",
|
|
158
|
+
flow_config: { path: ["mode"], equals: "operator-relay" },
|
|
159
|
+
},
|
|
160
|
+
select: { id: true, organisation_id: true, external_id: true, flow_config: true },
|
|
161
|
+
});
|
|
162
|
+
if (!bot) throw new Error("platform-fault-escalation: no active operator-relay WhatsApp bot row");
|
|
163
|
+
const phone = (bot.flow_config as { operator_phone?: string } | null)?.operator_phone;
|
|
164
|
+
if (!phone) throw new Error("platform-fault-escalation: operator-relay bot has no operator_phone");
|
|
165
|
+
return bot as OperatorRelayBot;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** GOWA device whose JID matches the PA number, so the escalation is sent BY the PA. */
|
|
169
|
+
async function resolvePaDeviceId(paPhoneDigits: string): Promise<string | undefined> {
|
|
170
|
+
const { userId, organisationId } = await getSystemIdentity();
|
|
171
|
+
const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
|
|
172
|
+
const { forwardToolToCentralMcp } = await import("@teamscala/chat-completion/central-tool-forwarder");
|
|
173
|
+
const res = (await forwardToolToCentralMcp(
|
|
174
|
+
"api_gowa_devices",
|
|
175
|
+
{ action: "listDevices" },
|
|
176
|
+
systemCtx,
|
|
177
|
+
)) as { results?: Array<{ id: string; jid?: string }> } | undefined;
|
|
178
|
+
const match = (res?.results ?? []).find((device) => (device.jid ?? "").includes(paPhoneDigits));
|
|
179
|
+
return match?.id;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function deliverEscalation(
|
|
183
|
+
kind: string,
|
|
184
|
+
aggregate: FaultAggregate,
|
|
185
|
+
config: FaultEscalationConfig,
|
|
186
|
+
): Promise<{ messageId: string; workItemId: string }> {
|
|
187
|
+
const log = getAppLogger();
|
|
188
|
+
const prisma = getPrisma();
|
|
189
|
+
const now = new Date();
|
|
190
|
+
|
|
191
|
+
// 1. Claim the episode BEFORE sending, so a send failure leaves a durable
|
|
192
|
+
// claim the next sweep retries instead of a lost escalation.
|
|
193
|
+
await prisma.platform_fault_escalations.upsert({
|
|
194
|
+
where: { kind },
|
|
195
|
+
create: {
|
|
196
|
+
kind,
|
|
197
|
+
first_seen_at: aggregate.firstSeen,
|
|
198
|
+
last_seen_at: aggregate.lastSeen,
|
|
199
|
+
event_count: aggregate.count,
|
|
200
|
+
},
|
|
201
|
+
update: {
|
|
202
|
+
first_seen_at: aggregate.firstSeen,
|
|
203
|
+
last_seen_at: aggregate.lastSeen,
|
|
204
|
+
event_count: aggregate.count,
|
|
205
|
+
resolved_at: null,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// 2. Resolve the PA path (bot → operator phone → PA device) and send.
|
|
210
|
+
const bot = await resolveOperatorRelay();
|
|
211
|
+
const operatorPhone = (bot.flow_config as { operator_phone?: string }).operator_phone!;
|
|
212
|
+
const copy = config.copy[kind] ?? config.default_copy;
|
|
213
|
+
const message = composeEscalationMessage({
|
|
214
|
+
kind,
|
|
215
|
+
aggregate,
|
|
216
|
+
impact: copy.impact,
|
|
217
|
+
recommendation: copy.recommendation,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const { userId, organisationId } = await getSystemIdentity();
|
|
221
|
+
const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
|
|
222
|
+
const { forwardToolToCentralMcp } = await import("@teamscala/chat-completion/central-tool-forwarder");
|
|
223
|
+
const paDigits = (bot.external_id ?? "").replace(/\D/g, "");
|
|
224
|
+
const deviceId = paDigits ? await resolvePaDeviceId(paDigits) : undefined;
|
|
225
|
+
if (paDigits && !deviceId) {
|
|
226
|
+
log.warn("[platform-fault-escalation] no GOWA device matched the PA number — sending via default device");
|
|
227
|
+
}
|
|
228
|
+
const sendResult = await forwardToolToCentralMcp(
|
|
229
|
+
"api_gowa_message",
|
|
230
|
+
{
|
|
231
|
+
action: "sendMessage",
|
|
232
|
+
params: { phone: operatorPhone, message, ...(deviceId ? { device_id: deviceId } : {}) },
|
|
233
|
+
},
|
|
234
|
+
systemCtx,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// 3. VERIFY delivery before recording it: the send must return a WhatsApp
|
|
238
|
+
// message id. No id => throw, the claim stays undelivered, next sweep retries.
|
|
239
|
+
const messageId = extractGowaMessageId(sendResult);
|
|
240
|
+
if (!messageId) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`platform-fault-escalation: WhatsApp send for '${kind}' returned no message id — not recording delivery`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 4. Durable record: the work_items task the PA reads back / the operator answers on.
|
|
247
|
+
const workItem = await prisma.work_items.create({
|
|
248
|
+
data: {
|
|
249
|
+
kind: "task",
|
|
250
|
+
parent_id: config.parent_project_id,
|
|
251
|
+
organisation_id: bot.organisation_id,
|
|
252
|
+
title: `Fault: ${kind}`.slice(0, 50),
|
|
253
|
+
description: composeEscalationDescription({ aggregate, impact: copy.impact, recommendation: copy.recommendation }),
|
|
254
|
+
status: "pending",
|
|
255
|
+
kind_data: {
|
|
256
|
+
platform_fault: true,
|
|
257
|
+
event_kind: kind,
|
|
258
|
+
event_count: aggregate.count,
|
|
259
|
+
first_seen_at: aggregate.firstSeen.toISOString(),
|
|
260
|
+
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
261
|
+
delivered_message_id: messageId,
|
|
262
|
+
escalated_at: now.toISOString(),
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
select: { id: true },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// 5. Readback-correct the episode: delivered + linked in one update.
|
|
269
|
+
await prisma.platform_fault_escalations.update({
|
|
270
|
+
where: { kind },
|
|
271
|
+
data: {
|
|
272
|
+
escalated_at: now,
|
|
273
|
+
work_item_id: workItem.id,
|
|
274
|
+
delivered_message_id: messageId,
|
|
275
|
+
last_updated_at: now,
|
|
276
|
+
updated_at: now,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
recordEvent({
|
|
281
|
+
kind: "platform.fault.escalated",
|
|
282
|
+
payload: {
|
|
283
|
+
event_kind: kind,
|
|
284
|
+
count: aggregate.count,
|
|
285
|
+
work_item_id: workItem.id,
|
|
286
|
+
delivered_message_id: messageId,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
return { messageId, workItemId: workItem.id };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpisode): Promise<void> {
|
|
293
|
+
const prisma = getPrisma();
|
|
294
|
+
const now = new Date();
|
|
295
|
+
await prisma.platform_fault_escalations.update({
|
|
296
|
+
where: { kind: episode.kind },
|
|
297
|
+
data: {
|
|
298
|
+
last_seen_at: aggregate.lastSeen,
|
|
299
|
+
event_count: aggregate.count,
|
|
300
|
+
last_updated_at: now,
|
|
301
|
+
updated_at: now,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
if (episode.workItemId) {
|
|
305
|
+
await prisma.comments.create({
|
|
306
|
+
data: {
|
|
307
|
+
work_item_id: episode.workItemId,
|
|
308
|
+
content: `Still recurring: ${aggregate.count} events, last at ${aggregate.lastSeen.toISOString()}. No new escalation (already escalated and unresolved).`,
|
|
309
|
+
sender_role: "system",
|
|
310
|
+
sender_name: "platform-fault-escalation",
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
recordEvent({
|
|
315
|
+
kind: "platform.fault.updated",
|
|
316
|
+
payload: { event_kind: episode.kind, count: aggregate.count, work_item_id: episode.workItemId },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function resolveEpisode(episode: EscalationEpisode, quietMinutes: number): Promise<void> {
|
|
321
|
+
const prisma = getPrisma();
|
|
322
|
+
const now = new Date();
|
|
323
|
+
await prisma.platform_fault_escalations.update({
|
|
324
|
+
where: { kind: episode.kind },
|
|
325
|
+
data: { resolved_at: now, updated_at: now },
|
|
326
|
+
});
|
|
327
|
+
if (episode.workItemId) {
|
|
328
|
+
await prisma.comments.create({
|
|
329
|
+
data: {
|
|
330
|
+
work_item_id: episode.workItemId,
|
|
331
|
+
content: `Fault stopped — no '${episode.kind}' events for ${Math.round(quietMinutes)} min. Episode closed; the fault will re-escalate if it recurs.`,
|
|
332
|
+
sender_role: "system",
|
|
333
|
+
sender_name: "platform-fault-escalation",
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
recordEvent({
|
|
338
|
+
kind: "platform.fault.resolved",
|
|
339
|
+
payload: { event_kind: episode.kind, quiet_minutes: Math.round(quietMinutes), work_item_id: episode.workItemId },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
344
|
+
const log = getAppLogger();
|
|
345
|
+
const config = await loadConfig();
|
|
346
|
+
const now = new Date();
|
|
347
|
+
|
|
348
|
+
const aggregates = await fetchAggregates(config.watched_kind_patterns, config.window_minutes);
|
|
349
|
+
// Only OPEN episodes matter: a resolved episode and no episode at all have
|
|
350
|
+
// the same meaning to decideSweep (a recurring sustained fault opens a NEW
|
|
351
|
+
// episode and escalates again).
|
|
352
|
+
const openEpisodes = await getPrisma().platform_fault_escalations.findMany({
|
|
353
|
+
where: { resolved_at: null },
|
|
354
|
+
});
|
|
355
|
+
const episodes: EscalationEpisode[] = openEpisodes.map((row) => ({
|
|
356
|
+
kind: row.kind,
|
|
357
|
+
firstSeenAt: new Date(row.first_seen_at),
|
|
358
|
+
lastSeenAt: new Date(row.last_seen_at),
|
|
359
|
+
eventCount: row.event_count,
|
|
360
|
+
escalatedAt: row.escalated_at ? new Date(row.escalated_at) : null,
|
|
361
|
+
workItemId: row.work_item_id,
|
|
362
|
+
resolvedAt: row.resolved_at ? new Date(row.resolved_at) : null,
|
|
363
|
+
lastUpdatedAt: row.last_updated_at ? new Date(row.last_updated_at) : null,
|
|
364
|
+
}));
|
|
365
|
+
|
|
366
|
+
const kindThresholds: Record<string, KindThresholdOverride> = {};
|
|
367
|
+
for (const [kind, override] of Object.entries(config.kind_thresholds ?? {})) {
|
|
368
|
+
kindThresholds[kind] = {
|
|
369
|
+
minCount: override?.min_count,
|
|
370
|
+
minSpanMinutes: override?.min_span_minutes,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const decision = decideSweep(
|
|
374
|
+
aggregates,
|
|
375
|
+
episodes,
|
|
376
|
+
{
|
|
377
|
+
minCount: config.min_count,
|
|
378
|
+
minSpanMinutes: config.min_span_minutes,
|
|
379
|
+
resolveAfterMinutes: config.resolve_after_minutes,
|
|
380
|
+
updateAfterMinutes: config.update_after_minutes,
|
|
381
|
+
},
|
|
382
|
+
now,
|
|
383
|
+
kindThresholds,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
// Per-kind isolation: one fault's delivery failure must never block the
|
|
387
|
+
// others. Failures are logged + emitted so the escalator itself is
|
|
388
|
+
// observable (the sweep that silently stops sweeping is the original bug).
|
|
389
|
+
for (const { aggregate } of decision.escalate) {
|
|
390
|
+
try {
|
|
391
|
+
const delivered = await deliverEscalation(aggregate.kind, aggregate, config);
|
|
392
|
+
log.warn(
|
|
393
|
+
`[platform-fault-escalation] ESCALATED '${aggregate.kind}' — WhatsApp message ${delivered.messageId}, work item ${delivered.workItemId}`,
|
|
394
|
+
);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
397
|
+
log.error(`[platform-fault-escalation] delivery for '${aggregate.kind}' failed (claim retained, retried next sweep): ${reason}`);
|
|
398
|
+
recordEvent({ kind: "platform.fault.escalation_failed", payload: { event_kind: aggregate.kind, reason } });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const { aggregate, episode } of decision.update) {
|
|
402
|
+
try {
|
|
403
|
+
await updateEpisode(aggregate, episode);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
log.error(`[platform-fault-escalation] update for '${episode.kind}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const { episode, quietMinutes } of decision.resolve) {
|
|
409
|
+
try {
|
|
410
|
+
await resolveEpisode(episode, quietMinutes);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
log.error(`[platform-fault-escalation] resolve for '${episode.kind}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Emitted every run, even when nothing fired — a sweep that only reports
|
|
417
|
+
// when it escalated is indistinguishable from one that stopped running.
|
|
418
|
+
recordEvent({
|
|
419
|
+
kind: "platform.fault.swept",
|
|
420
|
+
payload: {
|
|
421
|
+
watched_kinds: aggregates.length,
|
|
422
|
+
escalated: decision.escalate.length,
|
|
423
|
+
updated: decision.update.length,
|
|
424
|
+
resolved: decision.resolve.length,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
if (decision.escalate.length > 0 || decision.update.length > 0 || decision.resolve.length > 0) {
|
|
428
|
+
log.info(
|
|
429
|
+
`[platform-fault-escalation] sweep — ${aggregates.length} kind(s) watched, ${decision.escalate.length} escalated, ${decision.update.length} updated, ${decision.resolve.length} resolved`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
pgBossHandlerRegistry.register(
|
|
435
|
+
"@teamscala/service-boot:platform-fault-escalation",
|
|
436
|
+
platformFaultEscalationSweep as (ctx: { config: ScheduledJobConfig }) => Promise<void>,
|
|
437
|
+
);
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -35,6 +35,7 @@ import "./handlers/singleton-tools-currency-watch.ts";
|
|
|
35
35
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
36
36
|
import "./handlers/pane-outbox-drain.ts";
|
|
37
37
|
import "./handlers/lane-attention-sweep.ts";
|
|
38
|
+
import "./handlers/platform-fault-escalation.ts";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Module-level reference to the mounted PgBoss instance — set by mountPgBoss on
|