@teamscala/service-boot 0.23.406 → 0.23.407
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
|
@@ -176,39 +176,53 @@ export function extractGowaMessageId(sendResult: unknown): string | null {
|
|
|
176
176
|
return null;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* One sampled payload value rendered for the message's evidence line: a
|
|
181
|
+
* kind+count escalation is a pointer to evidence, not evidence (2026-08-26:
|
|
182
|
+
* the llm.provider.call.failed payloads held our own bounded-output contract
|
|
183
|
+
* violations while the escalation's impact line named healthy providers).
|
|
184
|
+
* JSON-encoded when non-string so structure survives, whitespace-collapsed
|
|
185
|
+
* to one line, credential shapes redacted before truncation (a hard cut can
|
|
186
|
+
* never expose the tail of a secret the length limit happened to spare),
|
|
187
|
+
* hard-truncated with an ellipsis so the reader knows the cut happened.
|
|
188
|
+
*/
|
|
189
|
+
export function formatEscalationSample(value: unknown): string {
|
|
190
|
+
const maxChars = 200;
|
|
191
|
+
const redactions: ReadonlyArray<[RegExp, string]> = [
|
|
192
|
+
[/\b(?:sk|gsk|ghp|gho|xox[baprs])[-_][A-Za-z0-9_-]{8,}/g, "[redacted]"],
|
|
193
|
+
[/\b(api[_-]?key|token|password|secret|authorization)\b["'=:\s]+(?:(?:bearer|basic)\s+)?[A-Za-z0-9._~+/=-]{4,}/gi, "$1=[redacted]"],
|
|
194
|
+
[/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]"],
|
|
195
|
+
];
|
|
196
|
+
const raw = typeof value === "string" ? value : (JSON.stringify(value) ?? "");
|
|
197
|
+
if (raw.length === 0) return "";
|
|
198
|
+
const redacted = redactions.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), raw);
|
|
199
|
+
const collapsed = redacted.replace(/\s+/g, " ").trim();
|
|
200
|
+
return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}…` : collapsed;
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
/**
|
|
180
204
|
* The escalation message — escalation-routing's three-field contract adapted
|
|
181
|
-
* to an automated detector: what was OBSERVED (evidence), what it
|
|
182
|
-
* (impact), and the RECOMMENDATION. Short: the channel is a phone.
|
|
205
|
+
* to an automated detector: what was OBSERVED (evidence + sample), what it
|
|
206
|
+
* MEANS (impact), and the RECOMMENDATION. Short: the channel is a phone. The
|
|
207
|
+
* sample line is the triage evidence — `sample_fields` whitelisted per kind
|
|
208
|
+
* by the caller, formatted by formatEscalationSample; empty means the kind
|
|
209
|
+
* samples nothing or the latest payload carried none of its fields.
|
|
183
210
|
*/
|
|
184
211
|
export function composeEscalationMessage(input: {
|
|
185
212
|
kind: string;
|
|
186
213
|
aggregate: FaultAggregate;
|
|
187
214
|
impact: string;
|
|
188
215
|
recommendation: string;
|
|
216
|
+
sample: string;
|
|
189
217
|
}): string {
|
|
190
218
|
const span = formatSpanMinutes(
|
|
191
219
|
(input.aggregate.lastSeen.getTime() - input.aggregate.firstSeen.getTime()) / 60_000,
|
|
192
220
|
);
|
|
193
|
-
|
|
221
|
+
const lines = [
|
|
194
222
|
`🔴 Platform fault: ${input.kind}`,
|
|
195
223
|
`Observed: ${input.aggregate.count} errors over ${span}, still recurring.`,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
/** The work-item description carries the same three fields plus the evidence. */
|
|
202
|
-
export function composeEscalationDescription(input: {
|
|
203
|
-
aggregate: FaultAggregate;
|
|
204
|
-
impact: string;
|
|
205
|
-
recommendation: string;
|
|
206
|
-
}): string {
|
|
207
|
-
return [
|
|
208
|
-
"Automated platform-fault escalation (platform-fault-escalation sweep).",
|
|
209
|
-
`Observed: ${input.aggregate.count} '${input.aggregate.kind}' events in event_log from ${input.aggregate.firstSeen.toISOString()} to ${input.aggregate.lastSeen.toISOString()}, still recurring.`,
|
|
210
|
-
`Impact: ${input.impact}`,
|
|
211
|
-
`Recommendation: ${input.recommendation}`,
|
|
212
|
-
"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.",
|
|
213
|
-
].join("\n");
|
|
224
|
+
];
|
|
225
|
+
if (input.sample) lines.push(`Sample: ${input.sample}`);
|
|
226
|
+
lines.push(`Impact: ${input.impact}`, `Recommendation: ${input.recommendation}`);
|
|
227
|
+
return lines.join("\n");
|
|
214
228
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
composeEscalationMessage,
|
|
14
14
|
decideSweep,
|
|
15
15
|
extractGowaMessageId,
|
|
16
|
+
formatEscalationSample,
|
|
16
17
|
type EscalationEpisode,
|
|
17
18
|
type FaultAggregate,
|
|
18
19
|
type FaultEscalationPolicy,
|
|
@@ -194,6 +195,7 @@ describe("composeEscalationMessage", () => {
|
|
|
194
195
|
aggregate: agg("host-command-bus.command.failed", 490, 720, 0),
|
|
195
196
|
impact: "scala-tools verbs are failing host-wide",
|
|
196
197
|
recommendation: "check oxmgr logs for the bus consumer",
|
|
198
|
+
sample: "",
|
|
197
199
|
});
|
|
198
200
|
expect(message).toContain("Platform fault: host-command-bus.command.failed");
|
|
199
201
|
expect(message).toContain("Observed: 490 errors over 12 h");
|
|
@@ -201,6 +203,57 @@ describe("composeEscalationMessage", () => {
|
|
|
201
203
|
expect(message).toContain("Recommendation: check oxmgr logs for the bus consumer");
|
|
202
204
|
expect(message.split("\n")).toHaveLength(4);
|
|
203
205
|
});
|
|
206
|
+
|
|
207
|
+
// 2026-08-26: three escalations reached the orchestrator seat carrying
|
|
208
|
+
// kind, count and duration only — triaging each required a hand event_log
|
|
209
|
+
// query, and the llm.provider.call.failed impact line pointed AWAY from
|
|
210
|
+
// the defect (payloads held our own 'Invalid length: Expected <=1200 but
|
|
211
|
+
// received 1255' contract violations recorded against healthy providers).
|
|
212
|
+
// The sample line carries the evidence in the first read.
|
|
213
|
+
test("carries the sampled payload evidence as its own line", () => {
|
|
214
|
+
const message = composeEscalationMessage({
|
|
215
|
+
kind: "llm.provider.call.failed",
|
|
216
|
+
aggregate: agg("llm.provider.call.failed", 26, 560, 0),
|
|
217
|
+
sample: 'status=429; message=Usage limit reached for 5 hour. Your limit will reset at 2026-08-26 01:19:03',
|
|
218
|
+
impact: "AI chat/completion calls are failing",
|
|
219
|
+
recommendation: "check provider quota",
|
|
220
|
+
});
|
|
221
|
+
const lines = message.split("\n");
|
|
222
|
+
expect(lines).toHaveLength(5);
|
|
223
|
+
expect(lines[2]).toContain("Sample: status=429");
|
|
224
|
+
expect(lines[2]).toContain("reset at 2026-08-26 01:19:03");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("formatEscalationSample — evidence truncated hard, secrets never shipped", () => {
|
|
229
|
+
test("non-string values are JSON-encoded so structure survives the phone channel", () => {
|
|
230
|
+
expect(formatEscalationSample(429)).toBe("429");
|
|
231
|
+
expect(formatEscalationSample(true)).toBe("true");
|
|
232
|
+
expect(formatEscalationSample({ expected: 1200, received: 1255 })).toBe('{"expected":1200,"received":1255}');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("truncates hard at the bound with an ellipsis so the reader knows the cut happened", () => {
|
|
236
|
+
const formatted = formatEscalationSample("x".repeat(500));
|
|
237
|
+
expect(formatted.length).toBe(201); // 200 chars + ellipsis
|
|
238
|
+
expect(formatted.endsWith("…")).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("redacts credential shapes before truncation — a cut can never expose a secret tail", () => {
|
|
242
|
+
expect(formatEscalationSample("key sk-abcdefghijklmnopqrstuvwxyz failed")).toBe("key [redacted] failed");
|
|
243
|
+
expect(formatEscalationSample("Authorization: Bearer abcdefghijklmnop")).toBe(
|
|
244
|
+
"Authorization=[redacted]",
|
|
245
|
+
);
|
|
246
|
+
expect(formatEscalationSample("api_key=abcdefgh1234 rejected")).toBe("api_key=[redacted] rejected");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("collapses whitespace to one line — the sample is one message line", () => {
|
|
250
|
+
expect(formatEscalationSample("line one\nline two\t tabbed")).toBe("line one line two tabbed");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("empty input yields an empty sample (no Sample line)", () => {
|
|
254
|
+
expect(formatEscalationSample(undefined)).toBe("");
|
|
255
|
+
expect(formatEscalationSample("")).toBe("");
|
|
256
|
+
});
|
|
204
257
|
});
|
|
205
258
|
|
|
206
259
|
describe("decideSweep — qualification asks 'is this happening', not 'did this happen'", () => {
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* THIS IS NOT A DRIFT-DETECTOR PATROLLING INSTANCES. It reacts only to
|
|
19
19
|
* components reporting THEIR OWN failure — event_log kinds that already
|
|
20
20
|
* assert a fault about their emitter. Thresholds (duration + recurrence,
|
|
21
|
-
* never a single event), the watched-kind patterns, the resolve window
|
|
22
|
-
*
|
|
21
|
+
* never a single event), the watched-kind patterns, the resolve window, the
|
|
22
|
+
* per-kind impact/recommendation copy and the per-kind payload sample-field
|
|
23
|
+
* whitelists are all in the
|
|
23
24
|
* `config/platform-fault-escalation` registry row — changing policy is a row
|
|
24
25
|
* edit, not a redeploy. Episode dedup lives in the
|
|
25
26
|
* `platform_fault_escalations` table (migration 501): one escalation per
|
|
@@ -52,10 +53,10 @@ import { recordEvent } from "@teamscala/event-log/record-event";
|
|
|
52
53
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
53
54
|
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
54
55
|
import {
|
|
55
|
-
composeEscalationDescription,
|
|
56
56
|
composeEscalationMessage,
|
|
57
57
|
decideSweep,
|
|
58
58
|
extractGowaMessageId,
|
|
59
|
+
formatEscalationSample,
|
|
59
60
|
type EscalationEpisode,
|
|
60
61
|
type FaultAggregate,
|
|
61
62
|
type KindThresholdOverride,
|
|
@@ -90,6 +91,10 @@ const FaultEscalationConfigSchema = v.object({
|
|
|
90
91
|
/** Per-kind copy for the message (impact + recommendation). Falls back to default_copy. */
|
|
91
92
|
copy: v.record(v.string(), v.object({ impact: v.string(), recommendation: v.string() })),
|
|
92
93
|
default_copy: v.object({ impact: v.string(), recommendation: v.string() }),
|
|
94
|
+
/** Per-kind payload-field whitelist sampled into the message's evidence
|
|
95
|
+
* line — named fields ONLY, never the whole payload: some kinds embed
|
|
96
|
+
* request context, and the escalation channel is WhatsApp. */
|
|
97
|
+
sample_fields: v.optional(v.record(v.string(), v.array(v.string()))),
|
|
93
98
|
});
|
|
94
99
|
|
|
95
100
|
type FaultEscalationConfig = v.InferOutput<typeof FaultEscalationConfigSchema>;
|
|
@@ -128,8 +133,9 @@ async function getSystemIdentity(): Promise<{ userId: string | undefined; organi
|
|
|
128
133
|
* BACKEND service, where the page-server client is never registered — the
|
|
129
134
|
* empty-router fallback returns a client with no model namespaces (the
|
|
130
135
|
* TypeError this replaced). Per `no-direct-prisma-outside-orpc`, every WRITE
|
|
131
|
-
* in this handler routes through this client; the
|
|
132
|
-
* event_log recurrence aggregate
|
|
136
|
+
* in this handler routes through this client; the two raw reads (the
|
|
137
|
+
* event_log recurrence aggregate and the latest-payload sample, shapes no
|
|
138
|
+
* generated CRUD expresses) use
|
|
133
139
|
* the assigned-client form with $queryRawUnsafe. Timestamps are ISO strings,
|
|
134
140
|
* never Date instances (the generated valibot schemas require it).
|
|
135
141
|
*/
|
|
@@ -184,9 +190,6 @@ interface OrpcSurface {
|
|
|
184
190
|
messaging_bots: {
|
|
185
191
|
findFirst(args: Record<string, unknown>): Promise<Record<string, unknown> | null>;
|
|
186
192
|
};
|
|
187
|
-
work_items: {
|
|
188
|
-
create(args: { data: Record<string, unknown>; select?: Record<string, unknown> }): Promise<{ id: string }>;
|
|
189
|
-
};
|
|
190
193
|
comments: {
|
|
191
194
|
create(args: { data: Record<string, unknown> }): Promise<{ id: string }>;
|
|
192
195
|
};
|
|
@@ -225,6 +228,42 @@ async function fetchAggregates(patterns: string[], windowMinutes: number): Promi
|
|
|
225
228
|
}));
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The message's evidence line: the whitelisted sample fields from the most
|
|
233
|
+
* recent payload for the kind. Field names come from the valibot-validated
|
|
234
|
+
* config row and are constrained to bare identifiers below, so the JSON path
|
|
235
|
+
* cannot break out of the string; only the named keys are selected, so an
|
|
236
|
+
* unlisted field is unreachable by construction rather than filtered later.
|
|
237
|
+
*/
|
|
238
|
+
async function fetchLatestPayloadSample(kind: string, fields: string[]): Promise<string> {
|
|
239
|
+
if (fields.length === 0) return "";
|
|
240
|
+
for (const field of fields) {
|
|
241
|
+
if (!/^[A-Za-z0-9_]+$/.test(field)) {
|
|
242
|
+
throw new Error(`config/${CONFIG_SLUG}: sample_fields entry is not a bare field name: ${field}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const selection = fields.map((field) => `payload_json->'${field}' AS "${field}"`).join(", ");
|
|
246
|
+
const prisma = getPrisma();
|
|
247
|
+
const rows = await prisma.$queryRawUnsafe<Array<Record<string, unknown>>>(
|
|
248
|
+
`SELECT ${selection}
|
|
249
|
+
FROM event_log
|
|
250
|
+
WHERE kind = $1::text
|
|
251
|
+
ORDER BY occurred_at DESC
|
|
252
|
+
LIMIT 1`,
|
|
253
|
+
kind,
|
|
254
|
+
);
|
|
255
|
+
const row = rows[0];
|
|
256
|
+
if (!row) return "";
|
|
257
|
+
const parts: string[] = [];
|
|
258
|
+
for (const field of fields) {
|
|
259
|
+
const value = row[field];
|
|
260
|
+
if (value === null || value === undefined) continue;
|
|
261
|
+
const formatted = formatEscalationSample(value);
|
|
262
|
+
if (formatted) parts.push(`${field}=${formatted}`);
|
|
263
|
+
}
|
|
264
|
+
return parts.join("; ");
|
|
265
|
+
}
|
|
266
|
+
|
|
228
267
|
async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
|
|
229
268
|
const bot = (await (await orpc()).messaging_bots.findFirst({
|
|
230
269
|
where: {
|
|
@@ -284,9 +323,11 @@ async function deliverEscalation(
|
|
|
284
323
|
const bot = await resolveOperatorRelay();
|
|
285
324
|
const operatorPhone = (bot.flow_config as { operator_phone?: string }).operator_phone!;
|
|
286
325
|
const copy = config.copy[kind] ?? config.default_copy;
|
|
326
|
+
const sample = await fetchLatestPayloadSample(kind, config.sample_fields?.[kind] ?? []);
|
|
287
327
|
const message = composeEscalationMessage({
|
|
288
328
|
kind,
|
|
289
329
|
aggregate,
|
|
330
|
+
sample,
|
|
290
331
|
impact: copy.impact,
|
|
291
332
|
recommendation: copy.recommendation,
|
|
292
333
|
});
|
|
@@ -325,12 +366,7 @@ async function deliverEscalation(
|
|
|
325
366
|
const comment = await (await orpc()).comments.create({
|
|
326
367
|
data: {
|
|
327
368
|
work_item_id: config.orchestrator_work_item_id,
|
|
328
|
-
content:
|
|
329
|
-
kind,
|
|
330
|
-
aggregate,
|
|
331
|
-
impact: copy.impact,
|
|
332
|
-
recommendation: copy.recommendation,
|
|
333
|
-
}),
|
|
369
|
+
content: message,
|
|
334
370
|
source_cli_session_id: null,
|
|
335
371
|
},
|
|
336
372
|
});
|
|
@@ -353,6 +389,7 @@ async function deliverEscalation(
|
|
|
353
389
|
payload: {
|
|
354
390
|
event_kind: kind,
|
|
355
391
|
count: aggregate.count,
|
|
392
|
+
sample,
|
|
356
393
|
work_item_id: config.orchestrator_work_item_id,
|
|
357
394
|
delivered_comment_id: comment.id,
|
|
358
395
|
delivered_message_id: messageId,
|