@vellumai/assistant 0.11.3-staging.2 → 0.11.3-staging.4
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/__tests__/call-controller.test.ts +120 -0
- package/src/__tests__/config-loader-backfill.test.ts +19 -0
- package/src/__tests__/config-schema.test.ts +142 -5
- package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
- package/src/__tests__/media-stream-output.test.ts +175 -0
- package/src/__tests__/media-stream-stt-session.test.ts +67 -0
- package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
- package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
- package/src/calls/call-controller.ts +30 -2
- package/src/calls/call-speech-output.ts +12 -4
- package/src/calls/call-transport.ts +18 -1
- package/src/calls/media-stream-output.ts +53 -5
- package/src/calls/media-stream-server.ts +12 -0
- package/src/calls/media-stream-stt-session.ts +34 -0
- package/src/calls/telephony-synthesis-language.ts +84 -0
- package/src/calls/tts-text-sanitizer.ts +12 -5
- package/src/calls/voice-session-bridge.ts +31 -3
- package/src/calls/voice-triage-escalate.ts +52 -5
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
- package/src/config/loader.ts +5 -0
- package/src/config/schemas/__tests__/live-voice.test.ts +35 -0
- package/src/config/schemas/calls.ts +0 -4
- package/src/config/schemas/live-voice.ts +25 -0
- package/src/config/schemas/tts.ts +63 -0
- package/src/live-voice/__tests__/front-decision.test.ts +120 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
- package/src/live-voice/__tests__/live-voice-integration.test.ts +5 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
- package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
- package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
- package/src/live-voice/__tests__/live-voice-vad.test.ts +278 -1
- package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
- package/src/live-voice/front-decision.ts +50 -3
- package/src/live-voice/live-voice-session.ts +517 -45
- package/src/live-voice/live-voice-tts.ts +18 -2
- package/src/live-voice/progress-phrases.ts +105 -2
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
- package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
- package/src/providers/speech-to-text/provider-catalog.ts +38 -0
- package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/local-actor-identity.ts +18 -5
- package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
- package/src/runtime/routes/events-routes.ts +17 -16
- package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
- package/src/stt/__tests__/language-metadata.test.ts +85 -0
- package/src/stt/__tests__/speech-energy.test.ts +79 -0
- package/src/stt/language-metadata.ts +65 -0
- package/src/stt/speech-energy.ts +115 -12
- package/src/stt/types.ts +16 -0
- package/src/tts/__tests__/provider-adapters.test.ts +147 -0
- package/src/tts/__tests__/speakable-segments.test.ts +470 -0
- package/src/tts/language-voices.ts +23 -0
- package/src/tts/providers/deepgram-provider.ts +3 -1
- package/src/tts/providers/elevenlabs-provider.ts +73 -1
- package/src/tts/providers/xai-provider.ts +28 -2
- package/src/tts/speakable-segments.ts +293 -23
- package/src/tts/synthesis-stream.ts +7 -0
- package/src/tts/types.ts +7 -0
- package/src/util/__tests__/language-subtag.test.ts +54 -0
- package/src/util/language-subtag.ts +43 -0
- package/src/util/unicode.ts +1 -1
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
SttProviderId,
|
|
17
17
|
TelephonySttMode,
|
|
18
18
|
} from "../../stt/types.js";
|
|
19
|
+
import { baseLanguageSubtag } from "../../util/language-subtag.js";
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
22
|
// Client display metadata
|
|
@@ -282,6 +283,43 @@ export function listProviderEntries(): readonly SttProviderEntry[] {
|
|
|
282
283
|
return [...CATALOG.values()];
|
|
283
284
|
}
|
|
284
285
|
|
|
286
|
+
/**
|
|
287
|
+
* A base-subtag regex over the pinned listening language. The pin is
|
|
288
|
+
* free-form workspace config, and it flows into prompt interpolation and
|
|
289
|
+
* per-language table lookups, so only a plausible ISO 639 base subtag
|
|
290
|
+
* passes; anything else (junk strings, prototype keys like "constructor")
|
|
291
|
+
* resolves as no pin.
|
|
292
|
+
*/
|
|
293
|
+
const PINNED_LANGUAGE_SUBTAG_REGEX = /^[a-z]{2,3}$/;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The configured `services.stt.language` pin as the caller's listening
|
|
297
|
+
* language, or undefined when the pin carries no signal.
|
|
298
|
+
*
|
|
299
|
+
* A persisted pin only counts when the provider honors manual language
|
|
300
|
+
* selection: auto-detecting providers (gemini, whisper) ignore the setting
|
|
301
|
+
* entirely, so treating it as the caller's language would force every
|
|
302
|
+
* turn into a stale pin. "multi" and blank mean auto-detect (no pin), and
|
|
303
|
+
* the value must normalize to a plausible base subtag. Shared by the
|
|
304
|
+
* telephony pre-speech prompt rule (voice-session-bridge.ts), live
|
|
305
|
+
* voice's turn language (live-voice-session.ts), and telephony synthesis
|
|
306
|
+
* (telephony-synthesis-language.ts) so the gate cannot drift.
|
|
307
|
+
*/
|
|
308
|
+
export function pinnedListeningLanguage(
|
|
309
|
+
provider: string,
|
|
310
|
+
configuredLanguage: string | undefined,
|
|
311
|
+
): string | undefined {
|
|
312
|
+
const providerHonorsLanguagePin =
|
|
313
|
+
getProviderEntry(provider as SttProviderId)?.languageSelection === "manual";
|
|
314
|
+
if (!providerHonorsLanguagePin || configuredLanguage?.trim() === "multi") {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
const base = baseLanguageSubtag(configuredLanguage);
|
|
318
|
+
return base !== undefined && PINNED_LANGUAGE_SUBTAG_REGEX.test(base)
|
|
319
|
+
? base
|
|
320
|
+
: undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
285
323
|
/**
|
|
286
324
|
* Look up the credential-provider name for a given STT provider.
|
|
287
325
|
*
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the cache-bypass option on the local guardian principal lookup.
|
|
3
|
+
*
|
|
4
|
+
* The guardian-delivery reader caches a successful read that finds no binding,
|
|
5
|
+
* and gateway-side binding writes do not invalidate the daemon's cache. A
|
|
6
|
+
* caller polling for a binding it expects to appear (the SSE actor-principal
|
|
7
|
+
* heal) therefore has to force the read, or every attempt re-reads the same
|
|
8
|
+
* empty answer until the TTL lapses.
|
|
9
|
+
*/
|
|
10
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import type { GuardianDelivery } from "@vellumai/gateway-client";
|
|
13
|
+
|
|
14
|
+
let cachedResult: GuardianDelivery[] | null = null;
|
|
15
|
+
let freshResult: GuardianDelivery[] | null = null;
|
|
16
|
+
let cachedCalls = 0;
|
|
17
|
+
let freshCalls = 0;
|
|
18
|
+
|
|
19
|
+
mock.module("../../config/env.js", () => ({
|
|
20
|
+
isHttpAuthDisabled: () => true,
|
|
21
|
+
hasUngatedHttpAuthDisabled: () => false,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../../contacts/guardian-delivery-reader.js", () => ({
|
|
25
|
+
getGuardianDelivery: () => {
|
|
26
|
+
cachedCalls++;
|
|
27
|
+
return Promise.resolve(cachedResult);
|
|
28
|
+
},
|
|
29
|
+
getGuardianDeliveryFresh: () => {
|
|
30
|
+
freshCalls++;
|
|
31
|
+
return Promise.resolve(freshResult);
|
|
32
|
+
},
|
|
33
|
+
peekCachedGuardianDelivery: () => undefined,
|
|
34
|
+
guardianForChannel: (list: GuardianDelivery[]) => list[0],
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
findLocalGuardianPrincipalId,
|
|
39
|
+
resolveActorPrincipalIdForLocalGuardian,
|
|
40
|
+
} from "../local-actor-identity.js";
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
mock.restore();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Minimal guardian row: only `principalId` is read here. */
|
|
47
|
+
function guardian(principalId: string): GuardianDelivery {
|
|
48
|
+
return { principalId } as unknown as GuardianDelivery;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("local guardian principal lookup — cache bypass", () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
cachedCalls = 0;
|
|
54
|
+
freshCalls = 0;
|
|
55
|
+
cachedResult = [];
|
|
56
|
+
freshResult = [];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reads the cache by default", async () => {
|
|
60
|
+
cachedResult = [guardian("guardian-cached")];
|
|
61
|
+
freshResult = [guardian("guardian-fresh")];
|
|
62
|
+
|
|
63
|
+
expect(await findLocalGuardianPrincipalId()).toBe("guardian-cached");
|
|
64
|
+
expect(cachedCalls).toBe(1);
|
|
65
|
+
expect(freshCalls).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("forceRefresh bypasses the cache and sees a binding the cache would miss", async () => {
|
|
69
|
+
// Cached read holds the empty result from before the binding existed.
|
|
70
|
+
cachedResult = [];
|
|
71
|
+
freshResult = [guardian("guardian-fresh")];
|
|
72
|
+
|
|
73
|
+
expect(await findLocalGuardianPrincipalId()).toBeUndefined();
|
|
74
|
+
expect(await findLocalGuardianPrincipalId({ forceRefresh: true })).toBe(
|
|
75
|
+
"guardian-fresh",
|
|
76
|
+
);
|
|
77
|
+
expect(freshCalls).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("resolveActorPrincipalIdForLocalGuardian threads forceRefresh through", async () => {
|
|
81
|
+
cachedResult = [];
|
|
82
|
+
freshResult = [guardian("guardian-fresh")];
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
await resolveActorPrincipalIdForLocalGuardian("dev-bypass"),
|
|
86
|
+
).toBeUndefined();
|
|
87
|
+
expect(
|
|
88
|
+
await resolveActorPrincipalIdForLocalGuardian("dev-bypass", {
|
|
89
|
+
forceRefresh: true,
|
|
90
|
+
}),
|
|
91
|
+
).toBe("guardian-fresh");
|
|
92
|
+
expect(freshCalls).toBe(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a non-dev-bypass principal is passed through without any lookup", async () => {
|
|
96
|
+
expect(
|
|
97
|
+
await resolveActorPrincipalIdForLocalGuardian("actor-123", {
|
|
98
|
+
forceRefresh: true,
|
|
99
|
+
}),
|
|
100
|
+
).toBe("actor-123");
|
|
101
|
+
expect(cachedCalls).toBe(0);
|
|
102
|
+
expect(freshCalls).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -465,6 +465,29 @@ export class AssistantEventHub {
|
|
|
465
465
|
return this.getClientById(clientId)?.actorPrincipalId;
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Whether the connection identified by `connectionId` is still an active
|
|
470
|
+
* client subscription that carries no `actorPrincipalId`.
|
|
471
|
+
*
|
|
472
|
+
* Drives the retrying SSE self-heal (`sse-actor-principal-heal.ts`): the loop
|
|
473
|
+
* re-checks before each attempt so it stops as soon as the connection closes,
|
|
474
|
+
* is replaced by a reconnect, or gets a principal from any path. Returns
|
|
475
|
+
* `false` for an unknown, inactive, or process-type connection.
|
|
476
|
+
*/
|
|
477
|
+
needsActorPrincipalHeal(connectionId: string): boolean {
|
|
478
|
+
for (const entry of this.subscribers) {
|
|
479
|
+
if (entry.connectionId !== connectionId) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
return (
|
|
483
|
+
entry.active &&
|
|
484
|
+
entry.type === "client" &&
|
|
485
|
+
entry.actorPrincipalId == null
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
468
491
|
/**
|
|
469
492
|
* Fill a missing `actorPrincipalId` on a live client subscription.
|
|
470
493
|
*
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { isHttpAuthDisabled } from "../config/env.js";
|
|
12
12
|
import {
|
|
13
13
|
getGuardianDelivery,
|
|
14
|
+
getGuardianDeliveryFresh,
|
|
14
15
|
guardianForChannel,
|
|
15
16
|
peekCachedGuardianDelivery,
|
|
16
17
|
} from "../contacts/guardian-delivery-reader.js";
|
|
@@ -52,11 +53,19 @@ export function buildLocalAuthContext(conversationId: string): AuthContext {
|
|
|
52
53
|
* Returns `undefined` when no vellum guardian binding exists (e.g. fresh
|
|
53
54
|
* install before bootstrap, or the gateway is unreachable). Callers should
|
|
54
55
|
* treat that case as "not yet available" and proceed without a principalId.
|
|
56
|
+
*
|
|
57
|
+
* `forceRefresh` bypasses the reader's TTL cache. A successful read that finds
|
|
58
|
+
* no binding is itself cached, and gateway-side binding writes do not
|
|
59
|
+
* invalidate the daemon's cache, so a caller that polls for a binding it
|
|
60
|
+
* expects to appear (the SSE actor-principal heal) must force the read or it
|
|
61
|
+
* re-reads the same empty answer until the TTL lapses.
|
|
55
62
|
*/
|
|
56
|
-
export async function findLocalGuardianPrincipalId(
|
|
57
|
-
|
|
58
|
-
> {
|
|
59
|
-
const list =
|
|
63
|
+
export async function findLocalGuardianPrincipalId(options?: {
|
|
64
|
+
forceRefresh?: boolean;
|
|
65
|
+
}): Promise<string | undefined> {
|
|
66
|
+
const list = options?.forceRefresh
|
|
67
|
+
? await getGuardianDeliveryFresh({ channelTypes: ["vellum"] })
|
|
68
|
+
: await getGuardianDelivery({ channelTypes: ["vellum"] });
|
|
60
69
|
if (!list) {
|
|
61
70
|
return undefined;
|
|
62
71
|
}
|
|
@@ -136,15 +145,19 @@ export function findLocalGuardianPrincipalIdFromStore(): string | undefined {
|
|
|
136
145
|
* `undefined` when dev-bypass is set but no guardian binding has been created
|
|
137
146
|
* yet (e.g. fresh install before bootstrap); callers must treat this the
|
|
138
147
|
* same as a missing principal.
|
|
148
|
+
*
|
|
149
|
+
* `forceRefresh` bypasses the guardian-delivery cache, for callers that poll
|
|
150
|
+
* for a binding they expect to appear. See {@link findLocalGuardianPrincipalId}.
|
|
139
151
|
*/
|
|
140
152
|
export async function resolveActorPrincipalIdForLocalGuardian(
|
|
141
153
|
rawHeader: string | undefined,
|
|
154
|
+
options?: { forceRefresh?: boolean },
|
|
142
155
|
): Promise<string | undefined> {
|
|
143
156
|
if (rawHeader !== "dev-bypass" || !isHttpAuthDisabled()) {
|
|
144
157
|
return rawHeader;
|
|
145
158
|
}
|
|
146
159
|
|
|
147
|
-
const guardianPrincipalId = await findLocalGuardianPrincipalId();
|
|
160
|
+
const guardianPrincipalId = await findLocalGuardianPrincipalId(options);
|
|
148
161
|
if (guardianPrincipalId) {
|
|
149
162
|
return guardianPrincipalId;
|
|
150
163
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the retrying SSE actor-principal self-heal.
|
|
3
|
+
*
|
|
4
|
+
* The invariant they pin: a single empty or failed guardian lookup must not
|
|
5
|
+
* strand the subscription without a principal. The read goes over the gateway
|
|
6
|
+
* IPC and comes back empty both when the transport fails and when no binding
|
|
7
|
+
* exists yet, and a subscription that stays principal-less rejects every
|
|
8
|
+
* host-proxy result its client submits with "Submitting actor does not match
|
|
9
|
+
* the target client's actor for this request" until it reconnects.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { startActorPrincipalHeal } from "../sse-actor-principal-heal.js";
|
|
14
|
+
|
|
15
|
+
/** All-zero schedule so the retries run without real wall-clock delay. */
|
|
16
|
+
const NO_DELAYS = [0, 0, 0];
|
|
17
|
+
|
|
18
|
+
/** Minimal hub double: `needs` flips to false once a principal is filled. */
|
|
19
|
+
function fakeHub(options?: { needs?: boolean }) {
|
|
20
|
+
let needs = options?.needs ?? true;
|
|
21
|
+
const filled: string[] = [];
|
|
22
|
+
return {
|
|
23
|
+
filled,
|
|
24
|
+
setNeeds(value: boolean) {
|
|
25
|
+
needs = value;
|
|
26
|
+
},
|
|
27
|
+
needsActorPrincipalHeal: mock(() => needs),
|
|
28
|
+
fillClientActorPrincipalId: mock((_connectionId: string, id: string) => {
|
|
29
|
+
filled.push(id);
|
|
30
|
+
needs = false;
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Let the heal's async loop run to completion. */
|
|
36
|
+
async function settle(): Promise<void> {
|
|
37
|
+
for (let i = 0; i < 10; i++) {
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("startActorPrincipalHeal", () => {
|
|
43
|
+
test("fills the principal on the first successful attempt", async () => {
|
|
44
|
+
const hub = fakeHub();
|
|
45
|
+
startActorPrincipalHeal({
|
|
46
|
+
hub,
|
|
47
|
+
connectionId: "conn-1",
|
|
48
|
+
resolve: () => Promise.resolve("guardian-real-id"),
|
|
49
|
+
delaysMs: NO_DELAYS,
|
|
50
|
+
});
|
|
51
|
+
await settle();
|
|
52
|
+
|
|
53
|
+
expect(hub.filled).toEqual(["guardian-real-id"]);
|
|
54
|
+
expect(hub.needsActorPrincipalHeal).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("retries after a lookup that resolves undefined (gateway unreachable)", async () => {
|
|
58
|
+
const hub = fakeHub();
|
|
59
|
+
let calls = 0;
|
|
60
|
+
startActorPrincipalHeal({
|
|
61
|
+
hub,
|
|
62
|
+
connectionId: "conn-1",
|
|
63
|
+
resolve: () => {
|
|
64
|
+
calls++;
|
|
65
|
+
// Two failed reads, then the gateway comes up.
|
|
66
|
+
return Promise.resolve(calls < 3 ? undefined : "guardian-real-id");
|
|
67
|
+
},
|
|
68
|
+
delaysMs: NO_DELAYS,
|
|
69
|
+
});
|
|
70
|
+
await settle();
|
|
71
|
+
|
|
72
|
+
expect(calls).toBe(3);
|
|
73
|
+
expect(hub.filled).toEqual(["guardian-real-id"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("retries after a lookup that rejects", async () => {
|
|
77
|
+
const hub = fakeHub();
|
|
78
|
+
let calls = 0;
|
|
79
|
+
startActorPrincipalHeal({
|
|
80
|
+
hub,
|
|
81
|
+
connectionId: "conn-1",
|
|
82
|
+
resolve: () => {
|
|
83
|
+
calls++;
|
|
84
|
+
return calls === 1
|
|
85
|
+
? Promise.reject(new Error("ipc timeout"))
|
|
86
|
+
: Promise.resolve("guardian-real-id");
|
|
87
|
+
},
|
|
88
|
+
delaysMs: NO_DELAYS,
|
|
89
|
+
});
|
|
90
|
+
await settle();
|
|
91
|
+
|
|
92
|
+
expect(calls).toBe(2);
|
|
93
|
+
expect(hub.filled).toEqual(["guardian-real-id"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("stops retrying once the connection no longer needs healing", async () => {
|
|
97
|
+
// Models a disconnect (or a reconnect that replaced this subscription)
|
|
98
|
+
// between attempts: the loop must not keep polling the gateway for a
|
|
99
|
+
// connection that is gone.
|
|
100
|
+
const hub = fakeHub();
|
|
101
|
+
let calls = 0;
|
|
102
|
+
startActorPrincipalHeal({
|
|
103
|
+
hub,
|
|
104
|
+
connectionId: "conn-1",
|
|
105
|
+
resolve: () => {
|
|
106
|
+
calls++;
|
|
107
|
+
hub.setNeeds(false);
|
|
108
|
+
return Promise.resolve(undefined);
|
|
109
|
+
},
|
|
110
|
+
delaysMs: NO_DELAYS,
|
|
111
|
+
});
|
|
112
|
+
await settle();
|
|
113
|
+
|
|
114
|
+
expect(calls).toBe(1);
|
|
115
|
+
expect(hub.filled).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("never starts a lookup when the connection is already healed or gone", async () => {
|
|
119
|
+
const hub = fakeHub({ needs: false });
|
|
120
|
+
const resolve = mock(() => Promise.resolve("guardian-real-id"));
|
|
121
|
+
startActorPrincipalHeal({
|
|
122
|
+
hub,
|
|
123
|
+
connectionId: "conn-1",
|
|
124
|
+
resolve,
|
|
125
|
+
delaysMs: NO_DELAYS,
|
|
126
|
+
});
|
|
127
|
+
await settle();
|
|
128
|
+
|
|
129
|
+
expect(resolve).toHaveBeenCalledTimes(0);
|
|
130
|
+
expect(hub.filled).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("gives up after the schedule is exhausted without filling", async () => {
|
|
134
|
+
const hub = fakeHub();
|
|
135
|
+
let calls = 0;
|
|
136
|
+
startActorPrincipalHeal({
|
|
137
|
+
hub,
|
|
138
|
+
connectionId: "conn-1",
|
|
139
|
+
resolve: () => {
|
|
140
|
+
calls++;
|
|
141
|
+
return Promise.resolve(undefined);
|
|
142
|
+
},
|
|
143
|
+
delaysMs: NO_DELAYS,
|
|
144
|
+
});
|
|
145
|
+
await settle();
|
|
146
|
+
|
|
147
|
+
expect(calls).toBe(NO_DELAYS.length);
|
|
148
|
+
expect(hub.filled).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("dispatches the first attempt synchronously", () => {
|
|
152
|
+
const hub = fakeHub();
|
|
153
|
+
const resolve = mock(() => Promise.resolve("guardian-real-id"));
|
|
154
|
+
startActorPrincipalHeal({
|
|
155
|
+
hub,
|
|
156
|
+
connectionId: "conn-1",
|
|
157
|
+
resolve,
|
|
158
|
+
delaysMs: NO_DELAYS,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// No await: the leading zero delay must not cost a macrotask, so a
|
|
162
|
+
// warm-cache heal lands before the stream delivers its first event.
|
|
163
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -59,6 +59,8 @@ let mockReResolve: { trustClass: string; sourceChannel: string } | null = null;
|
|
|
59
59
|
mock.module("../../../contacts/guardian-delivery-reader.js", () => ({
|
|
60
60
|
getGuardianDelivery: (_input?: { channelTypes?: string[] }) =>
|
|
61
61
|
Promise.resolve(mockGuardianList),
|
|
62
|
+
getGuardianDeliveryFresh: (_input?: { channelTypes?: string[] }) =>
|
|
63
|
+
Promise.resolve(mockGuardianList),
|
|
62
64
|
peekCachedGuardianDelivery: () => mockGuardianList ?? undefined,
|
|
63
65
|
guardianForChannel: (
|
|
64
66
|
list: Array<Record<string, unknown>>,
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
ServiceUnavailableError,
|
|
53
53
|
} from "./errors.js";
|
|
54
54
|
import { parseBody } from "./parse-body.js";
|
|
55
|
+
import { startActorPrincipalHeal } from "./sse-actor-principal-heal.js";
|
|
55
56
|
import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
|
|
56
57
|
|
|
57
58
|
const log = getLogger("events-routes");
|
|
@@ -455,28 +456,28 @@ export function handleSubscribeAssistantEvents(
|
|
|
455
456
|
throw err;
|
|
456
457
|
}
|
|
457
458
|
|
|
458
|
-
// Self-heal for dev-bypass connections: the sync resolution above reads
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
459
|
+
// Self-heal for dev-bypass connections: the sync resolution above reads only
|
|
460
|
+
// the guardian-delivery cache, which can be cold at connect time, and a
|
|
461
|
+
// subscription that carries no principal for its lifetime 403s every
|
|
462
|
+
// host-proxy result it submits. The heal retries on a bounded backoff
|
|
463
|
+
// (`sse-actor-principal-heal.ts`), fire-and-forget so the stream is not
|
|
464
|
+
// delayed, keyed by connectionId so a reconnect race cannot patch the
|
|
465
|
+
// subscription that replaced this one. The lookup forces a fresh gateway read
|
|
466
|
+
// because a cached empty result outlives the retry schedule.
|
|
464
467
|
if (
|
|
465
468
|
clientId &&
|
|
466
469
|
interfaceId &&
|
|
467
470
|
actorPrincipalId == null &&
|
|
468
471
|
rawActorPrincipalId?.trim() === "dev-bypass"
|
|
469
472
|
) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
// the next reconnect retries.
|
|
479
|
-
});
|
|
473
|
+
startActorPrincipalHeal({
|
|
474
|
+
hub,
|
|
475
|
+
connectionId: sub.connectionId,
|
|
476
|
+
resolve: () =>
|
|
477
|
+
resolveActorPrincipalIdForLocalGuardian("dev-bypass", {
|
|
478
|
+
forceRefresh: true,
|
|
479
|
+
}),
|
|
480
|
+
});
|
|
480
481
|
}
|
|
481
482
|
|
|
482
483
|
const stream = new ReadableStream<Uint8Array>(
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrying self-heal for an SSE client subscription that registered without an
|
|
3
|
+
* actor principal.
|
|
4
|
+
*
|
|
5
|
+
* In `DISABLE_HTTP_AUTH` deployments the SSE subscribe path cannot await, so it
|
|
6
|
+
* resolves the dev-bypass actor principal from an IO-free peek at the
|
|
7
|
+
* guardian-delivery cache. A cold cache registers the subscription with
|
|
8
|
+
* `actorPrincipalId: undefined`, and every host-proxy result that client
|
|
9
|
+
* submits then fails the same-actor gate with `missing_target`: a 403 that only
|
|
10
|
+
* a fresh registration clears.
|
|
11
|
+
*
|
|
12
|
+
* The heal resolves the guardian asynchronously and patches the live hub
|
|
13
|
+
* record. Each attempt can come back empty, since the lookup goes over the
|
|
14
|
+
* gateway IPC and yields nothing both when the transport fails (unreachable
|
|
15
|
+
* gateway, its 2s timeout, a pod still starting) and when no guardian binding
|
|
16
|
+
* exists yet, so attempts repeat on a bounded backoff. The loop stops on the
|
|
17
|
+
* first success, when the connection is gone or already carries a principal, or
|
|
18
|
+
* when the schedule is exhausted.
|
|
19
|
+
*
|
|
20
|
+
* Callers must pass a cache-bypassing lookup: the guardian-delivery reader
|
|
21
|
+
* caches a successful empty result for minutes, which outlives the whole
|
|
22
|
+
* schedule, so a cached read would spend every attempt on the same stale
|
|
23
|
+
* answer.
|
|
24
|
+
*/
|
|
25
|
+
import { getLogger } from "../../util/logger.js";
|
|
26
|
+
|
|
27
|
+
const log = getLogger("sse-actor-heal");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The hub surface this module needs: a liveness check and the fill. Narrow by
|
|
31
|
+
* design so tests can drive the loop without a real hub.
|
|
32
|
+
*/
|
|
33
|
+
export interface ActorPrincipalHealHub {
|
|
34
|
+
needsActorPrincipalHeal(connectionId: string): boolean;
|
|
35
|
+
fillClientActorPrincipalId(
|
|
36
|
+
connectionId: string,
|
|
37
|
+
actorPrincipalId: string,
|
|
38
|
+
): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Delay before each attempt, in milliseconds. The leading zero dispatches the
|
|
43
|
+
* first attempt inline with the subscribe; the rest back off to cover a gateway
|
|
44
|
+
* that is slow to come up, without holding a timer for the life of a long-lived
|
|
45
|
+
* stream. Total span is about 44s.
|
|
46
|
+
*/
|
|
47
|
+
export const ACTOR_PRINCIPAL_HEAL_DELAYS_MS: readonly number[] = [
|
|
48
|
+
0, 1_000, 3_000, 10_000, 30_000,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start the retrying heal for `connectionId`. Fire-and-forget: never awaited by
|
|
53
|
+
* the route, never delays the stream.
|
|
54
|
+
*
|
|
55
|
+
* `resolve` performs the daemon's own server-side guardian lookup, bypassing
|
|
56
|
+
* the guardian-delivery cache. The value must never come from client input. It
|
|
57
|
+
* may resolve `undefined` (no binding yet, or the gateway is unreachable) or
|
|
58
|
+
* reject; both count as a failed attempt.
|
|
59
|
+
*/
|
|
60
|
+
export function startActorPrincipalHeal(args: {
|
|
61
|
+
hub: ActorPrincipalHealHub;
|
|
62
|
+
connectionId: string;
|
|
63
|
+
resolve: () => Promise<string | undefined>;
|
|
64
|
+
/** Override the backoff schedule. Tests pass all-zero delays. */
|
|
65
|
+
delaysMs?: readonly number[];
|
|
66
|
+
}): void {
|
|
67
|
+
const { hub, connectionId, resolve } = args;
|
|
68
|
+
const delays = args.delaysMs ?? ACTOR_PRINCIPAL_HEAL_DELAYS_MS;
|
|
69
|
+
|
|
70
|
+
void (async () => {
|
|
71
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
72
|
+
const delayMs = delays[attempt] ?? 0;
|
|
73
|
+
if (delayMs > 0) {
|
|
74
|
+
await sleep(delayMs);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Re-check every pass: the connection may have closed, been replaced by a
|
|
78
|
+
// reconnect, or been healed by another path since the last one.
|
|
79
|
+
if (!hub.needsActorPrincipalHeal(connectionId)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let resolved: string | undefined;
|
|
84
|
+
try {
|
|
85
|
+
resolved = await resolve();
|
|
86
|
+
} catch {
|
|
87
|
+
resolved = undefined;
|
|
88
|
+
}
|
|
89
|
+
if (resolved) {
|
|
90
|
+
hub.fillClientActorPrincipalId(connectionId, resolved);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Log the give-up so a principal-less subscription is greppable next to the
|
|
96
|
+
// same-actor rejections it causes, rather than surfacing only as opaque
|
|
97
|
+
// 403s on the client.
|
|
98
|
+
if (hub.needsActorPrincipalHeal(connectionId)) {
|
|
99
|
+
log.warn(
|
|
100
|
+
{ connectionId, attempts: delays.length },
|
|
101
|
+
"gave up healing missing actorPrincipalId for client subscription; host-proxy results from this connection are rejected until it reconnects",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sleep(ms: number): Promise<void> {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const timer = setTimeout(resolve, ms);
|
|
110
|
+
// Never hold the process (or a test runner) open on a pending backoff.
|
|
111
|
+
(timer as { unref?: () => void }).unref?.();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
dominantLanguageTag,
|
|
5
|
+
rankLanguages,
|
|
6
|
+
voteDominantLanguage,
|
|
7
|
+
} from "../language-metadata.js";
|
|
8
|
+
|
|
9
|
+
describe("voteDominantLanguage", () => {
|
|
10
|
+
test("only the dominance-ranked first entry votes", () => {
|
|
11
|
+
const tally = new Map<string, number>();
|
|
12
|
+
voteDominantLanguage(tally, ["en", "es"]);
|
|
13
|
+
expect(tally.get("en")).toBe(1);
|
|
14
|
+
expect(tally.has("es")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("regional variants count toward their base subtag", () => {
|
|
18
|
+
const tally = new Map<string, number>();
|
|
19
|
+
voteDominantLanguage(tally, ["pt-BR"]);
|
|
20
|
+
voteDominantLanguage(tally, ["pt"]);
|
|
21
|
+
expect(tally.get("pt")).toBe(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("underscore-separated variants count toward their base subtag", () => {
|
|
25
|
+
const tally = new Map<string, number>();
|
|
26
|
+
voteDominantLanguage(tally, ["hi_IN"]);
|
|
27
|
+
expect(tally.get("hi")).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("blank and absent tags cast no vote", () => {
|
|
31
|
+
const tally = new Map<string, number>();
|
|
32
|
+
voteDominantLanguage(tally, undefined);
|
|
33
|
+
voteDominantLanguage(tally, []);
|
|
34
|
+
voteDominantLanguage(tally, [" "]);
|
|
35
|
+
expect(tally.size).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("dominantLanguageTag", () => {
|
|
40
|
+
test("most votes wins", () => {
|
|
41
|
+
const tally = new Map([
|
|
42
|
+
["en", 1],
|
|
43
|
+
["es", 3],
|
|
44
|
+
]);
|
|
45
|
+
expect(dominantLanguageTag(tally)).toBe("es");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ties break by first insertion", () => {
|
|
49
|
+
const tally = new Map([
|
|
50
|
+
["hi", 2],
|
|
51
|
+
["en", 2],
|
|
52
|
+
]);
|
|
53
|
+
expect(dominantLanguageTag(tally)).toBe("hi");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("undefined for an empty tally", () => {
|
|
57
|
+
expect(dominantLanguageTag(new Map())).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("rankLanguages", () => {
|
|
62
|
+
test("returns empty array for empty input", () => {
|
|
63
|
+
expect(rankLanguages([])).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("ranks by frequency, most frequent first", () => {
|
|
67
|
+
expect(rankLanguages(["es", "en", "en", "en", "es"])).toEqual(["en", "es"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("breaks ties by first appearance", () => {
|
|
71
|
+
expect(rankLanguages(["hi", "en", "en", "hi"])).toEqual(["hi", "en"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("counts regional variants toward their base subtag", () => {
|
|
75
|
+
expect(rankLanguages(["en-US", "es", "en", "en-GB"])).toEqual(["en", "es"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("skips blank tags", () => {
|
|
79
|
+
expect(rankLanguages(["", " ", "ja"])).toEqual(["ja"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("accepts any iterable", () => {
|
|
83
|
+
expect(rankLanguages(new Set(["fr", "de"]))).toEqual(["fr", "de"]);
|
|
84
|
+
});
|
|
85
|
+
});
|