Skip to content

Commit d5748d0

Browse files
committed
Fix execution owner directory Durable Object
1 parent c956af4 commit d5748d0

3 files changed

Lines changed: 179 additions & 27 deletions

File tree

packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,7 @@ class InMemoryExecutionOwnerDirectoryNamespace implements McpExecutionOwnerDirec
223223
get(id: string): McpExecutionOwnerDirectoryDO {
224224
let directory = this.directories.get(id);
225225
if (!directory) {
226-
directory = new McpExecutionOwnerDirectoryDO({
227-
storage: new FakeStorage(),
228-
});
226+
directory = new McpExecutionOwnerDirectoryDO(new FakeDurableObjectState(id), {});
229227
this.directories.set(id, directory);
230228
}
231229
return directory;

packages/hosts/cloudflare/src/mcp/execution-owner-directory.test.ts

Lines changed: 172 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,88 @@
1+
import { DurableObject } from "cloudflare:workers";
12
import { describe, expect, it } from "@effect/vitest";
3+
import { Effect } from "effect";
24

35
import {
46
McpExecutionOwnerDirectoryDO,
7+
mcpExecutionOwnerDirectoryFromNamespace,
8+
type McpExecutionOwnerDirectoryNamespace,
59
type McpExecutionOwnerRecord,
610
} from "./execution-owner-directory";
711

8-
class FakeStorage {
12+
class FakeStorage implements DurableObjectStorage {
913
private readonly values = new Map<string, unknown>();
14+
readonly sql = {} as DurableObjectStorage["sql"];
15+
readonly kv = {} as DurableObjectStorage["kv"];
1016
alarmAt: number | null = null;
1117

12-
async get<T>(key: string): Promise<T | undefined> {
13-
return this.values.get(key) as T | undefined;
18+
async get<T>(key: string): Promise<T | undefined>;
19+
async get<T>(keys: string[]): Promise<Map<string, T>>;
20+
async get<T>(keyOrKeys: string | string[]): Promise<T | undefined | Map<string, T>> {
21+
if (Array.isArray(keyOrKeys)) {
22+
return new Map(keyOrKeys.map((key) => [key, this.values.get(key) as T]));
23+
}
24+
return this.values.get(keyOrKeys) as T | undefined;
1425
}
1526

16-
async put<T>(key: string, value: T): Promise<void> {
17-
this.values.set(key, value);
27+
async put<T>(key: string, value: T): Promise<void>;
28+
async put<T>(entries: Record<string, T> | Map<string, T>): Promise<void>;
29+
async put<T>(
30+
keyOrEntries: string | Record<string, T> | Map<string, T>,
31+
value?: T,
32+
): Promise<void> {
33+
if (typeof keyOrEntries === "string") {
34+
this.values.set(keyOrEntries, value);
35+
return;
36+
}
37+
const entries =
38+
keyOrEntries instanceof Map ? keyOrEntries.entries() : Object.entries(keyOrEntries);
39+
for (const [key, entry] of entries) this.values.set(key, entry);
1840
}
1941

20-
async delete(key: string): Promise<boolean> {
21-
return this.values.delete(key);
42+
async delete(key: string): Promise<boolean>;
43+
async delete(keys: string[]): Promise<number>;
44+
async delete(keyOrKeys: string | string[]): Promise<boolean | number> {
45+
if (!Array.isArray(keyOrKeys)) return this.values.delete(keyOrKeys);
46+
let deleted = 0;
47+
for (const key of keyOrKeys) {
48+
if (this.values.delete(key)) deleted += 1;
49+
}
50+
return deleted;
51+
}
52+
53+
async list<T = unknown>(): Promise<Map<string, T>> {
54+
return new Map([...this.values.entries()].map(([key, value]) => [key, value as T]));
55+
}
56+
57+
async deleteAll(): Promise<void> {
58+
this.values.clear();
59+
this.alarmAt = null;
60+
}
61+
62+
transaction<T>(_closure: (txn: DurableObjectTransaction) => Promise<T>): Promise<T> {
63+
return Promise.resolve(undefined as T);
64+
}
65+
66+
transactionSync<T>(_closure: () => T): T {
67+
return undefined as T;
68+
}
69+
70+
async sync(): Promise<void> {}
71+
72+
async getAlarm(): Promise<number | null> {
73+
return this.alarmAt;
74+
}
75+
76+
async getCurrentBookmark(): Promise<string> {
77+
return "test-bookmark";
78+
}
79+
80+
async getBookmarkForTime(_timestamp: number | Date): Promise<string> {
81+
return "test-bookmark";
82+
}
83+
84+
onNextSessionRestoreBookmark(_bookmark: string): Promise<string> {
85+
return Promise.resolve("test-bookmark");
2286
}
2387

2488
async setAlarm(scheduledTime: number | Date): Promise<void> {
@@ -30,14 +94,93 @@ class FakeStorage {
3094
}
3195
}
3296

97+
class FakeDurableObjectState implements DurableObjectState {
98+
readonly id: DurableObjectId;
99+
readonly props: unknown = undefined;
100+
readonly facets = {} as DurableObjectState["facets"];
101+
readonly ctx = this;
102+
private readonly waitUntilPromises: Promise<unknown>[] = [];
103+
104+
constructor(
105+
name: string,
106+
readonly storage: FakeStorage,
107+
) {
108+
const id: Pick<DurableObjectId, "equals" | "name" | "toString"> = {
109+
equals: (other: DurableObjectId) => other.toString() === name,
110+
name,
111+
toString: () => name,
112+
};
113+
this.id = id as DurableObjectId;
114+
}
115+
116+
waitUntil(promise: Promise<unknown>): void {
117+
this.waitUntilPromises.push(promise);
118+
}
119+
120+
blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T> {
121+
return callback();
122+
}
123+
124+
acceptWebSocket(_ws: WebSocket, _tags?: string[]): void {}
125+
126+
getWebSockets(_tag?: string): WebSocket[] {
127+
return [];
128+
}
129+
130+
getTags(_ws: WebSocket): string[] {
131+
return [];
132+
}
133+
134+
setWebSocketAutoResponse(_pair?: WebSocketRequestResponsePair): void {}
135+
136+
getWebSocketAutoResponse(): WebSocketRequestResponsePair | null {
137+
return null;
138+
}
139+
140+
getWebSocketAutoResponseTimestamp(_ws: WebSocket): Date | null {
141+
return null;
142+
}
143+
144+
setHibernatableWebSocketEventTimeout(_timeoutMs?: number): void {}
145+
146+
getHibernatableWebSocketEventTimeout(): number | null {
147+
return null;
148+
}
149+
150+
abort(_reason?: string): void {}
151+
}
152+
33153
const makeDirectory = () => {
34154
const storage = new FakeStorage();
35-
const directory = new McpExecutionOwnerDirectoryDO({
36-
storage,
37-
});
155+
const directory = new McpExecutionOwnerDirectoryDO(
156+
new FakeDurableObjectState("execution-owner", storage),
157+
{},
158+
);
38159
return { directory, storage };
39160
};
40161

162+
class FakeDirectoryNamespace implements McpExecutionOwnerDirectoryNamespace<string> {
163+
private readonly directories = new Map<string, McpExecutionOwnerDirectoryDO>();
164+
165+
idFromName(name: string): string {
166+
return name;
167+
}
168+
169+
get(id: string): unknown {
170+
let directory = this.directories.get(id);
171+
if (!directory) {
172+
directory = makeDirectory().directory;
173+
this.directories.set(id, directory);
174+
}
175+
if (!(directory instanceof DurableObject)) return {};
176+
return {
177+
put: (entry: McpExecutionOwnerRecord) => directory.put(entry),
178+
get: (executionId: string) => directory.get(executionId),
179+
delete: (executionId: string) => directory.delete(executionId),
180+
};
181+
}
182+
}
183+
41184
const record = (input?: Partial<McpExecutionOwnerRecord>): McpExecutionOwnerRecord => ({
42185
executionId: "exec_1",
43186
owner: { sessionId: "session_a" },
@@ -49,6 +192,11 @@ const record = (input?: Partial<McpExecutionOwnerRecord>): McpExecutionOwnerReco
49192
});
50193

51194
describe("McpExecutionOwnerDirectoryDO", () => {
195+
it("exports a DurableObject subclass for RPC method dispatch", () => {
196+
expect(Object.getPrototypeOf(McpExecutionOwnerDirectoryDO)).toBe(DurableObject);
197+
expect(makeDirectory().directory).toBeInstanceOf(DurableObject);
198+
});
199+
52200
it("stores and reads an unexpired execution owner record", async () => {
53201
const { directory, storage } = makeDirectory();
54202
const entry = record();
@@ -81,4 +229,18 @@ describe("McpExecutionOwnerDirectoryDO", () => {
81229
expect(await storage.get("owner")).toBeUndefined();
82230
expect(storage.alarmAt).toBeNull();
83231
});
232+
233+
it("stores, reads, and deletes records through the namespace RPC path", async () => {
234+
const directory = mcpExecutionOwnerDirectoryFromNamespace(new FakeDirectoryNamespace());
235+
const entry = record({ executionId: "exec_rpc" });
236+
237+
expect(directory).not.toBeNull();
238+
await Effect.runPromise(directory!.put(entry));
239+
240+
expect(await Effect.runPromise(directory!.get("exec_rpc"))).toEqual(entry);
241+
242+
await Effect.runPromise(directory!.delete("exec_rpc"));
243+
244+
expect(await Effect.runPromise(directory!.get("exec_rpc"))).toBeNull();
245+
});
84246
});

packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DurableObject } from "cloudflare:workers";
12
import { Data, Effect } from "effect";
23

34
export type McpExecutionOwnerRoute = {
@@ -30,17 +31,7 @@ export interface McpExecutionOwnerDirectoryNamespace<Id> {
3031
readonly get: (id: Id) => unknown;
3132
}
3233

33-
interface McpExecutionOwnerDirectoryStorage {
34-
readonly get: <T>(key: string) => Promise<T | undefined>;
35-
readonly put: <T>(key: string, value: T) => Promise<void>;
36-
readonly delete: (key: string) => Promise<boolean>;
37-
readonly setAlarm: (scheduledTime: number | Date) => Promise<void>;
38-
readonly deleteAlarm: () => Promise<void>;
39-
}
40-
41-
interface McpExecutionOwnerDirectoryState {
42-
readonly storage: McpExecutionOwnerDirectoryStorage;
43-
}
34+
type McpExecutionOwnerDirectoryStorage = DurableObjectState["storage"];
4435

4536
const toMcpExecutionOwnerDirectoryStub = (stub: unknown): McpExecutionOwnerDirectoryStub =>
4637
stub as McpExecutionOwnerDirectoryStub;
@@ -73,10 +64,11 @@ const isOwnerRecord = (value: unknown): value is McpExecutionOwnerRecord =>
7364

7465
const expiryMs = (record: McpExecutionOwnerRecord): number => Date.parse(record.expiresAt);
7566

76-
export class McpExecutionOwnerDirectoryDO {
67+
export class McpExecutionOwnerDirectoryDO extends DurableObject<unknown> {
7768
private readonly storage: McpExecutionOwnerDirectoryStorage;
7869

79-
constructor(ctx: McpExecutionOwnerDirectoryState) {
70+
constructor(ctx: DurableObjectState, env: unknown) {
71+
super(ctx, env);
8072
this.storage = ctx.storage;
8173
}
8274

@@ -104,7 +96,7 @@ export class McpExecutionOwnerDirectoryDO {
10496
await Promise.all([this.storage.delete(RECORD_KEY), this.storage.deleteAlarm()]);
10597
}
10698

107-
async alarm(): Promise<void> {
99+
override async alarm(): Promise<void> {
108100
await Promise.all([this.storage.delete(RECORD_KEY), this.storage.deleteAlarm()]);
109101
}
110102
}

0 commit comments

Comments
 (0)