@teamscala/service-boot 0.23.429 → 0.23.430
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 +1 -1
- package/src/handlers/platform-fault-escalation/config.ts +6 -0
- package/src/handlers/platform-fault-escalation/delivery.ts +29 -17
- package/src/handlers/platform-fault-escalation-logic.ts +11 -0
- package/src/handlers/platform-fault-escalation-refresh.test.ts +25 -1
- package/src/handlers/platform-fault-escalation.ts +1 -1
package/package.json
CHANGED
|
@@ -33,6 +33,12 @@ export const FaultEscalationConfigSchema = v.object({
|
|
|
33
33
|
),
|
|
34
34
|
),
|
|
35
35
|
resolve_after_minutes: v.number(),
|
|
36
|
+
/** Kinds a standing agent lane answers, which therefore escalate to the
|
|
37
|
+
* work item WITHOUT paging the operator (operator ruling 2026-08-28: "why
|
|
38
|
+
* is the WhatsApp PA messaging me about stuff that you should be doing").
|
|
39
|
+
* The episode, the comment and the durable record are unchanged — only the
|
|
40
|
+
* phone is spared, so silencing a page never silences the signal. */
|
|
41
|
+
agent_handled_kinds: v.optional(v.array(v.string())),
|
|
36
42
|
/** Per-kind volume sources closing the quiet-vs-dead gap: a fault kind
|
|
37
43
|
* whose producer can DIE maps to the kind whose row VOLUME proves the
|
|
38
44
|
* pipeline alive. Without a source, resolution on quiet is unchanged;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
composeEscalationMessage,
|
|
19
19
|
composeZeroVolumeMessage,
|
|
20
20
|
extractWhatsAppMessageId,
|
|
21
|
+
pagesOperator,
|
|
21
22
|
type EscalationEpisode,
|
|
22
23
|
type FaultAggregate,
|
|
23
24
|
type ZeroVolumeSource,
|
|
@@ -53,7 +54,7 @@ export async function deliverEscalation(
|
|
|
53
54
|
kind: string,
|
|
54
55
|
aggregate: FaultAggregate,
|
|
55
56
|
config: FaultEscalationConfig,
|
|
56
|
-
): Promise<{ messageId: string; workItemId: string }> {
|
|
57
|
+
): Promise<{ messageId: string | null; workItemId: string }> {
|
|
57
58
|
const now = new Date();
|
|
58
59
|
|
|
59
60
|
// 1. Claim the episode BEFORE sending, so a send failure leaves a durable
|
|
@@ -80,8 +81,11 @@ export async function deliverEscalation(
|
|
|
80
81
|
// that picks the device and records the send in messaging_logs. The retired
|
|
81
82
|
// api_gowa_message tool (deactivated 2026-08-26) is no longer callable;
|
|
82
83
|
// naming it here left every escalation send failing with "Unknown tool".
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
|
|
85
|
+
// PAGING IS PER-KIND (operator ruling 2026-08-28). Only the phone is
|
|
86
|
+
// skipped; episode, comment and record are identical, so a silenced page
|
|
87
|
+
// never becomes a silenced signal.
|
|
88
|
+
const paging = pagesOperator(kind, config.agent_handled_kinds);
|
|
85
89
|
const copy = config.copy[kind] ?? config.default_copy;
|
|
86
90
|
const { sample, lastEvent } = await fetchLatestEvidence(kind, config.sample_fields?.[kind] ?? []);
|
|
87
91
|
const message = composeEscalationMessage({
|
|
@@ -93,23 +97,31 @@ export async function deliverEscalation(
|
|
|
93
97
|
recommendation: copy.recommendation,
|
|
94
98
|
});
|
|
95
99
|
|
|
96
|
-
const { userId, organisationId } = await getSystemIdentity();
|
|
97
|
-
const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
|
|
98
|
-
const { forwardToolToCentralMcp } = await import("@teamscala/chat-completion/central-tool-forwarder");
|
|
99
|
-
const sendResult = await forwardToolToCentralMcp(
|
|
100
|
-
"messaging_whatsapp_send",
|
|
101
|
-
{ phone: operatorPhone, message },
|
|
102
|
-
systemCtx,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
100
|
// 3. VERIFY delivery before recording it: the send must return a WhatsApp
|
|
106
101
|
// message id. No id => throw, the claim stays undelivered, next sweep retries.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
102
|
+
// An agent-handled kind sends nothing, so there is no id to verify and none
|
|
103
|
+
// is recorded — absence here means "never paged", never "paged unverified".
|
|
104
|
+
let messageId: string | null = null;
|
|
105
|
+
if (paging) {
|
|
106
|
+
const bot = await resolveOperatorRelay();
|
|
107
|
+
const operatorPhone = (bot.flow_config as { operator_phone?: string }).operator_phone!;
|
|
108
|
+
const { userId, organisationId } = await getSystemIdentity();
|
|
109
|
+
const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
|
|
110
|
+
const { forwardToolToCentralMcp } = await import(
|
|
111
|
+
"@teamscala/chat-completion/central-tool-forwarder"
|
|
112
|
+
);
|
|
113
|
+
const sendResult = await forwardToolToCentralMcp(
|
|
114
|
+
"messaging_whatsapp_send",
|
|
115
|
+
{ phone: operatorPhone, message },
|
|
116
|
+
systemCtx,
|
|
112
117
|
);
|
|
118
|
+
messageId = extractWhatsAppMessageId(sendResult);
|
|
119
|
+
if (!messageId) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`platform-fault-escalation: WhatsApp send for '${kind}' returned no message id — not recording delivery. ` +
|
|
122
|
+
`Response: ${JSON.stringify(sendResult).slice(0, 400)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
// 4. Durable record: a COMMENT on the orchestrator's work item — an
|
|
@@ -244,6 +244,17 @@ export function episodeNeedsRefresh(
|
|
|
244
244
|
return aggregate.lastSeen.getTime() > episode.lastSeenAt.getTime();
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Does this fault kind page the operator, or land on the work item alone?
|
|
249
|
+
*
|
|
250
|
+
* DEFAULT IS TO PAGE. An unlisted kind reaches the phone, so forgetting to
|
|
251
|
+
* classify a new fault fails loudly rather than silently — the opposite
|
|
252
|
+
* default would let a kind be silenced by omission.
|
|
253
|
+
*/
|
|
254
|
+
export function pagesOperator(kind: string, agentHandledKinds?: string[]): boolean {
|
|
255
|
+
return !(agentHandledKinds ?? []).includes(kind);
|
|
256
|
+
}
|
|
257
|
+
|
|
247
258
|
export function formatSpanMinutes(minutes: number): string {
|
|
248
259
|
if (minutes < 60) return `${Math.round(minutes)} min`;
|
|
249
260
|
const hours = minutes / 60;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* episode immediately instead of after its configured resolve window.
|
|
22
22
|
*/
|
|
23
23
|
import { describe, expect, test } from "bun:test";
|
|
24
|
-
import { episodeNeedsRefresh } from "./platform-fault-escalation-logic.ts";
|
|
24
|
+
import { episodeNeedsRefresh, pagesOperator } from "./platform-fault-escalation-logic.ts";
|
|
25
25
|
|
|
26
26
|
const at = (iso: string) => ({ lastSeenAt: new Date(iso) });
|
|
27
27
|
const seen = (iso: string) => ({ lastSeen: new Date(iso) });
|
|
@@ -59,3 +59,27 @@ describe("episodeNeedsRefresh", () => {
|
|
|
59
59
|
);
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
describe("pagesOperator", () => {
|
|
64
|
+
// Operator ruling 2026-08-28: "why is the WhatsApp PA messaging me about
|
|
65
|
+
// stuff that you should be doing". A kind a standing lane answers escalates
|
|
66
|
+
// to the work item; the phone is spared.
|
|
67
|
+
const AGENT_HANDLED = ["guard.fail.open", "host-command-bus.command.failed"];
|
|
68
|
+
|
|
69
|
+
test("a kind the maintenance lane answers does not page", () => {
|
|
70
|
+
expect(pagesOperator("guard.fail.open", AGENT_HANDLED)).toBe(false);
|
|
71
|
+
expect(pagesOperator("host-command-bus.command.failed", AGENT_HANDLED)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("an unlisted kind still pages", () => {
|
|
75
|
+
expect(pagesOperator("supervisor.halt.entered", AGENT_HANDLED)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("DEFAULT IS TO PAGE when nothing is configured", () => {
|
|
79
|
+
// The load-bearing direction: a kind is silenced only by being named.
|
|
80
|
+
// If the list is missing or empty, every fault reaches the operator, so
|
|
81
|
+
// a new kind can never be silenced by omission.
|
|
82
|
+
expect(pagesOperator("guard.fail.open", undefined)).toBe(true);
|
|
83
|
+
expect(pagesOperator("guard.fail.open", [])).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -119,7 +119,7 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
|
|
|
119
119
|
try {
|
|
120
120
|
const delivered = await deliverEscalation(aggregate.kind, aggregate, config);
|
|
121
121
|
log.warn(
|
|
122
|
-
`[platform-fault-escalation] ESCALATED '${aggregate.kind}' — WhatsApp message ${delivered.messageId}, work item ${delivered.workItemId}`,
|
|
122
|
+
`[platform-fault-escalation] ESCALATED '${aggregate.kind}' — ${delivered.messageId ? `WhatsApp message ${delivered.messageId}` : "work item only (agent-handled kind, operator not paged)"}, work item ${delivered.workItemId}`,
|
|
123
123
|
);
|
|
124
124
|
} catch (err) {
|
|
125
125
|
const reason = err instanceof Error ? err.message : String(err);
|