@teamscala/service-boot 0.23.366 → 0.23.368

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.366",
9
+ "version": "0.23.368",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -40,6 +40,7 @@ export interface EscalationEpisode {
40
40
  workItemId: string | null;
41
41
  resolvedAt: Date | null;
42
42
  lastUpdatedAt: Date | null;
43
+ deliveredCommentId?: string | null;
43
44
  }
44
45
 
45
46
  export interface FaultEscalationPolicy {
@@ -51,7 +51,6 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
51
51
  import { recordEvent } from "@teamscala/event-log/record-event";
52
52
  import { getAppLogger } from "@teamscala/logger/app-loggers";
53
53
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
54
- import { getServerClient } from "../page-server-orpc/server-client.ts";
55
54
  import {
56
55
  composeEscalationDescription,
57
56
  composeEscalationMessage,
@@ -83,8 +82,11 @@ const FaultEscalationConfigSchema = v.object({
83
82
  ),
84
83
  resolve_after_minutes: v.number(),
85
84
  update_after_minutes: v.number(),
86
- /** The developer-session project escalations are parented to. */
87
- parent_project_id: v.string(),
85
+ /** The orchestrator's work item — escalations are COMMENTS on it, never
86
+ * task rows (the fleet-task-placement CHECK constraint refuses a system
87
+ * task without session identity, and an escalation is a finding, not an
88
+ * assigned unit of work). */
89
+ orchestrator_work_item_id: v.string(),
88
90
  /** Per-kind copy for the message (impact + recommendation). Falls back to default_copy. */
89
91
  copy: v.record(v.string(), v.object({ impact: v.string(), recommendation: v.string() })),
90
92
  default_copy: v.object({ impact: v.string(), recommendation: v.string() }),
@@ -118,17 +120,55 @@ async function getSystemIdentity(): Promise<{ userId: string | undefined; organi
118
120
  }
119
121
 
120
122
  /**
121
- * The system-authed in-process ORPC client (set at boot after the router is
122
- * built — no-arg form carries the system super-admin context, correct for a
123
- * platform system action). Per `no-direct-prisma-outside-orpc`, every WRITE in
124
- * this handler routes through it; the single raw read (the event_log
125
- * recurrence aggregate, a GROUP BY no generated CRUD expresses) uses the
126
- * assigned-client form with $queryRawUnsafe — the lane-attention-sweep
123
+ * The system-authed in-process ORPC client, built LAZILY on first use from the
124
+ * SAME composed appRouter the /api/rpc mount uses (getAppRouter — never a raw
125
+ * re-import of the generated routers, per register-page-server-client's
126
+ * documented module-identity trap). Built here rather than read from
127
+ * page-server-orpc/server-client because a pg-boss handler can fire on a
128
+ * BACKEND service, where the page-server client is never registered — the
129
+ * empty-router fallback returns a client with no model namespaces (the
130
+ * 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
133
+ * the assigned-client form with $queryRawUnsafe — the lane-attention-sweep
127
134
  * precedent in this same directory. Timestamps are ISO strings, never Date
128
135
  * instances (the generated valibot schemas require it).
129
136
  */
130
- function orpc(): OrpcSurface {
131
- return getServerClient() as unknown as OrpcSurface;
137
+ let cachedOrpc: OrpcSurface | null = null;
138
+ async function orpc(): Promise<OrpcSurface> {
139
+ if (cachedOrpc) return cachedOrpc;
140
+ const { createRouterClient } = await import("@orpc/server");
141
+ const { getAppRouter } = await import("@teamscala/orpc/app-router");
142
+ const appRouter = (await getAppRouter()) as Record<string, unknown> | null;
143
+ if (!appRouter) {
144
+ throw new Error(
145
+ "platform-fault-escalation: no ORPC appRouter — the service has no generated routers to write through",
146
+ );
147
+ }
148
+ const { getPrisma } = await import("@teamscala/db/prisma-registry");
149
+ const { loadRegistryConfig } = await import("@teamscala/db/registry/load-config");
150
+ const { PlatformIdentitySchema } = await import(
151
+ "@teamscala/db-validation/registry-schemas/platform-identity"
152
+ );
153
+ const platformIdentity = await loadRegistryConfig(
154
+ "config",
155
+ "platform-identity",
156
+ PlatformIdentitySchema,
157
+ );
158
+ cachedOrpc = createRouterClient(
159
+ { ...appRouter } as never,
160
+ {
161
+ context: {
162
+ prisma: getPrisma(),
163
+ user: {
164
+ id: "system",
165
+ isSuperAdmin: true,
166
+ organisationId: platformIdentity.platformOrganisationId,
167
+ },
168
+ },
169
+ } as never,
170
+ ) as unknown as OrpcSurface;
171
+ return cachedOrpc;
132
172
  }
133
173
 
134
174
  /** The router subset this handler uses — mirrors the generated procedure args. */
@@ -187,7 +227,7 @@ async function fetchAggregates(patterns: string[], windowMinutes: number): Promi
187
227
  }
188
228
 
189
229
  async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
190
- const bot = (await orpc().messaging_bots.findFirst({
230
+ const bot = (await (await orpc()).messaging_bots.findFirst({
191
231
  where: {
192
232
  is_active: true,
193
233
  platform: "whatsapp",
@@ -225,7 +265,7 @@ async function deliverEscalation(
225
265
 
226
266
  // 1. Claim the episode BEFORE sending, so a send failure leaves a durable
227
267
  // claim the next sweep retries instead of a lost escalation.
228
- await orpc().platform_fault_escalations.upsert({
268
+ await (await orpc()).platform_fault_escalations.upsert({
229
269
  where: { kind },
230
270
  create: {
231
271
  kind,
@@ -279,34 +319,30 @@ async function deliverEscalation(
279
319
  );
280
320
  }
281
321
 
282
- // 4. Durable record: the work_items task the PA reads back / the operator answers on.
283
- const workItem = await orpc().work_items.create({
322
+ // 4. Durable record: a COMMENT on the orchestrator's work item — an
323
+ // escalation is a finding the orchestrator/operator triages, not an
324
+ // assigned task (the fleet-task-placement constraint refuses task rows
325
+ // without session identity by design).
326
+ const comment = await (await orpc()).comments.create({
284
327
  data: {
285
- kind: "task",
286
- parent_id: config.parent_project_id,
287
- organisation_id: bot.organisation_id,
288
- title: `Fault: ${kind}`.slice(0, 50),
289
- description: composeEscalationDescription({ aggregate, impact: copy.impact, recommendation: copy.recommendation }),
290
- status: "pending",
291
- kind_data: {
292
- platform_fault: true,
293
- event_kind: kind,
294
- event_count: aggregate.count,
295
- first_seen_at: aggregate.firstSeen.toISOString(),
296
- last_seen_at: aggregate.lastSeen.toISOString(),
297
- delivered_message_id: messageId,
298
- escalated_at: now.toISOString(),
299
- },
328
+ work_item_id: config.orchestrator_work_item_id,
329
+ content: composeEscalationMessage({
330
+ kind,
331
+ aggregate,
332
+ impact: copy.impact,
333
+ recommendation: copy.recommendation,
334
+ }),
335
+ source_cli_session_id: null,
300
336
  },
301
- select: { id: true },
302
337
  });
303
338
 
304
339
  // 5. Readback-correct the episode: delivered + linked in one update.
305
- await orpc().platform_fault_escalations.update({
340
+ await (await orpc()).platform_fault_escalations.update({
306
341
  where: { kind },
307
342
  data: {
308
343
  escalated_at: now.toISOString(),
309
- work_item_id: workItem.id,
344
+ work_item_id: config.orchestrator_work_item_id,
345
+ delivered_comment_id: comment.id,
310
346
  delivered_message_id: messageId,
311
347
  last_updated_at: now.toISOString(),
312
348
  updated_at: now.toISOString(),
@@ -318,16 +354,17 @@ async function deliverEscalation(
318
354
  payload: {
319
355
  event_kind: kind,
320
356
  count: aggregate.count,
321
- work_item_id: workItem.id,
357
+ work_item_id: config.orchestrator_work_item_id,
358
+ delivered_comment_id: comment.id,
322
359
  delivered_message_id: messageId,
323
360
  },
324
361
  });
325
- return { messageId, workItemId: workItem.id };
362
+ return { messageId, workItemId: config.orchestrator_work_item_id };
326
363
  }
327
364
 
328
365
  async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpisode): Promise<void> {
329
366
  const now = new Date();
330
- await orpc().platform_fault_escalations.update({
367
+ await (await orpc()).platform_fault_escalations.update({
331
368
  where: { kind: episode.kind },
332
369
  data: {
333
370
  last_seen_at: aggregate.lastSeen.toISOString(),
@@ -337,7 +374,7 @@ async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpiso
337
374
  },
338
375
  });
339
376
  if (episode.workItemId) {
340
- await orpc().comments.create({
377
+ await (await orpc()).comments.create({
341
378
  data: {
342
379
  work_item_id: episode.workItemId,
343
380
  content: `Still recurring: ${aggregate.count} events, last at ${aggregate.lastSeen.toISOString()}. No new escalation (already escalated and unresolved).`,
@@ -354,12 +391,12 @@ async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpiso
354
391
 
355
392
  async function resolveEpisode(episode: EscalationEpisode, quietMinutes: number): Promise<void> {
356
393
  const now = new Date();
357
- await orpc().platform_fault_escalations.update({
394
+ await (await orpc()).platform_fault_escalations.update({
358
395
  where: { kind: episode.kind },
359
396
  data: { resolved_at: now.toISOString(), updated_at: now.toISOString() },
360
397
  });
361
398
  if (episode.workItemId) {
362
- await orpc().comments.create({
399
+ await (await orpc()).comments.create({
363
400
  data: {
364
401
  work_item_id: episode.workItemId,
365
402
  content: `Fault stopped — no '${episode.kind}' events for ${Math.round(quietMinutes)} min. Episode closed; the fault will re-escalate if it recurs.`,
@@ -383,7 +420,7 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
383
420
  // Only OPEN episodes matter: a resolved episode and no episode at all have
384
421
  // the same meaning to decideSweep (a recurring sustained fault opens a NEW
385
422
  // episode and escalates again).
386
- const openEpisodes = (await orpc().platform_fault_escalations.findMany({
423
+ const openEpisodes = (await (await orpc()).platform_fault_escalations.findMany({
387
424
  where: { resolved_at: null },
388
425
  })) as Array<{
389
426
  kind: string;