@teamscala/service-boot 0.23.405 → 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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.405",
9
+ "version": "0.23.407",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -79,12 +79,19 @@ function isSustained(
79
79
  aggregate: FaultAggregate,
80
80
  policy: FaultEscalationPolicy,
81
81
  override: KindThresholdOverride | undefined,
82
+ now: Date,
82
83
  ): boolean {
83
84
  const minCount = override?.minCount ?? policy.minCount;
84
85
  const minSpan = override?.minSpanMinutes ?? policy.minSpanMinutes;
86
+ // Recency, not merely presence in the lookback window. Count and span
87
+ // answer "did this happen"; a fault qualifies only while it is STILL
88
+ // happening. Without this the 720-minute lookback keeps a dead fault
89
+ // qualifying for hours, and since a resolved episode reads as no episode
90
+ // it re-escalates on every sweep until its events age out (2026-08-26).
85
91
  return (
86
92
  aggregate.count >= minCount &&
87
- minutesBetween(aggregate.firstSeen, aggregate.lastSeen) >= minSpan
93
+ minutesBetween(aggregate.firstSeen, aggregate.lastSeen) >= minSpan &&
94
+ minutesBetween(aggregate.lastSeen, now) < policy.resolveAfterMinutes
88
95
  );
89
96
  }
90
97
 
@@ -107,7 +114,7 @@ export function decideSweep(
107
114
 
108
115
  for (const aggregate of aggregates) {
109
116
  const episode = byKind.get(aggregate.kind);
110
- if (!isSustained(aggregate, policy, kindThresholds[aggregate.kind])) continue;
117
+ if (!isSustained(aggregate, policy, kindThresholds[aggregate.kind], now)) continue;
111
118
  // No episode, or the previous episode closed (quiet past resolve window):
112
119
  // a NEW episode begins and must be delivered.
113
120
  if (!episode || episode.resolvedAt) {
@@ -169,39 +176,53 @@ export function extractGowaMessageId(sendResult: unknown): string | null {
169
176
  return null;
170
177
  }
171
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
+
172
203
  /**
173
204
  * The escalation message — escalation-routing's three-field contract adapted
174
- * to an automated detector: what was OBSERVED (evidence), what it MEANS
175
- * (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.
176
210
  */
177
211
  export function composeEscalationMessage(input: {
178
212
  kind: string;
179
213
  aggregate: FaultAggregate;
180
214
  impact: string;
181
215
  recommendation: string;
216
+ sample: string;
182
217
  }): string {
183
218
  const span = formatSpanMinutes(
184
219
  (input.aggregate.lastSeen.getTime() - input.aggregate.firstSeen.getTime()) / 60_000,
185
220
  );
186
- return [
221
+ const lines = [
187
222
  `🔴 Platform fault: ${input.kind}`,
188
223
  `Observed: ${input.aggregate.count} errors over ${span}, still recurring.`,
189
- `Impact: ${input.impact}`,
190
- `Recommendation: ${input.recommendation}`,
191
- ].join("\n");
192
- }
193
-
194
- /** The work-item description carries the same three fields plus the evidence. */
195
- export function composeEscalationDescription(input: {
196
- aggregate: FaultAggregate;
197
- impact: string;
198
- recommendation: string;
199
- }): string {
200
- return [
201
- "Automated platform-fault escalation (platform-fault-escalation sweep).",
202
- `Observed: ${input.aggregate.count} '${input.aggregate.kind}' events in event_log from ${input.aggregate.firstSeen.toISOString()} to ${input.aggregate.lastSeen.toISOString()}, still recurring.`,
203
- `Impact: ${input.impact}`,
204
- `Recommendation: ${input.recommendation}`,
205
- "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.",
206
- ].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");
207
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,4 +203,99 @@ 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
+ });
257
+ });
258
+
259
+ describe("decideSweep — qualification asks 'is this happening', not 'did this happen'", () => {
260
+ // 2026-08-26: llm.provider.call.failed re-escalated every ~10 minutes with
261
+ // lastSeen frozen at 16:54 — escalated 19:40 on a fault three hours dead,
262
+ // resolved 19:45, repeat. The tell was the closure counter climbing
263
+ // (141/151/171 min): quiet measured from one frozen aggregate lastSeen.
264
+ // isSustained checked count and span but never recency, so a dead fault
265
+ // kept qualifying for the rest of the 720-minute lookback while a resolved
266
+ // episode read as "no episode" and re-escalated it.
267
+ const resolved = (kind: string) =>
268
+ episode({ kind, resolvedAt: new Date(NOW.getTime() - 5 * 60_000) });
269
+
270
+ test("a fault quiet past the resolve window does not re-escalate after resolution", () => {
271
+ const decision = decideSweep(
272
+ [agg("stale.error", 30, 400, 170)],
273
+ [resolved("stale.error")],
274
+ POLICY,
275
+ NOW,
276
+ );
277
+ expect(decision.escalate).toHaveLength(0);
278
+ });
279
+
280
+ test("a fault quiet past the resolve window does not escalate with no episode at all", () => {
281
+ const decision = decideSweep([agg("stale.error", 30, 400, 170)], [], POLICY, NOW);
282
+ expect(decision.escalate).toHaveLength(0);
283
+ });
284
+
285
+ // The other direction, and the one that protects live escalations: the fix
286
+ // must not silence a fault that is still emitting.
287
+ test("a fault still emitting escalates", () => {
288
+ const decision = decideSweep([agg("live.error", 30, 400, 2)], [], POLICY, NOW);
289
+ expect(decision.escalate).toHaveLength(1);
290
+ });
291
+
292
+ test("a recurring fault opens a NEW episode after its previous one resolved", () => {
293
+ const decision = decideSweep(
294
+ [agg("live.error", 30, 400, 2)],
295
+ [resolved("live.error")],
296
+ POLICY,
297
+ NOW,
298
+ );
299
+ expect(decision.escalate).toHaveLength(1);
300
+ });
204
301
  });
@@ -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 and
22
- * the per-kind impact/recommendation copy are all in the
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 single raw read (the
132
- * event_log recurrence aggregate, a GROUP BY no generated CRUD expresses) uses
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: composeEscalationMessage({
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,