@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 +26 -1
- package/src/fn-routers/agreement-completion-email.ts +2 -3
- package/src/fn-routers/agreement-send-router.ts +2 -3
- package/src/fn-routers/proposal-send-router.ts +2 -3
- package/src/fn-routers/public-assessment-router.test.ts +4 -4
- package/src/fn-routers/public-assessment-router.ts +2 -3
- package/src/fn-routers/resend-client.ts +38 -0
- package/src/handlers/platform-fault-escalation-logic.ts +9 -2
- package/src/handlers/platform-fault-escalation.test.ts +44 -0
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
49
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
+
});
|