@teamscala/service-boot 0.23.404 → 0.23.406

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.404",
9
+ "version": "0.23.406",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -42,6 +42,7 @@
42
42
  "@teamscala/publish-registry": "*",
43
43
  "@teamscala/realtime-utils": "*",
44
44
  "@teamscala/resolver-engine": "*",
45
+ "@teamscala/safe-fallback": "*",
45
46
  "@teamscala/timing": "*",
46
47
  "@teamscala/tool-executor": "*",
47
48
  "@teamscala/watchdog": "*",
@@ -373,6 +374,14 @@
373
374
  "types": "./src/fn-routers/public-gpt-session-router.d.ts",
374
375
  "default": "./src/fn-routers/public-gpt-session-router.ts"
375
376
  },
377
+ "./fn-routers/resend-client": {
378
+ "types": "./src/fn-routers/resend-client.d.ts",
379
+ "default": "./src/fn-routers/resend-client.ts"
380
+ },
381
+ "./fn-routers/resend-client.ts": {
382
+ "types": "./src/fn-routers/resend-client.d.ts",
383
+ "default": "./src/fn-routers/resend-client.ts"
384
+ },
376
385
  "./fn-routers/sign-in-flow-router": {
377
386
  "types": "./src/fn-routers/sign-in-flow-router.d.ts",
378
387
  "default": "./src/fn-routers/sign-in-flow-router.ts"
@@ -421,6 +430,14 @@
421
430
  "types": "./src/handlers/bun-stable-watch.d.ts",
422
431
  "default": "./src/handlers/bun-stable-watch.ts"
423
432
  },
433
+ "./handlers/messaging-send-reconcile-logic": {
434
+ "types": "./src/handlers/messaging-send-reconcile-logic.d.ts",
435
+ "default": "./src/handlers/messaging-send-reconcile-logic.ts"
436
+ },
437
+ "./handlers/messaging-send-reconcile-logic.ts": {
438
+ "types": "./src/handlers/messaging-send-reconcile-logic.d.ts",
439
+ "default": "./src/handlers/messaging-send-reconcile-logic.ts"
440
+ },
424
441
  "./handlers/messaging-send-recording-logic": {
425
442
  "types": "./src/handlers/messaging-send-recording-logic.d.ts",
426
443
  "default": "./src/handlers/messaging-send-recording-logic.ts"
@@ -437,6 +454,14 @@
437
454
  "types": "./src/handlers/messaging-send-recording-watch.d.ts",
438
455
  "default": "./src/handlers/messaging-send-recording-watch.ts"
439
456
  },
457
+ "./handlers/messaging-send-sweep-selection": {
458
+ "types": "./src/handlers/messaging-send-sweep-selection.d.ts",
459
+ "default": "./src/handlers/messaging-send-sweep-selection.ts"
460
+ },
461
+ "./handlers/messaging-send-sweep-selection.ts": {
462
+ "types": "./src/handlers/messaging-send-sweep-selection.d.ts",
463
+ "default": "./src/handlers/messaging-send-sweep-selection.ts"
464
+ },
440
465
  "./handlers/pane-outbox-drain": {
441
466
  "types": "./src/handlers/pane-outbox-drain.d.ts",
442
467
  "default": "./src/handlers/pane-outbox-drain.ts"
@@ -17,7 +17,7 @@ import { getServerClient } from "@teamscala/orpc/server-client-registry";
17
17
  import type { PrismaClient } from "@teamscala/db/client";
18
18
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
19
19
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
20
- import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
20
+ import { sendResendEmail } from "./resend-client.ts";
21
21
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
22
22
  import { getAppLogger } from "@teamscala/logger/app-loggers";
23
23
  import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
@@ -64,7 +64,6 @@ export async function sendAgreementCompletionEmails(
64
64
  log.warn("[completion-email] Resend token unavailable — skipping");
65
65
  return;
66
66
  }
67
- initResendClient(decrypted.token);
68
67
  const from = await getDefaultEmailSender();
69
68
 
70
69
  const base = baseUrl.replace(/\/+$/, "");
@@ -90,7 +89,7 @@ export async function sendAgreementCompletionEmails(
90
89
  agreementName: agreement.name,
91
90
  url,
92
91
  });
93
- const res = await sendEmail({
92
+ const res = await sendResendEmail(decrypted.token, {
94
93
  from,
95
94
  to: p.email,
96
95
  subject: tpl.subject,
@@ -29,7 +29,7 @@ import {
29
29
  decryptSecretConfig,
30
30
  type SecretConfigLike,
31
31
  } from "@teamscala/encryption/crypto/secret-config";
32
- import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
32
+ import { sendResendEmail } from "./resend-client.ts";
33
33
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
34
34
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
35
35
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -232,7 +232,6 @@ async function runSend(
232
232
  if (!decrypted.token) {
233
233
  return { ok: false as const, error: "Resend API key missing token" };
234
234
  }
235
- initResendClient(decrypted.token);
236
235
 
237
236
  const sent: Array<{ email: string; ok: boolean; id?: string; error?: string }> =
238
237
  [];
@@ -265,7 +264,7 @@ async function runSend(
265
264
  url: recipientUrl,
266
265
  },
267
266
  );
268
- const res = await sendEmail({
267
+ const res = await sendResendEmail(decrypted.token, {
269
268
  from: sender.from,
270
269
  to: r.email,
271
270
  reply_to: sender.replyTo,
@@ -12,7 +12,7 @@
12
12
  import { os } from "@orpc/server";
13
13
  import * as v from "valibot";
14
14
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
15
- import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
15
+ import { sendResendEmail } from "./resend-client.ts";
16
16
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
17
17
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
18
18
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -155,7 +155,6 @@ export const proposalSendRouter = {
155
155
  if (!decrypted?.token) {
156
156
  return { ok: false as const, error: "Resend API key not configured" };
157
157
  }
158
- initResendClient(decrypted.token);
159
158
 
160
159
  const sender = await resolveSender(prisma, proposal.organisation_id);
161
160
  const token = signAccessToken(
@@ -180,7 +179,7 @@ export const proposalSendRouter = {
180
179
  proposalName: proposal.name,
181
180
  url,
182
181
  });
183
- const res = await sendEmail({
182
+ const res = await sendResendEmail(decrypted.token, {
184
183
  from: sender.from,
185
184
  to: email,
186
185
  reply_to: sender.replyTo,
@@ -45,12 +45,12 @@ mock.module("@teamscala/logger/app-loggers", () => ({
45
45
  getAppLogger: () => noopLogger,
46
46
  }));
47
47
 
48
- mock.module("@teamscala/api-resend/send-email", () => ({
49
- initResendClient: () => {},
50
- sendEmail: async (args: CapturedEmail) => {
48
+ mock.module("@teamscala/api-resend", () => ({
49
+ createResendAdapter: () => ({ bridged: true }),
50
+ sendEmail: async (_client: unknown, args: CapturedEmail) => {
51
51
  capturedEmail = args;
52
52
  emailResolve?.(args);
53
- return { success: true as const, id: "re_test_123" };
53
+ return { id: "re_test_123" };
54
54
  },
55
55
  }));
56
56
 
@@ -23,7 +23,7 @@
23
23
  import { os } from "@orpc/server";
24
24
  import * as v from "valibot";
25
25
  import { getAppLogger } from "@teamscala/logger/app-loggers";
26
- import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
26
+ import { sendResendEmail } from "./resend-client.ts";
27
27
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
28
28
  import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
29
29
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
@@ -129,7 +129,6 @@ async function sendAuthorityReportEmail(opts: {
129
129
  log.warn("[authority-report-email] Resend token unavailable — skipping");
130
130
  return;
131
131
  }
132
- initResendClient(decrypted.token);
133
132
 
134
133
  const brandRow =
135
134
  opts.organisationId && opts.prisma
@@ -259,7 +258,7 @@ async function sendAuthorityReportEmail(opts: {
259
258
  });
260
259
 
261
260
  const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
262
- sendEmail({
261
+ sendResendEmail(decrypted.token, {
263
262
  from: fromHeader,
264
263
  to: opts.to,
265
264
  subject: tpl.subject,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The fn-router Resend send: a per-call adapter (org rest_client token)
7
+ * carrying the {success, id?, error?} shape the routers consume. Replaces
8
+ * the module-global @teamscala/api-resend/send-email wrapper a regen wiped;
9
+ * failure routes through safe-fallback (fail-open-noisy).
10
+ */
11
+ import { createResendAdapter, sendEmail } from "@teamscala/api-resend";
12
+ import { createSafeFallback } from "@teamscala/safe-fallback/create-safe-fallback";
13
+
14
+ /** The Resend send body (from/to/subject + html/text/reply_to) — forwarded
15
+ * to the adapter operation verbatim; nothing is assembled here. */
16
+ export type ResendSendBody = Parameters<typeof sendEmail>[1];
17
+
18
+ export interface ResendSendResult {
19
+ success: boolean;
20
+ id?: string;
21
+ error?: string;
22
+ }
23
+
24
+ const sendLayer = createSafeFallback<ResendSendResult>({
25
+ name: "service-boot:resend-send",
26
+ onFailure: "fail-open-noisy",
27
+ degradedResult: { success: false, error: "resend send layer degraded" },
28
+ failureBudget: 5,
29
+ });
30
+
31
+ export async function sendResendEmail(token: string, body: ResendSendBody): Promise<ResendSendResult> {
32
+ const result = await sendLayer.call(async () => {
33
+ const client = createResendAdapter({ apiKey: token });
34
+ const res = await sendEmail(client, body);
35
+ return { success: true, id: res.id } satisfies ResendSendResult;
36
+ });
37
+ return result;
38
+ }
@@ -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) {
@@ -202,3 +202,47 @@ describe("composeEscalationMessage", () => {
202
202
  expect(message.split("\n")).toHaveLength(4);
203
203
  });
204
204
  });
205
+
206
+ describe("decideSweep — qualification asks 'is this happening', not 'did this happen'", () => {
207
+ // 2026-08-26: llm.provider.call.failed re-escalated every ~10 minutes with
208
+ // lastSeen frozen at 16:54 — escalated 19:40 on a fault three hours dead,
209
+ // resolved 19:45, repeat. The tell was the closure counter climbing
210
+ // (141/151/171 min): quiet measured from one frozen aggregate lastSeen.
211
+ // isSustained checked count and span but never recency, so a dead fault
212
+ // kept qualifying for the rest of the 720-minute lookback while a resolved
213
+ // episode read as "no episode" and re-escalated it.
214
+ const resolved = (kind: string) =>
215
+ episode({ kind, resolvedAt: new Date(NOW.getTime() - 5 * 60_000) });
216
+
217
+ test("a fault quiet past the resolve window does not re-escalate after resolution", () => {
218
+ const decision = decideSweep(
219
+ [agg("stale.error", 30, 400, 170)],
220
+ [resolved("stale.error")],
221
+ POLICY,
222
+ NOW,
223
+ );
224
+ expect(decision.escalate).toHaveLength(0);
225
+ });
226
+
227
+ test("a fault quiet past the resolve window does not escalate with no episode at all", () => {
228
+ const decision = decideSweep([agg("stale.error", 30, 400, 170)], [], POLICY, NOW);
229
+ expect(decision.escalate).toHaveLength(0);
230
+ });
231
+
232
+ // The other direction, and the one that protects live escalations: the fix
233
+ // must not silence a fault that is still emitting.
234
+ test("a fault still emitting escalates", () => {
235
+ const decision = decideSweep([agg("live.error", 30, 400, 2)], [], POLICY, NOW);
236
+ expect(decision.escalate).toHaveLength(1);
237
+ });
238
+
239
+ test("a recurring fault opens a NEW episode after its previous one resolved", () => {
240
+ const decision = decideSweep(
241
+ [agg("live.error", 30, 400, 2)],
242
+ [resolved("live.error")],
243
+ POLICY,
244
+ NOW,
245
+ );
246
+ expect(decision.escalate).toHaveLength(1);
247
+ });
248
+ });