@kuralle-syrinx/server-workers-mastra 2.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.
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/.wrangler/state/v3/do/voice-server-workers-mastra-MastraAgentDO/35f1372a1ef6c9d67aa12e58d4a5515ab33180e2b6b7a63a9818e0eead57ffc1.sqlite +0 -0
- package/.wrangler/state/v3/do/voice-server-workers-mastra-MastraAgentDO/35f1372a1ef6c9d67aa12e58d4a5515ab33180e2b6b7a63a9818e0eead57ffc1.sqlite-shm +0 -0
- package/.wrangler/state/v3/do/voice-server-workers-mastra-MastraAgentDO/35f1372a1ef6c9d67aa12e58d4a5515ab33180e2b6b7a63a9818e0eead57ffc1.sqlite-wal +0 -0
- package/.wrangler/state/v3/do/voice-server-workers-mastra-MastraAgentDO/metadata.sqlite +0 -0
- package/.wrangler/state/v3/do/voice-server-workers-mastra-MastraAgentDO/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/do/voice-server-workers-mastra-MastraAgentDO/metadata.sqlite-wal +0 -0
- package/LICENSE +22 -0
- package/README.md +49 -0
- package/package.json +31 -0
- package/src/alarm-scheduler.ts +67 -0
- package/src/durable-run-store.ts +57 -0
- package/src/mock-model.ts +104 -0
- package/src/worker.test.ts +146 -0
- package/src/worker.ts +247 -0
- package/tsconfig.json +22 -0
- package/wrangler.toml +13 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @kuralle-syrinx/server-workers-mastra
|
|
2
|
+
|
|
3
|
+
A **dedicated Cloudflare Worker Durable Object** that runs a Mastra `Agent` for **human-in-the-loop suspend/resume** across voice turns. It pairs Mastra's own snapshot storage (`@mastra/cloudflare` `CloudflareDOStorage` on `ctx.storage.sql`) with a tiny `{contextId → runId}` pointer store and the [`ReasoningBridge`](../aisdk/README.md).
|
|
4
|
+
|
|
5
|
+
This is **separate from** the lean AI-SDK product worker (`@kuralle-syrinx/server-workers`), which stays Mastra-free + edge-bundle-clean. Mastra (≈8 MB, `nodejs_compat`) is quarantined here.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
turn 1 (/suspend): agent.stream(...) → tool-call-suspended
|
|
11
|
+
ReasoningBridge → llm.done(question) + reasoning.suspended packet
|
|
12
|
+
DurableObjectRunStore.save(contextId, runId) // pointer in reasoning_run_pointers
|
|
13
|
+
Mastra snapshot persisted in CloudflareDOStorage // mastra_workflow_snapshot
|
|
14
|
+
|
|
15
|
+
── DO may hibernate between turns ──
|
|
16
|
+
|
|
17
|
+
turn 2 (/resume): RunStore.takePending(contextId) → runId
|
|
18
|
+
bridge builds ReasonerTurn.resume → agent.resumeStream(data, {runId})
|
|
19
|
+
Mastra reloads the snapshot from ctx.storage.sql on a fresh instance → completes
|
|
20
|
+
RunStore.discard(contextId)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`DurableObjectRunStore` (`src/durable-run-store.ts`) implements the bridge's `RunStore` interface over `ctx.storage.sql` (`reasoning_run_pointers(context_id PK, run_id, created_at_ms)`), with TTL-GC via a DO alarm — mirroring `DurableObjectSessionStore`.
|
|
24
|
+
|
|
25
|
+
## Deploy
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm --filter @kuralle-syrinx/server-workers-mastra exec wrangler deploy
|
|
29
|
+
wrangler secret put OPENAI_API_KEY # real model; absent → deterministic stub (tests)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`wrangler.toml` sets `compatibility_flags = ["nodejs_compat"]` + the `MastraAgentDO` SQLite DO binding. Endpoints: `/health`, `/suspend?contextId=`, `POST /resume?contextId=` (`{userText}`).
|
|
33
|
+
|
|
34
|
+
## Requirements & gotchas
|
|
35
|
+
|
|
36
|
+
- **`nodejs_compat`** — `@mastra/core` needs Node builtins; bundle via **wrangler** (it handles the `.wasm` + node polyfills), not raw esbuild.
|
|
37
|
+
- **Workers Paid tier** — the bundle is ~8 MB (exceeds the 3 MiB Free limit; within the 10 MiB Paid limit). A bundle diet to reach Free is backlog (RFC §9, B-05).
|
|
38
|
+
- **No filesystem on edge** — the Mastra agent must **not** use `fs`/workspace tools (`@mastra/core` bundles `fs`/`@ast-grep/napi`, but they fail on Workers if exercised).
|
|
39
|
+
- **Mastra owns the checkpoint**, we own the pointer — cross-turn resume works because `CloudflareDOStorage` persists the snapshot durably in the DO's SQLite (proven across a fresh instance over the same SQL).
|
|
40
|
+
|
|
41
|
+
## Tests
|
|
42
|
+
|
|
43
|
+
The workerd two-turn suspend→resume test is opt-in (slow — spins `wrangler unstable_dev`):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
SYRINX_MASTRA_EDGE_TEST=1 pnpm --filter @kuralle-syrinx/server-workers-mastra test
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
It uses a deterministic stub model (no network).
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/server-workers-mastra",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/worker.ts",
|
|
8
|
+
"types": "./src/worker.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@ai-sdk/openai": "^3.0.67",
|
|
11
|
+
"@mastra/cloudflare": "^1.4.1",
|
|
12
|
+
"@mastra/core": "^1.41.0",
|
|
13
|
+
"zod": "^4.1.8",
|
|
14
|
+
"@kuralle-syrinx/core": "2.1.0",
|
|
15
|
+
"@kuralle-syrinx/mastra": "2.1.0",
|
|
16
|
+
"@kuralle-syrinx/aisdk": "2.1.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@cloudflare/workers-types": "^4.20260601.0",
|
|
20
|
+
"miniflare": "^4.20260601.0",
|
|
21
|
+
"typescript": "^5.7.0",
|
|
22
|
+
"vitest": "^2.1.0",
|
|
23
|
+
"wrangler": "^4.19.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:edge": "SYRINX_MASTRA_EDGE_TEST=1 vitest run",
|
|
29
|
+
"wrangler:dry-run": "wrangler deploy --dry-run"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { RunPointer, RunStore } from "@kuralle-syrinx/aisdk";
|
|
4
|
+
import type { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
5
|
+
|
|
6
|
+
type SqlCursor<T> = Iterable<T>;
|
|
7
|
+
|
|
8
|
+
interface SqlStorage {
|
|
9
|
+
exec(query: string, ...bindings: unknown[]): SqlCursor<Record<string, unknown>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DurableRunStorage {
|
|
13
|
+
readonly sql: SqlStorage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_RUN_POINTER_TTL_MS = 15 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export class DurableObjectRunStore implements RunStore {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly storage: DurableRunStorage,
|
|
21
|
+
private readonly scheduler: DurableObjectAlarmScheduler,
|
|
22
|
+
private readonly ttlMs = DEFAULT_RUN_POINTER_TTL_MS,
|
|
23
|
+
) {
|
|
24
|
+
this.storage.sql.exec(
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS reasoning_run_pointers (
|
|
26
|
+
context_id TEXT PRIMARY KEY,
|
|
27
|
+
run_id TEXT NOT NULL,
|
|
28
|
+
created_at_ms INTEGER NOT NULL
|
|
29
|
+
)`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
save(contextId: string, runId: string): void {
|
|
34
|
+
this.storage.sql.exec(
|
|
35
|
+
"INSERT OR REPLACE INTO reasoning_run_pointers (context_id, run_id, created_at_ms) VALUES (?, ?, ?)",
|
|
36
|
+
contextId,
|
|
37
|
+
runId,
|
|
38
|
+
Date.now(),
|
|
39
|
+
);
|
|
40
|
+
this.scheduler.schedule(`run.ttl:${contextId}`, this.ttlMs, () => {
|
|
41
|
+
this.discard(contextId);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
takePending(contextId: string): RunPointer | null {
|
|
46
|
+
const [row] = [...this.storage.sql.exec(
|
|
47
|
+
"SELECT run_id FROM reasoning_run_pointers WHERE context_id = ?",
|
|
48
|
+
contextId,
|
|
49
|
+
)] as Array<{ run_id: string }>;
|
|
50
|
+
return row ? { runId: row.run_id } : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
discard(contextId: string): void {
|
|
54
|
+
this.scheduler.cancel(`run.ttl:${contextId}`);
|
|
55
|
+
this.storage.sql.exec("DELETE FROM reasoning_run_pointers WHERE context_id = ?", contextId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
type SqlStorage = {
|
|
4
|
+
exec(query: string, ...bindings: unknown[]): Iterable<Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function nextModelCall(sql: SqlStorage, key: string): number {
|
|
8
|
+
sql.exec(
|
|
9
|
+
`CREATE TABLE IF NOT EXISTS spike_mock_calls (
|
|
10
|
+
key TEXT PRIMARY KEY,
|
|
11
|
+
count INTEGER NOT NULL
|
|
12
|
+
)`,
|
|
13
|
+
);
|
|
14
|
+
const [row] = [...sql.exec("SELECT count FROM spike_mock_calls WHERE key = ?", key)];
|
|
15
|
+
const current = typeof row?.count === "number" ? row.count : 0;
|
|
16
|
+
const next = current + 1;
|
|
17
|
+
sql.exec("INSERT OR REPLACE INTO spike_mock_calls (key, count) VALUES (?, ?)", key, next);
|
|
18
|
+
return next;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toolCallStream(): ReadableStream<unknown> {
|
|
22
|
+
return new ReadableStream({
|
|
23
|
+
start(controller) {
|
|
24
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
25
|
+
controller.enqueue({
|
|
26
|
+
type: "response-metadata",
|
|
27
|
+
id: "id-0",
|
|
28
|
+
modelId: "mock",
|
|
29
|
+
timestamp: new Date(0),
|
|
30
|
+
});
|
|
31
|
+
controller.enqueue({
|
|
32
|
+
type: "tool-call",
|
|
33
|
+
toolCallId: "call-1",
|
|
34
|
+
toolName: "confirmAction",
|
|
35
|
+
input: '{"action":"deploy"}',
|
|
36
|
+
providerExecuted: false,
|
|
37
|
+
});
|
|
38
|
+
controller.enqueue({
|
|
39
|
+
type: "finish",
|
|
40
|
+
finishReason: "tool-calls",
|
|
41
|
+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
|
|
42
|
+
});
|
|
43
|
+
controller.close();
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function textStream(): ReadableStream<unknown> {
|
|
49
|
+
return new ReadableStream({
|
|
50
|
+
start(controller) {
|
|
51
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
52
|
+
controller.enqueue({
|
|
53
|
+
type: "response-metadata",
|
|
54
|
+
id: "id-1",
|
|
55
|
+
modelId: "mock",
|
|
56
|
+
timestamp: new Date(0),
|
|
57
|
+
});
|
|
58
|
+
controller.enqueue({ type: "text-start", id: "text-1" });
|
|
59
|
+
controller.enqueue({ type: "text-delta", id: "text-1", delta: "Deployed successfully." });
|
|
60
|
+
controller.enqueue({ type: "text-end", id: "text-1" });
|
|
61
|
+
controller.enqueue({
|
|
62
|
+
type: "finish",
|
|
63
|
+
finishReason: "stop",
|
|
64
|
+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
|
|
65
|
+
});
|
|
66
|
+
controller.close();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createSpikeMockModel(sql: SqlStorage, key = "mastra-session") {
|
|
72
|
+
return {
|
|
73
|
+
specificationVersion: "v2" as const,
|
|
74
|
+
provider: "spike-mock",
|
|
75
|
+
modelId: "spike-mock-v1",
|
|
76
|
+
supportedUrls: {},
|
|
77
|
+
doStream: async () => {
|
|
78
|
+
const callCount = nextModelCall(sql, key);
|
|
79
|
+
const stream = callCount === 1 ? toolCallStream() : textStream();
|
|
80
|
+
return {
|
|
81
|
+
stream,
|
|
82
|
+
rawCall: { rawPrompt: null, rawSettings: {} },
|
|
83
|
+
warnings: [],
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
doGenerate: async () => ({
|
|
87
|
+
content: [{ type: "text", text: "mock" }],
|
|
88
|
+
finishReason: "stop" as const,
|
|
89
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
90
|
+
rawCall: { rawPrompt: null, rawSettings: {} },
|
|
91
|
+
warnings: [],
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function resetMockModelCalls(sql: SqlStorage, key = "mastra-session"): void {
|
|
97
|
+
sql.exec(
|
|
98
|
+
`CREATE TABLE IF NOT EXISTS spike_mock_calls (
|
|
99
|
+
key TEXT PRIMARY KEY,
|
|
100
|
+
count INTEGER NOT NULL
|
|
101
|
+
)`,
|
|
102
|
+
);
|
|
103
|
+
sql.exec("DELETE FROM spike_mock_calls WHERE key = ?", key);
|
|
104
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { unstable_dev, type Unstable_DevWorker } from "wrangler";
|
|
11
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const PACKAGE_ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
15
|
+
const mastraEdgeEnabled = process.env["SYRINX_MASTRA_EDGE_TEST"] === "1";
|
|
16
|
+
|
|
17
|
+
type SuspendBody = {
|
|
18
|
+
phase: string;
|
|
19
|
+
contextId: string;
|
|
20
|
+
runId: string | null;
|
|
21
|
+
suspended: boolean;
|
|
22
|
+
prompt: string | null;
|
|
23
|
+
pointer: { runId: string } | null;
|
|
24
|
+
mastraTables: string[];
|
|
25
|
+
packetKinds: string[];
|
|
26
|
+
error?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ResumeBody = {
|
|
30
|
+
phase: string;
|
|
31
|
+
contextId: string;
|
|
32
|
+
suspended: boolean;
|
|
33
|
+
text: string;
|
|
34
|
+
pointer: { runId: string } | null;
|
|
35
|
+
mastraTables: string[];
|
|
36
|
+
packetKinds: string[];
|
|
37
|
+
error?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const tempDirs: string[] = [];
|
|
41
|
+
let bundleOutdir = "";
|
|
42
|
+
let bundleSizeKb = 0;
|
|
43
|
+
let bundleLog = "";
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("voice-server-workers-mastra default suite", () => {
|
|
50
|
+
it("gates workerd two-turn test behind SYRINX_MASTRA_EDGE_TEST", () => {
|
|
51
|
+
expect(mastraEdgeEnabled || process.env["SYRINX_MASTRA_EDGE_TEST"] !== "1").toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe.skipIf(!mastraEdgeEnabled)("mastra edge suspend/resume", () => {
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
const outdir = await mkdtemp(join(tmpdir(), "mastra-edge-bundle-"));
|
|
58
|
+
const wranglerBin = join(PACKAGE_ROOT, "node_modules/.bin/wrangler");
|
|
59
|
+
const { stdout, stderr } = await execFileAsync(
|
|
60
|
+
wranglerBin,
|
|
61
|
+
["deploy", "--dry-run", "--outdir", outdir],
|
|
62
|
+
{ cwd: PACKAGE_ROOT, env: process.env },
|
|
63
|
+
);
|
|
64
|
+
bundleLog = `${stdout}\n${stderr}`;
|
|
65
|
+
const workerPath = join(outdir, "worker.js");
|
|
66
|
+
if (!existsSync(workerPath)) {
|
|
67
|
+
throw new Error(`wrangler bundle missing worker.js\n${bundleLog}`);
|
|
68
|
+
}
|
|
69
|
+
bundleOutdir = outdir;
|
|
70
|
+
const bundleStat = await stat(workerPath);
|
|
71
|
+
bundleSizeKb = Math.round(bundleStat.size / 1024);
|
|
72
|
+
}, 120_000);
|
|
73
|
+
|
|
74
|
+
async function startDevWorker(): Promise<Unstable_DevWorker> {
|
|
75
|
+
return unstable_dev(join(PACKAGE_ROOT, "src/worker.ts"), {
|
|
76
|
+
config: join(PACKAGE_ROOT, "wrangler.toml"),
|
|
77
|
+
local: true,
|
|
78
|
+
compatibilityDate: "2026-06-01",
|
|
79
|
+
compatibilityFlags: ["nodejs_compat"],
|
|
80
|
+
experimental: { disableExperimentalWarning: true },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it("bundles @mastra/core + @mastra/cloudflare via wrangler (nodejs_compat)", () => {
|
|
85
|
+
expect(bundleOutdir).toBeTruthy();
|
|
86
|
+
expect(bundleSizeKb).toBeGreaterThan(0);
|
|
87
|
+
expect(bundleLog).not.toMatch(/Could not resolve|unresolved|ERROR/i);
|
|
88
|
+
}, 10_000);
|
|
89
|
+
|
|
90
|
+
it("suspend → fresh DO/same SQL → resume via ReasoningBridge + pointer RunStore", async () => {
|
|
91
|
+
const worker = await startDevWorker();
|
|
92
|
+
const fsHits: string[] = [];
|
|
93
|
+
const originalConsoleError = console.error;
|
|
94
|
+
console.error = (...args: unknown[]) => {
|
|
95
|
+
const line = args.map(String).join(" ");
|
|
96
|
+
if (/ENOENT|readFileSync|existsSync|fs\./i.test(line)) fsHits.push(line);
|
|
97
|
+
originalConsoleError(...args);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const health = await worker.fetch("http://localhost/health");
|
|
102
|
+
expect(health.status).toBe(200);
|
|
103
|
+
expect(await health.text()).toBe("ok");
|
|
104
|
+
|
|
105
|
+
const suspendRes = await worker.fetch("http://localhost/suspend");
|
|
106
|
+
const suspendBody = await suspendRes.json() as SuspendBody | { error: string };
|
|
107
|
+
if ("error" in suspendBody) {
|
|
108
|
+
throw new Error(`suspend failed: ${suspendBody.error}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(suspendRes.status).toBe(200);
|
|
112
|
+
expect(suspendBody.phase).toBe("suspend");
|
|
113
|
+
expect(suspendBody.suspended).toBe(true);
|
|
114
|
+
expect(suspendBody.runId).toBeTruthy();
|
|
115
|
+
expect(suspendBody.pointer).toEqual({ runId: suspendBody.runId });
|
|
116
|
+
expect(suspendBody.packetKinds).toContain("reasoning.suspended");
|
|
117
|
+
expect(suspendBody.packetKinds).toContain("llm.done");
|
|
118
|
+
expect(suspendBody.mastraTables.some((name) => name.includes("mastra") || name.includes("workflow"))).toBe(true);
|
|
119
|
+
|
|
120
|
+
const resumeRes = await worker.fetch("http://localhost/resume", {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "content-type": "application/json" },
|
|
123
|
+
body: JSON.stringify({ userText: "yes" }),
|
|
124
|
+
});
|
|
125
|
+
const resumeBody = await resumeRes.json() as ResumeBody | { error: string };
|
|
126
|
+
if ("error" in resumeBody) {
|
|
127
|
+
throw new Error(`resume failed: ${resumeBody.error}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
expect(resumeRes.status).toBe(200);
|
|
131
|
+
expect(resumeBody.phase).toBe("resume");
|
|
132
|
+
expect(resumeBody.suspended).toBe(false);
|
|
133
|
+
expect(resumeBody.text).toContain("Deployed successfully");
|
|
134
|
+
expect(resumeBody.packetKinds).toContain("llm.done");
|
|
135
|
+
expect(resumeBody.pointer).toBeNull();
|
|
136
|
+
|
|
137
|
+
if (fsHits.length > 0) {
|
|
138
|
+
// eslint-disable-next-line no-console
|
|
139
|
+
console.log("[fs hits]", fsHits);
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
console.error = originalConsoleError;
|
|
143
|
+
await worker.stop();
|
|
144
|
+
}
|
|
145
|
+
}, 120_000);
|
|
146
|
+
});
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { DurableObject } from "cloudflare:workers";
|
|
4
|
+
import { CloudflareDOStorage } from "@mastra/cloudflare/do";
|
|
5
|
+
import { Agent } from "@mastra/core/agent";
|
|
6
|
+
import { Mastra } from "@mastra/core";
|
|
7
|
+
import { createTool } from "@mastra/core/tools";
|
|
8
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { PipelineBusImpl, Route } from "@kuralle-syrinx/core";
|
|
11
|
+
import type { EndOfSpeechPacket } from "@kuralle-syrinx/core";
|
|
12
|
+
import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
|
|
13
|
+
import { fromMastraAgent, type MastraAgentLike } from "@kuralle-syrinx/mastra";
|
|
14
|
+
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
15
|
+
import { DurableObjectRunStore } from "./durable-run-store.js";
|
|
16
|
+
import { createSpikeMockModel, resetMockModelCalls } from "./mock-model.js";
|
|
17
|
+
|
|
18
|
+
export interface Env {
|
|
19
|
+
MASTRA_AGENT: DurableObjectNamespace;
|
|
20
|
+
/** When set, the agent uses a real OpenAI model (gpt-4.1-mini); else a deterministic stub (tests). */
|
|
21
|
+
OPENAI_API_KEY?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CONTEXT_ID = "mastra-session";
|
|
25
|
+
|
|
26
|
+
type SqlStorage = DurableObjectState["storage"]["sql"];
|
|
27
|
+
|
|
28
|
+
function isConfirmed(resumeData: unknown): boolean {
|
|
29
|
+
if (typeof resumeData === "object" && resumeData !== null && "confirmed" in resumeData) {
|
|
30
|
+
return (resumeData as { confirmed?: boolean }).confirmed === true;
|
|
31
|
+
}
|
|
32
|
+
if (typeof resumeData === "string") {
|
|
33
|
+
const normalized = resumeData.trim().toLowerCase();
|
|
34
|
+
return normalized === "yes" || normalized === "confirm" || normalized === "confirmed";
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const confirmTool = createTool({
|
|
40
|
+
id: "confirm-action",
|
|
41
|
+
description: "Confirms an action with the user",
|
|
42
|
+
inputSchema: z.object({ action: z.string() }),
|
|
43
|
+
suspendSchema: z.object({
|
|
44
|
+
action: z.string(),
|
|
45
|
+
reason: z.string().optional(),
|
|
46
|
+
}),
|
|
47
|
+
resumeSchema: z.object({
|
|
48
|
+
confirmed: z.boolean(),
|
|
49
|
+
}),
|
|
50
|
+
execute: async (input: { action: string }, context) => {
|
|
51
|
+
const ctx = context as {
|
|
52
|
+
resumeData?: unknown;
|
|
53
|
+
suspend?: (payload: { action: string; reason?: string }) => Promise<void>;
|
|
54
|
+
agent?: { resumeData?: unknown; suspend?: (payload: { action: string; reason?: string }) => Promise<void> };
|
|
55
|
+
workflow?: { resumeData?: unknown; suspend?: (payload: { action: string; reason?: string }) => Promise<void> };
|
|
56
|
+
} | undefined;
|
|
57
|
+
const resumeData = ctx?.resumeData ?? ctx?.agent?.resumeData ?? ctx?.workflow?.resumeData;
|
|
58
|
+
if (isConfirmed(resumeData)) {
|
|
59
|
+
return { result: `Action "${input.action}" confirmed`, resumed: true };
|
|
60
|
+
}
|
|
61
|
+
const suspend = ctx?.suspend ?? ctx?.agent?.suspend ?? ctx?.workflow?.suspend;
|
|
62
|
+
if (!suspend) throw new Error("suspend not available in tool context");
|
|
63
|
+
return await suspend({ action: input.action, reason: "Needs user confirmation" });
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function toMastraAgentLike(agent: Agent): MastraAgentLike {
|
|
68
|
+
return {
|
|
69
|
+
stream: (messages, options) =>
|
|
70
|
+
agent.stream(messages as never, {
|
|
71
|
+
abortSignal: options?.abortSignal,
|
|
72
|
+
requireToolApproval: false,
|
|
73
|
+
autoResumeSuspendedTools: false,
|
|
74
|
+
}) as unknown as ReturnType<MastraAgentLike["stream"]>,
|
|
75
|
+
resumeStream: (resumeData, options) =>
|
|
76
|
+
agent.resumeStream(resumeData, {
|
|
77
|
+
runId: options.runId,
|
|
78
|
+
toolCallId: options.toolCallId,
|
|
79
|
+
abortSignal: options.abortSignal,
|
|
80
|
+
requireToolApproval: false,
|
|
81
|
+
}) as unknown as ReturnType<MastraAgentLike["resumeStream"]>,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createMastra(sql: SqlStorage, apiKey?: string): { mastra: Mastra; agent: Agent } {
|
|
86
|
+
const storage = new CloudflareDOStorage({ sql });
|
|
87
|
+
// Real OpenAI model when a key is provided (live deploy); deterministic stub otherwise (tests).
|
|
88
|
+
const model = apiKey
|
|
89
|
+
? (createOpenAI({ apiKey })("gpt-4.1-mini") as never)
|
|
90
|
+
: (createSpikeMockModel(sql) as never);
|
|
91
|
+
const agent = new Agent({
|
|
92
|
+
id: "support",
|
|
93
|
+
name: "Support Agent",
|
|
94
|
+
instructions:
|
|
95
|
+
"You confirm potentially destructive actions with the user before doing them. " +
|
|
96
|
+
"When asked to deploy, call the confirmAction tool to get confirmation first.",
|
|
97
|
+
model,
|
|
98
|
+
tools: { confirmAction: confirmTool },
|
|
99
|
+
});
|
|
100
|
+
const mastra = new Mastra({
|
|
101
|
+
agents: { support: agent },
|
|
102
|
+
storage,
|
|
103
|
+
logger: false,
|
|
104
|
+
});
|
|
105
|
+
return { mastra, agent };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
109
|
+
return {
|
|
110
|
+
kind: "eos.turn_complete",
|
|
111
|
+
contextId,
|
|
112
|
+
timestampMs: Date.now(),
|
|
113
|
+
text,
|
|
114
|
+
transcripts: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function listMastraTables(sql: SqlStorage): string[] {
|
|
119
|
+
const rows = [...sql.exec(
|
|
120
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
121
|
+
)] as Array<{ name: string }>;
|
|
122
|
+
return rows.map((row) => row.name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readPointer(sql: SqlStorage, contextId: string): { runId: string } | null {
|
|
126
|
+
const [row] = [...sql.exec(
|
|
127
|
+
"SELECT run_id FROM reasoning_run_pointers WHERE context_id = ?",
|
|
128
|
+
contextId,
|
|
129
|
+
)] as Array<{ run_id: string }>;
|
|
130
|
+
return row ? { runId: row.run_id } : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function driveTurn(
|
|
134
|
+
storage: DurableObjectState["storage"],
|
|
135
|
+
contextId: string,
|
|
136
|
+
userText: string,
|
|
137
|
+
apiKey?: string,
|
|
138
|
+
): Promise<{
|
|
139
|
+
packets: Array<{ route: Route; packet: Record<string, unknown> }>;
|
|
140
|
+
pointer: { runId: string } | null;
|
|
141
|
+
mastraTables: string[];
|
|
142
|
+
}> {
|
|
143
|
+
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
144
|
+
const runStore = new DurableObjectRunStore(storage, scheduler);
|
|
145
|
+
const { agent } = createMastra(storage.sql, apiKey);
|
|
146
|
+
const bridge = new ReasoningBridge(fromMastraAgent(toMastraAgentLike(agent)), {
|
|
147
|
+
runStore,
|
|
148
|
+
onResumeConflict: "restart",
|
|
149
|
+
});
|
|
150
|
+
const packets: Array<{ route: Route; packet: Record<string, unknown> }> = [];
|
|
151
|
+
const bus = new PipelineBusImpl({
|
|
152
|
+
onPacket: (route, packet) => {
|
|
153
|
+
packets.push({ route, packet: packet as unknown as Record<string, unknown> });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
const drain = bus.start();
|
|
157
|
+
await bridge.initialize(bus, { timeout_ms: 30_000, max_history_turns: 12 });
|
|
158
|
+
bus.push(Route.Main, turnComplete(contextId, userText));
|
|
159
|
+
|
|
160
|
+
const deadline = Date.now() + 30_000;
|
|
161
|
+
while (Date.now() < deadline) {
|
|
162
|
+
const done = packets.some(({ packet }) =>
|
|
163
|
+
packet["kind"] === "llm.done" || packet["kind"] === "reasoning.suspended",
|
|
164
|
+
);
|
|
165
|
+
if (done) break;
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
bus.stop();
|
|
170
|
+
await drain;
|
|
171
|
+
await bridge.close();
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
packets,
|
|
175
|
+
pointer: readPointer(storage.sql, contextId),
|
|
176
|
+
mastraTables: listMastraTables(storage.sql),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export class MastraAgentDO extends DurableObject<Env> {
|
|
181
|
+
async fetch(request: Request): Promise<Response> {
|
|
182
|
+
const url = new URL(request.url);
|
|
183
|
+
const contextId = url.searchParams.get("contextId") ?? DEFAULT_CONTEXT_ID;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
if (url.pathname === "/health") {
|
|
187
|
+
return new Response("ok");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (url.pathname === "/suspend") {
|
|
191
|
+
if (!this.env.OPENAI_API_KEY) resetMockModelCalls(this.ctx.storage.sql, contextId);
|
|
192
|
+
const result = await driveTurn(this.ctx.storage, contextId, "Deploy to production", this.env.OPENAI_API_KEY);
|
|
193
|
+
const suspended = result.packets.find(({ packet }) => packet["kind"] === "reasoning.suspended");
|
|
194
|
+
const llmDone = result.packets.find(({ packet }) => packet["kind"] === "llm.done");
|
|
195
|
+
return Response.json({
|
|
196
|
+
phase: "suspend",
|
|
197
|
+
contextId,
|
|
198
|
+
suspended: suspended !== undefined,
|
|
199
|
+
runId: suspended?.packet["runId"] ?? null,
|
|
200
|
+
prompt: suspended?.packet["prompt"] ?? llmDone?.packet["text"] ?? null,
|
|
201
|
+
pointer: result.pointer,
|
|
202
|
+
mastraTables: result.mastraTables,
|
|
203
|
+
packetKinds: result.packets.map(({ packet }) => packet["kind"]),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (url.pathname === "/resume") {
|
|
208
|
+
const body = await request.json() as { userText?: string };
|
|
209
|
+
const userText = body.userText ?? "yes";
|
|
210
|
+
const result = await driveTurn(this.ctx.storage, contextId, userText, this.env.OPENAI_API_KEY);
|
|
211
|
+
const llmDone = result.packets.find(({ packet }) => packet["kind"] === "llm.done");
|
|
212
|
+
const suspended = result.packets.find(({ packet }) => packet["kind"] === "reasoning.suspended");
|
|
213
|
+
return Response.json({
|
|
214
|
+
phase: "resume",
|
|
215
|
+
contextId,
|
|
216
|
+
suspended: suspended !== undefined,
|
|
217
|
+
text: llmDone?.packet["text"] ?? "",
|
|
218
|
+
pointer: result.pointer,
|
|
219
|
+
mastraTables: result.mastraTables,
|
|
220
|
+
packetKinds: result.packets.map(({ packet }) => packet["kind"]),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return new Response("not found", { status: 404 });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const message = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ""}` : String(error);
|
|
227
|
+
return Response.json({ error: message }, { status: 500 });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async alarm(): Promise<void> {
|
|
232
|
+
const scheduler = new DurableObjectAlarmScheduler(this.ctx.storage);
|
|
233
|
+
await scheduler.runDue();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default {
|
|
238
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
239
|
+
const url = new URL(request.url);
|
|
240
|
+
if (url.pathname === "/health") {
|
|
241
|
+
return new Response("ok");
|
|
242
|
+
}
|
|
243
|
+
const id = env.MASTRA_AGENT.idFromName("mastra-session");
|
|
244
|
+
const stub = env.MASTRA_AGENT.get(id);
|
|
245
|
+
return stub.fetch(request);
|
|
246
|
+
},
|
|
247
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noImplicitReturns": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"rootDir": "./src",
|
|
16
|
+
"esModuleInterop": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"forceConsistentCasingInFileNames": true
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*.ts"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|
package/wrangler.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name = "voice-server-workers-mastra"
|
|
2
|
+
main = "src/worker.ts"
|
|
3
|
+
compatibility_date = "2026-06-01"
|
|
4
|
+
compatibility_flags = ["nodejs_compat"]
|
|
5
|
+
|
|
6
|
+
[durable_objects]
|
|
7
|
+
bindings = [
|
|
8
|
+
{ name = "MASTRA_AGENT", class_name = "MastraAgentDO" }
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[[migrations]]
|
|
12
|
+
tag = "v1"
|
|
13
|
+
new_sqlite_classes = ["MastraAgentDO"]
|