agents 0.9.0 → 0.10.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/dist/chat/index.d.ts +151 -2
- package/dist/chat/index.js +210 -11
- package/dist/chat/index.js.map +1 -1
- package/dist/{client-BwgM3cRz.js → client-QBjFV5de.js} +161 -49
- package/dist/client-QBjFV5de.js.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/{compaction-helpers-BFTBIzpK.js → compaction-helpers-BPE1_ziA.js} +1 -1
- package/dist/{compaction-helpers-BFTBIzpK.js.map → compaction-helpers-BPE1_ziA.js.map} +1 -1
- package/dist/{compaction-helpers-DkJreaDR.d.ts → compaction-helpers-CHNQeyRm.d.ts} +1 -1
- package/dist/{do-oauth-client-provider-C2jurFjW.d.ts → do-oauth-client-provider-31gqR33H.d.ts} +1 -1
- package/dist/{email-DwPlM0bQ.d.ts → email-Cql45SKP.d.ts} +1 -1
- package/dist/email.d.ts +2 -2
- package/dist/experimental/memory/session/index.d.ts +153 -82
- package/dist/experimental/memory/session/index.js +257 -24
- package/dist/experimental/memory/session/index.js.map +1 -1
- package/dist/experimental/memory/utils/index.d.ts +1 -1
- package/dist/experimental/memory/utils/index.js +1 -1
- package/dist/{index-BtHngIIG.d.ts → index-BPkkIqMn.d.ts} +204 -74
- package/dist/{index-Ua2Nfvbm.d.ts → index-DDSX-g7W.d.ts} +11 -1
- package/dist/index.d.ts +30 -26
- package/dist/index.js +2 -3049
- package/dist/{internal_context-DT8RxmAN.d.ts → internal_context-DuQZFvWI.d.ts} +1 -1
- package/dist/internal_context.d.ts +1 -1
- package/dist/mcp/client.d.ts +2 -2
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/do-oauth-client-provider.d.ts +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +2 -2
- package/dist/observability/index.d.ts +1 -1
- package/dist/react.d.ts +3 -1
- package/dist/react.js +3 -0
- package/dist/react.js.map +1 -1
- package/dist/{retries-DXMQGhG3.d.ts → retries-B_CN5KM9.d.ts} +1 -1
- package/dist/retries.d.ts +1 -1
- package/dist/{serializable-8Jt1B04R.d.ts → serializable-DGdO8CDh.d.ts} +1 -1
- package/dist/serializable.d.ts +1 -1
- package/dist/src-B8NZxxsO.js +3217 -0
- package/dist/src-B8NZxxsO.js.map +1 -0
- package/dist/{types-C-m0II8i.d.ts → types-B9A8AU7B.d.ts} +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/{workflow-types-CZNXKj_D.d.ts → workflow-types-XmOkuI7A.d.ts} +1 -1
- package/dist/workflow-types.d.ts +1 -1
- package/dist/workflows.d.ts +2 -2
- package/dist/workflows.js +1 -1
- package/package.json +12 -16
- package/dist/client-BwgM3cRz.js.map +0 -1
- package/dist/experimental/forever.d.ts +0 -64
- package/dist/experimental/forever.js +0 -338
- package/dist/experimental/forever.js.map +0 -1
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,3053 +1,6 @@
|
|
|
1
1
|
import { MessageType } from "./types.js";
|
|
2
|
-
import {
|
|
3
|
-
import { createHeaderBasedEmailResolver, signAgentHeaders } from "./email.js";
|
|
2
|
+
import { createHeaderBasedEmailResolver } from "./email.js";
|
|
4
3
|
import { __DO_NOT_USE_WILL_BREAK__agentContext } from "./internal_context.js";
|
|
5
|
-
import {
|
|
6
|
-
import { o as RPC_DO_PREFIX, r as MCPConnectionState, s as DisposableStore, t as MCPClientManager } from "./client-BwgM3cRz.js";
|
|
4
|
+
import { a as callable, c as routeAgentEmail, i as StreamingResponse, l as routeAgentRequest, n as DEFAULT_AGENT_STATIC_OPTIONS, o as getAgentByName, r as SqlError, s as getCurrentAgent, t as Agent, u as unstable_callable } from "./src-B8NZxxsO.js";
|
|
7
5
|
import { DurableObjectOAuthClientProvider } from "./mcp/do-oauth-client-provider.js";
|
|
8
|
-
import { genericObservability } from "./observability/index.js";
|
|
9
|
-
import { parseCronExpression } from "cron-schedule";
|
|
10
|
-
import { nanoid } from "nanoid";
|
|
11
|
-
import { EmailMessage } from "cloudflare:email";
|
|
12
|
-
import { Server, getServerByName, routePartykitRequest } from "partyserver";
|
|
13
|
-
//#region src/index.ts
|
|
14
|
-
/**
|
|
15
|
-
* Type guard for RPC request messages
|
|
16
|
-
*/
|
|
17
|
-
function isRPCRequest(msg) {
|
|
18
|
-
return typeof msg === "object" && msg !== null && "type" in msg && msg.type === MessageType.RPC && "id" in msg && typeof msg.id === "string" && "method" in msg && typeof msg.method === "string" && "args" in msg && Array.isArray(msg.args);
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Type guard for state update messages
|
|
22
|
-
*/
|
|
23
|
-
function isStateUpdateMessage(msg) {
|
|
24
|
-
return typeof msg === "object" && msg !== null && "type" in msg && msg.type === MessageType.CF_AGENT_STATE && "state" in msg;
|
|
25
|
-
}
|
|
26
|
-
const callableMetadata = /* @__PURE__ */ new WeakMap();
|
|
27
|
-
/**
|
|
28
|
-
* Error class for SQL execution failures, containing the query that failed
|
|
29
|
-
*/
|
|
30
|
-
var SqlError = class extends Error {
|
|
31
|
-
constructor(query, cause) {
|
|
32
|
-
const message = cause instanceof Error ? cause.message : String(cause);
|
|
33
|
-
super(`SQL query failed: ${message}`, { cause });
|
|
34
|
-
this.name = "SqlError";
|
|
35
|
-
this.query = query;
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
/**
|
|
39
|
-
* Decorator that marks a method as callable by clients
|
|
40
|
-
* @param metadata Optional metadata about the callable method
|
|
41
|
-
*/
|
|
42
|
-
function callable(metadata = {}) {
|
|
43
|
-
return function callableDecorator(target, _context) {
|
|
44
|
-
if (!callableMetadata.has(target)) callableMetadata.set(target, metadata);
|
|
45
|
-
return target;
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
let didWarnAboutUnstableCallable = false;
|
|
49
|
-
/**
|
|
50
|
-
* Decorator that marks a method as callable by clients
|
|
51
|
-
* @deprecated this has been renamed to callable, and unstable_callable will be removed in the next major version
|
|
52
|
-
* @param metadata Optional metadata about the callable method
|
|
53
|
-
*/
|
|
54
|
-
const unstable_callable = (metadata = {}) => {
|
|
55
|
-
if (!didWarnAboutUnstableCallable) {
|
|
56
|
-
didWarnAboutUnstableCallable = true;
|
|
57
|
-
console.warn("unstable_callable is deprecated, use callable instead. unstable_callable will be removed in the next major version.");
|
|
58
|
-
}
|
|
59
|
-
return callable(metadata);
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* Represents the public state of a fiber.
|
|
63
|
-
*/
|
|
64
|
-
function getNextCronTime(cron) {
|
|
65
|
-
return parseCronExpression(cron).getNextDate();
|
|
66
|
-
}
|
|
67
|
-
const KEEP_ALIVE_INTERVAL_MS = 3e4;
|
|
68
|
-
/**
|
|
69
|
-
* Schema version for the Agent's internal SQLite tables.
|
|
70
|
-
* Bump this when adding new tables, columns, or migrations.
|
|
71
|
-
* The constructor stores this as a row in cf_agents_state and checks it
|
|
72
|
-
* on wake to skip DDL on established DOs.
|
|
73
|
-
*/
|
|
74
|
-
const CURRENT_SCHEMA_VERSION = 2;
|
|
75
|
-
const SCHEMA_VERSION_ROW_ID = "cf_schema_version";
|
|
76
|
-
const STATE_ROW_ID = "cf_state_row_id";
|
|
77
|
-
const STATE_WAS_CHANGED = "cf_state_was_changed";
|
|
78
|
-
const DEFAULT_STATE = {};
|
|
79
|
-
/**
|
|
80
|
-
* Internal key used to store the readonly flag in connection state.
|
|
81
|
-
* Prefixed with _cf_ to avoid collision with user state keys.
|
|
82
|
-
*/
|
|
83
|
-
const CF_READONLY_KEY = "_cf_readonly";
|
|
84
|
-
/**
|
|
85
|
-
* Internal key used to store the no-protocol flag in connection state.
|
|
86
|
-
* When set, protocol messages (identity, state sync, MCP servers) are not
|
|
87
|
-
* sent to this connection — neither on connect nor via broadcasts.
|
|
88
|
-
*/
|
|
89
|
-
const CF_NO_PROTOCOL_KEY = "_cf_no_protocol";
|
|
90
|
-
/**
|
|
91
|
-
* The set of all internal keys stored in connection state that must be
|
|
92
|
-
* hidden from user code and preserved across setState calls.
|
|
93
|
-
*/
|
|
94
|
-
const CF_INTERNAL_KEYS = new Set([
|
|
95
|
-
CF_READONLY_KEY,
|
|
96
|
-
CF_NO_PROTOCOL_KEY,
|
|
97
|
-
"_cf_voiceInCall"
|
|
98
|
-
]);
|
|
99
|
-
/** Check if a raw connection state object contains any internal keys. */
|
|
100
|
-
function rawHasInternalKeys(raw) {
|
|
101
|
-
for (const key of Object.keys(raw)) if (CF_INTERNAL_KEYS.has(key)) return true;
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
/** Return a copy of `raw` with all internal keys removed, or null if no user keys remain. */
|
|
105
|
-
function stripInternalKeys(raw) {
|
|
106
|
-
const result = {};
|
|
107
|
-
let hasUserKeys = false;
|
|
108
|
-
for (const key of Object.keys(raw)) if (!CF_INTERNAL_KEYS.has(key)) {
|
|
109
|
-
result[key] = raw[key];
|
|
110
|
-
hasUserKeys = true;
|
|
111
|
-
}
|
|
112
|
-
return hasUserKeys ? result : null;
|
|
113
|
-
}
|
|
114
|
-
/** Return a copy containing only the internal keys present in `raw`. */
|
|
115
|
-
function extractInternalFlags(raw) {
|
|
116
|
-
const result = {};
|
|
117
|
-
for (const key of Object.keys(raw)) if (CF_INTERNAL_KEYS.has(key)) result[key] = raw[key];
|
|
118
|
-
return result;
|
|
119
|
-
}
|
|
120
|
-
/** Max length for error strings broadcast to clients. */
|
|
121
|
-
const MAX_ERROR_STRING_LENGTH = 500;
|
|
122
|
-
/**
|
|
123
|
-
* Sanitize an error string before broadcasting to clients.
|
|
124
|
-
* MCP error strings may contain untrusted content from external OAuth
|
|
125
|
-
* providers — truncate and strip control characters to limit XSS risk.
|
|
126
|
-
*/
|
|
127
|
-
const CONTROL_CHAR_RE = /* @__PURE__ */ new RegExp("[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F]", "g");
|
|
128
|
-
function sanitizeErrorString(error) {
|
|
129
|
-
if (error === null) return null;
|
|
130
|
-
let sanitized = error.replace(CONTROL_CHAR_RE, "");
|
|
131
|
-
if (sanitized.length > MAX_ERROR_STRING_LENGTH) sanitized = sanitized.substring(0, MAX_ERROR_STRING_LENGTH) + "...";
|
|
132
|
-
return sanitized;
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Tracks which agent constructors have already emitted the onStateUpdate
|
|
136
|
-
* deprecation warning, so it fires at most once per class.
|
|
137
|
-
*/
|
|
138
|
-
const _onStateUpdateWarnedClasses = /* @__PURE__ */ new WeakSet();
|
|
139
|
-
/**
|
|
140
|
-
* Tracks which agent constructors have already emitted the
|
|
141
|
-
* sendIdentityOnConnect deprecation warning, so it fires at most once per class.
|
|
142
|
-
*/
|
|
143
|
-
const _sendIdentityWarnedClasses = /* @__PURE__ */ new WeakSet();
|
|
144
|
-
/**
|
|
145
|
-
* Default options for Agent configuration.
|
|
146
|
-
* Child classes can override specific options without spreading.
|
|
147
|
-
*/
|
|
148
|
-
const DEFAULT_AGENT_STATIC_OPTIONS = {
|
|
149
|
-
hibernate: true,
|
|
150
|
-
sendIdentityOnConnect: true,
|
|
151
|
-
hungScheduleTimeoutSeconds: 30,
|
|
152
|
-
retry: {
|
|
153
|
-
maxAttempts: 3,
|
|
154
|
-
baseDelayMs: 100,
|
|
155
|
-
maxDelayMs: 3e3
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
/**
|
|
159
|
-
* Parse the raw `retry_options` TEXT column from a SQLite row into a
|
|
160
|
-
* typed `RetryOptions` object, or `undefined` if not set.
|
|
161
|
-
*/
|
|
162
|
-
function parseRetryOptions(row) {
|
|
163
|
-
const raw = row.retry_options;
|
|
164
|
-
if (typeof raw !== "string") return void 0;
|
|
165
|
-
return JSON.parse(raw);
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Resolve per-task retry options against class-level defaults and call
|
|
169
|
-
* `tryN`. This is the shared retry-execution path used by both queue
|
|
170
|
-
* flush and schedule alarm handlers.
|
|
171
|
-
*/
|
|
172
|
-
function resolveRetryConfig(taskRetry, defaults) {
|
|
173
|
-
return {
|
|
174
|
-
maxAttempts: taskRetry?.maxAttempts ?? defaults.maxAttempts,
|
|
175
|
-
baseDelayMs: taskRetry?.baseDelayMs ?? defaults.baseDelayMs,
|
|
176
|
-
maxDelayMs: taskRetry?.maxDelayMs ?? defaults.maxDelayMs
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
function getCurrentAgent() {
|
|
180
|
-
const store = __DO_NOT_USE_WILL_BREAK__agentContext.getStore();
|
|
181
|
-
if (!store) return {
|
|
182
|
-
agent: void 0,
|
|
183
|
-
connection: void 0,
|
|
184
|
-
request: void 0,
|
|
185
|
-
email: void 0
|
|
186
|
-
};
|
|
187
|
-
return store;
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Wraps a method to run within the agent context, ensuring getCurrentAgent() works properly
|
|
191
|
-
* @param agent The agent instance
|
|
192
|
-
* @param method The method to wrap
|
|
193
|
-
* @returns A wrapped method that runs within the agent context
|
|
194
|
-
*/
|
|
195
|
-
function withAgentContext(method) {
|
|
196
|
-
return function(...args) {
|
|
197
|
-
const { connection, request, email, agent } = getCurrentAgent();
|
|
198
|
-
if (agent === this) return method.apply(this, args);
|
|
199
|
-
return __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
200
|
-
agent: this,
|
|
201
|
-
connection,
|
|
202
|
-
request,
|
|
203
|
-
email
|
|
204
|
-
}, () => {
|
|
205
|
-
return method.apply(this, args);
|
|
206
|
-
});
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* Base class for creating Agent implementations
|
|
211
|
-
* @template Env Environment type containing bindings
|
|
212
|
-
* @template State State type to store within the Agent
|
|
213
|
-
*/
|
|
214
|
-
var Agent = class Agent extends Server {
|
|
215
|
-
/**
|
|
216
|
-
* Stable key for Workers AI session affinity (prefix-cache optimization).
|
|
217
|
-
*
|
|
218
|
-
* Uses the Durable Object ID, which is globally unique across all agent
|
|
219
|
-
* classes and stable for the lifetime of the instance. Pass this value as
|
|
220
|
-
* the `sessionAffinity` option when creating a Workers AI model so that
|
|
221
|
-
* requests from the same agent instance are routed to the same backend
|
|
222
|
-
* replica, improving KV-prefix-cache hit rates across conversation turns.
|
|
223
|
-
*
|
|
224
|
-
* @example
|
|
225
|
-
* ```typescript
|
|
226
|
-
* const workersai = createWorkersAI({ binding: this.env.AI });
|
|
227
|
-
* const model = workersai("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
|
|
228
|
-
* sessionAffinity: this.sessionAffinity,
|
|
229
|
-
* });
|
|
230
|
-
* ```
|
|
231
|
-
*/
|
|
232
|
-
get sessionAffinity() {
|
|
233
|
-
return this.ctx.id.toString();
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Current state of the Agent
|
|
237
|
-
*/
|
|
238
|
-
get state() {
|
|
239
|
-
if (this._state !== DEFAULT_STATE) return this._state;
|
|
240
|
-
const result = this.sql`
|
|
241
|
-
SELECT state FROM cf_agents_state WHERE id = ${STATE_ROW_ID}
|
|
242
|
-
`;
|
|
243
|
-
if (result.length > 0) {
|
|
244
|
-
const state = result[0].state;
|
|
245
|
-
try {
|
|
246
|
-
this._state = JSON.parse(state);
|
|
247
|
-
} catch (e) {
|
|
248
|
-
console.error("Failed to parse stored state, falling back to initialState:", e);
|
|
249
|
-
if (this.initialState !== DEFAULT_STATE) {
|
|
250
|
-
this._state = this.initialState;
|
|
251
|
-
this._setStateInternal(this.initialState);
|
|
252
|
-
} else {
|
|
253
|
-
this.sql`DELETE FROM cf_agents_state WHERE id = ${STATE_ROW_ID}`;
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return this._state;
|
|
258
|
-
}
|
|
259
|
-
if (this.initialState === DEFAULT_STATE) return;
|
|
260
|
-
this._setStateInternal(this.initialState);
|
|
261
|
-
return this.initialState;
|
|
262
|
-
}
|
|
263
|
-
get _resolvedOptions() {
|
|
264
|
-
if (this._cachedOptions) return this._cachedOptions;
|
|
265
|
-
const ctor = this.constructor;
|
|
266
|
-
const userRetry = ctor.options?.retry;
|
|
267
|
-
this._cachedOptions = {
|
|
268
|
-
hibernate: ctor.options?.hibernate ?? DEFAULT_AGENT_STATIC_OPTIONS.hibernate,
|
|
269
|
-
sendIdentityOnConnect: ctor.options?.sendIdentityOnConnect ?? DEFAULT_AGENT_STATIC_OPTIONS.sendIdentityOnConnect,
|
|
270
|
-
hungScheduleTimeoutSeconds: ctor.options?.hungScheduleTimeoutSeconds ?? DEFAULT_AGENT_STATIC_OPTIONS.hungScheduleTimeoutSeconds,
|
|
271
|
-
retry: {
|
|
272
|
-
maxAttempts: userRetry?.maxAttempts ?? DEFAULT_AGENT_STATIC_OPTIONS.retry.maxAttempts,
|
|
273
|
-
baseDelayMs: userRetry?.baseDelayMs ?? DEFAULT_AGENT_STATIC_OPTIONS.retry.baseDelayMs,
|
|
274
|
-
maxDelayMs: userRetry?.maxDelayMs ?? DEFAULT_AGENT_STATIC_OPTIONS.retry.maxDelayMs
|
|
275
|
-
}
|
|
276
|
-
};
|
|
277
|
-
return this._cachedOptions;
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Emit an observability event with auto-generated timestamp.
|
|
281
|
-
* @internal
|
|
282
|
-
*/
|
|
283
|
-
_emit(type, payload = {}) {
|
|
284
|
-
this.observability?.emit({
|
|
285
|
-
type,
|
|
286
|
-
agent: this._ParentClass.name,
|
|
287
|
-
name: this.name,
|
|
288
|
-
payload,
|
|
289
|
-
timestamp: Date.now()
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Execute SQL queries against the Agent's database
|
|
294
|
-
* @template T Type of the returned rows
|
|
295
|
-
* @param strings SQL query template strings
|
|
296
|
-
* @param values Values to be inserted into the query
|
|
297
|
-
* @returns Array of query results
|
|
298
|
-
*/
|
|
299
|
-
sql(strings, ...values) {
|
|
300
|
-
let query = "";
|
|
301
|
-
try {
|
|
302
|
-
query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? "?" : ""), "");
|
|
303
|
-
return [...this.ctx.storage.sql.exec(query, ...values)];
|
|
304
|
-
} catch (e) {
|
|
305
|
-
throw new SqlError(query, e);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Create all internal tables and run migrations if needed.
|
|
310
|
-
* Called by the constructor on every wake. Idempotent — skips DDL when
|
|
311
|
-
* the stored schema version matches CURRENT_SCHEMA_VERSION.
|
|
312
|
-
*
|
|
313
|
-
* Protected so that test agents can re-run the real migration path
|
|
314
|
-
* after manipulating DB state (since ctx.abort() is unavailable in
|
|
315
|
-
* local dev and the constructor only runs once per DO instance).
|
|
316
|
-
*/
|
|
317
|
-
_ensureSchema() {
|
|
318
|
-
this.sql`
|
|
319
|
-
CREATE TABLE IF NOT EXISTS cf_agents_state (
|
|
320
|
-
id TEXT PRIMARY KEY NOT NULL,
|
|
321
|
-
state TEXT
|
|
322
|
-
)
|
|
323
|
-
`;
|
|
324
|
-
const versionRow = this.sql`
|
|
325
|
-
SELECT state FROM cf_agents_state WHERE id = ${SCHEMA_VERSION_ROW_ID}
|
|
326
|
-
`;
|
|
327
|
-
const schemaVersion = versionRow.length > 0 ? Number(versionRow[0].state) : 0;
|
|
328
|
-
if (schemaVersion < CURRENT_SCHEMA_VERSION) {
|
|
329
|
-
this.sql`
|
|
330
|
-
CREATE TABLE IF NOT EXISTS cf_agents_mcp_servers (
|
|
331
|
-
id TEXT PRIMARY KEY NOT NULL,
|
|
332
|
-
name TEXT NOT NULL,
|
|
333
|
-
server_url TEXT NOT NULL,
|
|
334
|
-
callback_url TEXT NOT NULL,
|
|
335
|
-
client_id TEXT,
|
|
336
|
-
auth_url TEXT,
|
|
337
|
-
server_options TEXT
|
|
338
|
-
)
|
|
339
|
-
`;
|
|
340
|
-
this.sql`
|
|
341
|
-
CREATE TABLE IF NOT EXISTS cf_agents_queues (
|
|
342
|
-
id TEXT PRIMARY KEY NOT NULL,
|
|
343
|
-
payload TEXT,
|
|
344
|
-
callback TEXT,
|
|
345
|
-
created_at INTEGER DEFAULT (unixepoch())
|
|
346
|
-
)
|
|
347
|
-
`;
|
|
348
|
-
this.sql`
|
|
349
|
-
CREATE TABLE IF NOT EXISTS cf_agents_schedules (
|
|
350
|
-
id TEXT PRIMARY KEY NOT NULL DEFAULT (randomblob(9)),
|
|
351
|
-
callback TEXT,
|
|
352
|
-
payload TEXT,
|
|
353
|
-
type TEXT NOT NULL CHECK(type IN ('scheduled', 'delayed', 'cron', 'interval')),
|
|
354
|
-
time INTEGER,
|
|
355
|
-
delayInSeconds INTEGER,
|
|
356
|
-
cron TEXT,
|
|
357
|
-
intervalSeconds INTEGER,
|
|
358
|
-
running INTEGER DEFAULT 0,
|
|
359
|
-
created_at INTEGER DEFAULT (unixepoch()),
|
|
360
|
-
execution_started_at INTEGER,
|
|
361
|
-
retry_options TEXT
|
|
362
|
-
)
|
|
363
|
-
`;
|
|
364
|
-
const addColumnIfNotExists = (sql) => {
|
|
365
|
-
try {
|
|
366
|
-
this.ctx.storage.sql.exec(sql);
|
|
367
|
-
} catch (e) {
|
|
368
|
-
if (!(e instanceof Error ? e.message : String(e)).toLowerCase().includes("duplicate column")) throw e;
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
addColumnIfNotExists("ALTER TABLE cf_agents_schedules ADD COLUMN intervalSeconds INTEGER");
|
|
372
|
-
addColumnIfNotExists("ALTER TABLE cf_agents_schedules ADD COLUMN running INTEGER DEFAULT 0");
|
|
373
|
-
addColumnIfNotExists("ALTER TABLE cf_agents_schedules ADD COLUMN execution_started_at INTEGER");
|
|
374
|
-
addColumnIfNotExists("ALTER TABLE cf_agents_schedules ADD COLUMN retry_options TEXT");
|
|
375
|
-
addColumnIfNotExists("ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT");
|
|
376
|
-
{
|
|
377
|
-
const rows = this.ctx.storage.sql.exec("SELECT sql FROM sqlite_master WHERE type='table' AND name='cf_agents_schedules'").toArray();
|
|
378
|
-
if (rows.length > 0) {
|
|
379
|
-
if (!String(rows[0].sql).includes("'interval'")) {
|
|
380
|
-
this.ctx.storage.sql.exec("DROP TABLE IF EXISTS cf_agents_schedules_new");
|
|
381
|
-
this.ctx.storage.sql.exec(`
|
|
382
|
-
CREATE TABLE cf_agents_schedules_new (
|
|
383
|
-
id TEXT PRIMARY KEY NOT NULL DEFAULT (randomblob(9)),
|
|
384
|
-
callback TEXT,
|
|
385
|
-
payload TEXT,
|
|
386
|
-
type TEXT NOT NULL CHECK(type IN ('scheduled', 'delayed', 'cron', 'interval')),
|
|
387
|
-
time INTEGER,
|
|
388
|
-
delayInSeconds INTEGER,
|
|
389
|
-
cron TEXT,
|
|
390
|
-
intervalSeconds INTEGER,
|
|
391
|
-
running INTEGER DEFAULT 0,
|
|
392
|
-
created_at INTEGER DEFAULT (unixepoch()),
|
|
393
|
-
execution_started_at INTEGER,
|
|
394
|
-
retry_options TEXT
|
|
395
|
-
)
|
|
396
|
-
`);
|
|
397
|
-
this.ctx.storage.sql.exec(`
|
|
398
|
-
INSERT INTO cf_agents_schedules_new
|
|
399
|
-
(id, callback, payload, type, time, delayInSeconds, cron,
|
|
400
|
-
intervalSeconds, running, created_at, execution_started_at, retry_options)
|
|
401
|
-
SELECT id, callback, payload, type, time, delayInSeconds, cron,
|
|
402
|
-
intervalSeconds, running, created_at, execution_started_at, retry_options
|
|
403
|
-
FROM cf_agents_schedules
|
|
404
|
-
`);
|
|
405
|
-
this.ctx.storage.sql.exec("DROP TABLE cf_agents_schedules");
|
|
406
|
-
this.ctx.storage.sql.exec("ALTER TABLE cf_agents_schedules_new RENAME TO cf_agents_schedules");
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
this.sql`
|
|
411
|
-
CREATE TABLE IF NOT EXISTS cf_agents_workflows (
|
|
412
|
-
id TEXT PRIMARY KEY NOT NULL,
|
|
413
|
-
workflow_id TEXT NOT NULL UNIQUE,
|
|
414
|
-
workflow_name TEXT NOT NULL,
|
|
415
|
-
status TEXT NOT NULL CHECK(status IN (
|
|
416
|
-
'queued', 'running', 'paused', 'errored',
|
|
417
|
-
'terminated', 'complete', 'waiting',
|
|
418
|
-
'waitingForPause', 'unknown'
|
|
419
|
-
)),
|
|
420
|
-
metadata TEXT,
|
|
421
|
-
error_name TEXT,
|
|
422
|
-
error_message TEXT,
|
|
423
|
-
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
424
|
-
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
425
|
-
completed_at INTEGER
|
|
426
|
-
)
|
|
427
|
-
`;
|
|
428
|
-
this.sql`
|
|
429
|
-
CREATE INDEX IF NOT EXISTS idx_workflows_status ON cf_agents_workflows(status)
|
|
430
|
-
`;
|
|
431
|
-
this.sql`
|
|
432
|
-
CREATE INDEX IF NOT EXISTS idx_workflows_name ON cf_agents_workflows(workflow_name)
|
|
433
|
-
`;
|
|
434
|
-
this.ctx.storage.sql.exec("DELETE FROM cf_agents_state WHERE id = ?", STATE_WAS_CHANGED);
|
|
435
|
-
if (schemaVersion < 2) this.ctx.storage.sql.exec("DELETE FROM cf_agents_schedules WHERE callback = '_cf_keepAliveHeartbeat'");
|
|
436
|
-
this.sql`
|
|
437
|
-
INSERT OR REPLACE INTO cf_agents_state (id, state)
|
|
438
|
-
VALUES (${SCHEMA_VERSION_ROW_ID}, ${String(CURRENT_SCHEMA_VERSION)})
|
|
439
|
-
`;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
constructor(ctx, env) {
|
|
443
|
-
super(ctx, env);
|
|
444
|
-
this._state = DEFAULT_STATE;
|
|
445
|
-
this._disposables = new DisposableStore();
|
|
446
|
-
this._destroyed = false;
|
|
447
|
-
this._rawStateAccessors = /* @__PURE__ */ new WeakMap();
|
|
448
|
-
this._persistenceHookMode = "none";
|
|
449
|
-
this._isFacet = false;
|
|
450
|
-
this._insideOnStart = false;
|
|
451
|
-
this._warnedScheduleInOnStart = /* @__PURE__ */ new Set();
|
|
452
|
-
this._keepAliveRefs = 0;
|
|
453
|
-
this._ParentClass = Object.getPrototypeOf(this).constructor;
|
|
454
|
-
this.initialState = DEFAULT_STATE;
|
|
455
|
-
this.observability = genericObservability;
|
|
456
|
-
this._flushingQueue = false;
|
|
457
|
-
if (!wrappedClasses.has(this.constructor)) {
|
|
458
|
-
this._autoWrapCustomMethods();
|
|
459
|
-
wrappedClasses.add(this.constructor);
|
|
460
|
-
}
|
|
461
|
-
this._ensureSchema();
|
|
462
|
-
this.mcp = new MCPClientManager(this._ParentClass.name, "0.0.1", {
|
|
463
|
-
storage: this.ctx.storage,
|
|
464
|
-
createAuthProvider: (callbackUrl) => this.createMcpOAuthProvider(callbackUrl)
|
|
465
|
-
});
|
|
466
|
-
this._disposables.add(this.mcp.onServerStateChanged(async () => {
|
|
467
|
-
this.broadcastMcpServers();
|
|
468
|
-
}));
|
|
469
|
-
this._disposables.add(this.mcp.onObservabilityEvent((event) => {
|
|
470
|
-
this.observability?.emit({
|
|
471
|
-
...event,
|
|
472
|
-
agent: this._ParentClass.name,
|
|
473
|
-
name: this.name
|
|
474
|
-
});
|
|
475
|
-
}));
|
|
476
|
-
{
|
|
477
|
-
const proto = Object.getPrototypeOf(this);
|
|
478
|
-
const hasOwnNew = Object.prototype.hasOwnProperty.call(proto, "onStateChanged");
|
|
479
|
-
const hasOwnOld = Object.prototype.hasOwnProperty.call(proto, "onStateUpdate");
|
|
480
|
-
if (hasOwnNew && hasOwnOld) throw new Error("[Agent] Cannot override both onStateChanged and onStateUpdate. Remove onStateUpdate — it has been renamed to onStateChanged.");
|
|
481
|
-
if (hasOwnOld) {
|
|
482
|
-
const ctor = this.constructor;
|
|
483
|
-
if (!_onStateUpdateWarnedClasses.has(ctor)) {
|
|
484
|
-
_onStateUpdateWarnedClasses.add(ctor);
|
|
485
|
-
console.warn(`[Agent] onStateUpdate is deprecated. Rename to onStateChanged — the behavior is identical.`);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
const base = Agent.prototype;
|
|
489
|
-
if (proto.onStateChanged !== base.onStateChanged) this._persistenceHookMode = "new";
|
|
490
|
-
else if (proto.onStateUpdate !== base.onStateUpdate) this._persistenceHookMode = "old";
|
|
491
|
-
}
|
|
492
|
-
const _onRequest = this.onRequest.bind(this);
|
|
493
|
-
this.onRequest = (request) => {
|
|
494
|
-
return __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
495
|
-
agent: this,
|
|
496
|
-
connection: void 0,
|
|
497
|
-
request,
|
|
498
|
-
email: void 0
|
|
499
|
-
}, async () => {
|
|
500
|
-
const oauthResponse = await this.handleMcpOAuthCallback(request);
|
|
501
|
-
if (oauthResponse) return oauthResponse;
|
|
502
|
-
return this._tryCatch(() => _onRequest(request));
|
|
503
|
-
});
|
|
504
|
-
};
|
|
505
|
-
const _onMessage = this.onMessage.bind(this);
|
|
506
|
-
this.onMessage = async (connection, message) => {
|
|
507
|
-
this._ensureConnectionWrapped(connection);
|
|
508
|
-
return __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
509
|
-
agent: this,
|
|
510
|
-
connection,
|
|
511
|
-
request: void 0,
|
|
512
|
-
email: void 0
|
|
513
|
-
}, async () => {
|
|
514
|
-
if (typeof message !== "string") return this._tryCatch(() => _onMessage(connection, message));
|
|
515
|
-
let parsed;
|
|
516
|
-
try {
|
|
517
|
-
parsed = JSON.parse(message);
|
|
518
|
-
} catch (_e) {
|
|
519
|
-
return this._tryCatch(() => _onMessage(connection, message));
|
|
520
|
-
}
|
|
521
|
-
if (isStateUpdateMessage(parsed)) {
|
|
522
|
-
if (this.isConnectionReadonly(connection)) {
|
|
523
|
-
connection.send(JSON.stringify({
|
|
524
|
-
type: MessageType.CF_AGENT_STATE_ERROR,
|
|
525
|
-
error: "Connection is readonly"
|
|
526
|
-
}));
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
try {
|
|
530
|
-
this._setStateInternal(parsed.state, connection);
|
|
531
|
-
} catch (e) {
|
|
532
|
-
console.error("[Agent] State update rejected:", e);
|
|
533
|
-
connection.send(JSON.stringify({
|
|
534
|
-
type: MessageType.CF_AGENT_STATE_ERROR,
|
|
535
|
-
error: "State update rejected"
|
|
536
|
-
}));
|
|
537
|
-
}
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
if (isRPCRequest(parsed)) {
|
|
541
|
-
try {
|
|
542
|
-
const { id, method, args } = parsed;
|
|
543
|
-
const methodFn = this[method];
|
|
544
|
-
if (typeof methodFn !== "function") throw new Error(`Method ${method} does not exist`);
|
|
545
|
-
if (!this._isCallable(method)) throw new Error(`Method ${method} is not callable`);
|
|
546
|
-
const metadata = callableMetadata.get(methodFn);
|
|
547
|
-
if (metadata?.streaming) {
|
|
548
|
-
const stream = new StreamingResponse(connection, id);
|
|
549
|
-
this._emit("rpc", {
|
|
550
|
-
method,
|
|
551
|
-
streaming: true
|
|
552
|
-
});
|
|
553
|
-
try {
|
|
554
|
-
await methodFn.apply(this, [stream, ...args]);
|
|
555
|
-
} catch (err) {
|
|
556
|
-
console.error(`Error in streaming method "${method}":`, err);
|
|
557
|
-
this._emit("rpc:error", {
|
|
558
|
-
method,
|
|
559
|
-
error: err instanceof Error ? err.message : String(err)
|
|
560
|
-
});
|
|
561
|
-
if (!stream.isClosed) stream.error(err instanceof Error ? err.message : String(err));
|
|
562
|
-
}
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
const result = await methodFn.apply(this, args);
|
|
566
|
-
this._emit("rpc", {
|
|
567
|
-
method,
|
|
568
|
-
streaming: metadata?.streaming
|
|
569
|
-
});
|
|
570
|
-
const response = {
|
|
571
|
-
done: true,
|
|
572
|
-
id,
|
|
573
|
-
result,
|
|
574
|
-
success: true,
|
|
575
|
-
type: MessageType.RPC
|
|
576
|
-
};
|
|
577
|
-
connection.send(JSON.stringify(response));
|
|
578
|
-
} catch (e) {
|
|
579
|
-
const response = {
|
|
580
|
-
error: e instanceof Error ? e.message : "Unknown error occurred",
|
|
581
|
-
id: parsed.id,
|
|
582
|
-
success: false,
|
|
583
|
-
type: MessageType.RPC
|
|
584
|
-
};
|
|
585
|
-
connection.send(JSON.stringify(response));
|
|
586
|
-
console.error("RPC error:", e);
|
|
587
|
-
this._emit("rpc:error", {
|
|
588
|
-
method: parsed.method,
|
|
589
|
-
error: e instanceof Error ? e.message : String(e)
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
return this._tryCatch(() => _onMessage(connection, message));
|
|
595
|
-
});
|
|
596
|
-
};
|
|
597
|
-
const _onConnect = this.onConnect.bind(this);
|
|
598
|
-
this.onConnect = (connection, ctx) => {
|
|
599
|
-
this._ensureConnectionWrapped(connection);
|
|
600
|
-
return __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
601
|
-
agent: this,
|
|
602
|
-
connection,
|
|
603
|
-
request: ctx.request,
|
|
604
|
-
email: void 0
|
|
605
|
-
}, async () => {
|
|
606
|
-
if (this.shouldConnectionBeReadonly(connection, ctx)) this.setConnectionReadonly(connection, true);
|
|
607
|
-
if (this.shouldSendProtocolMessages(connection, ctx)) {
|
|
608
|
-
if (this._resolvedOptions.sendIdentityOnConnect) {
|
|
609
|
-
const ctor = this.constructor;
|
|
610
|
-
if (ctor.options?.sendIdentityOnConnect === void 0 && !_sendIdentityWarnedClasses.has(ctor)) {
|
|
611
|
-
if (!new URL(ctx.request.url).pathname.includes(this.name)) {
|
|
612
|
-
_sendIdentityWarnedClasses.add(ctor);
|
|
613
|
-
console.warn(`[Agent] ${ctor.name}: sending instance name "${this.name}" to clients via sendIdentityOnConnect (the name is not visible in the URL with custom routing). If this name is sensitive, add \`static options = { sendIdentityOnConnect: false }\` to opt out. Set it to true to silence this message.`);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
connection.send(JSON.stringify({
|
|
617
|
-
name: this.name,
|
|
618
|
-
agent: camelCaseToKebabCase(this._ParentClass.name),
|
|
619
|
-
type: MessageType.CF_AGENT_IDENTITY
|
|
620
|
-
}));
|
|
621
|
-
}
|
|
622
|
-
if (this.state) connection.send(JSON.stringify({
|
|
623
|
-
state: this.state,
|
|
624
|
-
type: MessageType.CF_AGENT_STATE
|
|
625
|
-
}));
|
|
626
|
-
connection.send(JSON.stringify({
|
|
627
|
-
mcp: this.getMcpServers(),
|
|
628
|
-
type: MessageType.CF_AGENT_MCP_SERVERS
|
|
629
|
-
}));
|
|
630
|
-
} else this._setConnectionNoProtocol(connection);
|
|
631
|
-
this._emit("connect", { connectionId: connection.id });
|
|
632
|
-
return this._tryCatch(() => _onConnect(connection, ctx));
|
|
633
|
-
});
|
|
634
|
-
};
|
|
635
|
-
const _onClose = this.onClose.bind(this);
|
|
636
|
-
this.onClose = (connection, code, reason, wasClean) => {
|
|
637
|
-
return __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
638
|
-
agent: this,
|
|
639
|
-
connection,
|
|
640
|
-
request: void 0,
|
|
641
|
-
email: void 0
|
|
642
|
-
}, () => {
|
|
643
|
-
this._emit("disconnect", {
|
|
644
|
-
connectionId: connection.id,
|
|
645
|
-
code,
|
|
646
|
-
reason
|
|
647
|
-
});
|
|
648
|
-
return _onClose(connection, code, reason, wasClean);
|
|
649
|
-
});
|
|
650
|
-
};
|
|
651
|
-
const _onStart = this.onStart.bind(this);
|
|
652
|
-
this.onStart = async (props) => {
|
|
653
|
-
return __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
654
|
-
agent: this,
|
|
655
|
-
connection: void 0,
|
|
656
|
-
request: void 0,
|
|
657
|
-
email: void 0
|
|
658
|
-
}, async () => {
|
|
659
|
-
if (await this.ctx.storage.get("cf_agents_is_facet")) this._isFacet = true;
|
|
660
|
-
await this._tryCatch(async () => {
|
|
661
|
-
await this.mcp.restoreConnectionsFromStorage(this.name);
|
|
662
|
-
await this._restoreRpcMcpServers();
|
|
663
|
-
this.broadcastMcpServers();
|
|
664
|
-
this._checkOrphanedWorkflows();
|
|
665
|
-
this._insideOnStart = true;
|
|
666
|
-
this._warnedScheduleInOnStart.clear();
|
|
667
|
-
try {
|
|
668
|
-
return await _onStart(props);
|
|
669
|
-
} finally {
|
|
670
|
-
this._insideOnStart = false;
|
|
671
|
-
}
|
|
672
|
-
});
|
|
673
|
-
});
|
|
674
|
-
};
|
|
675
|
-
}
|
|
676
|
-
/**
|
|
677
|
-
* Check for workflows referencing unknown bindings and warn with migration suggestion.
|
|
678
|
-
*/
|
|
679
|
-
_checkOrphanedWorkflows() {
|
|
680
|
-
const orphaned = this.sql`
|
|
681
|
-
SELECT
|
|
682
|
-
workflow_name,
|
|
683
|
-
COUNT(*) as total,
|
|
684
|
-
SUM(CASE WHEN status NOT IN ('complete', 'errored', 'terminated') THEN 1 ELSE 0 END) as active,
|
|
685
|
-
SUM(CASE WHEN status IN ('complete', 'errored', 'terminated') THEN 1 ELSE 0 END) as completed
|
|
686
|
-
FROM cf_agents_workflows
|
|
687
|
-
GROUP BY workflow_name
|
|
688
|
-
`.filter((row) => !this._findWorkflowBindingByName(row.workflow_name));
|
|
689
|
-
if (orphaned.length > 0) {
|
|
690
|
-
const currentBindings = this._getWorkflowBindingNames();
|
|
691
|
-
for (const { workflow_name: oldName, total, active, completed } of orphaned) {
|
|
692
|
-
const suggestion = currentBindings.length === 1 ? `this.migrateWorkflowBinding('${oldName}', '${currentBindings[0]}')` : `this.migrateWorkflowBinding('${oldName}', '<NEW_BINDING_NAME>')`;
|
|
693
|
-
const breakdown = active > 0 && completed > 0 ? ` (${active} active, ${completed} completed)` : active > 0 ? ` (${active} active)` : ` (${completed} completed)`;
|
|
694
|
-
console.warn(`[Agent] Found ${total} workflow(s) referencing unknown binding '${oldName}'${breakdown}. If you renamed the binding, call: ${suggestion}`);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
/**
|
|
699
|
-
* Broadcast a protocol message only to connections that have protocol
|
|
700
|
-
* messages enabled. Connections where shouldSendProtocolMessages returned
|
|
701
|
-
* false are excluded automatically.
|
|
702
|
-
* @param msg The JSON-encoded protocol message
|
|
703
|
-
* @param excludeIds Additional connection IDs to exclude (e.g. the source)
|
|
704
|
-
*/
|
|
705
|
-
_broadcastProtocol(msg, excludeIds = []) {
|
|
706
|
-
const exclude = [...excludeIds];
|
|
707
|
-
for (const conn of this.getConnections()) if (!this.isConnectionProtocolEnabled(conn)) exclude.push(conn.id);
|
|
708
|
-
this.broadcast(msg, exclude);
|
|
709
|
-
}
|
|
710
|
-
_setStateInternal(nextState, source = "server") {
|
|
711
|
-
this.validateStateChange(nextState, source);
|
|
712
|
-
this._state = nextState;
|
|
713
|
-
this.sql`
|
|
714
|
-
INSERT OR REPLACE INTO cf_agents_state (id, state)
|
|
715
|
-
VALUES (${STATE_ROW_ID}, ${JSON.stringify(nextState)})
|
|
716
|
-
`;
|
|
717
|
-
this._broadcastProtocol(JSON.stringify({
|
|
718
|
-
state: nextState,
|
|
719
|
-
type: MessageType.CF_AGENT_STATE
|
|
720
|
-
}), source !== "server" ? [source.id] : []);
|
|
721
|
-
const { connection, request, email } = __DO_NOT_USE_WILL_BREAK__agentContext.getStore() || {};
|
|
722
|
-
this.ctx.waitUntil((async () => {
|
|
723
|
-
try {
|
|
724
|
-
await __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
725
|
-
agent: this,
|
|
726
|
-
connection,
|
|
727
|
-
request,
|
|
728
|
-
email
|
|
729
|
-
}, async () => {
|
|
730
|
-
this._emit("state:update");
|
|
731
|
-
await this._callStatePersistenceHook(nextState, source);
|
|
732
|
-
});
|
|
733
|
-
} catch (e) {
|
|
734
|
-
try {
|
|
735
|
-
await this.onError(e);
|
|
736
|
-
} catch {}
|
|
737
|
-
}
|
|
738
|
-
})());
|
|
739
|
-
}
|
|
740
|
-
/**
|
|
741
|
-
* Update the Agent's state
|
|
742
|
-
* @param state New state to set
|
|
743
|
-
* @throws Error if called from a readonly connection context
|
|
744
|
-
*/
|
|
745
|
-
setState(state) {
|
|
746
|
-
const store = __DO_NOT_USE_WILL_BREAK__agentContext.getStore();
|
|
747
|
-
if (store?.connection && this.isConnectionReadonly(store.connection)) throw new Error("Connection is readonly");
|
|
748
|
-
this._setStateInternal(state, "server");
|
|
749
|
-
}
|
|
750
|
-
/**
|
|
751
|
-
* Wraps connection.state and connection.setState so that internal
|
|
752
|
-
* _cf_-prefixed flags (readonly, no-protocol) are hidden from user code
|
|
753
|
-
* and cannot be accidentally overwritten.
|
|
754
|
-
*
|
|
755
|
-
* Idempotent — safe to call multiple times on the same connection.
|
|
756
|
-
* After hibernation, the _rawStateAccessors WeakMap is empty but the
|
|
757
|
-
* connection's state getter still reads from the persisted WebSocket
|
|
758
|
-
* attachment. Calling this method re-captures the raw getter so that
|
|
759
|
-
* predicate methods (isConnectionReadonly, isConnectionProtocolEnabled)
|
|
760
|
-
* work correctly post-hibernation.
|
|
761
|
-
*/
|
|
762
|
-
_ensureConnectionWrapped(connection) {
|
|
763
|
-
if (this._rawStateAccessors.has(connection)) return;
|
|
764
|
-
const descriptor = Object.getOwnPropertyDescriptor(connection, "state");
|
|
765
|
-
let getRaw;
|
|
766
|
-
let setRaw;
|
|
767
|
-
if (descriptor?.get) {
|
|
768
|
-
getRaw = descriptor.get.bind(connection);
|
|
769
|
-
setRaw = connection.setState.bind(connection);
|
|
770
|
-
} else {
|
|
771
|
-
let rawState = connection.state ?? null;
|
|
772
|
-
getRaw = () => rawState;
|
|
773
|
-
setRaw = (state) => {
|
|
774
|
-
rawState = state;
|
|
775
|
-
return rawState;
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
this._rawStateAccessors.set(connection, {
|
|
779
|
-
getRaw,
|
|
780
|
-
setRaw
|
|
781
|
-
});
|
|
782
|
-
Object.defineProperty(connection, "state", {
|
|
783
|
-
configurable: true,
|
|
784
|
-
enumerable: true,
|
|
785
|
-
get() {
|
|
786
|
-
const raw = getRaw();
|
|
787
|
-
if (raw != null && typeof raw === "object" && rawHasInternalKeys(raw)) return stripInternalKeys(raw);
|
|
788
|
-
return raw;
|
|
789
|
-
}
|
|
790
|
-
});
|
|
791
|
-
Object.defineProperty(connection, "setState", {
|
|
792
|
-
configurable: true,
|
|
793
|
-
writable: true,
|
|
794
|
-
value(stateOrFn) {
|
|
795
|
-
const raw = getRaw();
|
|
796
|
-
const flags = raw != null && typeof raw === "object" ? extractInternalFlags(raw) : {};
|
|
797
|
-
const hasFlags = Object.keys(flags).length > 0;
|
|
798
|
-
let newUserState;
|
|
799
|
-
if (typeof stateOrFn === "function") newUserState = stateOrFn(hasFlags ? stripInternalKeys(raw) : raw);
|
|
800
|
-
else newUserState = stateOrFn;
|
|
801
|
-
if (hasFlags) {
|
|
802
|
-
if (newUserState != null && typeof newUserState === "object") return setRaw({
|
|
803
|
-
...newUserState,
|
|
804
|
-
...flags
|
|
805
|
-
});
|
|
806
|
-
return setRaw(flags);
|
|
807
|
-
}
|
|
808
|
-
return setRaw(newUserState);
|
|
809
|
-
}
|
|
810
|
-
});
|
|
811
|
-
}
|
|
812
|
-
/**
|
|
813
|
-
* Mark a connection as readonly or readwrite
|
|
814
|
-
* @param connection The connection to mark
|
|
815
|
-
* @param readonly Whether the connection should be readonly (default: true)
|
|
816
|
-
*/
|
|
817
|
-
setConnectionReadonly(connection, readonly = true) {
|
|
818
|
-
this._ensureConnectionWrapped(connection);
|
|
819
|
-
const accessors = this._rawStateAccessors.get(connection);
|
|
820
|
-
const raw = accessors.getRaw() ?? {};
|
|
821
|
-
if (readonly) accessors.setRaw({
|
|
822
|
-
...raw,
|
|
823
|
-
[CF_READONLY_KEY]: true
|
|
824
|
-
});
|
|
825
|
-
else {
|
|
826
|
-
const { [CF_READONLY_KEY]: _, ...rest } = raw;
|
|
827
|
-
accessors.setRaw(Object.keys(rest).length > 0 ? rest : null);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
/**
|
|
831
|
-
* Check if a connection is marked as readonly.
|
|
832
|
-
*
|
|
833
|
-
* Safe to call after hibernation — re-wraps the connection if the
|
|
834
|
-
* in-memory accessor cache was cleared.
|
|
835
|
-
* @param connection The connection to check
|
|
836
|
-
* @returns True if the connection is readonly
|
|
837
|
-
*/
|
|
838
|
-
isConnectionReadonly(connection) {
|
|
839
|
-
this._ensureConnectionWrapped(connection);
|
|
840
|
-
return !!this._rawStateAccessors.get(connection).getRaw()?.[CF_READONLY_KEY];
|
|
841
|
-
}
|
|
842
|
-
/**
|
|
843
|
-
* ⚠️ INTERNAL — DO NOT USE IN APPLICATION CODE. ⚠️
|
|
844
|
-
*
|
|
845
|
-
* Read an internal `_cf_`-prefixed flag from the raw connection state,
|
|
846
|
-
* bypassing the user-facing state wrapper that strips internal keys.
|
|
847
|
-
*
|
|
848
|
-
* This exists for framework mixins (e.g. voice) that need to persist
|
|
849
|
-
* flags in the connection attachment across hibernation. Application
|
|
850
|
-
* code should use `connection.state` and `connection.setState()` instead.
|
|
851
|
-
*
|
|
852
|
-
* @internal
|
|
853
|
-
*/
|
|
854
|
-
_unsafe_getConnectionFlag(connection, key) {
|
|
855
|
-
this._ensureConnectionWrapped(connection);
|
|
856
|
-
return this._rawStateAccessors.get(connection).getRaw()?.[key];
|
|
857
|
-
}
|
|
858
|
-
/**
|
|
859
|
-
* ⚠️ INTERNAL — DO NOT USE IN APPLICATION CODE. ⚠️
|
|
860
|
-
*
|
|
861
|
-
* Write an internal `_cf_`-prefixed flag to the raw connection state,
|
|
862
|
-
* bypassing the user-facing state wrapper. The key must be registered
|
|
863
|
-
* in `CF_INTERNAL_KEYS` so it is preserved across user `setState` calls
|
|
864
|
-
* and hidden from `connection.state`.
|
|
865
|
-
*
|
|
866
|
-
* @internal
|
|
867
|
-
*/
|
|
868
|
-
_unsafe_setConnectionFlag(connection, key, value) {
|
|
869
|
-
this._ensureConnectionWrapped(connection);
|
|
870
|
-
const accessors = this._rawStateAccessors.get(connection);
|
|
871
|
-
const raw = accessors.getRaw() ?? {};
|
|
872
|
-
if (value === void 0) {
|
|
873
|
-
const { [key]: _, ...rest } = raw;
|
|
874
|
-
accessors.setRaw(Object.keys(rest).length > 0 ? rest : null);
|
|
875
|
-
} else accessors.setRaw({
|
|
876
|
-
...raw,
|
|
877
|
-
[key]: value
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Override this method to determine if a connection should be readonly on connect
|
|
882
|
-
* @param _connection The connection that is being established
|
|
883
|
-
* @param _ctx Connection context
|
|
884
|
-
* @returns True if the connection should be readonly
|
|
885
|
-
*/
|
|
886
|
-
shouldConnectionBeReadonly(_connection, _ctx) {
|
|
887
|
-
return false;
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* Override this method to control whether protocol messages are sent to a
|
|
891
|
-
* connection. Protocol messages include identity (CF_AGENT_IDENTITY), state
|
|
892
|
-
* sync (CF_AGENT_STATE), and MCP server lists (CF_AGENT_MCP_SERVERS).
|
|
893
|
-
*
|
|
894
|
-
* When this returns `false` for a connection, that connection will not
|
|
895
|
-
* receive any protocol text frames — neither on connect nor via broadcasts.
|
|
896
|
-
* This is useful for binary-only clients (e.g. MQTT devices) that cannot
|
|
897
|
-
* handle JSON text frames.
|
|
898
|
-
*
|
|
899
|
-
* The connection can still send and receive regular messages, use RPC, and
|
|
900
|
-
* participate in all non-protocol communication.
|
|
901
|
-
*
|
|
902
|
-
* @param _connection The connection that is being established
|
|
903
|
-
* @param _ctx Connection context (includes the upgrade request)
|
|
904
|
-
* @returns True if protocol messages should be sent (default), false to suppress them
|
|
905
|
-
*/
|
|
906
|
-
shouldSendProtocolMessages(_connection, _ctx) {
|
|
907
|
-
return true;
|
|
908
|
-
}
|
|
909
|
-
/**
|
|
910
|
-
* Check if a connection has protocol messages enabled.
|
|
911
|
-
* Protocol messages include identity, state sync, and MCP server lists.
|
|
912
|
-
*
|
|
913
|
-
* Safe to call after hibernation — re-wraps the connection if the
|
|
914
|
-
* in-memory accessor cache was cleared.
|
|
915
|
-
* @param connection The connection to check
|
|
916
|
-
* @returns True if the connection receives protocol messages
|
|
917
|
-
*/
|
|
918
|
-
isConnectionProtocolEnabled(connection) {
|
|
919
|
-
this._ensureConnectionWrapped(connection);
|
|
920
|
-
return !this._rawStateAccessors.get(connection).getRaw()?.[CF_NO_PROTOCOL_KEY];
|
|
921
|
-
}
|
|
922
|
-
/**
|
|
923
|
-
* Mark a connection as having protocol messages disabled.
|
|
924
|
-
* Called internally when shouldSendProtocolMessages returns false.
|
|
925
|
-
*/
|
|
926
|
-
_setConnectionNoProtocol(connection) {
|
|
927
|
-
this._ensureConnectionWrapped(connection);
|
|
928
|
-
const accessors = this._rawStateAccessors.get(connection);
|
|
929
|
-
const raw = accessors.getRaw() ?? {};
|
|
930
|
-
accessors.setRaw({
|
|
931
|
-
...raw,
|
|
932
|
-
[CF_NO_PROTOCOL_KEY]: true
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Called before the Agent's state is persisted and broadcast.
|
|
937
|
-
* Override to validate or reject an update by throwing an error.
|
|
938
|
-
*
|
|
939
|
-
* IMPORTANT: This hook must be synchronous.
|
|
940
|
-
*/
|
|
941
|
-
validateStateChange(nextState, source) {}
|
|
942
|
-
/**
|
|
943
|
-
* Called after the Agent's state has been persisted and broadcast to all clients.
|
|
944
|
-
* This is a notification hook — errors here are routed to onError and do not
|
|
945
|
-
* affect state persistence or client broadcasts.
|
|
946
|
-
*
|
|
947
|
-
* @param state Updated state
|
|
948
|
-
* @param source Source of the state update ("server" or a client connection)
|
|
949
|
-
*/
|
|
950
|
-
onStateChanged(state, source) {}
|
|
951
|
-
/**
|
|
952
|
-
* @deprecated Renamed to `onStateChanged` — the behavior is identical.
|
|
953
|
-
* `onStateUpdate` will be removed in the next major version.
|
|
954
|
-
*
|
|
955
|
-
* Called after the Agent's state has been persisted and broadcast to all clients.
|
|
956
|
-
* This is a server-side notification hook. For the client-side state callback,
|
|
957
|
-
* see the `onStateUpdate` option in `useAgent` / `AgentClient`.
|
|
958
|
-
*
|
|
959
|
-
* @param state Updated state
|
|
960
|
-
* @param source Source of the state update ("server" or a client connection)
|
|
961
|
-
*/
|
|
962
|
-
onStateUpdate(state, source) {}
|
|
963
|
-
/**
|
|
964
|
-
* Dispatch to the appropriate persistence hook based on the mode
|
|
965
|
-
* cached in the constructor. No prototype walks at call time.
|
|
966
|
-
*/
|
|
967
|
-
async _callStatePersistenceHook(state, source) {
|
|
968
|
-
switch (this._persistenceHookMode) {
|
|
969
|
-
case "new":
|
|
970
|
-
await this.onStateChanged(state, source);
|
|
971
|
-
break;
|
|
972
|
-
case "old":
|
|
973
|
-
await this.onStateUpdate(state, source);
|
|
974
|
-
break;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* Called when the Agent receives an email via routeAgentEmail()
|
|
979
|
-
* Override this method to handle incoming emails
|
|
980
|
-
* @param email Email message to process
|
|
981
|
-
*/
|
|
982
|
-
async _onEmail(email) {
|
|
983
|
-
return __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
984
|
-
agent: this,
|
|
985
|
-
connection: void 0,
|
|
986
|
-
request: void 0,
|
|
987
|
-
email
|
|
988
|
-
}, async () => {
|
|
989
|
-
this._emit("email:receive", {
|
|
990
|
-
from: email.from,
|
|
991
|
-
to: email.to,
|
|
992
|
-
subject: email.headers.get("subject") ?? void 0
|
|
993
|
-
});
|
|
994
|
-
if ("onEmail" in this && typeof this.onEmail === "function") return this._tryCatch(() => this.onEmail(email));
|
|
995
|
-
else {
|
|
996
|
-
console.log("Received email from:", email.from, "to:", email.to);
|
|
997
|
-
console.log("Subject:", email.headers.get("subject"));
|
|
998
|
-
console.log("Implement onEmail(email: AgentEmail): Promise<void> in your agent to process emails");
|
|
999
|
-
}
|
|
1000
|
-
});
|
|
1001
|
-
}
|
|
1002
|
-
/**
|
|
1003
|
-
* Reply to an email
|
|
1004
|
-
* @param email The email to reply to
|
|
1005
|
-
* @param options Options for the reply
|
|
1006
|
-
* @param options.secret Secret for signing agent headers (enables secure reply routing).
|
|
1007
|
-
* Required if the email was routed via createSecureReplyEmailResolver.
|
|
1008
|
-
* Pass explicit `null` to opt-out of signing (not recommended for secure routing).
|
|
1009
|
-
* @returns void
|
|
1010
|
-
*/
|
|
1011
|
-
async replyToEmail(email, options) {
|
|
1012
|
-
return this._tryCatch(async () => {
|
|
1013
|
-
if (email._secureRouted && options.secret === void 0) throw new Error("This email was routed via createSecureReplyEmailResolver. You must pass a secret to replyToEmail() to sign replies, or pass explicit null to opt-out (not recommended).");
|
|
1014
|
-
const agentName = camelCaseToKebabCase(this._ParentClass.name);
|
|
1015
|
-
const agentId = this.name;
|
|
1016
|
-
const { createMimeMessage } = await import("mimetext");
|
|
1017
|
-
const msg = createMimeMessage();
|
|
1018
|
-
msg.setSender({
|
|
1019
|
-
addr: email.to,
|
|
1020
|
-
name: options.fromName
|
|
1021
|
-
});
|
|
1022
|
-
msg.setRecipient(email.from);
|
|
1023
|
-
msg.setSubject(options.subject || `Re: ${email.headers.get("subject")}` || "No subject");
|
|
1024
|
-
msg.addMessage({
|
|
1025
|
-
contentType: options.contentType || "text/plain",
|
|
1026
|
-
data: options.body
|
|
1027
|
-
});
|
|
1028
|
-
const messageId = `<${agentId}@${email.from.split("@")[1]}>`;
|
|
1029
|
-
msg.setHeader("In-Reply-To", email.headers.get("Message-ID"));
|
|
1030
|
-
msg.setHeader("Message-ID", messageId);
|
|
1031
|
-
msg.setHeader("X-Agent-Name", agentName);
|
|
1032
|
-
msg.setHeader("X-Agent-ID", agentId);
|
|
1033
|
-
if (typeof options.secret === "string") {
|
|
1034
|
-
const signedHeaders = await signAgentHeaders(options.secret, agentName, agentId);
|
|
1035
|
-
msg.setHeader("X-Agent-Sig", signedHeaders["X-Agent-Sig"]);
|
|
1036
|
-
msg.setHeader("X-Agent-Sig-Ts", signedHeaders["X-Agent-Sig-Ts"]);
|
|
1037
|
-
}
|
|
1038
|
-
if (options.headers) for (const [key, value] of Object.entries(options.headers)) msg.setHeader(key, value);
|
|
1039
|
-
await email.reply({
|
|
1040
|
-
from: email.to,
|
|
1041
|
-
raw: msg.asRaw(),
|
|
1042
|
-
to: email.from
|
|
1043
|
-
});
|
|
1044
|
-
const rawSubject = email.headers.get("subject");
|
|
1045
|
-
this._emit("email:reply", {
|
|
1046
|
-
from: email.to,
|
|
1047
|
-
to: email.from,
|
|
1048
|
-
subject: options.subject ?? (rawSubject ? `Re: ${rawSubject}` : void 0)
|
|
1049
|
-
});
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
async _tryCatch(fn) {
|
|
1053
|
-
try {
|
|
1054
|
-
return await fn();
|
|
1055
|
-
} catch (e) {
|
|
1056
|
-
throw this.onError(e);
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
/**
|
|
1060
|
-
* Automatically wrap custom methods with agent context
|
|
1061
|
-
* This ensures getCurrentAgent() works in all custom methods without decorators
|
|
1062
|
-
*/
|
|
1063
|
-
_autoWrapCustomMethods() {
|
|
1064
|
-
const basePrototypes = [Agent.prototype, Server.prototype];
|
|
1065
|
-
const baseMethods = /* @__PURE__ */ new Set();
|
|
1066
|
-
for (const baseProto of basePrototypes) {
|
|
1067
|
-
let proto = baseProto;
|
|
1068
|
-
while (proto && proto !== Object.prototype) {
|
|
1069
|
-
const methodNames = Object.getOwnPropertyNames(proto);
|
|
1070
|
-
for (const methodName of methodNames) baseMethods.add(methodName);
|
|
1071
|
-
proto = Object.getPrototypeOf(proto);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
let proto = Object.getPrototypeOf(this);
|
|
1075
|
-
let depth = 0;
|
|
1076
|
-
while (proto && proto !== Object.prototype && depth < 10) {
|
|
1077
|
-
const methodNames = Object.getOwnPropertyNames(proto);
|
|
1078
|
-
for (const methodName of methodNames) {
|
|
1079
|
-
const descriptor = Object.getOwnPropertyDescriptor(proto, methodName);
|
|
1080
|
-
if (baseMethods.has(methodName) || methodName.startsWith("_") || !descriptor || !!descriptor.get || typeof descriptor.value !== "function") continue;
|
|
1081
|
-
const wrappedFunction = withAgentContext(this[methodName]);
|
|
1082
|
-
if (this._isCallable(methodName)) callableMetadata.set(wrappedFunction, callableMetadata.get(this[methodName]));
|
|
1083
|
-
this.constructor.prototype[methodName] = wrappedFunction;
|
|
1084
|
-
}
|
|
1085
|
-
proto = Object.getPrototypeOf(proto);
|
|
1086
|
-
depth++;
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
onError(connectionOrError, error) {
|
|
1090
|
-
let theError;
|
|
1091
|
-
if (connectionOrError && error) {
|
|
1092
|
-
theError = error;
|
|
1093
|
-
console.error("Error on websocket connection:", connectionOrError.id, theError);
|
|
1094
|
-
console.error("Override onError(connection, error) to handle websocket connection errors");
|
|
1095
|
-
} else {
|
|
1096
|
-
theError = connectionOrError;
|
|
1097
|
-
console.error("Error on server:", theError);
|
|
1098
|
-
console.error("Override onError(error) to handle server errors");
|
|
1099
|
-
}
|
|
1100
|
-
throw theError;
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* Render content (not implemented in base class)
|
|
1104
|
-
*/
|
|
1105
|
-
render() {
|
|
1106
|
-
throw new Error("Not implemented");
|
|
1107
|
-
}
|
|
1108
|
-
/**
|
|
1109
|
-
* Retry an async operation with exponential backoff and jitter.
|
|
1110
|
-
* Retries on all errors by default. Use `shouldRetry` to bail early on non-retryable errors.
|
|
1111
|
-
*
|
|
1112
|
-
* @param fn The async function to retry. Receives the current attempt number (1-indexed).
|
|
1113
|
-
* @param options Retry configuration.
|
|
1114
|
-
* @param options.maxAttempts Maximum number of attempts (including the first). Falls back to static options, then 3.
|
|
1115
|
-
* @param options.baseDelayMs Base delay in ms for exponential backoff. Falls back to static options, then 100.
|
|
1116
|
-
* @param options.maxDelayMs Maximum delay cap in ms. Falls back to static options, then 3000.
|
|
1117
|
-
* @param options.shouldRetry Predicate called with the error and next attempt number. Return false to stop retrying immediately. Default: retry all errors.
|
|
1118
|
-
* @returns The result of fn on success.
|
|
1119
|
-
* @throws The last error if all attempts fail or shouldRetry returns false.
|
|
1120
|
-
*/
|
|
1121
|
-
async retry(fn, options) {
|
|
1122
|
-
const defaults = this._resolvedOptions.retry;
|
|
1123
|
-
if (options) validateRetryOptions(options, defaults);
|
|
1124
|
-
return tryN(options?.maxAttempts ?? defaults.maxAttempts, fn, {
|
|
1125
|
-
baseDelayMs: options?.baseDelayMs ?? defaults.baseDelayMs,
|
|
1126
|
-
maxDelayMs: options?.maxDelayMs ?? defaults.maxDelayMs,
|
|
1127
|
-
shouldRetry: options?.shouldRetry
|
|
1128
|
-
});
|
|
1129
|
-
}
|
|
1130
|
-
/**
|
|
1131
|
-
* Queue a task to be executed in the future
|
|
1132
|
-
* @param callback Name of the method to call
|
|
1133
|
-
* @param payload Payload to pass to the callback
|
|
1134
|
-
* @param options Options for the queued task
|
|
1135
|
-
* @param options.retry Retry options for the callback execution
|
|
1136
|
-
* @returns The ID of the queued task
|
|
1137
|
-
*/
|
|
1138
|
-
async queue(callback, payload, options) {
|
|
1139
|
-
const id = nanoid(9);
|
|
1140
|
-
if (typeof callback !== "string") throw new Error("Callback must be a string");
|
|
1141
|
-
if (typeof this[callback] !== "function") throw new Error(`this.${callback} is not a function`);
|
|
1142
|
-
if (options?.retry) validateRetryOptions(options.retry, this._resolvedOptions.retry);
|
|
1143
|
-
const retryJson = options?.retry ? JSON.stringify(options.retry) : null;
|
|
1144
|
-
this.sql`
|
|
1145
|
-
INSERT OR REPLACE INTO cf_agents_queues (id, payload, callback, retry_options)
|
|
1146
|
-
VALUES (${id}, ${JSON.stringify(payload)}, ${callback}, ${retryJson})
|
|
1147
|
-
`;
|
|
1148
|
-
this._emit("queue:create", {
|
|
1149
|
-
callback,
|
|
1150
|
-
id
|
|
1151
|
-
});
|
|
1152
|
-
this._flushQueue().catch((e) => {
|
|
1153
|
-
console.error("Error flushing queue:", e);
|
|
1154
|
-
});
|
|
1155
|
-
return id;
|
|
1156
|
-
}
|
|
1157
|
-
async _flushQueue() {
|
|
1158
|
-
if (this._flushingQueue) return;
|
|
1159
|
-
this._flushingQueue = true;
|
|
1160
|
-
try {
|
|
1161
|
-
while (true) {
|
|
1162
|
-
const result = this.sql`
|
|
1163
|
-
SELECT * FROM cf_agents_queues
|
|
1164
|
-
ORDER BY created_at ASC
|
|
1165
|
-
`;
|
|
1166
|
-
if (!result || result.length === 0) break;
|
|
1167
|
-
for (const row of result || []) {
|
|
1168
|
-
const callback = this[row.callback];
|
|
1169
|
-
if (!callback) {
|
|
1170
|
-
console.error(`callback ${row.callback} not found`);
|
|
1171
|
-
await this.dequeue(row.id);
|
|
1172
|
-
continue;
|
|
1173
|
-
}
|
|
1174
|
-
const { connection, request, email } = __DO_NOT_USE_WILL_BREAK__agentContext.getStore() || {};
|
|
1175
|
-
await __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
1176
|
-
agent: this,
|
|
1177
|
-
connection,
|
|
1178
|
-
request,
|
|
1179
|
-
email
|
|
1180
|
-
}, async () => {
|
|
1181
|
-
const { maxAttempts, baseDelayMs, maxDelayMs } = resolveRetryConfig(parseRetryOptions(row), this._resolvedOptions.retry);
|
|
1182
|
-
const parsedPayload = JSON.parse(row.payload);
|
|
1183
|
-
try {
|
|
1184
|
-
await tryN(maxAttempts, async (attempt) => {
|
|
1185
|
-
if (attempt > 1) this._emit("queue:retry", {
|
|
1186
|
-
callback: row.callback,
|
|
1187
|
-
id: row.id,
|
|
1188
|
-
attempt,
|
|
1189
|
-
maxAttempts
|
|
1190
|
-
});
|
|
1191
|
-
await callback.bind(this)(parsedPayload, row);
|
|
1192
|
-
}, {
|
|
1193
|
-
baseDelayMs,
|
|
1194
|
-
maxDelayMs
|
|
1195
|
-
});
|
|
1196
|
-
} catch (e) {
|
|
1197
|
-
console.error(`queue callback "${row.callback}" failed after ${maxAttempts} attempts`, e);
|
|
1198
|
-
this._emit("queue:error", {
|
|
1199
|
-
callback: row.callback,
|
|
1200
|
-
id: row.id,
|
|
1201
|
-
error: e instanceof Error ? e.message : String(e),
|
|
1202
|
-
attempts: maxAttempts
|
|
1203
|
-
});
|
|
1204
|
-
try {
|
|
1205
|
-
await this.onError(e);
|
|
1206
|
-
} catch {}
|
|
1207
|
-
} finally {
|
|
1208
|
-
this.dequeue(row.id);
|
|
1209
|
-
}
|
|
1210
|
-
});
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
} finally {
|
|
1214
|
-
this._flushingQueue = false;
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
/**
|
|
1218
|
-
* Dequeue a task by ID
|
|
1219
|
-
* @param id ID of the task to dequeue
|
|
1220
|
-
*/
|
|
1221
|
-
dequeue(id) {
|
|
1222
|
-
this.sql`DELETE FROM cf_agents_queues WHERE id = ${id}`;
|
|
1223
|
-
}
|
|
1224
|
-
/**
|
|
1225
|
-
* Dequeue all tasks
|
|
1226
|
-
*/
|
|
1227
|
-
dequeueAll() {
|
|
1228
|
-
this.sql`DELETE FROM cf_agents_queues`;
|
|
1229
|
-
}
|
|
1230
|
-
/**
|
|
1231
|
-
* Dequeue all tasks by callback
|
|
1232
|
-
* @param callback Name of the callback to dequeue
|
|
1233
|
-
*/
|
|
1234
|
-
dequeueAllByCallback(callback) {
|
|
1235
|
-
this.sql`DELETE FROM cf_agents_queues WHERE callback = ${callback}`;
|
|
1236
|
-
}
|
|
1237
|
-
/**
|
|
1238
|
-
* Get a queued task by ID
|
|
1239
|
-
* @param id ID of the task to get
|
|
1240
|
-
* @returns The task or undefined if not found
|
|
1241
|
-
*/
|
|
1242
|
-
getQueue(id) {
|
|
1243
|
-
const result = this.sql`
|
|
1244
|
-
SELECT * FROM cf_agents_queues WHERE id = ${id}
|
|
1245
|
-
`;
|
|
1246
|
-
if (!result || result.length === 0) return void 0;
|
|
1247
|
-
const row = result[0];
|
|
1248
|
-
return {
|
|
1249
|
-
...row,
|
|
1250
|
-
payload: JSON.parse(row.payload),
|
|
1251
|
-
retry: parseRetryOptions(row)
|
|
1252
|
-
};
|
|
1253
|
-
}
|
|
1254
|
-
/**
|
|
1255
|
-
* Get all queues by key and value
|
|
1256
|
-
* @param key Key to filter by
|
|
1257
|
-
* @param value Value to filter by
|
|
1258
|
-
* @returns Array of matching QueueItem objects
|
|
1259
|
-
*/
|
|
1260
|
-
getQueues(key, value) {
|
|
1261
|
-
return this.sql`
|
|
1262
|
-
SELECT * FROM cf_agents_queues
|
|
1263
|
-
`.filter((row) => JSON.parse(row.payload)[key] === value).map((row) => ({
|
|
1264
|
-
...row,
|
|
1265
|
-
payload: JSON.parse(row.payload),
|
|
1266
|
-
retry: parseRetryOptions(row)
|
|
1267
|
-
}));
|
|
1268
|
-
}
|
|
1269
|
-
/**
|
|
1270
|
-
* Schedule a task to be executed in the future
|
|
1271
|
-
*
|
|
1272
|
-
* Cron schedules are **idempotent by default** — calling `schedule("0 * * * *", "tick")`
|
|
1273
|
-
* multiple times with the same callback, cron expression, and payload returns
|
|
1274
|
-
* the existing schedule instead of creating a duplicate. Set `idempotent: false`
|
|
1275
|
-
* to override this.
|
|
1276
|
-
*
|
|
1277
|
-
* For delayed and scheduled (Date) types, set `idempotent: true` to opt in
|
|
1278
|
-
* to the same dedup behavior (matched on callback + payload). This is useful
|
|
1279
|
-
* when calling `schedule()` in `onStart()` to avoid accumulating duplicate
|
|
1280
|
-
* rows across Durable Object restarts.
|
|
1281
|
-
*
|
|
1282
|
-
* @template T Type of the payload data
|
|
1283
|
-
* @param when When to execute the task (Date, seconds delay, or cron expression)
|
|
1284
|
-
* @param callback Name of the method to call
|
|
1285
|
-
* @param payload Data to pass to the callback
|
|
1286
|
-
* @param options Options for the scheduled task
|
|
1287
|
-
* @param options.retry Retry options for the callback execution
|
|
1288
|
-
* @param options.idempotent Dedup by callback+payload. Defaults to `true` for cron, `false` otherwise.
|
|
1289
|
-
* @returns Schedule object representing the scheduled task
|
|
1290
|
-
*/
|
|
1291
|
-
async schedule(when, callback, payload, options) {
|
|
1292
|
-
if (this._isFacet) throw new Error("Scheduling is not supported in sub-agents. Schedule from the parent agent instead.");
|
|
1293
|
-
if (typeof callback !== "string") throw new Error("Callback must be a string");
|
|
1294
|
-
if (typeof this[callback] !== "function") throw new Error(`this.${callback} is not a function`);
|
|
1295
|
-
if (options?.retry) validateRetryOptions(options.retry, this._resolvedOptions.retry);
|
|
1296
|
-
const retryJson = options?.retry ? JSON.stringify(options.retry) : null;
|
|
1297
|
-
const payloadJson = JSON.stringify(payload);
|
|
1298
|
-
if (this._insideOnStart && options?.idempotent === void 0 && typeof when !== "string" && !this._warnedScheduleInOnStart.has(callback)) {
|
|
1299
|
-
this._warnedScheduleInOnStart.add(callback);
|
|
1300
|
-
console.warn(`schedule("${callback}") called inside onStart() without { idempotent: true }. This creates a new row on every Durable Object restart, which can cause duplicate executions. Pass { idempotent: true } to deduplicate, or use scheduleEvery() for recurring tasks.`);
|
|
1301
|
-
}
|
|
1302
|
-
if (when instanceof Date) {
|
|
1303
|
-
const timestamp = Math.floor(when.getTime() / 1e3);
|
|
1304
|
-
if (options?.idempotent) {
|
|
1305
|
-
const existing = this.sql`
|
|
1306
|
-
SELECT * FROM cf_agents_schedules
|
|
1307
|
-
WHERE type = 'scheduled'
|
|
1308
|
-
AND callback = ${callback}
|
|
1309
|
-
AND payload IS ${payloadJson}
|
|
1310
|
-
LIMIT 1
|
|
1311
|
-
`;
|
|
1312
|
-
if (existing.length > 0) {
|
|
1313
|
-
const row = existing[0];
|
|
1314
|
-
await this._scheduleNextAlarm();
|
|
1315
|
-
return {
|
|
1316
|
-
callback: row.callback,
|
|
1317
|
-
id: row.id,
|
|
1318
|
-
payload: JSON.parse(row.payload),
|
|
1319
|
-
retry: parseRetryOptions(row),
|
|
1320
|
-
time: row.time,
|
|
1321
|
-
type: "scheduled"
|
|
1322
|
-
};
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
const id = nanoid(9);
|
|
1326
|
-
this.sql`
|
|
1327
|
-
INSERT OR REPLACE INTO cf_agents_schedules (id, callback, payload, type, time, retry_options)
|
|
1328
|
-
VALUES (${id}, ${callback}, ${payloadJson}, 'scheduled', ${timestamp}, ${retryJson})
|
|
1329
|
-
`;
|
|
1330
|
-
await this._scheduleNextAlarm();
|
|
1331
|
-
const schedule = {
|
|
1332
|
-
callback,
|
|
1333
|
-
id,
|
|
1334
|
-
payload,
|
|
1335
|
-
retry: options?.retry,
|
|
1336
|
-
time: timestamp,
|
|
1337
|
-
type: "scheduled"
|
|
1338
|
-
};
|
|
1339
|
-
this._emit("schedule:create", {
|
|
1340
|
-
callback,
|
|
1341
|
-
id
|
|
1342
|
-
});
|
|
1343
|
-
return schedule;
|
|
1344
|
-
}
|
|
1345
|
-
if (typeof when === "number") {
|
|
1346
|
-
const time = new Date(Date.now() + when * 1e3);
|
|
1347
|
-
const timestamp = Math.floor(time.getTime() / 1e3);
|
|
1348
|
-
if (options?.idempotent) {
|
|
1349
|
-
const existing = this.sql`
|
|
1350
|
-
SELECT * FROM cf_agents_schedules
|
|
1351
|
-
WHERE type = 'delayed'
|
|
1352
|
-
AND callback = ${callback}
|
|
1353
|
-
AND payload IS ${payloadJson}
|
|
1354
|
-
LIMIT 1
|
|
1355
|
-
`;
|
|
1356
|
-
if (existing.length > 0) {
|
|
1357
|
-
const row = existing[0];
|
|
1358
|
-
await this._scheduleNextAlarm();
|
|
1359
|
-
return {
|
|
1360
|
-
callback: row.callback,
|
|
1361
|
-
delayInSeconds: row.delayInSeconds,
|
|
1362
|
-
id: row.id,
|
|
1363
|
-
payload: JSON.parse(row.payload),
|
|
1364
|
-
retry: parseRetryOptions(row),
|
|
1365
|
-
time: row.time,
|
|
1366
|
-
type: "delayed"
|
|
1367
|
-
};
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
const id = nanoid(9);
|
|
1371
|
-
this.sql`
|
|
1372
|
-
INSERT OR REPLACE INTO cf_agents_schedules (id, callback, payload, type, delayInSeconds, time, retry_options)
|
|
1373
|
-
VALUES (${id}, ${callback}, ${payloadJson}, 'delayed', ${when}, ${timestamp}, ${retryJson})
|
|
1374
|
-
`;
|
|
1375
|
-
await this._scheduleNextAlarm();
|
|
1376
|
-
const schedule = {
|
|
1377
|
-
callback,
|
|
1378
|
-
delayInSeconds: when,
|
|
1379
|
-
id,
|
|
1380
|
-
payload,
|
|
1381
|
-
retry: options?.retry,
|
|
1382
|
-
time: timestamp,
|
|
1383
|
-
type: "delayed"
|
|
1384
|
-
};
|
|
1385
|
-
this._emit("schedule:create", {
|
|
1386
|
-
callback,
|
|
1387
|
-
id
|
|
1388
|
-
});
|
|
1389
|
-
return schedule;
|
|
1390
|
-
}
|
|
1391
|
-
if (typeof when === "string") {
|
|
1392
|
-
const nextExecutionTime = getNextCronTime(when);
|
|
1393
|
-
const timestamp = Math.floor(nextExecutionTime.getTime() / 1e3);
|
|
1394
|
-
if (options?.idempotent !== false) {
|
|
1395
|
-
const existing = this.sql`
|
|
1396
|
-
SELECT * FROM cf_agents_schedules
|
|
1397
|
-
WHERE type = 'cron'
|
|
1398
|
-
AND callback = ${callback}
|
|
1399
|
-
AND cron = ${when}
|
|
1400
|
-
AND payload IS ${payloadJson}
|
|
1401
|
-
LIMIT 1
|
|
1402
|
-
`;
|
|
1403
|
-
if (existing.length > 0) {
|
|
1404
|
-
const row = existing[0];
|
|
1405
|
-
await this._scheduleNextAlarm();
|
|
1406
|
-
return {
|
|
1407
|
-
callback: row.callback,
|
|
1408
|
-
cron: row.cron,
|
|
1409
|
-
id: row.id,
|
|
1410
|
-
payload: JSON.parse(row.payload),
|
|
1411
|
-
retry: parseRetryOptions(row),
|
|
1412
|
-
time: row.time,
|
|
1413
|
-
type: "cron"
|
|
1414
|
-
};
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
const id = nanoid(9);
|
|
1418
|
-
this.sql`
|
|
1419
|
-
INSERT OR REPLACE INTO cf_agents_schedules (id, callback, payload, type, cron, time, retry_options)
|
|
1420
|
-
VALUES (${id}, ${callback}, ${payloadJson}, 'cron', ${when}, ${timestamp}, ${retryJson})
|
|
1421
|
-
`;
|
|
1422
|
-
await this._scheduleNextAlarm();
|
|
1423
|
-
const schedule = {
|
|
1424
|
-
callback,
|
|
1425
|
-
cron: when,
|
|
1426
|
-
id,
|
|
1427
|
-
payload,
|
|
1428
|
-
retry: options?.retry,
|
|
1429
|
-
time: timestamp,
|
|
1430
|
-
type: "cron"
|
|
1431
|
-
};
|
|
1432
|
-
this._emit("schedule:create", {
|
|
1433
|
-
callback,
|
|
1434
|
-
id
|
|
1435
|
-
});
|
|
1436
|
-
return schedule;
|
|
1437
|
-
}
|
|
1438
|
-
throw new Error(`Invalid schedule type: ${JSON.stringify(when)}(${typeof when}) trying to schedule ${callback}`);
|
|
1439
|
-
}
|
|
1440
|
-
/**
|
|
1441
|
-
* Schedule a task to run repeatedly at a fixed interval.
|
|
1442
|
-
*
|
|
1443
|
-
* This method is **idempotent** — calling it multiple times with the same
|
|
1444
|
-
* `callback`, `intervalSeconds`, and `payload` returns the existing schedule
|
|
1445
|
-
* instead of creating a duplicate. A different interval or payload is
|
|
1446
|
-
* treated as a distinct schedule and creates a new row.
|
|
1447
|
-
*
|
|
1448
|
-
* This makes it safe to call in `onStart()`, which runs on every Durable
|
|
1449
|
-
* Object wake:
|
|
1450
|
-
*
|
|
1451
|
-
* ```ts
|
|
1452
|
-
* async onStart() {
|
|
1453
|
-
* // Only one schedule is created, no matter how many times the DO wakes
|
|
1454
|
-
* await this.scheduleEvery(30, "tick");
|
|
1455
|
-
* }
|
|
1456
|
-
* ```
|
|
1457
|
-
*
|
|
1458
|
-
* @template T Type of the payload data
|
|
1459
|
-
* @param intervalSeconds Number of seconds between executions
|
|
1460
|
-
* @param callback Name of the method to call
|
|
1461
|
-
* @param payload Data to pass to the callback
|
|
1462
|
-
* @param options Options for the scheduled task
|
|
1463
|
-
* @param options.retry Retry options for the callback execution
|
|
1464
|
-
* @returns Schedule object representing the scheduled task
|
|
1465
|
-
*/
|
|
1466
|
-
async scheduleEvery(intervalSeconds, callback, payload, options) {
|
|
1467
|
-
if (this._isFacet) throw new Error("Scheduling is not supported in sub-agents. Schedule from the parent agent instead.");
|
|
1468
|
-
const MAX_INTERVAL_SECONDS = 720 * 60 * 60;
|
|
1469
|
-
if (typeof intervalSeconds !== "number" || intervalSeconds <= 0) throw new Error("intervalSeconds must be a positive number");
|
|
1470
|
-
if (intervalSeconds > MAX_INTERVAL_SECONDS) throw new Error(`intervalSeconds cannot exceed ${MAX_INTERVAL_SECONDS} seconds (30 days)`);
|
|
1471
|
-
if (typeof callback !== "string") throw new Error("Callback must be a string");
|
|
1472
|
-
if (typeof this[callback] !== "function") throw new Error(`this.${callback} is not a function`);
|
|
1473
|
-
if (options?.retry) validateRetryOptions(options.retry, this._resolvedOptions.retry);
|
|
1474
|
-
const idempotent = options?._idempotent !== false;
|
|
1475
|
-
const payloadJson = JSON.stringify(payload);
|
|
1476
|
-
if (idempotent) {
|
|
1477
|
-
const existing = this.sql`
|
|
1478
|
-
SELECT * FROM cf_agents_schedules
|
|
1479
|
-
WHERE type = 'interval'
|
|
1480
|
-
AND callback = ${callback}
|
|
1481
|
-
AND intervalSeconds = ${intervalSeconds}
|
|
1482
|
-
AND payload IS ${payloadJson}
|
|
1483
|
-
LIMIT 1
|
|
1484
|
-
`;
|
|
1485
|
-
if (existing.length > 0) {
|
|
1486
|
-
const row = existing[0];
|
|
1487
|
-
await this._scheduleNextAlarm();
|
|
1488
|
-
return {
|
|
1489
|
-
callback: row.callback,
|
|
1490
|
-
id: row.id,
|
|
1491
|
-
intervalSeconds: row.intervalSeconds,
|
|
1492
|
-
payload: JSON.parse(row.payload),
|
|
1493
|
-
retry: parseRetryOptions(row),
|
|
1494
|
-
time: row.time,
|
|
1495
|
-
type: "interval"
|
|
1496
|
-
};
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
const id = nanoid(9);
|
|
1500
|
-
const time = new Date(Date.now() + intervalSeconds * 1e3);
|
|
1501
|
-
const timestamp = Math.floor(time.getTime() / 1e3);
|
|
1502
|
-
const retryJson = options?.retry ? JSON.stringify(options.retry) : null;
|
|
1503
|
-
this.sql`
|
|
1504
|
-
INSERT OR REPLACE INTO cf_agents_schedules (id, callback, payload, type, intervalSeconds, time, running, retry_options)
|
|
1505
|
-
VALUES (${id}, ${callback}, ${payloadJson}, 'interval', ${intervalSeconds}, ${timestamp}, 0, ${retryJson})
|
|
1506
|
-
`;
|
|
1507
|
-
await this._scheduleNextAlarm();
|
|
1508
|
-
const schedule = {
|
|
1509
|
-
callback,
|
|
1510
|
-
id,
|
|
1511
|
-
intervalSeconds,
|
|
1512
|
-
payload,
|
|
1513
|
-
retry: options?.retry,
|
|
1514
|
-
time: timestamp,
|
|
1515
|
-
type: "interval"
|
|
1516
|
-
};
|
|
1517
|
-
this._emit("schedule:create", {
|
|
1518
|
-
callback,
|
|
1519
|
-
id
|
|
1520
|
-
});
|
|
1521
|
-
return schedule;
|
|
1522
|
-
}
|
|
1523
|
-
/**
|
|
1524
|
-
* Get a scheduled task by ID
|
|
1525
|
-
* @template T Type of the payload data
|
|
1526
|
-
* @param id ID of the scheduled task
|
|
1527
|
-
* @returns The Schedule object or undefined if not found
|
|
1528
|
-
*/
|
|
1529
|
-
getSchedule(id) {
|
|
1530
|
-
const result = this.sql`
|
|
1531
|
-
SELECT * FROM cf_agents_schedules WHERE id = ${id}
|
|
1532
|
-
`;
|
|
1533
|
-
if (!result || result.length === 0) return;
|
|
1534
|
-
const row = result[0];
|
|
1535
|
-
return {
|
|
1536
|
-
...row,
|
|
1537
|
-
payload: JSON.parse(row.payload),
|
|
1538
|
-
retry: parseRetryOptions(row)
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
/**
|
|
1542
|
-
* Get scheduled tasks matching the given criteria
|
|
1543
|
-
* @template T Type of the payload data
|
|
1544
|
-
* @param criteria Criteria to filter schedules
|
|
1545
|
-
* @returns Array of matching Schedule objects
|
|
1546
|
-
*/
|
|
1547
|
-
getSchedules(criteria = {}) {
|
|
1548
|
-
let query = "SELECT * FROM cf_agents_schedules WHERE 1=1";
|
|
1549
|
-
const params = [];
|
|
1550
|
-
if (criteria.id) {
|
|
1551
|
-
query += " AND id = ?";
|
|
1552
|
-
params.push(criteria.id);
|
|
1553
|
-
}
|
|
1554
|
-
if (criteria.type) {
|
|
1555
|
-
query += " AND type = ?";
|
|
1556
|
-
params.push(criteria.type);
|
|
1557
|
-
}
|
|
1558
|
-
if (criteria.timeRange) {
|
|
1559
|
-
query += " AND time >= ? AND time <= ?";
|
|
1560
|
-
const start = criteria.timeRange.start || /* @__PURE__ */ new Date(0);
|
|
1561
|
-
const end = criteria.timeRange.end || /* @__PURE__ */ new Date(999999999999999);
|
|
1562
|
-
params.push(Math.floor(start.getTime() / 1e3), Math.floor(end.getTime() / 1e3));
|
|
1563
|
-
}
|
|
1564
|
-
return this.ctx.storage.sql.exec(query, ...params).toArray().map((row) => ({
|
|
1565
|
-
...row,
|
|
1566
|
-
payload: JSON.parse(row.payload),
|
|
1567
|
-
retry: parseRetryOptions(row)
|
|
1568
|
-
}));
|
|
1569
|
-
}
|
|
1570
|
-
/**
|
|
1571
|
-
* Cancel a scheduled task
|
|
1572
|
-
* @param id ID of the task to cancel
|
|
1573
|
-
* @returns true if the task was cancelled, false if the task was not found
|
|
1574
|
-
*/
|
|
1575
|
-
async cancelSchedule(id) {
|
|
1576
|
-
if (this._isFacet) throw new Error("Scheduling is not supported in sub-agents. Schedule from the parent agent instead.");
|
|
1577
|
-
const schedule = this.getSchedule(id);
|
|
1578
|
-
if (!schedule) return false;
|
|
1579
|
-
this._emit("schedule:cancel", {
|
|
1580
|
-
callback: schedule.callback,
|
|
1581
|
-
id: schedule.id
|
|
1582
|
-
});
|
|
1583
|
-
this.sql`DELETE FROM cf_agents_schedules WHERE id = ${id}`;
|
|
1584
|
-
await this._scheduleNextAlarm();
|
|
1585
|
-
return true;
|
|
1586
|
-
}
|
|
1587
|
-
/**
|
|
1588
|
-
* Keep the Durable Object alive via alarm heartbeats.
|
|
1589
|
-
* Returns a disposer function that stops the heartbeat when called.
|
|
1590
|
-
*
|
|
1591
|
-
* Use this when you have long-running work and need to prevent the
|
|
1592
|
-
* DO from going idle (eviction after ~70-140s of inactivity).
|
|
1593
|
-
* The heartbeat fires every 30 seconds via the alarm system, without
|
|
1594
|
-
* creating schedule rows or emitting observability events.
|
|
1595
|
-
*
|
|
1596
|
-
* @example
|
|
1597
|
-
* ```ts
|
|
1598
|
-
* const dispose = await this.keepAlive();
|
|
1599
|
-
* try {
|
|
1600
|
-
* // ... long-running work ...
|
|
1601
|
-
* } finally {
|
|
1602
|
-
* dispose();
|
|
1603
|
-
* }
|
|
1604
|
-
* ```
|
|
1605
|
-
*/
|
|
1606
|
-
async keepAlive() {
|
|
1607
|
-
if (this._isFacet) throw new Error("keepAlive() is not supported in sub-agents. Use keepAlive() from the parent agent instead.");
|
|
1608
|
-
this._keepAliveRefs++;
|
|
1609
|
-
if (this._keepAliveRefs === 1) await this._scheduleNextAlarm();
|
|
1610
|
-
let disposed = false;
|
|
1611
|
-
return () => {
|
|
1612
|
-
if (disposed) return;
|
|
1613
|
-
disposed = true;
|
|
1614
|
-
this._keepAliveRefs = Math.max(0, this._keepAliveRefs - 1);
|
|
1615
|
-
};
|
|
1616
|
-
}
|
|
1617
|
-
/**
|
|
1618
|
-
* Run an async function while keeping the Durable Object alive.
|
|
1619
|
-
* The heartbeat is automatically stopped when the function completes
|
|
1620
|
-
* (whether it succeeds or throws).
|
|
1621
|
-
*
|
|
1622
|
-
* This is the recommended way to use keepAlive — it guarantees cleanup
|
|
1623
|
-
* so you cannot forget to dispose the heartbeat.
|
|
1624
|
-
*
|
|
1625
|
-
* @example
|
|
1626
|
-
* ```ts
|
|
1627
|
-
* const result = await this.keepAliveWhile(async () => {
|
|
1628
|
-
* const data = await longRunningComputation();
|
|
1629
|
-
* return data;
|
|
1630
|
-
* });
|
|
1631
|
-
* ```
|
|
1632
|
-
*/
|
|
1633
|
-
async keepAliveWhile(fn) {
|
|
1634
|
-
const dispose = await this.keepAlive();
|
|
1635
|
-
try {
|
|
1636
|
-
return await fn();
|
|
1637
|
-
} finally {
|
|
1638
|
-
dispose();
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
/**
|
|
1642
|
-
* Hook invoked on every alarm cycle, after schedule processing.
|
|
1643
|
-
* Override in subclasses (e.g. the fiber mixin) to run
|
|
1644
|
-
* housekeeping — such as detecting fibers interrupted by eviction.
|
|
1645
|
-
* @internal
|
|
1646
|
-
*/
|
|
1647
|
-
async _onAlarmHousekeeping() {}
|
|
1648
|
-
async _scheduleNextAlarm() {
|
|
1649
|
-
const nowMs = Date.now();
|
|
1650
|
-
const hungCutoffSeconds = Math.floor(nowMs / 1e3) - this._resolvedOptions.hungScheduleTimeoutSeconds;
|
|
1651
|
-
const readySchedules = this.sql`
|
|
1652
|
-
SELECT time FROM cf_agents_schedules
|
|
1653
|
-
WHERE type != 'interval'
|
|
1654
|
-
OR running = 0
|
|
1655
|
-
OR coalesce(execution_started_at, 0) <= ${hungCutoffSeconds}
|
|
1656
|
-
ORDER BY time ASC
|
|
1657
|
-
LIMIT 1
|
|
1658
|
-
`;
|
|
1659
|
-
const recoveringIntervals = this.sql`
|
|
1660
|
-
SELECT execution_started_at FROM cf_agents_schedules
|
|
1661
|
-
WHERE type = 'interval'
|
|
1662
|
-
AND running = 1
|
|
1663
|
-
AND coalesce(execution_started_at, 0) > ${hungCutoffSeconds}
|
|
1664
|
-
ORDER BY execution_started_at ASC
|
|
1665
|
-
LIMIT 1
|
|
1666
|
-
`;
|
|
1667
|
-
let nextTimeMs = null;
|
|
1668
|
-
if (readySchedules.length > 0 && "time" in readySchedules[0]) nextTimeMs = Math.max(readySchedules[0].time * 1e3, nowMs + 1);
|
|
1669
|
-
if (recoveringIntervals.length > 0 && recoveringIntervals[0].execution_started_at !== null) {
|
|
1670
|
-
const recoveryTimeMs = (recoveringIntervals[0].execution_started_at + this._resolvedOptions.hungScheduleTimeoutSeconds) * 1e3;
|
|
1671
|
-
nextTimeMs = nextTimeMs === null ? recoveryTimeMs : Math.min(nextTimeMs, recoveryTimeMs);
|
|
1672
|
-
}
|
|
1673
|
-
if (this._keepAliveRefs > 0) {
|
|
1674
|
-
const keepAliveMs = nowMs + KEEP_ALIVE_INTERVAL_MS;
|
|
1675
|
-
nextTimeMs = nextTimeMs === null ? keepAliveMs : Math.min(nextTimeMs, keepAliveMs);
|
|
1676
|
-
}
|
|
1677
|
-
if (nextTimeMs !== null) await this.ctx.storage.setAlarm(nextTimeMs);
|
|
1678
|
-
else await this.ctx.storage.deleteAlarm();
|
|
1679
|
-
}
|
|
1680
|
-
/**
|
|
1681
|
-
* Override PartyServer's onAlarm hook as a no-op.
|
|
1682
|
-
* Agent handles alarm logic directly in the alarm() method override,
|
|
1683
|
-
* but super.alarm() calls onAlarm() after #ensureInitialized(),
|
|
1684
|
-
* so we suppress the default "Implement onAlarm" warning.
|
|
1685
|
-
*/
|
|
1686
|
-
onAlarm() {}
|
|
1687
|
-
/**
|
|
1688
|
-
* Method called when an alarm fires.
|
|
1689
|
-
* Executes any scheduled tasks that are due.
|
|
1690
|
-
*
|
|
1691
|
-
* Calls super.alarm() first to ensure PartyServer's #ensureInitialized()
|
|
1692
|
-
* runs, which hydrates this.name from storage and calls onStart() if needed.
|
|
1693
|
-
*
|
|
1694
|
-
* @remarks
|
|
1695
|
-
* To schedule a task, please use the `this.schedule` method instead.
|
|
1696
|
-
* See {@link https://developers.cloudflare.com/agents/api-reference/schedule-tasks/}
|
|
1697
|
-
*/
|
|
1698
|
-
async alarm() {
|
|
1699
|
-
await super.alarm();
|
|
1700
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
1701
|
-
const result = this.sql`
|
|
1702
|
-
SELECT * FROM cf_agents_schedules WHERE time <= ${now}
|
|
1703
|
-
`;
|
|
1704
|
-
if (result && Array.isArray(result)) {
|
|
1705
|
-
const DUPLICATE_SCHEDULE_THRESHOLD = 10;
|
|
1706
|
-
const oneShotCounts = /* @__PURE__ */ new Map();
|
|
1707
|
-
for (const row of result) if (row.type === "delayed" || row.type === "scheduled") oneShotCounts.set(row.callback, (oneShotCounts.get(row.callback) ?? 0) + 1);
|
|
1708
|
-
for (const [cb, count] of oneShotCounts) if (count >= DUPLICATE_SCHEDULE_THRESHOLD) try {
|
|
1709
|
-
console.warn(`Processing ${count} stale "${cb}" schedules in a single alarm cycle. This usually means schedule() is being called repeatedly without the idempotent option. Consider using scheduleEvery() for recurring tasks or passing { idempotent: true } to schedule().`);
|
|
1710
|
-
this._emit("schedule:duplicate_warning", {
|
|
1711
|
-
callback: cb,
|
|
1712
|
-
count,
|
|
1713
|
-
type: "one-shot"
|
|
1714
|
-
});
|
|
1715
|
-
} catch {}
|
|
1716
|
-
for (const row of result) {
|
|
1717
|
-
const callback = this[row.callback];
|
|
1718
|
-
if (!callback) {
|
|
1719
|
-
console.error(`callback ${row.callback} not found`);
|
|
1720
|
-
continue;
|
|
1721
|
-
}
|
|
1722
|
-
if (row.type === "interval" && row.running === 1) {
|
|
1723
|
-
const executionStartedAt = row.execution_started_at ?? 0;
|
|
1724
|
-
const hungTimeoutSeconds = this._resolvedOptions.hungScheduleTimeoutSeconds;
|
|
1725
|
-
const elapsedSeconds = now - executionStartedAt;
|
|
1726
|
-
if (elapsedSeconds < hungTimeoutSeconds) {
|
|
1727
|
-
console.warn(`Skipping interval schedule ${row.id}: previous execution still running`);
|
|
1728
|
-
continue;
|
|
1729
|
-
}
|
|
1730
|
-
console.warn(`Forcing reset of hung interval schedule ${row.id} (started ${elapsedSeconds}s ago)`);
|
|
1731
|
-
}
|
|
1732
|
-
if (row.type === "interval") this.sql`UPDATE cf_agents_schedules SET running = 1, execution_started_at = ${now} WHERE id = ${row.id}`;
|
|
1733
|
-
await __DO_NOT_USE_WILL_BREAK__agentContext.run({
|
|
1734
|
-
agent: this,
|
|
1735
|
-
connection: void 0,
|
|
1736
|
-
request: void 0,
|
|
1737
|
-
email: void 0
|
|
1738
|
-
}, async () => {
|
|
1739
|
-
const { maxAttempts, baseDelayMs, maxDelayMs } = resolveRetryConfig(parseRetryOptions(row), this._resolvedOptions.retry);
|
|
1740
|
-
let parsedPayload;
|
|
1741
|
-
try {
|
|
1742
|
-
parsedPayload = JSON.parse(row.payload);
|
|
1743
|
-
} catch (e) {
|
|
1744
|
-
console.error(`Failed to parse payload for schedule "${row.id}" (callback "${row.callback}")`, e);
|
|
1745
|
-
this._emit("schedule:error", {
|
|
1746
|
-
callback: row.callback,
|
|
1747
|
-
id: row.id,
|
|
1748
|
-
error: e instanceof Error ? e.message : String(e),
|
|
1749
|
-
attempts: 0
|
|
1750
|
-
});
|
|
1751
|
-
return;
|
|
1752
|
-
}
|
|
1753
|
-
try {
|
|
1754
|
-
this._emit("schedule:execute", {
|
|
1755
|
-
callback: row.callback,
|
|
1756
|
-
id: row.id
|
|
1757
|
-
});
|
|
1758
|
-
await tryN(maxAttempts, async (attempt) => {
|
|
1759
|
-
if (attempt > 1) this._emit("schedule:retry", {
|
|
1760
|
-
callback: row.callback,
|
|
1761
|
-
id: row.id,
|
|
1762
|
-
attempt,
|
|
1763
|
-
maxAttempts
|
|
1764
|
-
});
|
|
1765
|
-
await callback.bind(this)(parsedPayload, row);
|
|
1766
|
-
}, {
|
|
1767
|
-
baseDelayMs,
|
|
1768
|
-
maxDelayMs
|
|
1769
|
-
});
|
|
1770
|
-
} catch (e) {
|
|
1771
|
-
console.error(`error executing callback "${row.callback}" after ${maxAttempts} attempts`, e);
|
|
1772
|
-
this._emit("schedule:error", {
|
|
1773
|
-
callback: row.callback,
|
|
1774
|
-
id: row.id,
|
|
1775
|
-
error: e instanceof Error ? e.message : String(e),
|
|
1776
|
-
attempts: maxAttempts
|
|
1777
|
-
});
|
|
1778
|
-
try {
|
|
1779
|
-
await this.onError(e);
|
|
1780
|
-
} catch {}
|
|
1781
|
-
}
|
|
1782
|
-
});
|
|
1783
|
-
if (this._destroyed) return;
|
|
1784
|
-
if (row.type === "cron") {
|
|
1785
|
-
const nextExecutionTime = getNextCronTime(row.cron);
|
|
1786
|
-
const nextTimestamp = Math.floor(nextExecutionTime.getTime() / 1e3);
|
|
1787
|
-
this.sql`
|
|
1788
|
-
UPDATE cf_agents_schedules SET time = ${nextTimestamp} WHERE id = ${row.id}
|
|
1789
|
-
`;
|
|
1790
|
-
} else if (row.type === "interval") {
|
|
1791
|
-
const nextTimestamp = Math.floor(Date.now() / 1e3) + (row.intervalSeconds ?? 0);
|
|
1792
|
-
this.sql`
|
|
1793
|
-
UPDATE cf_agents_schedules SET running = 0, time = ${nextTimestamp} WHERE id = ${row.id}
|
|
1794
|
-
`;
|
|
1795
|
-
} else this.sql`
|
|
1796
|
-
DELETE FROM cf_agents_schedules WHERE id = ${row.id}
|
|
1797
|
-
`;
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
if (this._destroyed) return;
|
|
1801
|
-
await this._onAlarmHousekeeping();
|
|
1802
|
-
await this._scheduleNextAlarm();
|
|
1803
|
-
}
|
|
1804
|
-
/**
|
|
1805
|
-
* Marks this agent as running inside a facet (sub-agent). Once set,
|
|
1806
|
-
* scheduling methods throw a clear error instead of crashing on
|
|
1807
|
-
* `setAlarm()` (which is not supported in facets).
|
|
1808
|
-
* @internal
|
|
1809
|
-
*/
|
|
1810
|
-
async _cf_markAsFacet() {
|
|
1811
|
-
this._isFacet = true;
|
|
1812
|
-
await this.ctx.storage.put("cf_agents_is_facet", true);
|
|
1813
|
-
}
|
|
1814
|
-
/**
|
|
1815
|
-
* Get or create a named sub-agent — a child Durable Object (facet)
|
|
1816
|
-
* with its own isolated SQLite storage running on the same machine.
|
|
1817
|
-
*
|
|
1818
|
-
* The child class must extend `Agent` and be exported from the worker
|
|
1819
|
-
* entry point. The first call for a given name triggers the child's
|
|
1820
|
-
* `onStart()`. Subsequent calls return the existing instance.
|
|
1821
|
-
*
|
|
1822
|
-
* @experimental Requires the `"experimental"` compatibility flag.
|
|
1823
|
-
*
|
|
1824
|
-
* @param cls The Agent subclass (must be exported from the worker)
|
|
1825
|
-
* @param name Unique name for this child instance
|
|
1826
|
-
* @returns A typed RPC stub for calling methods on the child
|
|
1827
|
-
*
|
|
1828
|
-
* @example
|
|
1829
|
-
* ```typescript
|
|
1830
|
-
* const searcher = await this.subAgent(SearchAgent, "main-search");
|
|
1831
|
-
* const results = await searcher.search("cloudflare agents");
|
|
1832
|
-
* ```
|
|
1833
|
-
*/
|
|
1834
|
-
async subAgent(cls, name) {
|
|
1835
|
-
const ctx = this.ctx;
|
|
1836
|
-
if (!ctx.facets || !ctx.exports) throw new Error("subAgent() requires the \"experimental\" compatibility flag. Add it to your wrangler.jsonc compatibility_flags.");
|
|
1837
|
-
if (!ctx.exports[cls.name]) throw new Error(`Sub-agent class "${cls.name}" not found in worker exports. Make sure the class is exported from your worker entry point and that the export name matches the class name.`);
|
|
1838
|
-
const facetKey = `${cls.name}\0${name}`;
|
|
1839
|
-
const stub = ctx.facets.get(facetKey, () => ({ class: ctx.exports[cls.name] }));
|
|
1840
|
-
const req = new Request("http://dummy-example.cloudflare.com/cdn-cgi/partyserver/set-name/");
|
|
1841
|
-
req.headers.set("x-partykit-room", name);
|
|
1842
|
-
await stub.fetch(req).then((res) => res.text());
|
|
1843
|
-
await stub._cf_markAsFacet();
|
|
1844
|
-
return stub;
|
|
1845
|
-
}
|
|
1846
|
-
/**
|
|
1847
|
-
* Forcefully abort a running sub-agent. The child stops executing
|
|
1848
|
-
* immediately and will be restarted on next {@link subAgent} call.
|
|
1849
|
-
* Pending RPC calls receive the reason as an error.
|
|
1850
|
-
* Transitively aborts the child's own children.
|
|
1851
|
-
*
|
|
1852
|
-
* @experimental Requires the `"experimental"` compatibility flag.
|
|
1853
|
-
*
|
|
1854
|
-
* @param cls The Agent subclass used when creating the child
|
|
1855
|
-
* @param name Name of the child to abort
|
|
1856
|
-
* @param reason Error thrown to pending/future RPC callers
|
|
1857
|
-
*/
|
|
1858
|
-
abortSubAgent(cls, name, reason) {
|
|
1859
|
-
const ctx = this.ctx;
|
|
1860
|
-
if (!ctx.facets) throw new Error("abortSubAgent() requires the \"experimental\" compatibility flag.");
|
|
1861
|
-
const facetKey = `${cls.name}\0${name}`;
|
|
1862
|
-
ctx.facets.abort(facetKey, reason);
|
|
1863
|
-
}
|
|
1864
|
-
/**
|
|
1865
|
-
* Delete a sub-agent: abort it if running, then permanently wipe its
|
|
1866
|
-
* storage. Transitively deletes the child's own children.
|
|
1867
|
-
*
|
|
1868
|
-
* @experimental Requires the `"experimental"` compatibility flag.
|
|
1869
|
-
*
|
|
1870
|
-
* @param cls The Agent subclass used when creating the child
|
|
1871
|
-
* @param name Name of the child to delete
|
|
1872
|
-
*/
|
|
1873
|
-
deleteSubAgent(cls, name) {
|
|
1874
|
-
const ctx = this.ctx;
|
|
1875
|
-
if (!ctx.facets) throw new Error("deleteSubAgent() requires the \"experimental\" compatibility flag.");
|
|
1876
|
-
const facetKey = `${cls.name}\0${name}`;
|
|
1877
|
-
ctx.facets.delete(facetKey);
|
|
1878
|
-
}
|
|
1879
|
-
/**
|
|
1880
|
-
* Destroy the Agent, removing all state and scheduled tasks
|
|
1881
|
-
*/
|
|
1882
|
-
async destroy() {
|
|
1883
|
-
this.sql`DROP TABLE IF EXISTS cf_agents_mcp_servers`;
|
|
1884
|
-
this.sql`DROP TABLE IF EXISTS cf_agents_state`;
|
|
1885
|
-
this.sql`DROP TABLE IF EXISTS cf_agents_schedules`;
|
|
1886
|
-
this.sql`DROP TABLE IF EXISTS cf_agents_queues`;
|
|
1887
|
-
this.sql`DROP TABLE IF EXISTS cf_agents_workflows`;
|
|
1888
|
-
if (!this._isFacet) await this.ctx.storage.deleteAlarm();
|
|
1889
|
-
await this.ctx.storage.deleteAll();
|
|
1890
|
-
this._disposables.dispose();
|
|
1891
|
-
await this.mcp.dispose();
|
|
1892
|
-
this._destroyed = true;
|
|
1893
|
-
setTimeout(() => {
|
|
1894
|
-
this.ctx.abort("destroyed");
|
|
1895
|
-
}, 0);
|
|
1896
|
-
this._emit("destroy");
|
|
1897
|
-
}
|
|
1898
|
-
/**
|
|
1899
|
-
* Check if a method is callable
|
|
1900
|
-
* @param method The method name to check
|
|
1901
|
-
* @returns True if the method is marked as callable
|
|
1902
|
-
*/
|
|
1903
|
-
_isCallable(method) {
|
|
1904
|
-
return callableMetadata.has(this[method]);
|
|
1905
|
-
}
|
|
1906
|
-
/**
|
|
1907
|
-
* Get all methods marked as callable on this Agent
|
|
1908
|
-
* @returns A map of method names to their metadata
|
|
1909
|
-
*/
|
|
1910
|
-
getCallableMethods() {
|
|
1911
|
-
const result = /* @__PURE__ */ new Map();
|
|
1912
|
-
let prototype = Object.getPrototypeOf(this);
|
|
1913
|
-
while (prototype && prototype !== Object.prototype) {
|
|
1914
|
-
for (const name of Object.getOwnPropertyNames(prototype)) {
|
|
1915
|
-
if (name === "constructor") continue;
|
|
1916
|
-
if (result.has(name)) continue;
|
|
1917
|
-
try {
|
|
1918
|
-
const fn = prototype[name];
|
|
1919
|
-
if (typeof fn === "function") {
|
|
1920
|
-
const meta = callableMetadata.get(fn);
|
|
1921
|
-
if (meta) result.set(name, meta);
|
|
1922
|
-
}
|
|
1923
|
-
} catch (e) {
|
|
1924
|
-
if (!(e instanceof TypeError)) throw e;
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
prototype = Object.getPrototypeOf(prototype);
|
|
1928
|
-
}
|
|
1929
|
-
return result;
|
|
1930
|
-
}
|
|
1931
|
-
/**
|
|
1932
|
-
* Start a workflow and track it in this Agent's database.
|
|
1933
|
-
* Automatically injects agent identity into the workflow params.
|
|
1934
|
-
*
|
|
1935
|
-
* @template P - Type of params to pass to the workflow
|
|
1936
|
-
* @param workflowName - Name of the workflow binding in env (e.g., 'MY_WORKFLOW')
|
|
1937
|
-
* @param params - Params to pass to the workflow
|
|
1938
|
-
* @param options - Optional workflow options
|
|
1939
|
-
* @returns The workflow instance ID
|
|
1940
|
-
*
|
|
1941
|
-
* @example
|
|
1942
|
-
* ```typescript
|
|
1943
|
-
* const workflowId = await this.runWorkflow(
|
|
1944
|
-
* 'MY_WORKFLOW',
|
|
1945
|
-
* { taskId: '123', data: 'process this' }
|
|
1946
|
-
* );
|
|
1947
|
-
* ```
|
|
1948
|
-
*/
|
|
1949
|
-
async runWorkflow(workflowName, params, options) {
|
|
1950
|
-
const workflow = this._findWorkflowBindingByName(workflowName);
|
|
1951
|
-
if (!workflow) throw new Error(`Workflow binding '${workflowName}' not found in environment`);
|
|
1952
|
-
const agentBindingName = options?.agentBinding ?? this._findAgentBindingName();
|
|
1953
|
-
if (!agentBindingName) throw new Error("Could not detect Agent binding name from class name. Pass it explicitly via options.agentBinding");
|
|
1954
|
-
const workflowId = options?.id ?? nanoid();
|
|
1955
|
-
const augmentedParams = {
|
|
1956
|
-
...params,
|
|
1957
|
-
__agentName: this.name,
|
|
1958
|
-
__agentBinding: agentBindingName,
|
|
1959
|
-
__workflowName: workflowName
|
|
1960
|
-
};
|
|
1961
|
-
const instance = await workflow.create({
|
|
1962
|
-
id: workflowId,
|
|
1963
|
-
params: augmentedParams
|
|
1964
|
-
});
|
|
1965
|
-
const id = nanoid();
|
|
1966
|
-
const metadataJson = options?.metadata ? JSON.stringify(options.metadata) : null;
|
|
1967
|
-
try {
|
|
1968
|
-
this.sql`
|
|
1969
|
-
INSERT INTO cf_agents_workflows (id, workflow_id, workflow_name, status, metadata)
|
|
1970
|
-
VALUES (${id}, ${instance.id}, ${workflowName}, 'queued', ${metadataJson})
|
|
1971
|
-
`;
|
|
1972
|
-
} catch (e) {
|
|
1973
|
-
if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) throw new Error(`Workflow with ID "${workflowId}" is already being tracked`);
|
|
1974
|
-
throw e;
|
|
1975
|
-
}
|
|
1976
|
-
this._emit("workflow:start", {
|
|
1977
|
-
workflowId: instance.id,
|
|
1978
|
-
workflowName
|
|
1979
|
-
});
|
|
1980
|
-
return instance.id;
|
|
1981
|
-
}
|
|
1982
|
-
/**
|
|
1983
|
-
* Send an event to a running workflow.
|
|
1984
|
-
* The workflow can wait for this event using step.waitForEvent().
|
|
1985
|
-
*
|
|
1986
|
-
* @param workflowName - Name of the workflow binding in env (e.g., 'MY_WORKFLOW')
|
|
1987
|
-
* @param workflowId - ID of the workflow instance
|
|
1988
|
-
* @param event - Event to send
|
|
1989
|
-
*
|
|
1990
|
-
* @example
|
|
1991
|
-
* ```typescript
|
|
1992
|
-
* await this.sendWorkflowEvent(
|
|
1993
|
-
* 'MY_WORKFLOW',
|
|
1994
|
-
* workflowId,
|
|
1995
|
-
* { type: 'approval', payload: { approved: true } }
|
|
1996
|
-
* );
|
|
1997
|
-
* ```
|
|
1998
|
-
*/
|
|
1999
|
-
async sendWorkflowEvent(workflowName, workflowId, event) {
|
|
2000
|
-
const workflow = this._findWorkflowBindingByName(workflowName);
|
|
2001
|
-
if (!workflow) throw new Error(`Workflow binding '${workflowName}' not found in environment`);
|
|
2002
|
-
const instance = await workflow.get(workflowId);
|
|
2003
|
-
await tryN(3, async () => instance.sendEvent(event), {
|
|
2004
|
-
shouldRetry: isErrorRetryable,
|
|
2005
|
-
baseDelayMs: 200,
|
|
2006
|
-
maxDelayMs: 3e3
|
|
2007
|
-
});
|
|
2008
|
-
this._emit("workflow:event", {
|
|
2009
|
-
workflowId,
|
|
2010
|
-
eventType: event.type
|
|
2011
|
-
});
|
|
2012
|
-
}
|
|
2013
|
-
/**
|
|
2014
|
-
* Approve a waiting workflow.
|
|
2015
|
-
* Sends an approval event to the workflow that can be received by waitForApproval().
|
|
2016
|
-
*
|
|
2017
|
-
* @param workflowId - ID of the workflow to approve
|
|
2018
|
-
* @param data - Optional approval data (reason, metadata)
|
|
2019
|
-
*
|
|
2020
|
-
* @example
|
|
2021
|
-
* ```typescript
|
|
2022
|
-
* await this.approveWorkflow(workflowId, {
|
|
2023
|
-
* reason: 'Approved by admin',
|
|
2024
|
-
* metadata: { approvedBy: userId }
|
|
2025
|
-
* });
|
|
2026
|
-
* ```
|
|
2027
|
-
*/
|
|
2028
|
-
async approveWorkflow(workflowId, data) {
|
|
2029
|
-
const workflowInfo = this.getWorkflow(workflowId);
|
|
2030
|
-
if (!workflowInfo) throw new Error(`Workflow ${workflowId} not found in tracking table`);
|
|
2031
|
-
await this.sendWorkflowEvent(workflowInfo.workflowName, workflowId, {
|
|
2032
|
-
type: "approval",
|
|
2033
|
-
payload: {
|
|
2034
|
-
approved: true,
|
|
2035
|
-
reason: data?.reason,
|
|
2036
|
-
metadata: data?.metadata
|
|
2037
|
-
}
|
|
2038
|
-
});
|
|
2039
|
-
this._emit("workflow:approved", {
|
|
2040
|
-
workflowId,
|
|
2041
|
-
reason: data?.reason
|
|
2042
|
-
});
|
|
2043
|
-
}
|
|
2044
|
-
/**
|
|
2045
|
-
* Reject a waiting workflow.
|
|
2046
|
-
* Sends a rejection event to the workflow that will cause waitForApproval() to throw.
|
|
2047
|
-
*
|
|
2048
|
-
* @param workflowId - ID of the workflow to reject
|
|
2049
|
-
* @param data - Optional rejection data (reason)
|
|
2050
|
-
*
|
|
2051
|
-
* @example
|
|
2052
|
-
* ```typescript
|
|
2053
|
-
* await this.rejectWorkflow(workflowId, {
|
|
2054
|
-
* reason: 'Request denied by admin'
|
|
2055
|
-
* });
|
|
2056
|
-
* ```
|
|
2057
|
-
*/
|
|
2058
|
-
async rejectWorkflow(workflowId, data) {
|
|
2059
|
-
const workflowInfo = this.getWorkflow(workflowId);
|
|
2060
|
-
if (!workflowInfo) throw new Error(`Workflow ${workflowId} not found in tracking table`);
|
|
2061
|
-
await this.sendWorkflowEvent(workflowInfo.workflowName, workflowId, {
|
|
2062
|
-
type: "approval",
|
|
2063
|
-
payload: {
|
|
2064
|
-
approved: false,
|
|
2065
|
-
reason: data?.reason
|
|
2066
|
-
}
|
|
2067
|
-
});
|
|
2068
|
-
this._emit("workflow:rejected", {
|
|
2069
|
-
workflowId,
|
|
2070
|
-
reason: data?.reason
|
|
2071
|
-
});
|
|
2072
|
-
}
|
|
2073
|
-
/**
|
|
2074
|
-
* Terminate a running workflow.
|
|
2075
|
-
* This immediately stops the workflow and sets its status to "terminated".
|
|
2076
|
-
*
|
|
2077
|
-
* @param workflowId - ID of the workflow to terminate (must be tracked via runWorkflow)
|
|
2078
|
-
* @throws Error if workflow not found in tracking table
|
|
2079
|
-
* @throws Error if workflow binding not found in environment
|
|
2080
|
-
* @throws Error if workflow is already completed/errored/terminated (from Cloudflare)
|
|
2081
|
-
*
|
|
2082
|
-
* @example
|
|
2083
|
-
* ```typescript
|
|
2084
|
-
* await this.terminateWorkflow(workflowId);
|
|
2085
|
-
* ```
|
|
2086
|
-
*/
|
|
2087
|
-
async terminateWorkflow(workflowId) {
|
|
2088
|
-
const workflowInfo = this.getWorkflow(workflowId);
|
|
2089
|
-
if (!workflowInfo) throw new Error(`Workflow ${workflowId} not found in tracking table`);
|
|
2090
|
-
const workflow = this._findWorkflowBindingByName(workflowInfo.workflowName);
|
|
2091
|
-
if (!workflow) throw new Error(`Workflow binding '${workflowInfo.workflowName}' not found in environment`);
|
|
2092
|
-
const instance = await workflow.get(workflowId);
|
|
2093
|
-
await tryN(3, async () => instance.terminate(), {
|
|
2094
|
-
shouldRetry: isErrorRetryable,
|
|
2095
|
-
baseDelayMs: 200,
|
|
2096
|
-
maxDelayMs: 3e3
|
|
2097
|
-
});
|
|
2098
|
-
const status = await instance.status();
|
|
2099
|
-
this._updateWorkflowTracking(workflowId, status);
|
|
2100
|
-
this._emit("workflow:terminated", {
|
|
2101
|
-
workflowId,
|
|
2102
|
-
workflowName: workflowInfo.workflowName
|
|
2103
|
-
});
|
|
2104
|
-
}
|
|
2105
|
-
/**
|
|
2106
|
-
* Pause a running workflow.
|
|
2107
|
-
* The workflow can be resumed later with resumeWorkflow().
|
|
2108
|
-
*
|
|
2109
|
-
* @param workflowId - ID of the workflow to pause (must be tracked via runWorkflow)
|
|
2110
|
-
* @throws Error if workflow not found in tracking table
|
|
2111
|
-
* @throws Error if workflow binding not found in environment
|
|
2112
|
-
* @throws Error if workflow is not running (from Cloudflare)
|
|
2113
|
-
*
|
|
2114
|
-
* @example
|
|
2115
|
-
* ```typescript
|
|
2116
|
-
* await this.pauseWorkflow(workflowId);
|
|
2117
|
-
* ```
|
|
2118
|
-
*/
|
|
2119
|
-
async pauseWorkflow(workflowId) {
|
|
2120
|
-
const workflowInfo = this.getWorkflow(workflowId);
|
|
2121
|
-
if (!workflowInfo) throw new Error(`Workflow ${workflowId} not found in tracking table`);
|
|
2122
|
-
const workflow = this._findWorkflowBindingByName(workflowInfo.workflowName);
|
|
2123
|
-
if (!workflow) throw new Error(`Workflow binding '${workflowInfo.workflowName}' not found in environment`);
|
|
2124
|
-
const instance = await workflow.get(workflowId);
|
|
2125
|
-
await tryN(3, async () => instance.pause(), {
|
|
2126
|
-
shouldRetry: isErrorRetryable,
|
|
2127
|
-
baseDelayMs: 200,
|
|
2128
|
-
maxDelayMs: 3e3
|
|
2129
|
-
});
|
|
2130
|
-
const status = await instance.status();
|
|
2131
|
-
this._updateWorkflowTracking(workflowId, status);
|
|
2132
|
-
this._emit("workflow:paused", {
|
|
2133
|
-
workflowId,
|
|
2134
|
-
workflowName: workflowInfo.workflowName
|
|
2135
|
-
});
|
|
2136
|
-
}
|
|
2137
|
-
/**
|
|
2138
|
-
* Resume a paused workflow.
|
|
2139
|
-
*
|
|
2140
|
-
* @param workflowId - ID of the workflow to resume (must be tracked via runWorkflow)
|
|
2141
|
-
* @throws Error if workflow not found in tracking table
|
|
2142
|
-
* @throws Error if workflow binding not found in environment
|
|
2143
|
-
* @throws Error if workflow is not paused (from Cloudflare)
|
|
2144
|
-
*
|
|
2145
|
-
* @example
|
|
2146
|
-
* ```typescript
|
|
2147
|
-
* await this.resumeWorkflow(workflowId);
|
|
2148
|
-
* ```
|
|
2149
|
-
*/
|
|
2150
|
-
async resumeWorkflow(workflowId) {
|
|
2151
|
-
const workflowInfo = this.getWorkflow(workflowId);
|
|
2152
|
-
if (!workflowInfo) throw new Error(`Workflow ${workflowId} not found in tracking table`);
|
|
2153
|
-
const workflow = this._findWorkflowBindingByName(workflowInfo.workflowName);
|
|
2154
|
-
if (!workflow) throw new Error(`Workflow binding '${workflowInfo.workflowName}' not found in environment`);
|
|
2155
|
-
const instance = await workflow.get(workflowId);
|
|
2156
|
-
await tryN(3, async () => instance.resume(), {
|
|
2157
|
-
shouldRetry: isErrorRetryable,
|
|
2158
|
-
baseDelayMs: 200,
|
|
2159
|
-
maxDelayMs: 3e3
|
|
2160
|
-
});
|
|
2161
|
-
const status = await instance.status();
|
|
2162
|
-
this._updateWorkflowTracking(workflowId, status);
|
|
2163
|
-
this._emit("workflow:resumed", {
|
|
2164
|
-
workflowId,
|
|
2165
|
-
workflowName: workflowInfo.workflowName
|
|
2166
|
-
});
|
|
2167
|
-
}
|
|
2168
|
-
/**
|
|
2169
|
-
* Restart a workflow instance.
|
|
2170
|
-
* This re-runs the workflow from the beginning with the same ID.
|
|
2171
|
-
*
|
|
2172
|
-
* @param workflowId - ID of the workflow to restart (must be tracked via runWorkflow)
|
|
2173
|
-
* @param options - Optional settings
|
|
2174
|
-
* @param options.resetTracking - If true (default), resets created_at and clears error fields.
|
|
2175
|
-
* If false, preserves original timestamps.
|
|
2176
|
-
* @throws Error if workflow not found in tracking table
|
|
2177
|
-
* @throws Error if workflow binding not found in environment
|
|
2178
|
-
*
|
|
2179
|
-
* @example
|
|
2180
|
-
* ```typescript
|
|
2181
|
-
* // Reset tracking (default)
|
|
2182
|
-
* await this.restartWorkflow(workflowId);
|
|
2183
|
-
*
|
|
2184
|
-
* // Preserve original timestamps
|
|
2185
|
-
* await this.restartWorkflow(workflowId, { resetTracking: false });
|
|
2186
|
-
* ```
|
|
2187
|
-
*/
|
|
2188
|
-
async restartWorkflow(workflowId, options = {}) {
|
|
2189
|
-
const { resetTracking = true } = options;
|
|
2190
|
-
const workflowInfo = this.getWorkflow(workflowId);
|
|
2191
|
-
if (!workflowInfo) throw new Error(`Workflow ${workflowId} not found in tracking table`);
|
|
2192
|
-
const workflow = this._findWorkflowBindingByName(workflowInfo.workflowName);
|
|
2193
|
-
if (!workflow) throw new Error(`Workflow binding '${workflowInfo.workflowName}' not found in environment`);
|
|
2194
|
-
const instance = await workflow.get(workflowId);
|
|
2195
|
-
await tryN(3, async () => instance.restart(), {
|
|
2196
|
-
shouldRetry: isErrorRetryable,
|
|
2197
|
-
baseDelayMs: 200,
|
|
2198
|
-
maxDelayMs: 3e3
|
|
2199
|
-
});
|
|
2200
|
-
if (resetTracking) {
|
|
2201
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2202
|
-
this.sql`
|
|
2203
|
-
UPDATE cf_agents_workflows
|
|
2204
|
-
SET status = 'queued',
|
|
2205
|
-
created_at = ${now},
|
|
2206
|
-
updated_at = ${now},
|
|
2207
|
-
completed_at = NULL,
|
|
2208
|
-
error_name = NULL,
|
|
2209
|
-
error_message = NULL
|
|
2210
|
-
WHERE workflow_id = ${workflowId}
|
|
2211
|
-
`;
|
|
2212
|
-
} else {
|
|
2213
|
-
const status = await instance.status();
|
|
2214
|
-
this._updateWorkflowTracking(workflowId, status);
|
|
2215
|
-
}
|
|
2216
|
-
this._emit("workflow:restarted", {
|
|
2217
|
-
workflowId,
|
|
2218
|
-
workflowName: workflowInfo.workflowName
|
|
2219
|
-
});
|
|
2220
|
-
}
|
|
2221
|
-
/**
|
|
2222
|
-
* Find a workflow binding by its name.
|
|
2223
|
-
*/
|
|
2224
|
-
_findWorkflowBindingByName(workflowName) {
|
|
2225
|
-
const binding = this.env[workflowName];
|
|
2226
|
-
if (binding && typeof binding === "object" && "create" in binding && "get" in binding) return binding;
|
|
2227
|
-
}
|
|
2228
|
-
/**
|
|
2229
|
-
* Get all workflow binding names from the environment.
|
|
2230
|
-
*/
|
|
2231
|
-
_getWorkflowBindingNames() {
|
|
2232
|
-
const names = [];
|
|
2233
|
-
for (const [key, value] of Object.entries(this.env)) if (value && typeof value === "object" && "create" in value && "get" in value) names.push(key);
|
|
2234
|
-
return names;
|
|
2235
|
-
}
|
|
2236
|
-
/**
|
|
2237
|
-
* Get the status of a workflow and update the tracking record.
|
|
2238
|
-
*
|
|
2239
|
-
* @param workflowName - Name of the workflow binding in env (e.g., 'MY_WORKFLOW')
|
|
2240
|
-
* @param workflowId - ID of the workflow instance
|
|
2241
|
-
* @returns The workflow status
|
|
2242
|
-
*/
|
|
2243
|
-
async getWorkflowStatus(workflowName, workflowId) {
|
|
2244
|
-
const workflow = this._findWorkflowBindingByName(workflowName);
|
|
2245
|
-
if (!workflow) throw new Error(`Workflow binding '${workflowName}' not found in environment`);
|
|
2246
|
-
const status = await (await workflow.get(workflowId)).status();
|
|
2247
|
-
this._updateWorkflowTracking(workflowId, status);
|
|
2248
|
-
return status;
|
|
2249
|
-
}
|
|
2250
|
-
/**
|
|
2251
|
-
* Get a tracked workflow by ID.
|
|
2252
|
-
*
|
|
2253
|
-
* @param workflowId - Workflow instance ID
|
|
2254
|
-
* @returns Workflow info or undefined if not found
|
|
2255
|
-
*/
|
|
2256
|
-
getWorkflow(workflowId) {
|
|
2257
|
-
const rows = this.sql`
|
|
2258
|
-
SELECT * FROM cf_agents_workflows WHERE workflow_id = ${workflowId}
|
|
2259
|
-
`;
|
|
2260
|
-
if (!rows || rows.length === 0) return;
|
|
2261
|
-
return this._rowToWorkflowInfo(rows[0]);
|
|
2262
|
-
}
|
|
2263
|
-
/**
|
|
2264
|
-
* Query tracked workflows with cursor-based pagination.
|
|
2265
|
-
*
|
|
2266
|
-
* @param criteria - Query criteria including optional cursor for pagination
|
|
2267
|
-
* @returns WorkflowPage with workflows, total count, and next cursor
|
|
2268
|
-
*
|
|
2269
|
-
* @example
|
|
2270
|
-
* ```typescript
|
|
2271
|
-
* // First page
|
|
2272
|
-
* const page1 = this.getWorkflows({ status: 'running', limit: 20 });
|
|
2273
|
-
*
|
|
2274
|
-
* // Next page
|
|
2275
|
-
* if (page1.nextCursor) {
|
|
2276
|
-
* const page2 = this.getWorkflows({
|
|
2277
|
-
* status: 'running',
|
|
2278
|
-
* limit: 20,
|
|
2279
|
-
* cursor: page1.nextCursor
|
|
2280
|
-
* });
|
|
2281
|
-
* }
|
|
2282
|
-
* ```
|
|
2283
|
-
*/
|
|
2284
|
-
getWorkflows(criteria = {}) {
|
|
2285
|
-
const limit = Math.min(criteria.limit ?? 50, 100);
|
|
2286
|
-
const isAsc = criteria.orderBy === "asc";
|
|
2287
|
-
const total = this._countWorkflows(criteria);
|
|
2288
|
-
let query = "SELECT * FROM cf_agents_workflows WHERE 1=1";
|
|
2289
|
-
const params = [];
|
|
2290
|
-
if (criteria.status) {
|
|
2291
|
-
const statuses = Array.isArray(criteria.status) ? criteria.status : [criteria.status];
|
|
2292
|
-
const placeholders = statuses.map(() => "?").join(", ");
|
|
2293
|
-
query += ` AND status IN (${placeholders})`;
|
|
2294
|
-
params.push(...statuses);
|
|
2295
|
-
}
|
|
2296
|
-
if (criteria.workflowName) {
|
|
2297
|
-
query += " AND workflow_name = ?";
|
|
2298
|
-
params.push(criteria.workflowName);
|
|
2299
|
-
}
|
|
2300
|
-
if (criteria.metadata) for (const [key, value] of Object.entries(criteria.metadata)) {
|
|
2301
|
-
query += ` AND json_extract(metadata, '$.' || ?) = ?`;
|
|
2302
|
-
params.push(key, value);
|
|
2303
|
-
}
|
|
2304
|
-
if (criteria.cursor) {
|
|
2305
|
-
const cursor = this._decodeCursor(criteria.cursor);
|
|
2306
|
-
if (isAsc) query += " AND (created_at > ? OR (created_at = ? AND workflow_id > ?))";
|
|
2307
|
-
else query += " AND (created_at < ? OR (created_at = ? AND workflow_id < ?))";
|
|
2308
|
-
params.push(cursor.createdAt, cursor.createdAt, cursor.workflowId);
|
|
2309
|
-
}
|
|
2310
|
-
query += ` ORDER BY created_at ${isAsc ? "ASC" : "DESC"}, workflow_id ${isAsc ? "ASC" : "DESC"}`;
|
|
2311
|
-
query += " LIMIT ?";
|
|
2312
|
-
params.push(limit + 1);
|
|
2313
|
-
const rows = this.ctx.storage.sql.exec(query, ...params).toArray();
|
|
2314
|
-
const hasMore = rows.length > limit;
|
|
2315
|
-
const workflows = (hasMore ? rows.slice(0, limit) : rows).map((row) => this._rowToWorkflowInfo(row));
|
|
2316
|
-
return {
|
|
2317
|
-
workflows,
|
|
2318
|
-
total,
|
|
2319
|
-
nextCursor: hasMore && workflows.length > 0 ? this._encodeCursor(workflows[workflows.length - 1]) : null
|
|
2320
|
-
};
|
|
2321
|
-
}
|
|
2322
|
-
/**
|
|
2323
|
-
* Count workflows matching criteria (for pagination total).
|
|
2324
|
-
*/
|
|
2325
|
-
_countWorkflows(criteria) {
|
|
2326
|
-
let query = "SELECT COUNT(*) as count FROM cf_agents_workflows WHERE 1=1";
|
|
2327
|
-
const params = [];
|
|
2328
|
-
if (criteria.status) {
|
|
2329
|
-
const statuses = Array.isArray(criteria.status) ? criteria.status : [criteria.status];
|
|
2330
|
-
const placeholders = statuses.map(() => "?").join(", ");
|
|
2331
|
-
query += ` AND status IN (${placeholders})`;
|
|
2332
|
-
params.push(...statuses);
|
|
2333
|
-
}
|
|
2334
|
-
if (criteria.workflowName) {
|
|
2335
|
-
query += " AND workflow_name = ?";
|
|
2336
|
-
params.push(criteria.workflowName);
|
|
2337
|
-
}
|
|
2338
|
-
if (criteria.metadata) for (const [key, value] of Object.entries(criteria.metadata)) {
|
|
2339
|
-
query += ` AND json_extract(metadata, '$.' || ?) = ?`;
|
|
2340
|
-
params.push(key, value);
|
|
2341
|
-
}
|
|
2342
|
-
if (criteria.createdBefore) {
|
|
2343
|
-
query += " AND created_at < ?";
|
|
2344
|
-
params.push(Math.floor(criteria.createdBefore.getTime() / 1e3));
|
|
2345
|
-
}
|
|
2346
|
-
return this.ctx.storage.sql.exec(query, ...params).toArray()[0]?.count ?? 0;
|
|
2347
|
-
}
|
|
2348
|
-
/**
|
|
2349
|
-
* Encode a cursor from workflow info for pagination.
|
|
2350
|
-
* Stores createdAt as Unix timestamp in seconds (matching DB storage).
|
|
2351
|
-
*/
|
|
2352
|
-
_encodeCursor(workflow) {
|
|
2353
|
-
return btoa(JSON.stringify({
|
|
2354
|
-
c: Math.floor(workflow.createdAt.getTime() / 1e3),
|
|
2355
|
-
i: workflow.workflowId
|
|
2356
|
-
}));
|
|
2357
|
-
}
|
|
2358
|
-
/**
|
|
2359
|
-
* Decode a pagination cursor.
|
|
2360
|
-
* Returns createdAt as Unix timestamp in seconds (matching DB storage).
|
|
2361
|
-
*/
|
|
2362
|
-
_decodeCursor(cursor) {
|
|
2363
|
-
try {
|
|
2364
|
-
const data = JSON.parse(atob(cursor));
|
|
2365
|
-
if (typeof data.c !== "number" || typeof data.i !== "string") throw new Error("Invalid cursor structure");
|
|
2366
|
-
return {
|
|
2367
|
-
createdAt: data.c,
|
|
2368
|
-
workflowId: data.i
|
|
2369
|
-
};
|
|
2370
|
-
} catch {
|
|
2371
|
-
throw new Error("Invalid pagination cursor. The cursor may be malformed or corrupted.");
|
|
2372
|
-
}
|
|
2373
|
-
}
|
|
2374
|
-
/**
|
|
2375
|
-
* Delete a workflow tracking record.
|
|
2376
|
-
*
|
|
2377
|
-
* @param workflowId - ID of the workflow to delete
|
|
2378
|
-
* @returns true if a record was deleted, false if not found
|
|
2379
|
-
*/
|
|
2380
|
-
deleteWorkflow(workflowId) {
|
|
2381
|
-
const existing = this.sql`
|
|
2382
|
-
SELECT COUNT(*) as count FROM cf_agents_workflows WHERE workflow_id = ${workflowId}
|
|
2383
|
-
`;
|
|
2384
|
-
if (!existing[0] || existing[0].count === 0) return false;
|
|
2385
|
-
this.sql`DELETE FROM cf_agents_workflows WHERE workflow_id = ${workflowId}`;
|
|
2386
|
-
return true;
|
|
2387
|
-
}
|
|
2388
|
-
/**
|
|
2389
|
-
* Delete workflow tracking records matching criteria.
|
|
2390
|
-
* Useful for cleaning up old completed/errored workflows.
|
|
2391
|
-
*
|
|
2392
|
-
* @param criteria - Criteria for which workflows to delete
|
|
2393
|
-
* @returns Number of records matching criteria (expected deleted count)
|
|
2394
|
-
*
|
|
2395
|
-
* @example
|
|
2396
|
-
* ```typescript
|
|
2397
|
-
* // Delete all completed workflows created more than 7 days ago
|
|
2398
|
-
* const deleted = this.deleteWorkflows({
|
|
2399
|
-
* status: 'complete',
|
|
2400
|
-
* createdBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
2401
|
-
* });
|
|
2402
|
-
*
|
|
2403
|
-
* // Delete all errored and terminated workflows
|
|
2404
|
-
* const deleted = this.deleteWorkflows({
|
|
2405
|
-
* status: ['errored', 'terminated']
|
|
2406
|
-
* });
|
|
2407
|
-
* ```
|
|
2408
|
-
*/
|
|
2409
|
-
deleteWorkflows(criteria = {}) {
|
|
2410
|
-
let query = "DELETE FROM cf_agents_workflows WHERE 1=1";
|
|
2411
|
-
const params = [];
|
|
2412
|
-
if (criteria.status) {
|
|
2413
|
-
const statuses = Array.isArray(criteria.status) ? criteria.status : [criteria.status];
|
|
2414
|
-
const placeholders = statuses.map(() => "?").join(", ");
|
|
2415
|
-
query += ` AND status IN (${placeholders})`;
|
|
2416
|
-
params.push(...statuses);
|
|
2417
|
-
}
|
|
2418
|
-
if (criteria.workflowName) {
|
|
2419
|
-
query += " AND workflow_name = ?";
|
|
2420
|
-
params.push(criteria.workflowName);
|
|
2421
|
-
}
|
|
2422
|
-
if (criteria.metadata) for (const [key, value] of Object.entries(criteria.metadata)) {
|
|
2423
|
-
query += ` AND json_extract(metadata, '$.' || ?) = ?`;
|
|
2424
|
-
params.push(key, value);
|
|
2425
|
-
}
|
|
2426
|
-
if (criteria.createdBefore) {
|
|
2427
|
-
query += " AND created_at < ?";
|
|
2428
|
-
params.push(Math.floor(criteria.createdBefore.getTime() / 1e3));
|
|
2429
|
-
}
|
|
2430
|
-
return this.ctx.storage.sql.exec(query, ...params).rowsWritten;
|
|
2431
|
-
}
|
|
2432
|
-
/**
|
|
2433
|
-
* Migrate workflow tracking records from an old binding name to a new one.
|
|
2434
|
-
* Use this after renaming a workflow binding in wrangler.toml.
|
|
2435
|
-
*
|
|
2436
|
-
* @param oldName - Previous workflow binding name
|
|
2437
|
-
* @param newName - New workflow binding name
|
|
2438
|
-
* @returns Number of records migrated
|
|
2439
|
-
*
|
|
2440
|
-
* @example
|
|
2441
|
-
* ```typescript
|
|
2442
|
-
* // After renaming OLD_WORKFLOW to NEW_WORKFLOW in wrangler.toml
|
|
2443
|
-
* async onStart() {
|
|
2444
|
-
* const migrated = this.migrateWorkflowBinding('OLD_WORKFLOW', 'NEW_WORKFLOW');
|
|
2445
|
-
* }
|
|
2446
|
-
* ```
|
|
2447
|
-
*/
|
|
2448
|
-
migrateWorkflowBinding(oldName, newName) {
|
|
2449
|
-
if (!this._findWorkflowBindingByName(newName)) throw new Error(`Workflow binding '${newName}' not found in environment`);
|
|
2450
|
-
const count = this.sql`
|
|
2451
|
-
SELECT COUNT(*) as count FROM cf_agents_workflows WHERE workflow_name = ${oldName}
|
|
2452
|
-
`[0]?.count ?? 0;
|
|
2453
|
-
if (count > 0) {
|
|
2454
|
-
this.sql`UPDATE cf_agents_workflows SET workflow_name = ${newName} WHERE workflow_name = ${oldName}`;
|
|
2455
|
-
console.log(`[Agent] Migrated ${count} workflow(s) from '${oldName}' to '${newName}'`);
|
|
2456
|
-
}
|
|
2457
|
-
return count;
|
|
2458
|
-
}
|
|
2459
|
-
/**
|
|
2460
|
-
* Update workflow tracking record from InstanceStatus
|
|
2461
|
-
*/
|
|
2462
|
-
_updateWorkflowTracking(workflowId, status) {
|
|
2463
|
-
const statusName = status.status;
|
|
2464
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2465
|
-
const completedAt = [
|
|
2466
|
-
"complete",
|
|
2467
|
-
"errored",
|
|
2468
|
-
"terminated"
|
|
2469
|
-
].includes(statusName) ? now : null;
|
|
2470
|
-
const errorName = status.error?.name ?? null;
|
|
2471
|
-
const errorMessage = status.error?.message ?? null;
|
|
2472
|
-
this.sql`
|
|
2473
|
-
UPDATE cf_agents_workflows
|
|
2474
|
-
SET status = ${statusName},
|
|
2475
|
-
error_name = ${errorName},
|
|
2476
|
-
error_message = ${errorMessage},
|
|
2477
|
-
updated_at = ${now},
|
|
2478
|
-
completed_at = ${completedAt}
|
|
2479
|
-
WHERE workflow_id = ${workflowId}
|
|
2480
|
-
`;
|
|
2481
|
-
}
|
|
2482
|
-
/**
|
|
2483
|
-
* Convert a database row to WorkflowInfo
|
|
2484
|
-
*/
|
|
2485
|
-
_rowToWorkflowInfo(row) {
|
|
2486
|
-
return {
|
|
2487
|
-
id: row.id,
|
|
2488
|
-
workflowId: row.workflow_id,
|
|
2489
|
-
workflowName: row.workflow_name,
|
|
2490
|
-
status: row.status,
|
|
2491
|
-
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
2492
|
-
error: row.error_name ? {
|
|
2493
|
-
name: row.error_name,
|
|
2494
|
-
message: row.error_message ?? ""
|
|
2495
|
-
} : null,
|
|
2496
|
-
createdAt: /* @__PURE__ */ new Date(row.created_at * 1e3),
|
|
2497
|
-
updatedAt: /* @__PURE__ */ new Date(row.updated_at * 1e3),
|
|
2498
|
-
completedAt: row.completed_at ? /* @__PURE__ */ new Date(row.completed_at * 1e3) : null
|
|
2499
|
-
};
|
|
2500
|
-
}
|
|
2501
|
-
/**
|
|
2502
|
-
* Find the binding name for this Agent's namespace by matching class name.
|
|
2503
|
-
* Returns undefined if no match found - use options.agentBinding as fallback.
|
|
2504
|
-
*/
|
|
2505
|
-
_findAgentBindingName() {
|
|
2506
|
-
const className = this._ParentClass.name;
|
|
2507
|
-
for (const [key, value] of Object.entries(this.env)) if (value && typeof value === "object" && "idFromName" in value && typeof value.idFromName === "function") {
|
|
2508
|
-
if (key === className || camelCaseToKebabCase(key) === camelCaseToKebabCase(className)) return key;
|
|
2509
|
-
}
|
|
2510
|
-
}
|
|
2511
|
-
_findBindingNameForNamespace(namespace) {
|
|
2512
|
-
for (const [key, value] of Object.entries(this.env)) if (value === namespace) return key;
|
|
2513
|
-
}
|
|
2514
|
-
async _restoreRpcMcpServers() {
|
|
2515
|
-
const rpcServers = this.mcp.getRpcServersFromStorage();
|
|
2516
|
-
for (const server of rpcServers) {
|
|
2517
|
-
if (this.mcp.mcpConnections[server.id]) continue;
|
|
2518
|
-
const opts = server.server_options ? JSON.parse(server.server_options) : {};
|
|
2519
|
-
const namespace = this.env[opts.bindingName];
|
|
2520
|
-
if (!namespace) {
|
|
2521
|
-
console.warn(`[Agent] Cannot restore RPC MCP server "${server.name}": binding "${opts.bindingName}" not found in env`);
|
|
2522
|
-
continue;
|
|
2523
|
-
}
|
|
2524
|
-
const normalizedName = server.server_url.replace(RPC_DO_PREFIX, "");
|
|
2525
|
-
try {
|
|
2526
|
-
await this.mcp.connect(`${RPC_DO_PREFIX}${normalizedName}`, {
|
|
2527
|
-
reconnect: { id: server.id },
|
|
2528
|
-
transport: {
|
|
2529
|
-
type: "rpc",
|
|
2530
|
-
namespace,
|
|
2531
|
-
name: normalizedName,
|
|
2532
|
-
props: opts.props
|
|
2533
|
-
}
|
|
2534
|
-
});
|
|
2535
|
-
const conn = this.mcp.mcpConnections[server.id];
|
|
2536
|
-
if (conn && conn.connectionState === MCPConnectionState.CONNECTED) await this.mcp.discoverIfConnected(server.id);
|
|
2537
|
-
} catch (error) {
|
|
2538
|
-
console.error(`[Agent] Error restoring RPC MCP server "${server.name}":`, error);
|
|
2539
|
-
}
|
|
2540
|
-
}
|
|
2541
|
-
}
|
|
2542
|
-
/**
|
|
2543
|
-
* Handle a callback from a workflow.
|
|
2544
|
-
* Called when the Agent receives a callback at /_workflow/callback.
|
|
2545
|
-
* Override this to handle all callback types in one place.
|
|
2546
|
-
*
|
|
2547
|
-
* @param callback - The callback payload
|
|
2548
|
-
*/
|
|
2549
|
-
async onWorkflowCallback(callback) {
|
|
2550
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2551
|
-
switch (callback.type) {
|
|
2552
|
-
case "progress":
|
|
2553
|
-
this.sql`
|
|
2554
|
-
UPDATE cf_agents_workflows
|
|
2555
|
-
SET status = 'running', updated_at = ${now}
|
|
2556
|
-
WHERE workflow_id = ${callback.workflowId} AND status IN ('queued', 'waiting')
|
|
2557
|
-
`;
|
|
2558
|
-
await this.onWorkflowProgress(callback.workflowName, callback.workflowId, callback.progress);
|
|
2559
|
-
break;
|
|
2560
|
-
case "complete":
|
|
2561
|
-
this.sql`
|
|
2562
|
-
UPDATE cf_agents_workflows
|
|
2563
|
-
SET status = 'complete', updated_at = ${now}, completed_at = ${now}
|
|
2564
|
-
WHERE workflow_id = ${callback.workflowId}
|
|
2565
|
-
AND status NOT IN ('terminated', 'paused')
|
|
2566
|
-
`;
|
|
2567
|
-
await this.onWorkflowComplete(callback.workflowName, callback.workflowId, callback.result);
|
|
2568
|
-
break;
|
|
2569
|
-
case "error":
|
|
2570
|
-
this.sql`
|
|
2571
|
-
UPDATE cf_agents_workflows
|
|
2572
|
-
SET status = 'errored', updated_at = ${now}, completed_at = ${now},
|
|
2573
|
-
error_name = 'WorkflowError', error_message = ${callback.error}
|
|
2574
|
-
WHERE workflow_id = ${callback.workflowId}
|
|
2575
|
-
AND status NOT IN ('terminated', 'paused')
|
|
2576
|
-
`;
|
|
2577
|
-
await this.onWorkflowError(callback.workflowName, callback.workflowId, callback.error);
|
|
2578
|
-
break;
|
|
2579
|
-
case "event":
|
|
2580
|
-
await this.onWorkflowEvent(callback.workflowName, callback.workflowId, callback.event);
|
|
2581
|
-
break;
|
|
2582
|
-
}
|
|
2583
|
-
}
|
|
2584
|
-
/**
|
|
2585
|
-
* Called when a workflow reports progress.
|
|
2586
|
-
* Override to handle progress updates.
|
|
2587
|
-
*
|
|
2588
|
-
* @param workflowName - Workflow binding name
|
|
2589
|
-
* @param workflowId - ID of the workflow
|
|
2590
|
-
* @param progress - Typed progress data (default: DefaultProgress)
|
|
2591
|
-
*/
|
|
2592
|
-
async onWorkflowProgress(workflowName, workflowId, progress) {}
|
|
2593
|
-
/**
|
|
2594
|
-
* Called when a workflow completes successfully.
|
|
2595
|
-
* Override to handle completion.
|
|
2596
|
-
*
|
|
2597
|
-
* @param workflowName - Workflow binding name
|
|
2598
|
-
* @param workflowId - ID of the workflow
|
|
2599
|
-
* @param result - Optional result data
|
|
2600
|
-
*/
|
|
2601
|
-
async onWorkflowComplete(workflowName, workflowId, result) {}
|
|
2602
|
-
/**
|
|
2603
|
-
* Called when a workflow encounters an error.
|
|
2604
|
-
* Override to handle errors.
|
|
2605
|
-
*
|
|
2606
|
-
* @param workflowName - Workflow binding name
|
|
2607
|
-
* @param workflowId - ID of the workflow
|
|
2608
|
-
* @param error - Error message
|
|
2609
|
-
*/
|
|
2610
|
-
async onWorkflowError(workflowName, workflowId, error) {
|
|
2611
|
-
console.error(`Workflow error [${workflowName}/${workflowId}]: ${error}\nOverride onWorkflowError() in your Agent to handle workflow errors.`);
|
|
2612
|
-
}
|
|
2613
|
-
/**
|
|
2614
|
-
* Called when a workflow sends a custom event.
|
|
2615
|
-
* Override to handle custom events.
|
|
2616
|
-
*
|
|
2617
|
-
* @param workflowName - Workflow binding name
|
|
2618
|
-
* @param workflowId - ID of the workflow
|
|
2619
|
-
* @param event - Custom event payload
|
|
2620
|
-
*/
|
|
2621
|
-
async onWorkflowEvent(workflowName, workflowId, event) {}
|
|
2622
|
-
/**
|
|
2623
|
-
* Handle a workflow callback via RPC.
|
|
2624
|
-
* @internal - Called by AgentWorkflow, do not call directly
|
|
2625
|
-
*/
|
|
2626
|
-
async _workflow_handleCallback(callback) {
|
|
2627
|
-
await this.__unsafe_ensureInitialized();
|
|
2628
|
-
await this.onWorkflowCallback(callback);
|
|
2629
|
-
}
|
|
2630
|
-
/**
|
|
2631
|
-
* Broadcast a message to all connected clients via RPC.
|
|
2632
|
-
* @internal - Called by AgentWorkflow, do not call directly
|
|
2633
|
-
*/
|
|
2634
|
-
async _workflow_broadcast(message) {
|
|
2635
|
-
await this.__unsafe_ensureInitialized();
|
|
2636
|
-
this.broadcast(JSON.stringify(message));
|
|
2637
|
-
}
|
|
2638
|
-
/**
|
|
2639
|
-
* Update agent state via RPC.
|
|
2640
|
-
* @internal - Called by AgentWorkflow, do not call directly
|
|
2641
|
-
*/
|
|
2642
|
-
async _workflow_updateState(action, state) {
|
|
2643
|
-
await this.__unsafe_ensureInitialized();
|
|
2644
|
-
if (action === "set") this.setState(state);
|
|
2645
|
-
else if (action === "merge") {
|
|
2646
|
-
const currentState = this.state ?? {};
|
|
2647
|
-
this.setState({
|
|
2648
|
-
...currentState,
|
|
2649
|
-
...state
|
|
2650
|
-
});
|
|
2651
|
-
} else if (action === "reset") this.setState(this.initialState);
|
|
2652
|
-
}
|
|
2653
|
-
async addMcpServer(serverName, urlOrBinding, callbackHostOrOptions, agentsPrefix, options) {
|
|
2654
|
-
const isHttpTransport = typeof urlOrBinding === "string";
|
|
2655
|
-
const normalizedUrl = isHttpTransport ? new URL(urlOrBinding).href : void 0;
|
|
2656
|
-
const existingServer = this.mcp.listServers().find((s) => s.name === serverName && (!isHttpTransport || new URL(s.server_url).href === normalizedUrl));
|
|
2657
|
-
if (existingServer && this.mcp.mcpConnections[existingServer.id]) {
|
|
2658
|
-
const conn = this.mcp.mcpConnections[existingServer.id];
|
|
2659
|
-
if (conn.connectionState === MCPConnectionState.AUTHENTICATING && conn.options.transport.authProvider?.authUrl) return {
|
|
2660
|
-
id: existingServer.id,
|
|
2661
|
-
state: MCPConnectionState.AUTHENTICATING,
|
|
2662
|
-
authUrl: conn.options.transport.authProvider.authUrl
|
|
2663
|
-
};
|
|
2664
|
-
if (conn.connectionState === MCPConnectionState.FAILED) throw new Error(`MCP server "${serverName}" is in failed state: ${conn.connectionError}`);
|
|
2665
|
-
return {
|
|
2666
|
-
id: existingServer.id,
|
|
2667
|
-
state: MCPConnectionState.READY
|
|
2668
|
-
};
|
|
2669
|
-
}
|
|
2670
|
-
if (typeof urlOrBinding !== "string") {
|
|
2671
|
-
const rpcOpts = callbackHostOrOptions;
|
|
2672
|
-
const normalizedName = serverName.toLowerCase().replace(/\s+/g, "-");
|
|
2673
|
-
const reconnectId = existingServer?.id;
|
|
2674
|
-
const { id } = await this.mcp.connect(`${RPC_DO_PREFIX}${normalizedName}`, {
|
|
2675
|
-
reconnect: reconnectId ? { id: reconnectId } : void 0,
|
|
2676
|
-
transport: {
|
|
2677
|
-
type: "rpc",
|
|
2678
|
-
namespace: urlOrBinding,
|
|
2679
|
-
name: normalizedName,
|
|
2680
|
-
props: rpcOpts?.props
|
|
2681
|
-
}
|
|
2682
|
-
});
|
|
2683
|
-
const conn = this.mcp.mcpConnections[id];
|
|
2684
|
-
if (conn && conn.connectionState === MCPConnectionState.CONNECTED) {
|
|
2685
|
-
const discoverResult = await this.mcp.discoverIfConnected(id);
|
|
2686
|
-
if (discoverResult && !discoverResult.success) throw new Error(`Failed to discover MCP server capabilities: ${discoverResult.error}`);
|
|
2687
|
-
} else if (conn && conn.connectionState === MCPConnectionState.FAILED) throw new Error(`Failed to connect to MCP server "${serverName}" via RPC: ${conn.connectionError}`);
|
|
2688
|
-
const bindingName = this._findBindingNameForNamespace(urlOrBinding);
|
|
2689
|
-
if (bindingName) this.mcp.saveRpcServerToStorage(id, serverName, normalizedName, bindingName, rpcOpts?.props);
|
|
2690
|
-
return {
|
|
2691
|
-
id,
|
|
2692
|
-
state: MCPConnectionState.READY
|
|
2693
|
-
};
|
|
2694
|
-
}
|
|
2695
|
-
const httpOptions = callbackHostOrOptions;
|
|
2696
|
-
let resolvedCallbackHost;
|
|
2697
|
-
let resolvedAgentsPrefix;
|
|
2698
|
-
let resolvedOptions;
|
|
2699
|
-
let resolvedCallbackPath;
|
|
2700
|
-
if (typeof httpOptions === "object" && httpOptions !== null) {
|
|
2701
|
-
resolvedCallbackHost = httpOptions.callbackHost;
|
|
2702
|
-
resolvedCallbackPath = httpOptions.callbackPath;
|
|
2703
|
-
resolvedAgentsPrefix = httpOptions.agentsPrefix ?? "agents";
|
|
2704
|
-
resolvedOptions = {
|
|
2705
|
-
client: httpOptions.client,
|
|
2706
|
-
transport: httpOptions.transport,
|
|
2707
|
-
retry: httpOptions.retry
|
|
2708
|
-
};
|
|
2709
|
-
} else {
|
|
2710
|
-
resolvedCallbackHost = httpOptions;
|
|
2711
|
-
resolvedAgentsPrefix = agentsPrefix ?? "agents";
|
|
2712
|
-
resolvedOptions = options;
|
|
2713
|
-
}
|
|
2714
|
-
if (!this._resolvedOptions.sendIdentityOnConnect && resolvedCallbackHost && !resolvedCallbackPath) throw new Error("callbackPath is required in addMcpServer options when sendIdentityOnConnect is false — the default callback URL would expose the instance name. Provide a callbackPath and route the callback request to this agent via getAgentByName.");
|
|
2715
|
-
if (!resolvedCallbackHost) {
|
|
2716
|
-
const { request, connection } = getCurrentAgent();
|
|
2717
|
-
if (request) {
|
|
2718
|
-
const requestUrl = new URL(request.url);
|
|
2719
|
-
resolvedCallbackHost = `${requestUrl.protocol}//${requestUrl.host}`;
|
|
2720
|
-
} else if (connection?.uri) {
|
|
2721
|
-
const connectionUrl = new URL(connection.uri);
|
|
2722
|
-
resolvedCallbackHost = `${connectionUrl.protocol}//${connectionUrl.host}`;
|
|
2723
|
-
}
|
|
2724
|
-
}
|
|
2725
|
-
let callbackUrl;
|
|
2726
|
-
if (resolvedCallbackHost) {
|
|
2727
|
-
const normalizedHost = resolvedCallbackHost.replace(/\/$/, "");
|
|
2728
|
-
callbackUrl = resolvedCallbackPath ? `${normalizedHost}/${resolvedCallbackPath.replace(/^\//, "")}` : `${normalizedHost}/${resolvedAgentsPrefix}/${camelCaseToKebabCase(this._ParentClass.name)}/${this.name}/callback`;
|
|
2729
|
-
}
|
|
2730
|
-
const id = nanoid(8);
|
|
2731
|
-
let authProvider;
|
|
2732
|
-
if (callbackUrl) {
|
|
2733
|
-
authProvider = this.createMcpOAuthProvider(callbackUrl);
|
|
2734
|
-
authProvider.serverId = id;
|
|
2735
|
-
}
|
|
2736
|
-
const transportType = resolvedOptions?.transport?.type ?? "auto";
|
|
2737
|
-
let headerTransportOpts = {};
|
|
2738
|
-
if (resolvedOptions?.transport?.headers) headerTransportOpts = {
|
|
2739
|
-
eventSourceInit: { fetch: (url, init) => fetch(url, {
|
|
2740
|
-
...init,
|
|
2741
|
-
headers: resolvedOptions?.transport?.headers
|
|
2742
|
-
}) },
|
|
2743
|
-
requestInit: { headers: resolvedOptions?.transport?.headers }
|
|
2744
|
-
};
|
|
2745
|
-
await this.mcp.registerServer(id, {
|
|
2746
|
-
url: normalizedUrl,
|
|
2747
|
-
name: serverName,
|
|
2748
|
-
callbackUrl,
|
|
2749
|
-
client: resolvedOptions?.client,
|
|
2750
|
-
transport: {
|
|
2751
|
-
...headerTransportOpts,
|
|
2752
|
-
authProvider,
|
|
2753
|
-
type: transportType
|
|
2754
|
-
},
|
|
2755
|
-
retry: resolvedOptions?.retry
|
|
2756
|
-
});
|
|
2757
|
-
const result = await this.mcp.connectToServer(id);
|
|
2758
|
-
if (result.state === MCPConnectionState.FAILED) throw new Error(`Failed to connect to MCP server at ${normalizedUrl}: ${result.error}`);
|
|
2759
|
-
if (result.state === MCPConnectionState.AUTHENTICATING) {
|
|
2760
|
-
if (!callbackUrl) throw new Error("This MCP server requires OAuth authentication. Provide callbackHost in addMcpServer options to enable the OAuth flow.");
|
|
2761
|
-
return {
|
|
2762
|
-
id,
|
|
2763
|
-
state: result.state,
|
|
2764
|
-
authUrl: result.authUrl
|
|
2765
|
-
};
|
|
2766
|
-
}
|
|
2767
|
-
const discoverResult = await this.mcp.discoverIfConnected(id);
|
|
2768
|
-
if (discoverResult && !discoverResult.success) throw new Error(`Failed to discover MCP server capabilities: ${discoverResult.error}`);
|
|
2769
|
-
return {
|
|
2770
|
-
id,
|
|
2771
|
-
state: MCPConnectionState.READY
|
|
2772
|
-
};
|
|
2773
|
-
}
|
|
2774
|
-
async removeMcpServer(id) {
|
|
2775
|
-
await this.mcp.removeServer(id);
|
|
2776
|
-
}
|
|
2777
|
-
getMcpServers() {
|
|
2778
|
-
const mcpState = {
|
|
2779
|
-
prompts: this.mcp.listPrompts(),
|
|
2780
|
-
resources: this.mcp.listResources(),
|
|
2781
|
-
servers: {},
|
|
2782
|
-
tools: this.mcp.listTools()
|
|
2783
|
-
};
|
|
2784
|
-
const servers = this.mcp.listServers();
|
|
2785
|
-
if (servers && Array.isArray(servers) && servers.length > 0) for (const server of servers) {
|
|
2786
|
-
const serverConn = this.mcp.mcpConnections[server.id];
|
|
2787
|
-
let defaultState = "not-connected";
|
|
2788
|
-
if (!serverConn && server.auth_url) defaultState = "authenticating";
|
|
2789
|
-
mcpState.servers[server.id] = {
|
|
2790
|
-
auth_url: server.auth_url,
|
|
2791
|
-
capabilities: serverConn?.serverCapabilities ?? null,
|
|
2792
|
-
error: sanitizeErrorString(serverConn?.connectionError ?? null),
|
|
2793
|
-
instructions: serverConn?.instructions ?? null,
|
|
2794
|
-
name: server.name,
|
|
2795
|
-
server_url: server.server_url,
|
|
2796
|
-
state: serverConn?.connectionState ?? defaultState
|
|
2797
|
-
};
|
|
2798
|
-
}
|
|
2799
|
-
return mcpState;
|
|
2800
|
-
}
|
|
2801
|
-
/**
|
|
2802
|
-
* Create the OAuth provider used when connecting to MCP servers that require authentication.
|
|
2803
|
-
*
|
|
2804
|
-
* Override this method in a subclass to supply a custom OAuth provider implementation,
|
|
2805
|
-
* for example to use pre-registered client credentials, mTLS-based authentication,
|
|
2806
|
-
* or any other OAuth flow beyond dynamic client registration.
|
|
2807
|
-
*
|
|
2808
|
-
* @example
|
|
2809
|
-
* // Custom OAuth provider
|
|
2810
|
-
* class MyAgent extends Agent {
|
|
2811
|
-
* createMcpOAuthProvider(callbackUrl: string): AgentMcpOAuthProvider {
|
|
2812
|
-
* return new MyCustomOAuthProvider(
|
|
2813
|
-
* this.ctx.storage,
|
|
2814
|
-
* this.name,
|
|
2815
|
-
* callbackUrl
|
|
2816
|
-
* );
|
|
2817
|
-
* }
|
|
2818
|
-
* }
|
|
2819
|
-
*
|
|
2820
|
-
* @param callbackUrl The OAuth callback URL for the authorization flow
|
|
2821
|
-
* @returns An {@link AgentMcpOAuthProvider} instance used by {@link addMcpServer}
|
|
2822
|
-
*/
|
|
2823
|
-
createMcpOAuthProvider(callbackUrl) {
|
|
2824
|
-
return new DurableObjectOAuthClientProvider(this.ctx.storage, this.name, callbackUrl);
|
|
2825
|
-
}
|
|
2826
|
-
broadcastMcpServers() {
|
|
2827
|
-
this._broadcastProtocol(JSON.stringify({
|
|
2828
|
-
mcp: this.getMcpServers(),
|
|
2829
|
-
type: MessageType.CF_AGENT_MCP_SERVERS
|
|
2830
|
-
}));
|
|
2831
|
-
}
|
|
2832
|
-
/**
|
|
2833
|
-
* Handle MCP OAuth callback request if it's an OAuth callback.
|
|
2834
|
-
*
|
|
2835
|
-
* This method encapsulates the entire OAuth callback flow:
|
|
2836
|
-
* 1. Checks if the request is an MCP OAuth callback
|
|
2837
|
-
* 2. Processes the OAuth code exchange
|
|
2838
|
-
* 3. Establishes the connection if successful
|
|
2839
|
-
* 4. Broadcasts MCP server state updates
|
|
2840
|
-
* 5. Returns the appropriate HTTP response
|
|
2841
|
-
*
|
|
2842
|
-
* @param request The incoming HTTP request
|
|
2843
|
-
* @returns Response if this was an OAuth callback, null otherwise
|
|
2844
|
-
*/
|
|
2845
|
-
async handleMcpOAuthCallback(request) {
|
|
2846
|
-
if (!this.mcp.isCallbackRequest(request)) return null;
|
|
2847
|
-
const result = await this.mcp.handleCallbackRequest(request);
|
|
2848
|
-
if (result.authSuccess) this.mcp.establishConnection(result.serverId).catch((error) => {
|
|
2849
|
-
console.error("[Agent handleMcpOAuthCallback] Connection establishment failed:", error);
|
|
2850
|
-
});
|
|
2851
|
-
this.broadcastMcpServers();
|
|
2852
|
-
return this.handleOAuthCallbackResponse(result, request);
|
|
2853
|
-
}
|
|
2854
|
-
/**
|
|
2855
|
-
* Handle OAuth callback response using MCPClientManager configuration
|
|
2856
|
-
* @param result OAuth callback result
|
|
2857
|
-
* @param request The original request (needed for base URL)
|
|
2858
|
-
* @returns Response for the OAuth callback
|
|
2859
|
-
*/
|
|
2860
|
-
handleOAuthCallbackResponse(result, request) {
|
|
2861
|
-
const config = this.mcp.getOAuthCallbackConfig();
|
|
2862
|
-
if (config?.customHandler) return config.customHandler(result);
|
|
2863
|
-
const baseOrigin = new URL(request.url).origin;
|
|
2864
|
-
if (config?.successRedirect && result.authSuccess) try {
|
|
2865
|
-
return Response.redirect(new URL(config.successRedirect, baseOrigin).href);
|
|
2866
|
-
} catch (e) {
|
|
2867
|
-
console.error("Invalid successRedirect URL:", config.successRedirect, e);
|
|
2868
|
-
return Response.redirect(baseOrigin);
|
|
2869
|
-
}
|
|
2870
|
-
if (config?.errorRedirect && !result.authSuccess) try {
|
|
2871
|
-
const errorUrl = `${config.errorRedirect}?error=${encodeURIComponent(result.authError || "Unknown error")}`;
|
|
2872
|
-
return Response.redirect(new URL(errorUrl, baseOrigin).href);
|
|
2873
|
-
} catch (e) {
|
|
2874
|
-
console.error("Invalid errorRedirect URL:", config.errorRedirect, e);
|
|
2875
|
-
return Response.redirect(baseOrigin);
|
|
2876
|
-
}
|
|
2877
|
-
return Response.redirect(baseOrigin);
|
|
2878
|
-
}
|
|
2879
|
-
};
|
|
2880
|
-
Agent.options = { hibernate: true };
|
|
2881
|
-
const wrappedClasses = /* @__PURE__ */ new Set();
|
|
2882
|
-
/**
|
|
2883
|
-
* Route a request to the appropriate Agent
|
|
2884
|
-
* @param request Request to route
|
|
2885
|
-
* @param env Environment containing Agent bindings
|
|
2886
|
-
* @param options Routing options
|
|
2887
|
-
* @returns Response from the Agent or undefined if no route matched
|
|
2888
|
-
*/
|
|
2889
|
-
async function routeAgentRequest(request, env, options) {
|
|
2890
|
-
return routePartykitRequest(request, env, {
|
|
2891
|
-
prefix: "agents",
|
|
2892
|
-
...options
|
|
2893
|
-
});
|
|
2894
|
-
}
|
|
2895
|
-
const agentMapCache = /* @__PURE__ */ new WeakMap();
|
|
2896
|
-
/**
|
|
2897
|
-
* Route an email to the appropriate Agent
|
|
2898
|
-
* @param email The email to route
|
|
2899
|
-
* @param env The environment containing the Agent bindings
|
|
2900
|
-
* @param options The options for routing the email
|
|
2901
|
-
* @returns A promise that resolves when the email has been routed
|
|
2902
|
-
*/
|
|
2903
|
-
async function routeAgentEmail(email, env, options) {
|
|
2904
|
-
const routingInfo = await options.resolver(email, env);
|
|
2905
|
-
if (!routingInfo) {
|
|
2906
|
-
if (options.onNoRoute) await options.onNoRoute(email);
|
|
2907
|
-
else console.warn("No routing information found for email, dropping message");
|
|
2908
|
-
return;
|
|
2909
|
-
}
|
|
2910
|
-
if (!agentMapCache.has(env)) {
|
|
2911
|
-
const map = {};
|
|
2912
|
-
const originalNames = [];
|
|
2913
|
-
for (const [key, value] of Object.entries(env)) if (value && typeof value === "object" && "idFromName" in value && typeof value.idFromName === "function") {
|
|
2914
|
-
map[key] = value;
|
|
2915
|
-
map[camelCaseToKebabCase(key)] = value;
|
|
2916
|
-
map[key.toLowerCase()] = value;
|
|
2917
|
-
originalNames.push(key);
|
|
2918
|
-
}
|
|
2919
|
-
agentMapCache.set(env, {
|
|
2920
|
-
map,
|
|
2921
|
-
originalNames
|
|
2922
|
-
});
|
|
2923
|
-
}
|
|
2924
|
-
const cached = agentMapCache.get(env);
|
|
2925
|
-
const namespace = cached.map[routingInfo.agentName];
|
|
2926
|
-
if (!namespace) {
|
|
2927
|
-
const availableAgents = cached.originalNames.join(", ");
|
|
2928
|
-
throw new Error(`Agent namespace '${routingInfo.agentName}' not found in environment. Available agents: ${availableAgents}`);
|
|
2929
|
-
}
|
|
2930
|
-
const agent = await getAgentByName(namespace, routingInfo.agentId);
|
|
2931
|
-
const serialisableEmail = {
|
|
2932
|
-
getRaw: async () => {
|
|
2933
|
-
const reader = email.raw.getReader();
|
|
2934
|
-
const chunks = [];
|
|
2935
|
-
let done = false;
|
|
2936
|
-
while (!done) {
|
|
2937
|
-
const { value, done: readerDone } = await reader.read();
|
|
2938
|
-
done = readerDone;
|
|
2939
|
-
if (value) chunks.push(value);
|
|
2940
|
-
}
|
|
2941
|
-
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
2942
|
-
const combined = new Uint8Array(totalLength);
|
|
2943
|
-
let offset = 0;
|
|
2944
|
-
for (const chunk of chunks) {
|
|
2945
|
-
combined.set(chunk, offset);
|
|
2946
|
-
offset += chunk.length;
|
|
2947
|
-
}
|
|
2948
|
-
return combined;
|
|
2949
|
-
},
|
|
2950
|
-
headers: email.headers,
|
|
2951
|
-
rawSize: email.rawSize,
|
|
2952
|
-
setReject: (reason) => {
|
|
2953
|
-
email.setReject(reason);
|
|
2954
|
-
},
|
|
2955
|
-
forward: (rcptTo, headers) => {
|
|
2956
|
-
return email.forward(rcptTo, headers);
|
|
2957
|
-
},
|
|
2958
|
-
reply: (replyOptions) => {
|
|
2959
|
-
return email.reply(new EmailMessage(replyOptions.from, replyOptions.to, replyOptions.raw));
|
|
2960
|
-
},
|
|
2961
|
-
from: email.from,
|
|
2962
|
-
to: email.to,
|
|
2963
|
-
_secureRouted: routingInfo._secureRouted
|
|
2964
|
-
};
|
|
2965
|
-
await agent._onEmail(serialisableEmail);
|
|
2966
|
-
}
|
|
2967
|
-
/**
|
|
2968
|
-
* Get or create an Agent by name
|
|
2969
|
-
* @template Env Environment type containing bindings
|
|
2970
|
-
* @template T Type of the Agent class
|
|
2971
|
-
* @param namespace Agent namespace
|
|
2972
|
-
* @param name Name of the Agent instance
|
|
2973
|
-
* @param options Options for Agent creation
|
|
2974
|
-
* @returns Promise resolving to an Agent instance stub
|
|
2975
|
-
*/
|
|
2976
|
-
async function getAgentByName(namespace, name, options) {
|
|
2977
|
-
return getServerByName(namespace, name, options);
|
|
2978
|
-
}
|
|
2979
|
-
/**
|
|
2980
|
-
* A wrapper for streaming responses in callable methods
|
|
2981
|
-
*/
|
|
2982
|
-
var StreamingResponse = class {
|
|
2983
|
-
constructor(connection, id) {
|
|
2984
|
-
this._closed = false;
|
|
2985
|
-
this._connection = connection;
|
|
2986
|
-
this._id = id;
|
|
2987
|
-
}
|
|
2988
|
-
/**
|
|
2989
|
-
* Whether the stream has been closed (via end() or error())
|
|
2990
|
-
*/
|
|
2991
|
-
get isClosed() {
|
|
2992
|
-
return this._closed;
|
|
2993
|
-
}
|
|
2994
|
-
/**
|
|
2995
|
-
* Send a chunk of data to the client
|
|
2996
|
-
* @param chunk The data to send
|
|
2997
|
-
* @returns false if stream is already closed (no-op), true if sent
|
|
2998
|
-
*/
|
|
2999
|
-
send(chunk) {
|
|
3000
|
-
if (this._closed) {
|
|
3001
|
-
console.warn("StreamingResponse.send() called after stream was closed - data not sent");
|
|
3002
|
-
return false;
|
|
3003
|
-
}
|
|
3004
|
-
const response = {
|
|
3005
|
-
done: false,
|
|
3006
|
-
id: this._id,
|
|
3007
|
-
result: chunk,
|
|
3008
|
-
success: true,
|
|
3009
|
-
type: MessageType.RPC
|
|
3010
|
-
};
|
|
3011
|
-
this._connection.send(JSON.stringify(response));
|
|
3012
|
-
return true;
|
|
3013
|
-
}
|
|
3014
|
-
/**
|
|
3015
|
-
* End the stream and send the final chunk (if any)
|
|
3016
|
-
* @param finalChunk Optional final chunk of data to send
|
|
3017
|
-
* @returns false if stream is already closed (no-op), true if sent
|
|
3018
|
-
*/
|
|
3019
|
-
end(finalChunk) {
|
|
3020
|
-
if (this._closed) return false;
|
|
3021
|
-
this._closed = true;
|
|
3022
|
-
const response = {
|
|
3023
|
-
done: true,
|
|
3024
|
-
id: this._id,
|
|
3025
|
-
result: finalChunk,
|
|
3026
|
-
success: true,
|
|
3027
|
-
type: MessageType.RPC
|
|
3028
|
-
};
|
|
3029
|
-
this._connection.send(JSON.stringify(response));
|
|
3030
|
-
return true;
|
|
3031
|
-
}
|
|
3032
|
-
/**
|
|
3033
|
-
* Send an error to the client and close the stream
|
|
3034
|
-
* @param message Error message to send
|
|
3035
|
-
* @returns false if stream is already closed (no-op), true if sent
|
|
3036
|
-
*/
|
|
3037
|
-
error(message) {
|
|
3038
|
-
if (this._closed) return false;
|
|
3039
|
-
this._closed = true;
|
|
3040
|
-
const response = {
|
|
3041
|
-
error: message,
|
|
3042
|
-
id: this._id,
|
|
3043
|
-
success: false,
|
|
3044
|
-
type: MessageType.RPC
|
|
3045
|
-
};
|
|
3046
|
-
this._connection.send(JSON.stringify(response));
|
|
3047
|
-
return true;
|
|
3048
|
-
}
|
|
3049
|
-
};
|
|
3050
|
-
//#endregion
|
|
3051
6
|
export { Agent, DEFAULT_AGENT_STATIC_OPTIONS, DurableObjectOAuthClientProvider, MessageType, SqlError, StreamingResponse, __DO_NOT_USE_WILL_BREAK__agentContext, callable, createHeaderBasedEmailResolver, getAgentByName, getCurrentAgent, routeAgentEmail, routeAgentRequest, unstable_callable };
|
|
3052
|
-
|
|
3053
|
-
//# sourceMappingURL=index.js.map
|