@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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/call-controller.test.ts +120 -0
  3. package/src/__tests__/config-loader-backfill.test.ts +19 -0
  4. package/src/__tests__/config-schema.test.ts +142 -5
  5. package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
  6. package/src/__tests__/media-stream-output.test.ts +175 -0
  7. package/src/__tests__/media-stream-stt-session.test.ts +67 -0
  8. package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
  9. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
  10. package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
  11. package/src/calls/call-controller.ts +30 -2
  12. package/src/calls/call-speech-output.ts +12 -4
  13. package/src/calls/call-transport.ts +18 -1
  14. package/src/calls/media-stream-output.ts +53 -5
  15. package/src/calls/media-stream-server.ts +12 -0
  16. package/src/calls/media-stream-stt-session.ts +34 -0
  17. package/src/calls/telephony-synthesis-language.ts +84 -0
  18. package/src/calls/tts-text-sanitizer.ts +12 -5
  19. package/src/calls/voice-session-bridge.ts +31 -3
  20. package/src/calls/voice-triage-escalate.ts +52 -5
  21. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
  22. package/src/config/loader.ts +5 -0
  23. package/src/config/schemas/__tests__/live-voice.test.ts +35 -0
  24. package/src/config/schemas/calls.ts +0 -4
  25. package/src/config/schemas/live-voice.ts +25 -0
  26. package/src/config/schemas/tts.ts +63 -0
  27. package/src/live-voice/__tests__/front-decision.test.ts +120 -0
  28. package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
  29. package/src/live-voice/__tests__/live-voice-integration.test.ts +5 -0
  30. package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
  31. package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
  32. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
  33. package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
  34. package/src/live-voice/__tests__/live-voice-vad.test.ts +278 -1
  35. package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
  36. package/src/live-voice/front-decision.ts +50 -3
  37. package/src/live-voice/live-voice-session.ts +517 -45
  38. package/src/live-voice/live-voice-tts.ts +18 -2
  39. package/src/live-voice/progress-phrases.ts +105 -2
  40. package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
  41. package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
  42. package/src/providers/speech-to-text/provider-catalog.ts +38 -0
  43. package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
  44. package/src/runtime/assistant-event-hub.ts +23 -0
  45. package/src/runtime/local-actor-identity.ts +18 -5
  46. package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
  47. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
  48. package/src/runtime/routes/events-routes.ts +17 -16
  49. package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
  50. package/src/stt/__tests__/language-metadata.test.ts +85 -0
  51. package/src/stt/__tests__/speech-energy.test.ts +79 -0
  52. package/src/stt/language-metadata.ts +65 -0
  53. package/src/stt/speech-energy.ts +115 -12
  54. package/src/stt/types.ts +16 -0
  55. package/src/tts/__tests__/provider-adapters.test.ts +147 -0
  56. package/src/tts/__tests__/speakable-segments.test.ts +470 -0
  57. package/src/tts/language-voices.ts +23 -0
  58. package/src/tts/providers/deepgram-provider.ts +3 -1
  59. package/src/tts/providers/elevenlabs-provider.ts +73 -1
  60. package/src/tts/providers/xai-provider.ts +28 -2
  61. package/src/tts/speakable-segments.ts +293 -23
  62. package/src/tts/synthesis-stream.ts +7 -0
  63. package/src/tts/types.ts +7 -0
  64. package/src/util/__tests__/language-subtag.test.ts +54 -0
  65. package/src/util/language-subtag.ts +43 -0
  66. 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(): Promise<
57
- string | undefined
58
- > {
59
- const list = await getGuardianDelivery({ channelTypes: ["vellum"] });
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
- // only the guardian-delivery cache, which can be cold at connect time.
460
- // Without this, the subscription would carry no principal for its whole
461
- // lifetime and every host-proxy result would 403. Fire-and-forget so the
462
- // stream is not delayed; keyed by connectionId so a reconnect race cannot
463
- // patch the subscription that replaced this one.
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
- void resolveActorPrincipalIdForLocalGuardian("dev-bypass")
471
- .then((resolved) => {
472
- if (resolved) {
473
- hub.fillClientActorPrincipalId(sub.connectionId, resolved);
474
- }
475
- })
476
- .catch(() => {
477
- // Best-effort: an unreachable gateway leaves the record unhealed;
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
+ });