@kuralle-syrinx/server-workers 2.1.1 → 3.1.0

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.
@@ -9,6 +9,7 @@ import { execFile } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
10
  import { Miniflare } from "miniflare";
11
11
  import { afterEach, describe, expect, it } from "vitest";
12
+ import { decodeMuLawToPcm16, encodePcm16ToMuLaw } from "@kuralle-syrinx/core/audio";
12
13
 
13
14
  const execFileAsync = promisify(execFile);
14
15
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -58,6 +59,7 @@ function newMiniflare(script: string, bindings: Record<string, unknown>): Minifl
58
59
  compatibilityFlags: ["nodejs_compat"],
59
60
  durableObjects: {
60
61
  VOICE_CONVERSATIONS: { className: "VoiceConversation", useSQLite: true },
62
+ TWILIO_VOICE_CONVERSATIONS: { className: "TwilioVoiceConversation", useSQLite: true },
61
63
  },
62
64
  vectorize: {
63
65
  VECTORIZE: { dimensions: 1536, metric: "cosine", index_name: "kuralle-university-kb" },
@@ -163,6 +165,148 @@ describe("VoiceConversation worker runtime", () => {
163
165
  );
164
166
  });
165
167
 
168
+ describe("TwilioVoiceConversation worker runtime", () => {
169
+ // Deterministic, no keys: the Twilio Media Streams front accepts a WS upgrade at /twilio
170
+ // through withVoice(Agent, { transport: "twilio" }) — the cf-agents telephony front bundles
171
+ // and boots in workerd.
172
+ it("accepts a Twilio Media Streams WebSocket upgrade at /twilio", async () => {
173
+ const script = await buildWorker();
174
+ const mf = newMiniflare(script, {});
175
+ try {
176
+ const response = await mf.dispatchFetch("http://localhost/twilio?sessionId=twilio-boot", {
177
+ headers: { Upgrade: "websocket" },
178
+ });
179
+ expect(response.status).toBe(101);
180
+ const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
181
+ expect(ws).toBeTruthy();
182
+ ws!.accept();
183
+ ws!.close();
184
+ } finally {
185
+ await mf.dispose();
186
+ }
187
+ }, 20_000);
188
+
189
+ // Deterministic: the Twilio Voice webhook returns TwiML that bridges the call to /twilio.
190
+ it("returns <Connect><Stream> TwiML from /incoming-call pointing at /twilio", async () => {
191
+ const script = await buildWorker();
192
+ const mf = newMiniflare(script, {});
193
+ try {
194
+ const res = await mf.dispatchFetch("http://localhost/incoming-call", {
195
+ method: "POST",
196
+ headers: { "content-type": "application/x-www-form-urlencoded" },
197
+ body: "CallSid=CA0123456789abcdef&From=%2B15551234567",
198
+ });
199
+ expect(res.status).toBe(200);
200
+ expect(res.headers.get("content-type")).toContain("text/xml");
201
+ const twiml = await res.text();
202
+ expect(twiml).toContain("<Connect>");
203
+ expect(twiml).toContain("<Stream");
204
+ // The Twilio CallSid becomes the /twilio sessionId.
205
+ expect(twiml).toContain("/twilio?sessionId=CA0123456789abcdef");
206
+ } finally {
207
+ await mf.dispose();
208
+ }
209
+ }, 20_000);
210
+
211
+ // Live: EMULATE A PSTN CALL — speak the Twilio Media Streams protocol (connected/start/media
212
+ // as base64 μ-law 8 kHz) at /twilio and assert the agent answers with non-silent μ-law media
213
+ // frames on the phone leg. No carrier, no phone number — the protocol is just a WebSocket.
214
+ it.skipIf(!liveTurnEnabled)(
215
+ "answers an emulated Twilio Media Streams call end-to-end in workerd",
216
+ async () => {
217
+ expect(existsSync(TURN_DETECTION_FIXTURE)).toBe(true);
218
+ const pcm16k = readWav16kMono(TURN_DETECTION_FIXTURE);
219
+ const pcm8k = downsampleTo8k(pcm16k);
220
+ const script = await buildWorker();
221
+ const mf = newMiniflare(script, liveEnv);
222
+ try {
223
+ const response = await mf.dispatchFetch("http://localhost/twilio?sessionId=twilio-live", {
224
+ headers: { Upgrade: "websocket" },
225
+ });
226
+ expect(response.status).toBe(101);
227
+ const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
228
+ expect(ws).toBeTruthy();
229
+
230
+ let downlinkFrames = 0;
231
+ let downlinkPeak = 0;
232
+ let clearReceived = false;
233
+ ws!.addEventListener("message", (event) => {
234
+ const data = event.data as string | ArrayBuffer;
235
+ if (typeof data !== "string" || !data.startsWith("{")) return;
236
+ const msg = JSON.parse(data) as Record<string, unknown>;
237
+ if (msg.event === "media") {
238
+ downlinkFrames += 1;
239
+ const media = msg.media as { payload?: string } | undefined;
240
+ if (media?.payload) {
241
+ const pcm = decodeMuLawToPcm16(base64ToBytes(media.payload));
242
+ for (const s of pcm) downlinkPeak = Math.max(downlinkPeak, Math.abs(s));
243
+ }
244
+ } else if (msg.event === "clear") {
245
+ clearReceived = true;
246
+ }
247
+ });
248
+ ws!.accept();
249
+
250
+ const streamSid = "MZtwiliolivesmoke";
251
+ ws!.send(JSON.stringify({ event: "connected", protocol: "Call", version: "1.0.0" }));
252
+ ws!.send(JSON.stringify({
253
+ event: "start",
254
+ streamSid,
255
+ start: { streamSid, callSid: "CAtwiliolivesmoke", mediaFormat: { encoding: "audio/x-mulaw", sampleRate: 8000, channels: 1 } },
256
+ }));
257
+
258
+ const sendMulaw = (frame: Int16Array): void => {
259
+ ws!.send(JSON.stringify({
260
+ event: "media",
261
+ streamSid,
262
+ media: { payload: bytesToBase64(encodePcm16ToMuLaw(frame)) },
263
+ }));
264
+ };
265
+
266
+ const frameSamples8k = 160; // 20ms at 8 kHz
267
+ for (let offset = 0; offset < pcm8k.length; offset += frameSamples8k) {
268
+ const frame = new Int16Array(frameSamples8k);
269
+ frame.set(pcm8k.subarray(offset, Math.min(offset + frameSamples8k, pcm8k.length)));
270
+ sendMulaw(frame);
271
+ await sleep(20);
272
+ }
273
+
274
+ // Pad silence until the agent answers (Deepgram endpointing + kuralle + TTS).
275
+ const deadline = Date.now() + 60_000;
276
+ while (downlinkFrames === 0 && Date.now() < deadline) {
277
+ sendMulaw(new Int16Array(frameSamples8k));
278
+ await sleep(20);
279
+ }
280
+
281
+ ws!.send(JSON.stringify({ event: "stop", streamSid }));
282
+ ws!.close();
283
+
284
+ // eslint-disable-next-line no-console
285
+ console.log(`[twilio-live] downlink media frames=${downlinkFrames} peak=${downlinkPeak} clear=${clearReceived}`);
286
+ expect(downlinkFrames).toBeGreaterThan(0);
287
+ expect(downlinkPeak).toBeGreaterThan(100); // non-silent answer on the phone leg
288
+ } finally {
289
+ await mf.dispose();
290
+ }
291
+ },
292
+ 120_000,
293
+ );
294
+ });
295
+
296
+ function downsampleTo8k(samples: Int16Array): Int16Array {
297
+ const out = new Int16Array(Math.floor(samples.length / 2));
298
+ for (let i = 0; i < out.length; i += 1) out[i] = samples[i * 2]!;
299
+ return out;
300
+ }
301
+
302
+ function base64ToBytes(value: string): Uint8Array {
303
+ return new Uint8Array(Buffer.from(value, "base64"));
304
+ }
305
+
306
+ function bytesToBase64(bytes: Uint8Array): string {
307
+ return Buffer.from(bytes).toString("base64");
308
+ }
309
+
166
310
  function firstSessionError(messages: ReadonlyArray<string | ArrayBuffer>): string | null {
167
311
  for (const m of messages) {
168
312
  if (typeof m !== "string") continue;
package/src/worker.ts CHANGED
@@ -1,18 +1,24 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ //
3
+ // Cloudflare Workers cascaded voice host, built on `withVoice(Agent)` (issue #10 / W1).
4
+ // The Durable Object IS an `agents` SDK Agent: it provides hibernation, the
5
+ // `keepAlive()` lease, `Connection` lifecycle, and SQLite natively — so the prior
6
+ // manual alarm scheduler, durable session store, WebSocket lifecycle, and the 1012
7
+ // eviction-orphan workaround are gone. The brain is the `reasoner` param; the
8
+ // pipeline (Deepgram STT/TTS + kuralle) lives in live-session.ts.
2
9
 
10
+ import { Agent } from "agents";
11
+ import { withVoice } from "@kuralle-syrinx/cf-agents";
12
+ import { R2EdgeRecorder } from "@kuralle-syrinx/cf-agents/r2-recorder";
3
13
  import {
4
- createVoiceEdgeWebSocketUpgrade,
5
- type VoiceEdgeWebSocketUpgrade,
6
- } from "@kuralle-syrinx/server-websocket/edge";
7
- import { createTwilioEdgeWebSocketUpgrade } from "@kuralle-syrinx/server-websocket/edge-twilio";
8
- import type { WorkersInboundSocketController } from "@kuralle-syrinx/ws/workers";
9
- import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
10
- import { DurableObjectSessionStore } from "./durable-session-store.js";
11
- import { createLiveVoiceAgentSession, type LiveSessionEnv } from "./live-session.js";
12
- import { R2EdgeRecorder } from "./r2-recorder.js";
14
+ createLiveReasoner,
15
+ liveCascadedPipeline,
16
+ type LiveSessionEnv,
17
+ } from "./live-session.js";
13
18
 
14
19
  export interface Env extends LiveSessionEnv {
15
20
  VOICE_CONVERSATIONS: DurableObjectNamespace;
21
+ TWILIO_VOICE_CONVERSATIONS: DurableObjectNamespace;
16
22
  /** Optional: when bound, full call audio is recorded to this bucket. */
17
23
  RECORDINGS?: R2Bucket;
18
24
  }
@@ -20,20 +26,83 @@ export interface Env extends LiveSessionEnv {
20
26
  const INPUT_SAMPLE_RATE_HZ = 16000;
21
27
  const OUTPUT_SAMPLE_RATE_HZ = 16000;
22
28
 
29
+ /** Browser/edge cascaded host (Syrinx JSON+envelope protocol over /ws). */
30
+ export class VoiceConversation extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
31
+ pipeline: liveCascadedPipeline,
32
+ reasoner: (env, ctx) => createLiveReasoner(env, ctx),
33
+ recorder: (env, { sessionId }) =>
34
+ env.RECORDINGS
35
+ ? new R2EdgeRecorder({ bucket: env.RECORDINGS, sessionId, startedAtMs: Date.now() })
36
+ : undefined,
37
+ inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
38
+ outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
39
+ resumeWindowMs: 15_000,
40
+ }) {}
41
+
42
+ /** Telephony cascaded host (Twilio Media Streams μ-law 8 kHz over /twilio). Same pipeline/brain. */
43
+ export class TwilioVoiceConversation extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
44
+ transport: "twilio",
45
+ pipeline: liveCascadedPipeline,
46
+ reasoner: (env, ctx) => createLiveReasoner(env, ctx),
47
+ inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
48
+ resumeWindowMs: 15_000,
49
+ }) {}
50
+
23
51
  export default {
24
52
  async fetch(request: Request, env: Env): Promise<Response> {
25
53
  const url = new URL(request.url);
26
54
  if (url.pathname === "/health") return new Response("ok");
27
55
  if (url.pathname === "/recordings") return await listRecordings(url, env);
28
- if (url.pathname !== "/ws" && url.pathname !== "/twilio") {
29
- return new Response("not found", { status: 404 });
30
- }
56
+ // Twilio Voice webhook: returns TwiML that bridges the inbound PSTN call to this
57
+ // worker's own /twilio Media Streams endpoint over a bidirectional <Stream>. Point a
58
+ // Twilio number's "A call comes in" webhook at https://<host>/incoming-call.
59
+ if (url.pathname === "/incoming-call") return await twilioIncomingCallResponse(request, url);
60
+ // Name-addressed routing: one DO per sessionId (callSid for telephony). The Agent
61
+ // (partyserver) resolves its name from ctx.id.name, so a direct stub.fetch() upgrade
62
+ // is valid for both transports.
31
63
  const sessionId = url.searchParams.get("sessionId") ?? crypto.randomUUID();
64
+ if (url.pathname === "/twilio") {
65
+ const id = env.TWILIO_VOICE_CONVERSATIONS.idFromName(sessionId);
66
+ return await env.TWILIO_VOICE_CONVERSATIONS.get(id).fetch(request);
67
+ }
68
+ if (url.pathname !== "/ws") return new Response("not found", { status: 404 });
32
69
  const id = env.VOICE_CONVERSATIONS.idFromName(sessionId);
33
70
  return await env.VOICE_CONVERSATIONS.get(id).fetch(request);
34
71
  },
35
72
  };
36
73
 
74
+ /**
75
+ * Twilio Voice webhook → TwiML connecting the PSTN leg to /twilio over a bidirectional
76
+ * <Stream>. The Twilio CallSid becomes the session id, so the call's media stream and any
77
+ * resume/recording key off the same stable id.
78
+ */
79
+ async function twilioIncomingCallResponse(request: Request, url: URL): Promise<Response> {
80
+ let callSid = url.searchParams.get("CallSid") ?? "";
81
+ if (!callSid && request.method === "POST") {
82
+ const form = await request.formData().catch(() => null);
83
+ const value = form?.get("CallSid");
84
+ if (typeof value === "string") callSid = value;
85
+ }
86
+ const sessionId = callSid || crypto.randomUUID();
87
+ const wsScheme = url.protocol === "https:" ? "wss" : "ws";
88
+ const streamUrl = `${wsScheme}://${url.host}/twilio?sessionId=${encodeURIComponent(sessionId)}`;
89
+ const twiml = [
90
+ '<?xml version="1.0" encoding="UTF-8"?>',
91
+ "<Response>",
92
+ " <Connect>",
93
+ ` <Stream url="${xmlEscape(streamUrl)}" />`,
94
+ " </Connect>",
95
+ "</Response>",
96
+ ].join("\n");
97
+ return new Response(twiml, { headers: { "content-type": "text/xml; charset=utf-8" } });
98
+ }
99
+
100
+ function xmlEscape(value: string): string {
101
+ return value.replace(/[<>&"']/g, (c) =>
102
+ ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c] ?? c,
103
+ );
104
+ }
105
+
37
106
  /** List recorded objects for a session: GET /recordings?sessionId=<id>. */
38
107
  async function listRecordings(url: URL, env: Env): Promise<Response> {
39
108
  const sessionId = url.searchParams.get("sessionId");
@@ -41,85 +110,3 @@ async function listRecordings(url: URL, env: Env): Promise<Response> {
41
110
  const listed = await env.RECORDINGS.list({ prefix: `recordings/${sessionId}/` });
42
111
  return Response.json(listed.objects.map((o) => ({ key: o.key, size: o.size })));
43
112
  }
44
-
45
- export class VoiceConversation {
46
- private readonly scheduler: DurableObjectAlarmScheduler;
47
- private readonly store: DurableObjectSessionStore;
48
- private activeUpgrade: VoiceEdgeWebSocketUpgrade | null = null;
49
-
50
- constructor(
51
- private readonly ctx: DurableObjectState,
52
- private readonly env: Env,
53
- ) {
54
- this.scheduler = new DurableObjectAlarmScheduler(ctx.storage);
55
- this.store = new DurableObjectSessionStore(ctx.storage, this.scheduler);
56
- }
57
-
58
- async fetch(request: Request): Promise<Response> {
59
- if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
60
- return new Response("expected websocket", { status: 426 });
61
- }
62
- const requestUrl = new URL(request.url);
63
- const sessionId = requestUrl.searchParams.get("sessionId") ?? crypto.randomUUID();
64
- if (requestUrl.pathname === "/twilio") {
65
- const upgrade = createTwilioEdgeWebSocketUpgrade(request, {
66
- sessionStore: this.store,
67
- scheduler: this.scheduler,
68
- createSession: () =>
69
- createLiveVoiceAgentSession(this.env, {
70
- sessionId,
71
- inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
72
- outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
73
- }),
74
- engineSampleRateHz: INPUT_SAMPLE_RATE_HZ,
75
- }, {
76
- acceptWebSocket: (socket) => this.ctx.acceptWebSocket(socket as WebSocket),
77
- });
78
- this.activeUpgrade = upgrade;
79
- return upgrade.response;
80
- }
81
- const recorder = this.env.RECORDINGS
82
- ? new R2EdgeRecorder({ bucket: this.env.RECORDINGS, sessionId, startedAtMs: Date.now() })
83
- : undefined;
84
- const upgrade = createVoiceEdgeWebSocketUpgrade(request, {
85
- sessionStore: this.store,
86
- scheduler: this.scheduler,
87
- recorder,
88
- createSession: () =>
89
- createLiveVoiceAgentSession(this.env, {
90
- sessionId,
91
- inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
92
- outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
93
- }),
94
- sessionId: () => sessionId,
95
- inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
96
- outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
97
- resumeWindowMs: 15_000,
98
- }, {
99
- acceptWebSocket: (socket) => this.ctx.acceptWebSocket(socket as WebSocket),
100
- });
101
- this.activeUpgrade = upgrade;
102
- return upgrade.response;
103
- }
104
-
105
- async alarm(): Promise<void> {
106
- await this.scheduler.runDue();
107
- }
108
-
109
- webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): void {
110
- this.controller?.message(message);
111
- }
112
-
113
- webSocketClose(_ws: WebSocket, code: number, reason: string): void {
114
- this.controller?.close(code, reason);
115
- this.activeUpgrade = null;
116
- }
117
-
118
- webSocketError(_ws: WebSocket, error: unknown): void {
119
- this.controller?.error(error instanceof Error ? error : new Error(String(error)));
120
- }
121
-
122
- private get controller(): WorkersInboundSocketController | undefined {
123
- return this.activeUpgrade?.controller;
124
- }
125
- }
package/wrangler.jsonc CHANGED
@@ -9,6 +9,10 @@
9
9
  {
10
10
  "name": "VOICE_CONVERSATIONS",
11
11
  "class_name": "VoiceConversation"
12
+ },
13
+ {
14
+ "name": "TWILIO_VOICE_CONVERSATIONS",
15
+ "class_name": "TwilioVoiceConversation"
12
16
  }
13
17
  ]
14
18
  },
@@ -22,6 +26,10 @@
22
26
  {
23
27
  "tag": "v1",
24
28
  "new_sqlite_classes": ["VoiceConversation"]
29
+ },
30
+ {
31
+ "tag": "v2",
32
+ "new_sqlite_classes": ["TwilioVoiceConversation"]
25
33
  }
26
34
  ],
27
35
  "vectorize": [
@@ -1,44 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
5
- import { MemoryDurableStorage } from "./test-storage.js";
6
-
7
- describe("DurableObjectAlarmScheduler", () => {
8
- it("persists deadlines, fires due callbacks, and rearms the nearest remaining task", async () => {
9
- const storage = new MemoryDurableStorage();
10
- const scheduler = new DurableObjectAlarmScheduler(storage);
11
- const fired: string[] = [];
12
-
13
- scheduler.schedule("late", 1000, () => {
14
- fired.push("late");
15
- });
16
- scheduler.schedule("soon", 50, () => {
17
- fired.push("soon");
18
- });
19
-
20
- expect(storage.tasks.has("soon")).toBe(true);
21
- expect(storage.alarm).toBeLessThanOrEqual(Date.now() + 1000);
22
-
23
- await scheduler.runDue(Date.now() + 100);
24
-
25
- expect(fired).toEqual(["soon"]);
26
- expect(storage.tasks.has("soon")).toBe(false);
27
- expect(storage.tasks.has("late")).toBe(true);
28
- });
29
-
30
- it("cancels persisted tasks before the alarm fires", async () => {
31
- const storage = new MemoryDurableStorage();
32
- const scheduler = new DurableObjectAlarmScheduler(storage);
33
- let fired = false;
34
-
35
- scheduler.schedule("task", 1, () => {
36
- fired = true;
37
- });
38
- scheduler.cancel("task");
39
- await scheduler.runDue(Date.now() + 10);
40
-
41
- expect(fired).toBe(false);
42
- expect(storage.tasks.size).toBe(0);
43
- });
44
- });
@@ -1,67 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import type { ScheduledCallback, Scheduler } from "@kuralle-syrinx/core";
4
-
5
- type SqlCursor<T> = Iterable<T>;
6
-
7
- interface SqlStorage {
8
- exec(query: string, ...bindings: unknown[]): SqlCursor<Record<string, unknown>>;
9
- }
10
-
11
- export interface DurableSchedulerStorage {
12
- readonly sql: SqlStorage;
13
- setAlarm(scheduledTime: number | Date): Promise<void>;
14
- deleteAlarm(): Promise<void>;
15
- }
16
-
17
- export class DurableObjectAlarmScheduler implements Scheduler {
18
- private readonly callbacks = new Map<string, ScheduledCallback>();
19
-
20
- constructor(private readonly storage: DurableSchedulerStorage) {
21
- this.storage.sql.exec(
22
- "CREATE TABLE IF NOT EXISTS scheduled_tasks (key TEXT PRIMARY KEY, deadline_ms INTEGER NOT NULL)",
23
- );
24
- }
25
-
26
- schedule(key: string, delayMs: number, cb: ScheduledCallback): void {
27
- const deadlineMs = Date.now() + Math.max(0, delayMs);
28
- this.callbacks.set(key, cb);
29
- this.storage.sql.exec(
30
- "INSERT OR REPLACE INTO scheduled_tasks (key, deadline_ms) VALUES (?, ?)",
31
- key,
32
- deadlineMs,
33
- );
34
- void this.armNext();
35
- }
36
-
37
- cancel(key: string): void {
38
- this.callbacks.delete(key);
39
- this.storage.sql.exec("DELETE FROM scheduled_tasks WHERE key = ?", key);
40
- void this.armNext();
41
- }
42
-
43
- async runDue(nowMs = Date.now()): Promise<void> {
44
- const due = [...this.storage.sql.exec(
45
- "SELECT key FROM scheduled_tasks WHERE deadline_ms <= ? ORDER BY deadline_ms ASC",
46
- nowMs,
47
- )] as Array<{ key: string }>;
48
- for (const row of due) {
49
- this.storage.sql.exec("DELETE FROM scheduled_tasks WHERE key = ?", row.key);
50
- const cb = this.callbacks.get(row.key);
51
- this.callbacks.delete(row.key);
52
- if (cb) await cb();
53
- }
54
- await this.armNext();
55
- }
56
-
57
- private async armNext(): Promise<void> {
58
- const [next] = [...this.storage.sql.exec(
59
- "SELECT deadline_ms FROM scheduled_tasks ORDER BY deadline_ms ASC LIMIT 1",
60
- )] as Array<{ deadline_ms: number }>;
61
- if (!next) {
62
- await this.storage.deleteAlarm();
63
- return;
64
- }
65
- await this.storage.setAlarm(next.deadline_ms);
66
- }
67
- }
@@ -1,60 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import type { ManagedSession } from "@kuralle-syrinx/server-websocket/session-store";
5
- import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
6
- import { DurableObjectSessionStore } from "./durable-session-store.js";
7
- import { MemoryDurableStorage } from "./test-storage.js";
8
-
9
- describe("DurableObjectSessionStore", () => {
10
- it("persists turn metadata and restores it when the live session is recreated", async () => {
11
- const storage = new MemoryDurableStorage();
12
- const scheduler = new DurableObjectAlarmScheduler(storage);
13
- const store = new DurableObjectSessionStore(storage, scheduler);
14
-
15
- const first = await store.lease("s1", async () => managed("s1", "turn-a"));
16
- store.update("s1", (stored) => {
17
- stored.currentContextId = "turn-b";
18
- stored.inputSequence.lastSequence = 7;
19
- });
20
- first.managed.connectionCount = 0;
21
- await store.release("s1", 1000);
22
-
23
- const recreatedStore = new DurableObjectSessionStore(storage, scheduler);
24
- const second = await recreatedStore.lease("s1", async () => managed("s1", "fresh"));
25
-
26
- expect(second.resumed).toBe(true);
27
- expect(second.managed.currentContextId).toBe("turn-b");
28
- expect(second.managed.inputSequence.lastSequence).toBe(7);
29
- });
30
-
31
- it("deletes retained sessions when the scheduled release fires", async () => {
32
- const storage = new MemoryDurableStorage();
33
- const scheduler = new DurableObjectAlarmScheduler(storage);
34
- const store = new DurableObjectSessionStore(storage, scheduler);
35
- const leased = await store.lease("s2", async () => managed("s2", "turn-a"));
36
- leased.managed.connectionCount = 0;
37
-
38
- await store.release("s2", 5);
39
- await scheduler.runDue(Date.now() + 10);
40
-
41
- expect(await store.get("s2")).toBeNull();
42
- expect(storage.sessions.has("s2")).toBe(false);
43
- });
44
- });
45
-
46
- function managed(id: string, currentContextId: string): ManagedSession {
47
- return {
48
- id,
49
- session: {
50
- start: async () => undefined,
51
- close: async () => undefined,
52
- } as ManagedSession["session"],
53
- currentContextId,
54
- contextSampleRates: new Map(),
55
- inputSequence: { lastSequence: null },
56
- turnMetricsTurns: new Map(),
57
- closeTimer: null,
58
- connectionCount: 0,
59
- };
60
- }