@teamscala/service-boot 0.23.417 → 0.23.419
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 +33 -1
- package/src/handlers/platform-fault-escalation/config.ts +54 -0
- package/src/handlers/platform-fault-escalation/delivery.ts +173 -0
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +122 -0
- package/src/handlers/platform-fault-escalation/orpc-client.ts +97 -0
- package/src/handlers/platform-fault-escalation.ts +15 -387
- package/src/register-registry-page-route/page-data-shared.ts +124 -0
- package/src/register-registry-page-route/public-shell-page-data.ts +169 -0
- package/src/register-registry-page-route/region-slots-page-data.ts +175 -0
- package/src/register-registry-page-route/resolve-page-data.ts +16 -515
- package/src/register-registry-page-route/single-page-data.ts +171 -0
|
@@ -1,411 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The `platform-fault-escalation` pg-boss handler. Full contract in curated
|
|
7
7
|
* `apps/escalation.md` — episodes EMIT ON TRANSITIONS ONLY; evidence bounded
|
|
8
8
|
* `occurred_at <= now()` and cited by resolvable event id; delivery verified
|
|
9
9
|
* by WhatsApp message id before `escalated_at` is recorded.
|
|
10
|
+
*
|
|
11
|
+
* This module is the SWEEP unit (load config → aggregate → decide → deliver/
|
|
12
|
+
* resolve per kind, isolated). The machinery lives as siblings under
|
|
13
|
+
* ./platform-fault-escalation/ (config, event-log-queries, orpc-client,
|
|
14
|
+
* delivery) — split when the parent held them all at 500 lines; the pure
|
|
15
|
+
* decision logic was already platform-fault-escalation-logic.ts.
|
|
10
16
|
*/
|
|
11
|
-
import type { ExecutionContext } from "@teamscala/os/contracts/mcp";
|
|
12
17
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
13
|
-
import * as v from "valibot";
|
|
14
|
-
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
15
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
16
18
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
17
19
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
18
20
|
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
19
21
|
import {
|
|
20
|
-
composeEscalationMessage,
|
|
21
22
|
decideSweep,
|
|
22
|
-
extractWhatsAppMessageId,
|
|
23
|
-
formatEscalationSample,
|
|
24
23
|
type EscalationEpisode,
|
|
25
|
-
type FaultAggregate,
|
|
26
24
|
type KindThresholdOverride,
|
|
27
25
|
} from "./platform-fault-escalation-logic.ts";
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
window_minutes: v.number(),
|
|
36
|
-
min_count: v.number(),
|
|
37
|
-
min_span_minutes: v.number(),
|
|
38
|
-
/** Per-kind recurrence/duration overrides — low-rate-but-persistent kinds. */
|
|
39
|
-
kind_thresholds: v.optional(
|
|
40
|
-
v.record(
|
|
41
|
-
v.string(),
|
|
42
|
-
v.object({
|
|
43
|
-
min_count: v.optional(v.number()),
|
|
44
|
-
min_span_minutes: v.optional(v.number()),
|
|
45
|
-
}),
|
|
46
|
-
),
|
|
47
|
-
),
|
|
48
|
-
resolve_after_minutes: v.number(),
|
|
49
|
-
/** The orchestrator's work item — escalations are COMMENTS on it, never
|
|
50
|
-
* task rows (the fleet-task-placement CHECK constraint refuses a system
|
|
51
|
-
* task without session identity, and an escalation is a finding, not an
|
|
52
|
-
* assigned unit of work). */
|
|
53
|
-
orchestrator_work_item_id: v.string(),
|
|
54
|
-
/** Per-kind copy for the message (impact + recommendation). Falls back to default_copy. */
|
|
55
|
-
copy: v.record(v.string(), v.object({ impact: v.string(), recommendation: v.string() })),
|
|
56
|
-
default_copy: v.object({ impact: v.string(), recommendation: v.string() }),
|
|
57
|
-
/** Per-kind payload-field whitelist sampled into the message's evidence
|
|
58
|
-
* line — named fields ONLY, never the whole payload: some kinds embed
|
|
59
|
-
* request context, and the escalation channel is WhatsApp. */
|
|
60
|
-
sample_fields: v.optional(v.record(v.string(), v.array(v.string()))),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
type FaultEscalationConfig = v.InferOutput<typeof FaultEscalationConfigSchema>;
|
|
64
|
-
|
|
65
|
-
interface EventLogAggregateRow {
|
|
66
|
-
kind: string;
|
|
67
|
-
n: bigint;
|
|
68
|
-
first_seen: Date;
|
|
69
|
-
last_seen: Date;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface OperatorRelayBot {
|
|
73
|
-
id: string;
|
|
74
|
-
organisation_id: string;
|
|
75
|
-
external_id: string | null;
|
|
76
|
-
flow_config: { operator_phone?: string } | null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
|
|
80
|
-
async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
81
|
-
if (cachedSystemIdentity) return cachedSystemIdentity;
|
|
82
|
-
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
83
|
-
cachedSystemIdentity = {
|
|
84
|
-
userId: row?.system_user_id as string | undefined,
|
|
85
|
-
organisationId: row?.system_org_id as string | undefined,
|
|
86
|
-
};
|
|
87
|
-
return cachedSystemIdentity;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The system-authed in-process ORPC client, built LAZILY on first use from the
|
|
92
|
-
* SAME composed appRouter the /api/rpc mount uses (getAppRouter — never a raw
|
|
93
|
-
* re-import of the generated routers, per register-page-server-client's
|
|
94
|
-
* documented module-identity trap). Built here rather than read from
|
|
95
|
-
* page-server-orpc/server-client because a pg-boss handler can fire on a
|
|
96
|
-
* BACKEND service, where the page-server client is never registered — the
|
|
97
|
-
* empty-router fallback returns a client with no model namespaces (the
|
|
98
|
-
* TypeError this replaced). Per `no-direct-prisma-outside-orpc`, every WRITE
|
|
99
|
-
* in this handler routes through this client; the two raw reads (the
|
|
100
|
-
* event_log recurrence aggregate and the latest-payload sample, shapes no
|
|
101
|
-
* generated CRUD expresses) use
|
|
102
|
-
* the assigned-client form with $queryRawUnsafe. Timestamps are ISO strings,
|
|
103
|
-
* never Date instances (the generated valibot schemas require it).
|
|
104
|
-
*/
|
|
105
|
-
let cachedOrpc: OrpcSurface | null = null;
|
|
106
|
-
async function orpc(): Promise<OrpcSurface> {
|
|
107
|
-
if (cachedOrpc) return cachedOrpc;
|
|
108
|
-
const { createRouterClient } = await import("@orpc/server");
|
|
109
|
-
const { getAppRouter } = await import("@teamscala/orpc/app-router");
|
|
110
|
-
const appRouter = (await getAppRouter()) as Record<string, unknown> | null;
|
|
111
|
-
if (!appRouter) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
"platform-fault-escalation: no ORPC appRouter — the service has no generated routers to write through",
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
const prismaRegistry = await import("@teamscala/db/prisma-registry");
|
|
117
|
-
const registryLoader = await import("@teamscala/db/registry/load-config");
|
|
118
|
-
const { PlatformIdentitySchema } = await import(
|
|
119
|
-
"@teamscala/db-validation/registry-schemas/platform-identity"
|
|
120
|
-
);
|
|
121
|
-
const platformIdentity = await registryLoader.loadRegistryConfig(
|
|
122
|
-
"config",
|
|
123
|
-
"platform-identity",
|
|
124
|
-
PlatformIdentitySchema,
|
|
125
|
-
);
|
|
126
|
-
cachedOrpc = createRouterClient(
|
|
127
|
-
{ ...appRouter } as never,
|
|
128
|
-
{
|
|
129
|
-
context: {
|
|
130
|
-
prisma: prismaRegistry.getPrisma(),
|
|
131
|
-
user: {
|
|
132
|
-
id: "system",
|
|
133
|
-
isSuperAdmin: true,
|
|
134
|
-
organisationId: platformIdentity.platformOrganisationId,
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
} as never,
|
|
138
|
-
) as unknown as OrpcSurface;
|
|
139
|
-
return cachedOrpc;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The router subset this handler uses — mirrors the generated procedure args. */
|
|
143
|
-
interface OrpcSurface {
|
|
144
|
-
platform_fault_escalations: {
|
|
145
|
-
upsert(args: {
|
|
146
|
-
where: { kind: string };
|
|
147
|
-
create: Record<string, unknown>;
|
|
148
|
-
update: Record<string, unknown>;
|
|
149
|
-
}): Promise<unknown>;
|
|
150
|
-
update(args: { where: { kind: string }; data: Record<string, unknown> }): Promise<unknown>;
|
|
151
|
-
findMany(args?: { where?: Record<string, unknown> }): Promise<Array<Record<string, unknown>>>;
|
|
152
|
-
};
|
|
153
|
-
messaging_bots: {
|
|
154
|
-
findFirst(args: Record<string, unknown>): Promise<Record<string, unknown> | null>;
|
|
155
|
-
};
|
|
156
|
-
comments: {
|
|
157
|
-
create(args: { data: Record<string, unknown> }): Promise<{ id: string }>;
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async function loadConfig(): Promise<FaultEscalationConfig> {
|
|
162
|
-
return loadRegistryConfig("config", CONFIG_SLUG, FaultEscalationConfigSchema);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async function fetchAggregates(patterns: string[], windowMinutes: number): Promise<FaultAggregate[]> {
|
|
166
|
-
const prisma = getPrisma();
|
|
167
|
-
// Patterns come from the valibot-validated registry row and are regex-only
|
|
168
|
-
// by construction below; embedding them as one alternation keeps the query
|
|
169
|
-
// parameter-free on the pattern side while the window is a real parameter.
|
|
170
|
-
// The window is bounded on BOTH sides by the database's own statement
|
|
171
|
-
// clock: `occurred_at` is producer-claimed, and an unbounded top edge
|
|
172
|
-
// admits rows stamped in the future by a skewed or mocked producer clock —
|
|
173
|
-
// which then fabricate "still recurring" for a quiet fault (measured
|
|
174
|
-
// 2026-08-26). A future row is not evidence of anything happening now.
|
|
175
|
-
const regexClass = /^[A-Za-z0-9_\\^$.*+?()[\]{}|-]+$/;
|
|
176
|
-
for (const pattern of patterns) {
|
|
177
|
-
if (!regexClass.test(pattern)) {
|
|
178
|
-
throw new Error(`config/${CONFIG_SLUG}: watched_kind_pattern is not regex-only: ${pattern}`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
const combined = `(${patterns.join("|")})`;
|
|
182
|
-
const rows = await prisma.$queryRawUnsafe<EventLogAggregateRow[]>(
|
|
183
|
-
`SELECT kind, count(*)::bigint AS n, min(occurred_at) AS first_seen, max(occurred_at) AS last_seen
|
|
184
|
-
FROM event_log
|
|
185
|
-
WHERE occurred_at > now() - ($1::int * interval '1 minute')
|
|
186
|
-
AND occurred_at <= now()
|
|
187
|
-
AND kind ~ $2::text
|
|
188
|
-
GROUP BY kind`,
|
|
189
|
-
windowMinutes,
|
|
190
|
-
combined,
|
|
191
|
-
);
|
|
192
|
-
return rows.map((row) => ({
|
|
193
|
-
kind: row.kind,
|
|
194
|
-
count: Number(row.n),
|
|
195
|
-
firstSeen: new Date(row.first_seen),
|
|
196
|
-
lastSeen: new Date(row.last_seen),
|
|
197
|
-
}));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* The message's evidence: the whitelisted sample fields AND the row identity
|
|
202
|
-
* (id + occurred_at) of the newest payload for the kind. Field names come
|
|
203
|
-
* from the valibot-validated config row and are constrained to bare
|
|
204
|
-
* identifiers below, so the JSON path cannot break out of the string. The
|
|
205
|
-
* `occurred_at <= now()` bound matches the aggregate's top edge: the cited
|
|
206
|
-
* "last" row must be a real, non-future event whose id rides in the message
|
|
207
|
-
* so the claim resolves against event_log.
|
|
208
|
-
*/
|
|
209
|
-
async function fetchLatestEvidence(
|
|
210
|
-
kind: string,
|
|
211
|
-
fields: string[],
|
|
212
|
-
): Promise<{ sample: string; lastEvent: { occurredAt: string; eventId: string } | null }> {
|
|
213
|
-
const prisma = getPrisma();
|
|
214
|
-
if (fields.length === 0) {
|
|
215
|
-
const rows = await prisma.$queryRawUnsafe<Array<{ id: unknown; occurred_at: Date }>>(
|
|
216
|
-
`SELECT id::text, occurred_at
|
|
217
|
-
FROM event_log
|
|
218
|
-
WHERE kind = $1::text AND occurred_at <= now()
|
|
219
|
-
ORDER BY occurred_at DESC
|
|
220
|
-
LIMIT 1`,
|
|
221
|
-
kind,
|
|
222
|
-
);
|
|
223
|
-
const top = rows[0];
|
|
224
|
-
return { sample: "", lastEvent: top ? { occurredAt: isoSeconds(top.occurred_at), eventId: String(top.id) } : null };
|
|
225
|
-
}
|
|
226
|
-
for (const field of fields) {
|
|
227
|
-
if (!/^[A-Za-z0-9_]+$/.test(field)) {
|
|
228
|
-
throw new Error(`config/${CONFIG_SLUG}: sample_fields entry is not a bare field name: ${field}`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
const selection = fields.map((field) => `payload_json->'${field}' AS "${field}"`).join(", ");
|
|
232
|
-
const rows = await prisma.$queryRawUnsafe<Array<Record<string, unknown>>>(
|
|
233
|
-
`SELECT id::text, occurred_at, ${selection}
|
|
234
|
-
FROM event_log
|
|
235
|
-
WHERE kind = $1::text AND occurred_at <= now()
|
|
236
|
-
ORDER BY occurred_at DESC
|
|
237
|
-
LIMIT 1`,
|
|
238
|
-
kind,
|
|
239
|
-
);
|
|
240
|
-
const row = rows[0];
|
|
241
|
-
if (!row) return { sample: "", lastEvent: null };
|
|
242
|
-
const parts: string[] = [];
|
|
243
|
-
for (const field of fields) {
|
|
244
|
-
const value = row[field];
|
|
245
|
-
if (value === null || value === undefined) continue;
|
|
246
|
-
const formatted = formatEscalationSample(value);
|
|
247
|
-
if (formatted) parts.push(`${field}=${formatted}`);
|
|
248
|
-
}
|
|
249
|
-
return {
|
|
250
|
-
sample: parts.join("; "),
|
|
251
|
-
lastEvent: {
|
|
252
|
-
occurredAt: isoSeconds(row.occurred_at as string | Date),
|
|
253
|
-
eventId: String(row.id),
|
|
254
|
-
},
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** ISO-8601 to whole seconds — the same precision event_log stores, so the
|
|
259
|
-
* cited string round-trips a `WHERE occurred_at = '<cited>'` query. */
|
|
260
|
-
function isoSeconds(value: Date | string): string {
|
|
261
|
-
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
|
|
265
|
-
const bot = (await (await orpc()).messaging_bots.findFirst({
|
|
266
|
-
where: {
|
|
267
|
-
is_active: true,
|
|
268
|
-
platform: "whatsapp",
|
|
269
|
-
flow_config: { path: ["mode"], equals: "operator-relay" },
|
|
270
|
-
},
|
|
271
|
-
select: { id: true, organisation_id: true, external_id: true, flow_config: true },
|
|
272
|
-
})) as unknown as OperatorRelayBot | null;
|
|
273
|
-
if (!bot) throw new Error("platform-fault-escalation: no active operator-relay WhatsApp bot row");
|
|
274
|
-
const phone = (bot.flow_config as { operator_phone?: string } | null)?.operator_phone;
|
|
275
|
-
if (!phone) throw new Error("platform-fault-escalation: operator-relay bot has no operator_phone");
|
|
276
|
-
return bot as OperatorRelayBot;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function deliverEscalation(
|
|
280
|
-
kind: string,
|
|
281
|
-
aggregate: FaultAggregate,
|
|
282
|
-
config: FaultEscalationConfig,
|
|
283
|
-
): Promise<{ messageId: string; workItemId: string }> {
|
|
284
|
-
const log = getAppLogger();
|
|
285
|
-
const now = new Date();
|
|
286
|
-
|
|
287
|
-
// 1. Claim the episode BEFORE sending, so a send failure leaves a durable
|
|
288
|
-
// claim the next sweep retries instead of a lost escalation.
|
|
289
|
-
await (await orpc()).platform_fault_escalations.upsert({
|
|
290
|
-
where: { kind },
|
|
291
|
-
create: {
|
|
292
|
-
kind,
|
|
293
|
-
first_seen_at: aggregate.firstSeen.toISOString(),
|
|
294
|
-
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
295
|
-
event_count: aggregate.count,
|
|
296
|
-
},
|
|
297
|
-
update: {
|
|
298
|
-
first_seen_at: aggregate.firstSeen.toISOString(),
|
|
299
|
-
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
300
|
-
event_count: aggregate.count,
|
|
301
|
-
resolved_at: null,
|
|
302
|
-
},
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
// 2. Resolve the operator relay and send. The send surface is the
|
|
306
|
-
// `messaging_whatsapp_send` intrinsic (scala-mcp / messaging-mcp-boot),
|
|
307
|
-
// which delegates to the Rust scala-messaging-service — the channel owner
|
|
308
|
-
// that picks the device and records the send in messaging_logs. The retired
|
|
309
|
-
// api_gowa_message tool (deactivated 2026-08-26) is no longer callable;
|
|
310
|
-
// naming it here left every escalation send failing with "Unknown tool".
|
|
311
|
-
const bot = await resolveOperatorRelay();
|
|
312
|
-
const operatorPhone = (bot.flow_config as { operator_phone?: string }).operator_phone!;
|
|
313
|
-
const copy = config.copy[kind] ?? config.default_copy;
|
|
314
|
-
const { sample, lastEvent } = await fetchLatestEvidence(kind, config.sample_fields?.[kind] ?? []);
|
|
315
|
-
const message = composeEscalationMessage({
|
|
316
|
-
kind,
|
|
317
|
-
aggregate,
|
|
318
|
-
sample,
|
|
319
|
-
lastEvent,
|
|
320
|
-
impact: copy.impact,
|
|
321
|
-
recommendation: copy.recommendation,
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
const { userId, organisationId } = await getSystemIdentity();
|
|
325
|
-
const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
|
|
326
|
-
const { forwardToolToCentralMcp } = await import("@teamscala/chat-completion/central-tool-forwarder");
|
|
327
|
-
const sendResult = await forwardToolToCentralMcp(
|
|
328
|
-
"messaging_whatsapp_send",
|
|
329
|
-
{ phone: operatorPhone, message },
|
|
330
|
-
systemCtx,
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
// 3. VERIFY delivery before recording it: the send must return a WhatsApp
|
|
334
|
-
// message id. No id => throw, the claim stays undelivered, next sweep retries.
|
|
335
|
-
const messageId = extractWhatsAppMessageId(sendResult);
|
|
336
|
-
if (!messageId) {
|
|
337
|
-
throw new Error(
|
|
338
|
-
`platform-fault-escalation: WhatsApp send for '${kind}' returned no message id — not recording delivery. ` +
|
|
339
|
-
`Response: ${JSON.stringify(sendResult).slice(0, 400)}`,
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// 4. Durable record: a COMMENT on the orchestrator's work item — an
|
|
344
|
-
// escalation is a finding the orchestrator/operator triages, not an
|
|
345
|
-
// assigned task (the fleet-task-placement constraint refuses task rows
|
|
346
|
-
// without session identity by design).
|
|
347
|
-
const comment = await (await orpc()).comments.create({
|
|
348
|
-
data: {
|
|
349
|
-
work_item_id: config.orchestrator_work_item_id,
|
|
350
|
-
content: message,
|
|
351
|
-
source_cli_session_id: null,
|
|
352
|
-
},
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// 5. Readback-correct the episode: delivered + linked in one update.
|
|
356
|
-
await (await orpc()).platform_fault_escalations.update({
|
|
357
|
-
where: { kind },
|
|
358
|
-
data: {
|
|
359
|
-
escalated_at: now.toISOString(),
|
|
360
|
-
work_item_id: config.orchestrator_work_item_id,
|
|
361
|
-
delivered_comment_id: comment.id,
|
|
362
|
-
delivered_message_id: messageId,
|
|
363
|
-
last_updated_at: now.toISOString(),
|
|
364
|
-
updated_at: now.toISOString(),
|
|
365
|
-
},
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
recordEvent({
|
|
369
|
-
kind: "platform.fault.escalated",
|
|
370
|
-
payload: {
|
|
371
|
-
event_kind: kind,
|
|
372
|
-
count: aggregate.count,
|
|
373
|
-
sample,
|
|
374
|
-
last_event_id: lastEvent?.eventId ?? null,
|
|
375
|
-
last_event_occurred_at: lastEvent?.occurredAt ?? null,
|
|
376
|
-
work_item_id: config.orchestrator_work_item_id,
|
|
377
|
-
delivered_comment_id: comment.id,
|
|
378
|
-
delivered_message_id: messageId,
|
|
379
|
-
},
|
|
380
|
-
});
|
|
381
|
-
return { messageId, workItemId: config.orchestrator_work_item_id };
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async function resolveEpisode(episode: EscalationEpisode, quietMinutes: number): Promise<void> {
|
|
385
|
-
const now = new Date();
|
|
386
|
-
await (await orpc()).platform_fault_escalations.update({
|
|
387
|
-
where: { kind: episode.kind },
|
|
388
|
-
data: { resolved_at: now.toISOString(), updated_at: now.toISOString() },
|
|
389
|
-
});
|
|
390
|
-
if (episode.workItemId) {
|
|
391
|
-
await (await orpc()).comments.create({
|
|
392
|
-
data: {
|
|
393
|
-
work_item_id: episode.workItemId,
|
|
394
|
-
content: `Fault stopped — no '${episode.kind}' events for ${Math.round(quietMinutes)} min. Episode closed; the fault will re-escalate if it recurs.`,
|
|
395
|
-
sender_role: "system",
|
|
396
|
-
sender_name: "platform-fault-escalation",
|
|
397
|
-
},
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
recordEvent({
|
|
401
|
-
kind: "platform.fault.resolved",
|
|
402
|
-
payload: { event_kind: episode.kind, quiet_minutes: Math.round(quietMinutes), work_item_id: episode.workItemId },
|
|
403
|
-
});
|
|
404
|
-
}
|
|
26
|
+
import { loadFaultEscalationConfig } from "./platform-fault-escalation/config.ts";
|
|
27
|
+
import { fetchAggregates } from "./platform-fault-escalation/event-log-queries.ts";
|
|
28
|
+
import { orpc } from "./platform-fault-escalation/orpc-client.ts";
|
|
29
|
+
import {
|
|
30
|
+
deliverEscalation,
|
|
31
|
+
resolveEpisode,
|
|
32
|
+
} from "./platform-fault-escalation/delivery.ts";
|
|
405
33
|
|
|
406
34
|
async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
407
35
|
const log = getAppLogger();
|
|
408
|
-
const config = await
|
|
36
|
+
const config = await loadFaultEscalationConfig();
|
|
409
37
|
const now = new Date();
|
|
410
38
|
|
|
411
39
|
const aggregates = await fetchAggregates(config.watched_kind_patterns, config.window_minutes);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Shared Puck-page-data helpers for the resolved-graph → pageData conversion:
|
|
7
|
+
* the local PuckData structural type, JSON extraction with an honest null on
|
|
8
|
+
* malformed input, layout-default application, region root-prop picking, the
|
|
9
|
+
* RegionEntry shape, and the unresolved-region-slot warn. Plus the brand-token
|
|
10
|
+
* prologue shared by all three render branches.
|
|
11
|
+
*
|
|
12
|
+
* Split out of resolve-page-data.ts (547 lines) — byte-for-byte moves; only
|
|
13
|
+
* closure variables became explicit parameters.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Local structural type for Puck page data. Avoids a type-only import of
|
|
20
|
+
* `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
|
|
21
|
+
* excludes whole-statement type-only imports, so the bare specifier has no
|
|
22
|
+
* installed package to resolve against at publish typecheck time. The shape used
|
|
23
|
+
* here (root/content/zones) is all this module reads or constructs.
|
|
24
|
+
*/
|
|
25
|
+
export type PuckData = {
|
|
26
|
+
root: { props?: Record<string, unknown> };
|
|
27
|
+
content: unknown[];
|
|
28
|
+
zones?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
|
|
32
|
+
|
|
33
|
+
// Returns null on malformed JSON (observed via warn log) instead of a silent
|
|
34
|
+
// masking empty fallback — protective-layers-fail-explicitly. Callers default
|
|
35
|
+
// to EMPTY_PUCK_DATA where an empty render is the intended degradation.
|
|
36
|
+
export function extractPuckData(source: Record<string, unknown>): PuckData | null {
|
|
37
|
+
const raw = source.puck_data ?? source.puckData;
|
|
38
|
+
if (!raw) return EMPTY_PUCK_DATA;
|
|
39
|
+
if (typeof raw === "string") {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw) as PuckData;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
getAppLogger().warn(
|
|
44
|
+
"[extractPuckData] malformed puck_data JSON — rendering empty",
|
|
45
|
+
{ err },
|
|
46
|
+
);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return raw as PuckData;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applyLayoutDefaults(
|
|
54
|
+
puckData: PuckData,
|
|
55
|
+
defaults: Record<string, string>,
|
|
56
|
+
): void {
|
|
57
|
+
if (!defaults || Object.keys(defaults).length === 0) return;
|
|
58
|
+
const data = puckData as { root?: { props?: Record<string, unknown> } };
|
|
59
|
+
if (!data.root) data.root = { props: {} };
|
|
60
|
+
if (!data.root.props) data.root.props = {};
|
|
61
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
62
|
+
if (data.root.props[key] == null) data.root.props[key] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function pickRootProp<T>(regions: RegionEntry[], key: string): T | undefined {
|
|
67
|
+
for (const region of regions) {
|
|
68
|
+
const props = (
|
|
69
|
+
region.puckData as { root?: { props?: Record<string, unknown> } } | null
|
|
70
|
+
)?.root?.props;
|
|
71
|
+
const value = props?.[key];
|
|
72
|
+
if (value != null && value !== "") return value as T;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RegionEntry {
|
|
78
|
+
slot: string;
|
|
79
|
+
puckData: PuckData;
|
|
80
|
+
pageId: string;
|
|
81
|
+
website?: { slug: string; name: string; organisation_id?: string | null };
|
|
82
|
+
metaTitle?: string | null;
|
|
83
|
+
metaDescription?: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A region slot the operator DECLARED (resource_loader.region_slots) whose
|
|
87
|
+
// source did not resolve used to vanish with no signal anywhere — the page
|
|
88
|
+
// rendered minus a region while the page, source, attributes, template and
|
|
89
|
+
// tree builder all looked innocent (measured 2026-08-18: a footer region
|
|
90
|
+
// added to a report produced no footer and no diagnostic; the cause was the
|
|
91
|
+
// boot-time config capture named in the message). One warn per slot per
|
|
92
|
+
// process, throttled like the block-coverage guard in the caller, so the hot
|
|
93
|
+
// path never spams it.
|
|
94
|
+
const unresolvedRegionSlotsLogged = new Set<string>();
|
|
95
|
+
|
|
96
|
+
export function warnUnresolvedRegionSlot(slot: string): void {
|
|
97
|
+
if (unresolvedRegionSlotsLogged.has(slot)) return;
|
|
98
|
+
unresolvedRegionSlotsLogged.add(slot);
|
|
99
|
+
getAppLogger().warn(
|
|
100
|
+
`[resolve-page-data] region slot "${slot}" is declared in the resource_loader config but no source resolved for it — the region is omitted from this render. A resource_loader edit reaches a booted service only after a restart.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The resolved brand tokens every branch reads (the old file's shared prologue). */
|
|
105
|
+
export function readBrandTokens(resolved: Record<string, unknown>): {
|
|
106
|
+
brand: Record<string, unknown>;
|
|
107
|
+
primaryColor: string;
|
|
108
|
+
headingFont: string | null;
|
|
109
|
+
bodyFont: string | null;
|
|
110
|
+
logo: { light: string | null; dark: string | null; brand: string | null };
|
|
111
|
+
brandTheme: string | undefined;
|
|
112
|
+
} {
|
|
113
|
+
const brand = (resolved.brand as Record<string, unknown>) ?? {};
|
|
114
|
+
const primaryColor = (brand.primaryColor as string) ?? "";
|
|
115
|
+
const headingFont = (brand.headingFont as string) ?? null;
|
|
116
|
+
const bodyFont = (brand.bodyFont as string) ?? null;
|
|
117
|
+
const logo = (brand.logo as {
|
|
118
|
+
light: string | null;
|
|
119
|
+
dark: string | null;
|
|
120
|
+
brand: string | null;
|
|
121
|
+
}) ?? { light: null, dark: null, brand: null };
|
|
122
|
+
const brandTheme = typeof brand.theme === "string" ? brand.theme : undefined;
|
|
123
|
+
return { brand, primaryColor, headingFont, bodyFont, logo, brandTheme };
|
|
124
|
+
}
|