@ewhauser/world-celld 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +201 -0
- package/NOTICE +24 -0
- package/README.md +292 -0
- package/celld-worker/README.md +34 -0
- package/celld-worker/worker.ts +5 -0
- package/celld-worker/wrangler.jsonc +32 -0
- package/dist/index.d.ts +250 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +949 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +134 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1808 -0
- package/dist/testing.js.map +1 -0
- package/dist/util-BvWlkqgV.js +122 -0
- package/dist/util-BvWlkqgV.js.map +1 -0
- package/dist/worker.d.ts +406 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +1582 -0
- package/dist/worker.js.map +1 -0
- package/package.json +90 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import { a as stringify, i as parse, n as rpcParse, r as rpcStringify, t as compact } from "./util-BvWlkqgV.js";
|
|
2
|
+
import { EventSchema, HookSchema, MessageId, SPEC_VERSION_CURRENT, StepSchema, WorkflowRunSchema, parseQueueName } from "@workflow/world";
|
|
3
|
+
import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError } from "@workflow/errors";
|
|
4
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
5
|
+
import { monotonicFactory } from "ulid";
|
|
6
|
+
//#region src/config.ts
|
|
7
|
+
function resolveConfig(config) {
|
|
8
|
+
return {
|
|
9
|
+
fleetUrl: config?.fleetUrl ?? process.env.CELLD_FLEET_URL,
|
|
10
|
+
secret: config?.secret ?? process.env.CELLD_WORLD_SECRET,
|
|
11
|
+
env: config?.env,
|
|
12
|
+
deploymentId: config?.deploymentId ?? process.env.CELLD_DEPLOYMENT_ID ?? "celld-default",
|
|
13
|
+
baseUrl: config?.baseUrl,
|
|
14
|
+
queueShards: config?.queueShards ?? 1,
|
|
15
|
+
readPollMs: config?.readPollMs ?? 250,
|
|
16
|
+
rpcTimeoutMs: config?.rpcTimeoutMs ?? 3e4
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/remote/errors.ts
|
|
21
|
+
/**
|
|
22
|
+
* Reconstruct a typed error from a router error payload. `@workflow/errors`
|
|
23
|
+
* matches with name-based `.is()` checks, so a WorkflowWorldError with the
|
|
24
|
+
* original name (and status) restored behaves identically to the source
|
|
25
|
+
* error for every consumer in the workflow runtime.
|
|
26
|
+
*/
|
|
27
|
+
function reconstructError(wire, httpStatus) {
|
|
28
|
+
const message = wire?.message ?? `world-celld rpc failed with HTTP ${httpStatus}`;
|
|
29
|
+
const error = new WorkflowWorldError(message, { status: wire?.status ?? httpStatus });
|
|
30
|
+
if (wire?.name) error.name = wire.name;
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
33
|
+
/** Transport-level failure (fleet unreachable, malformed response). */
|
|
34
|
+
var FleetTransportError = class extends Error {
|
|
35
|
+
cause;
|
|
36
|
+
constructor(message, cause) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.cause = cause;
|
|
39
|
+
this.name = "FleetTransportError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/remote/rpc-client.ts
|
|
44
|
+
/** Statuses that mean "the fleet couldn't take the request" (safe to retry reads). */
|
|
45
|
+
const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
46
|
+
502,
|
|
47
|
+
503,
|
|
48
|
+
504
|
|
49
|
+
]);
|
|
50
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
51
|
+
function delayMs(ms) {
|
|
52
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Invoke one DO method through the worker router.
|
|
56
|
+
*
|
|
57
|
+
* Mutating calls run exactly once from the client's perspective: a blind
|
|
58
|
+
* retry of e.g. applyEvent would turn commit-then-network-fail into a
|
|
59
|
+
* spurious ENTITY_CONFLICT. The workflow runtime already gets at-least-once
|
|
60
|
+
* via queue redelivery. Read-only calls retry on transport failures.
|
|
61
|
+
*/
|
|
62
|
+
async function callDO(transport, binding, name, method, args, opts) {
|
|
63
|
+
const doFetch = transport.fetchImpl ?? fetch;
|
|
64
|
+
const url = `${transport.fleetUrl.replace(/\/$/, "")}/v1/rpc/${binding}/${encodeURIComponent(name)}/${method}`;
|
|
65
|
+
const attempts = opts?.idempotent ? 3 : 1;
|
|
66
|
+
let lastError;
|
|
67
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
68
|
+
if (attempt > 1) await delayMs(100 * attempt + Math.random() * 200);
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await doFetch(url, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
"content-type": "application/json",
|
|
75
|
+
authorization: `Bearer ${transport.secret}`
|
|
76
|
+
},
|
|
77
|
+
body: rpcStringify(args),
|
|
78
|
+
signal: AbortSignal.timeout(Math.max(1, transport.timeoutMs ?? DEFAULT_TIMEOUT_MS))
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
lastError = new FleetTransportError(`world-celld: fleet unreachable at ${url}`, error);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (response.ok) try {
|
|
85
|
+
return rpcParse(await response.text());
|
|
86
|
+
} catch (error) {
|
|
87
|
+
lastError = new FleetTransportError(`world-celld: malformed successful response from ${url}`, error);
|
|
88
|
+
if (attempt < attempts) continue;
|
|
89
|
+
throw lastError;
|
|
90
|
+
}
|
|
91
|
+
const text = await response.text();
|
|
92
|
+
let wire;
|
|
93
|
+
try {
|
|
94
|
+
wire = JSON.parse(text).error;
|
|
95
|
+
} catch {
|
|
96
|
+
wire = { message: text || `HTTP ${response.status}` };
|
|
97
|
+
}
|
|
98
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < attempts) {
|
|
99
|
+
lastError = reconstructError(wire, response.status);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
throw reconstructError(wire, response.status);
|
|
103
|
+
}
|
|
104
|
+
throw lastError instanceof Error ? lastError : new FleetTransportError("world-celld: rpc failed", lastError);
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/remote/namespaces.ts
|
|
108
|
+
const RUNS = {
|
|
109
|
+
methods: [
|
|
110
|
+
"applyEvent",
|
|
111
|
+
"getRun",
|
|
112
|
+
"getStep",
|
|
113
|
+
"getEvent",
|
|
114
|
+
"listEvents",
|
|
115
|
+
"listSteps",
|
|
116
|
+
"listHooks"
|
|
117
|
+
],
|
|
118
|
+
mutating: /* @__PURE__ */ new Set(["applyEvent"])
|
|
119
|
+
};
|
|
120
|
+
const STREAMS = {
|
|
121
|
+
methods: [
|
|
122
|
+
"writeChunk",
|
|
123
|
+
"closeStream",
|
|
124
|
+
"getChunks",
|
|
125
|
+
"getInfo",
|
|
126
|
+
"registerStream",
|
|
127
|
+
"listStreams"
|
|
128
|
+
],
|
|
129
|
+
mutating: /* @__PURE__ */ new Set([
|
|
130
|
+
"writeChunk",
|
|
131
|
+
"closeStream",
|
|
132
|
+
"registerStream"
|
|
133
|
+
])
|
|
134
|
+
};
|
|
135
|
+
const QUEUE = {
|
|
136
|
+
methods: [
|
|
137
|
+
"enqueue",
|
|
138
|
+
"stats",
|
|
139
|
+
"listDeadLetters",
|
|
140
|
+
"redriveDeadLetter",
|
|
141
|
+
"purgeDeadLetters",
|
|
142
|
+
"rearmAlarm"
|
|
143
|
+
],
|
|
144
|
+
mutating: /* @__PURE__ */ new Set([
|
|
145
|
+
"enqueue",
|
|
146
|
+
"redriveDeadLetter",
|
|
147
|
+
"purgeDeadLetters",
|
|
148
|
+
"rearmAlarm"
|
|
149
|
+
])
|
|
150
|
+
};
|
|
151
|
+
function makeStub(transport, binding, name, spec) {
|
|
152
|
+
const stub = {};
|
|
153
|
+
for (const method of spec.methods) stub[method] = (...args) => callDO(transport, binding, name, method, args, { idempotent: !spec.mutating.has(method) });
|
|
154
|
+
return stub;
|
|
155
|
+
}
|
|
156
|
+
function makeNamespace(transport, binding, spec) {
|
|
157
|
+
return {
|
|
158
|
+
idFromName(name) {
|
|
159
|
+
return { toString: () => name };
|
|
160
|
+
},
|
|
161
|
+
get(id) {
|
|
162
|
+
return makeStub(transport, binding, id.toString(), spec);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** The IndexDO is a single well-known cell. */
|
|
167
|
+
const INDEX_CELL_NAME = "index";
|
|
168
|
+
function makeIndexNamespace(transport) {
|
|
169
|
+
const call = (method, args, idempotent) => callDO(transport, "index", INDEX_CELL_NAME, method, args, { idempotent });
|
|
170
|
+
return {
|
|
171
|
+
get: (key) => call("get", [key], true),
|
|
172
|
+
put: (key, value) => call("put", [key, value], false),
|
|
173
|
+
delete: (key) => call("delete", [key], false),
|
|
174
|
+
list: (options) => call("list", [options], true),
|
|
175
|
+
reserveHookToken: (token, owner) => call("reserveHookToken", [token, owner], false),
|
|
176
|
+
finalizeHookIndexes: (token, hookId, serializedHook, owner) => call("finalizeHookIndexes", [
|
|
177
|
+
token,
|
|
178
|
+
hookId,
|
|
179
|
+
serializedHook,
|
|
180
|
+
owner
|
|
181
|
+
], false),
|
|
182
|
+
releaseHookToken: (token, owner) => call("releaseHookToken", [token, owner], false),
|
|
183
|
+
deleteHookIndexes: (token, hookId, owner) => call("deleteHookIndexes", [
|
|
184
|
+
token,
|
|
185
|
+
hookId,
|
|
186
|
+
owner
|
|
187
|
+
], false)
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function createRemoteEnv(transport) {
|
|
191
|
+
return {
|
|
192
|
+
WORKFLOW_DB: makeNamespace(transport, "runs", RUNS),
|
|
193
|
+
WORKFLOW_STREAMS: makeNamespace(transport, "streams", STREAMS),
|
|
194
|
+
WORKFLOW_INDEX: makeIndexNamespace(transport),
|
|
195
|
+
WORKFLOW_QUEUE: makeNamespace(transport, "queue", QUEUE)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/vendor/shared/debug.ts
|
|
200
|
+
/**
|
|
201
|
+
* Creates a namespaced debug logger.
|
|
202
|
+
* Logs to stderr to avoid corrupting CLI JSON output on stdout.
|
|
203
|
+
*
|
|
204
|
+
* Control via WORKFLOW_DEBUG environment variable:
|
|
205
|
+
* WORKFLOW_DEBUG=1 // Enable all
|
|
206
|
+
* WORKFLOW_DEBUG=mysql-world // Specific namespace
|
|
207
|
+
* WORKFLOW_DEBUG=mysql-world,redis-world // Multiple namespaces
|
|
208
|
+
*
|
|
209
|
+
* Vendored from vinnymac/worlds packages/shared, modified: guarded for
|
|
210
|
+
* non-Node runtimes (celld/workerd), where `process` may be absent or
|
|
211
|
+
* stubbed — falls back to console.error.
|
|
212
|
+
*/
|
|
213
|
+
function createDebugLogger(namespace) {
|
|
214
|
+
return (...args) => {
|
|
215
|
+
const debug = typeof process === "undefined" ? void 0 : process.env?.WORKFLOW_DEBUG;
|
|
216
|
+
if (!debug) return;
|
|
217
|
+
if (!(debug === "1" || debug === "true" || debug === "*" || debug.split(",").some((ns) => ns.trim() === namespace))) return;
|
|
218
|
+
const prefix = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${namespace}]`;
|
|
219
|
+
const message = args.map((arg) => typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)).join(" ");
|
|
220
|
+
if (typeof process.stderr?.write === "function") process.stderr.write(`${prefix} ${message}\n`);
|
|
221
|
+
else console.error(`${prefix} ${message}`);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/util.ts
|
|
226
|
+
const debug = createDebugLogger("celld-world");
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/queue.ts
|
|
229
|
+
/**
|
|
230
|
+
* Queue implementation for the celld world.
|
|
231
|
+
*
|
|
232
|
+
* Vendored from vinnymac/worlds packages/world-cloudflare/src/queue.ts
|
|
233
|
+
* (Apache-2.0, see NOTICE), modified for celld:
|
|
234
|
+
*
|
|
235
|
+
* - The Cloudflare Queues producer is replaced by an `enqueue` RPC to a
|
|
236
|
+
* QueueDO cell (`q:<shard>`), which owns the message lifecycle: scheduling
|
|
237
|
+
* via durable alarms, delivery via outbound fetch, retries with capped
|
|
238
|
+
* backoff, and a dead-letter table.
|
|
239
|
+
* - The consumer-side claim-DO idempotency mechanism is gone: a queue cell is
|
|
240
|
+
* a single serialized writer, so idempotencyKey dedup happens inside the
|
|
241
|
+
* cell (at enqueue and across the delivery state machine).
|
|
242
|
+
* - `createQueueHandler` has ONE dialect — the x-vqs wire format the test
|
|
243
|
+
* pump has always used — extended so permanent errors surface as their
|
|
244
|
+
* HTTP statuses. QueueDO deliveries and the in-process test pump hit the
|
|
245
|
+
* exact same handler path.
|
|
246
|
+
*/
|
|
247
|
+
/**
|
|
248
|
+
* HTTP status codes that indicate permanent (non-retryable) failures.
|
|
249
|
+
* Deliveries with these statuses are dropped immediately instead of retried.
|
|
250
|
+
*
|
|
251
|
+
* - 404: Resource not found (e.g., run was deleted)
|
|
252
|
+
* - 409: Conflict (e.g., duplicate event that can't be replayed)
|
|
253
|
+
* - 410: Gone (e.g., run was already terminal)
|
|
254
|
+
* - 422: Unprocessable entity (e.g., invalid payload structure)
|
|
255
|
+
*/
|
|
256
|
+
const PERMANENT_ERROR_STATUSES = /* @__PURE__ */ new Set([
|
|
257
|
+
404,
|
|
258
|
+
409,
|
|
259
|
+
410,
|
|
260
|
+
422
|
|
261
|
+
]);
|
|
262
|
+
function isPermanentError(err) {
|
|
263
|
+
if (err instanceof WorkflowWorldError && err.status !== void 0) return PERMANENT_ERROR_STATUSES.has(err.status);
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
/** Caps at 60 seconds. */
|
|
267
|
+
function computeBackoff(attempt) {
|
|
268
|
+
return Math.min(60, 2 ** attempt);
|
|
269
|
+
}
|
|
270
|
+
const QUEUE_PATHNAMES = {
|
|
271
|
+
workflow: "flow",
|
|
272
|
+
step: "step"
|
|
273
|
+
};
|
|
274
|
+
function isTestMode() {
|
|
275
|
+
if (process.env.CELLD_QUEUE_MODE === "cells") return false;
|
|
276
|
+
return process.env.VITEST === "true" || process.env.NODE_ENV === "test";
|
|
277
|
+
}
|
|
278
|
+
function resolveBaseUrl(config) {
|
|
279
|
+
if (config.baseUrl) return config.baseUrl;
|
|
280
|
+
if (process.env.WORKFLOW_BASE_URL) return process.env.WORKFLOW_BASE_URL;
|
|
281
|
+
return `http://localhost:${process.env.PORT ?? "3000"}`;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* FNV-1a hash for shard selection. All messages sharing an idempotencyKey
|
|
285
|
+
* land on the same cell so dedup stays strictly consistent.
|
|
286
|
+
*/
|
|
287
|
+
function shardFor(key, shards) {
|
|
288
|
+
if (shards <= 1) return 0;
|
|
289
|
+
let hash = 2166136261;
|
|
290
|
+
for (let i = 0; i < key.length; i++) {
|
|
291
|
+
hash ^= key.charCodeAt(i);
|
|
292
|
+
hash = Math.imul(hash, 16777619);
|
|
293
|
+
}
|
|
294
|
+
return (hash >>> 0) % shards;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* In-process test pump. Holds two in-memory FIFOs and HTTP-dispatches
|
|
298
|
+
* envelopes to the user's server at
|
|
299
|
+
* `${baseUrl}/.well-known/workflow/v1/{flow|step}`.
|
|
300
|
+
*
|
|
301
|
+
* Mirrors world-local's idempotency semantics: messages are deduplicated on
|
|
302
|
+
* `idempotencyKey` while a message with the same key is in flight, and the
|
|
303
|
+
* key is released only when the message is fully handled (success or drop).
|
|
304
|
+
*
|
|
305
|
+
* Modified vs upstream: permanent-error statuses (404/409/410/422) from the
|
|
306
|
+
* handler drop the message immediately instead of burning retry budget —
|
|
307
|
+
* matching QueueDO's production semantics.
|
|
308
|
+
*/
|
|
309
|
+
function createTestPump(config) {
|
|
310
|
+
const httpTimeoutMs = config.httpTimeoutMs ?? 3e5;
|
|
311
|
+
const maxAttempts = config.maxAttempts ?? 5;
|
|
312
|
+
const baseBackoffMs = config.backoffDelayMs ?? 1e3;
|
|
313
|
+
const queues = {
|
|
314
|
+
flow: [],
|
|
315
|
+
step: []
|
|
316
|
+
};
|
|
317
|
+
const wakers = {
|
|
318
|
+
flow: [],
|
|
319
|
+
step: []
|
|
320
|
+
};
|
|
321
|
+
/** Inflight messageIds by idempotencyKey (world-local queue.js semantics). */
|
|
322
|
+
const inflightMessages = /* @__PURE__ */ new Map();
|
|
323
|
+
let running = false;
|
|
324
|
+
function release(envelope) {
|
|
325
|
+
if (envelope.idempotencyKey) inflightMessages.delete(envelope.idempotencyKey);
|
|
326
|
+
}
|
|
327
|
+
function enqueue(pathname, envelope) {
|
|
328
|
+
queues[pathname].push(envelope);
|
|
329
|
+
wakers[pathname].shift()?.();
|
|
330
|
+
}
|
|
331
|
+
async function take(pathname) {
|
|
332
|
+
const existing = queues[pathname].shift();
|
|
333
|
+
if (existing) return existing;
|
|
334
|
+
return new Promise((resolve) => {
|
|
335
|
+
wakers[pathname].push(() => resolve(queues[pathname].shift() ?? null));
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async function dispatch(envelope, pathname) {
|
|
339
|
+
const url = `${resolveBaseUrl(config)}/.well-known/workflow/v1/${pathname}`;
|
|
340
|
+
const response = await fetch(url, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: {
|
|
343
|
+
"content-type": "application/json",
|
|
344
|
+
"x-vqs-queue-name": envelope.queueName,
|
|
345
|
+
"x-vqs-message-id": envelope.messageId,
|
|
346
|
+
"x-vqs-message-attempt": String(envelope.attempt)
|
|
347
|
+
},
|
|
348
|
+
body: stringify(envelope.message),
|
|
349
|
+
signal: AbortSignal.timeout(httpTimeoutMs)
|
|
350
|
+
});
|
|
351
|
+
if (response.ok) {
|
|
352
|
+
release(envelope);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const text = await response.text();
|
|
356
|
+
if (response.status === 503) {
|
|
357
|
+
let parsed;
|
|
358
|
+
try {
|
|
359
|
+
parsed = JSON.parse(text);
|
|
360
|
+
} catch {
|
|
361
|
+
parsed = null;
|
|
362
|
+
}
|
|
363
|
+
const timeoutSeconds = parsed?.timeoutSeconds;
|
|
364
|
+
if (typeof timeoutSeconds === "number") {
|
|
365
|
+
setTimeout$1(timeoutSeconds * 1e3).then(() => enqueue(pathname, envelope));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (PERMANENT_ERROR_STATUSES.has(response.status)) {
|
|
370
|
+
release(envelope);
|
|
371
|
+
debug(`[world-celld test pump] dropping ${envelope.messageId}: permanent HTTP ${response.status}: ${text}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (envelope.attempt < maxAttempts) {
|
|
375
|
+
const next = {
|
|
376
|
+
...envelope,
|
|
377
|
+
attempt: envelope.attempt + 1
|
|
378
|
+
};
|
|
379
|
+
const backoff = baseBackoffMs * 2 ** (next.attempt - 1);
|
|
380
|
+
setTimeout$1(backoff).then(() => enqueue(pathname, next));
|
|
381
|
+
} else {
|
|
382
|
+
release(envelope);
|
|
383
|
+
console.error(`[world-celld test pump] dropping ${envelope.messageId} after ${envelope.attempt} attempts: HTTP ${response.status}: ${text}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function loop(pathname) {
|
|
387
|
+
while (true) {
|
|
388
|
+
if (!running) return;
|
|
389
|
+
const envelope = await take(pathname);
|
|
390
|
+
if (!envelope) continue;
|
|
391
|
+
try {
|
|
392
|
+
await dispatch(envelope, pathname);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
release(envelope);
|
|
395
|
+
console.error(`[world-celld test pump] dispatch error on ${pathname}:`, err);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
/** Returns the inflight messageId when the key is already claimed. */
|
|
401
|
+
inflight(idempotencyKey) {
|
|
402
|
+
return idempotencyKey ? inflightMessages.get(idempotencyKey) : void 0;
|
|
403
|
+
},
|
|
404
|
+
push(pathname, envelope, delaySeconds) {
|
|
405
|
+
if (envelope.idempotencyKey) inflightMessages.set(envelope.idempotencyKey, MessageId.parse(envelope.messageId));
|
|
406
|
+
if (delaySeconds && delaySeconds > 0) setTimeout$1(delaySeconds * 1e3).then(() => enqueue(pathname, envelope));
|
|
407
|
+
else enqueue(pathname, envelope);
|
|
408
|
+
},
|
|
409
|
+
async start() {
|
|
410
|
+
if (running) return;
|
|
411
|
+
running = true;
|
|
412
|
+
loop("flow");
|
|
413
|
+
loop("step");
|
|
414
|
+
},
|
|
415
|
+
stop() {
|
|
416
|
+
running = false;
|
|
417
|
+
for (const list of Object.values(wakers)) for (const w of list) w();
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function createQueue(config) {
|
|
422
|
+
const { env, deploymentId } = config;
|
|
423
|
+
const queueShards = config.queueShards ?? 1;
|
|
424
|
+
const generateMessageId = monotonicFactory();
|
|
425
|
+
const testPump = createTestPump(config);
|
|
426
|
+
const getQueueStub = (shard) => {
|
|
427
|
+
const id = env.WORKFLOW_QUEUE.idFromName(`q:${shard}`);
|
|
428
|
+
return env.WORKFLOW_QUEUE.get(id);
|
|
429
|
+
};
|
|
430
|
+
return {
|
|
431
|
+
async queue(queueName, message, opts) {
|
|
432
|
+
const { kind } = parseQueueName(queueName);
|
|
433
|
+
if (isTestMode()) {
|
|
434
|
+
const existing = testPump.inflight(opts?.idempotencyKey);
|
|
435
|
+
if (existing) return { messageId: existing };
|
|
436
|
+
const messageId = MessageId.parse(`msg_${generateMessageId()}`);
|
|
437
|
+
testPump.push(QUEUE_PATHNAMES[kind], {
|
|
438
|
+
messageId,
|
|
439
|
+
queueName,
|
|
440
|
+
attempt: 1,
|
|
441
|
+
message,
|
|
442
|
+
idempotencyKey: opts?.idempotencyKey
|
|
443
|
+
}, opts?.delaySeconds);
|
|
444
|
+
return { messageId };
|
|
445
|
+
}
|
|
446
|
+
const messageId = MessageId.parse(`msg_${generateMessageId()}`);
|
|
447
|
+
const shard = shardFor(opts?.idempotencyKey ?? messageId, queueShards);
|
|
448
|
+
const outcome = await getQueueStub(shard).enqueue({
|
|
449
|
+
messageId,
|
|
450
|
+
queueName,
|
|
451
|
+
pathname: QUEUE_PATHNAMES[kind],
|
|
452
|
+
body: stringify(message),
|
|
453
|
+
idempotencyKey: opts?.idempotencyKey,
|
|
454
|
+
delaySeconds: opts?.delaySeconds,
|
|
455
|
+
config: {
|
|
456
|
+
targetBaseUrl: resolveBaseUrl(config),
|
|
457
|
+
queueShards
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
if (!outcome.ok) throw new WorkflowWorldError(outcome.message, { status: 409 });
|
|
461
|
+
return { messageId: MessageId.parse(outcome.messageId) };
|
|
462
|
+
},
|
|
463
|
+
createQueueHandler(queueNamePrefix, handler) {
|
|
464
|
+
return async (req) => {
|
|
465
|
+
const reqQueueName = req.headers.get("x-vqs-queue-name");
|
|
466
|
+
const reqMessageId = req.headers.get("x-vqs-message-id");
|
|
467
|
+
const attemptStr = req.headers.get("x-vqs-message-attempt");
|
|
468
|
+
if (!reqQueueName || !reqMessageId || !attemptStr || !req.body) return Response.json({ error: "Missing required headers or body" }, { status: 400 });
|
|
469
|
+
if (!reqQueueName.startsWith(queueNamePrefix)) return Response.json({ error: "Unhandled queue" }, { status: 400 });
|
|
470
|
+
const attempt = Number.parseInt(attemptStr, 10);
|
|
471
|
+
try {
|
|
472
|
+
const result = await handler(parse(await req.text()), {
|
|
473
|
+
attempt,
|
|
474
|
+
queueName: reqQueueName,
|
|
475
|
+
messageId: reqMessageId
|
|
476
|
+
});
|
|
477
|
+
if (result && typeof result.timeoutSeconds === "number") return Response.json({ timeoutSeconds: result.timeoutSeconds }, {
|
|
478
|
+
status: 503,
|
|
479
|
+
headers: { "Retry-After": String(result.timeoutSeconds) }
|
|
480
|
+
});
|
|
481
|
+
return Response.json({ ok: true });
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (isPermanentError(error)) {
|
|
484
|
+
debug("[world-celld queue handler] permanent error:", {
|
|
485
|
+
status: error.status,
|
|
486
|
+
error: String(error)
|
|
487
|
+
});
|
|
488
|
+
return Response.json({
|
|
489
|
+
error: String(error),
|
|
490
|
+
permanent: true
|
|
491
|
+
}, { status: error.status });
|
|
492
|
+
}
|
|
493
|
+
const backoffSeconds = computeBackoff(attempt);
|
|
494
|
+
debug("[world-celld queue handler] transient error, will retry:", {
|
|
495
|
+
error: String(error),
|
|
496
|
+
backoffSeconds
|
|
497
|
+
});
|
|
498
|
+
return Response.json({
|
|
499
|
+
error: String(error),
|
|
500
|
+
retryAfter: backoffSeconds
|
|
501
|
+
}, {
|
|
502
|
+
status: 500,
|
|
503
|
+
headers: { "Retry-After": String(backoffSeconds) }
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
async getDeploymentId() {
|
|
509
|
+
return deploymentId;
|
|
510
|
+
},
|
|
511
|
+
async start() {
|
|
512
|
+
if (isTestMode()) await testPump.start();
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
//#endregion
|
|
517
|
+
//#region src/storage.ts
|
|
518
|
+
function sortableTimestamp(date) {
|
|
519
|
+
return date.getTime().toString().padStart(13, "0");
|
|
520
|
+
}
|
|
521
|
+
function workflowRunIndexKey(run) {
|
|
522
|
+
return `run:${run.workflowName}:${sortableTimestamp(run.createdAt)}:${run.runId}`;
|
|
523
|
+
}
|
|
524
|
+
function globalRunIndexKey(run) {
|
|
525
|
+
return `runall:${sortableTimestamp(run.createdAt)}:${run.runId}`;
|
|
526
|
+
}
|
|
527
|
+
function hookOwner(hook) {
|
|
528
|
+
return {
|
|
529
|
+
runId: hook.runId,
|
|
530
|
+
hookId: hook.hookId
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Filter data based on ResolveData parameter.
|
|
535
|
+
* When resolveData is 'none', strips specified keys to reduce data transfer.
|
|
536
|
+
*/
|
|
537
|
+
function filterData(data, resolveData, keysToStrip) {
|
|
538
|
+
if (resolveData === "none") {
|
|
539
|
+
const newData = { ...data };
|
|
540
|
+
for (const key of keysToStrip) if (key in newData) delete newData[key];
|
|
541
|
+
return newData;
|
|
542
|
+
}
|
|
543
|
+
return data;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Filter hook data based on resolveData parameter
|
|
547
|
+
*/
|
|
548
|
+
function filterHookData(hook, resolveData) {
|
|
549
|
+
if (resolveData === "none" && "metadata" in hook) {
|
|
550
|
+
const { metadata: _, ...rest } = hook;
|
|
551
|
+
return {
|
|
552
|
+
metadata: void 0,
|
|
553
|
+
...rest
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return hook;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Convert a structured applyEvent failure back into the typed error the
|
|
560
|
+
* runtime matches on (via error-name based `.is()` checks). Typed errors
|
|
561
|
+
* cannot cross the DO RPC boundary intact, which is why the DO returns
|
|
562
|
+
* outcome objects instead of throwing.
|
|
563
|
+
*/
|
|
564
|
+
function throwOutcomeError(outcome, runId, data) {
|
|
565
|
+
switch (outcome.code) {
|
|
566
|
+
case "RUN_NOT_FOUND": throw new WorkflowRunNotFoundError(runId);
|
|
567
|
+
case "STEP_NOT_FOUND": throw new WorkflowWorldError(outcome.message, { status: 404 });
|
|
568
|
+
case "HOOK_NOT_FOUND": throw new HookNotFoundError(data.correlationId ?? runId);
|
|
569
|
+
case "ENTITY_CONFLICT": throw new EntityConflictError(outcome.message);
|
|
570
|
+
case "RUN_EXPIRED": throw new RunExpiredError(outcome.message);
|
|
571
|
+
case "TOO_EARLY": throw new TooEarlyError(outcome.message, { retryAfter: outcome.retryAfterSeconds });
|
|
572
|
+
case "RUN_NOT_SUPPORTED": throw new RunNotSupportedError(outcome.runSpecVersion ?? 0, SPEC_VERSION_CURRENT);
|
|
573
|
+
case "LEGACY_RUN_NOT_SUPPORTED": throw new WorkflowWorldError(outcome.message, { status: 422 });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const parseRun = (run) => WorkflowRunSchema.parse(compact(run));
|
|
577
|
+
const parseStep = (step) => StepSchema.parse(compact(step));
|
|
578
|
+
const parseHook = (hook) => HookSchema.parse(compact(hook));
|
|
579
|
+
const parseEvent = (event) => EventSchema.parse(compact(event));
|
|
580
|
+
function createStorage(config) {
|
|
581
|
+
const { env } = config;
|
|
582
|
+
const ulid = monotonicFactory();
|
|
583
|
+
const getRunDO = (runId) => {
|
|
584
|
+
const id = env.WORKFLOW_DB.idFromName(runId);
|
|
585
|
+
return env.WORKFLOW_DB.get(id);
|
|
586
|
+
};
|
|
587
|
+
const runsGet = async (runId, params) => {
|
|
588
|
+
const run = await getRunDO(runId).getRun();
|
|
589
|
+
if (!run) throw new WorkflowRunNotFoundError(runId);
|
|
590
|
+
return filterData(parseRun(run), params?.resolveData, ["input", "output"]);
|
|
591
|
+
};
|
|
592
|
+
return {
|
|
593
|
+
runs: {
|
|
594
|
+
get: runsGet,
|
|
595
|
+
async list(params) {
|
|
596
|
+
const limit = params?.pagination?.limit ?? 20;
|
|
597
|
+
const prefix = params?.workflowName ? `run:${params.workflowName}:` : "runall:";
|
|
598
|
+
const reverse = params?.pagination?.sortOrder === "desc";
|
|
599
|
+
const matches = [];
|
|
600
|
+
let scanCursor = params?.pagination?.cursor;
|
|
601
|
+
let exhausted = false;
|
|
602
|
+
while (matches.length <= limit && !exhausted) {
|
|
603
|
+
const kvList = await env.WORKFLOW_INDEX.list({
|
|
604
|
+
prefix,
|
|
605
|
+
limit: Math.min(1e3, Math.max(50, limit)),
|
|
606
|
+
cursor: scanCursor,
|
|
607
|
+
reverse
|
|
608
|
+
});
|
|
609
|
+
if (kvList.keys.length === 0) {
|
|
610
|
+
exhausted = true;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
for (const key of kvList.keys) {
|
|
614
|
+
const meta = await env.WORKFLOW_INDEX.get(key.name);
|
|
615
|
+
if (!meta) continue;
|
|
616
|
+
const { runId } = JSON.parse(meta);
|
|
617
|
+
try {
|
|
618
|
+
const run = await runsGet(runId, { resolveData: params?.resolveData });
|
|
619
|
+
if (!params?.status || run.status === params.status) {
|
|
620
|
+
matches.push({
|
|
621
|
+
key: key.name,
|
|
622
|
+
run
|
|
623
|
+
});
|
|
624
|
+
if (matches.length > limit) break;
|
|
625
|
+
}
|
|
626
|
+
} catch (error) {
|
|
627
|
+
if (!WorkflowRunNotFoundError.is(error)) throw error;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
exhausted = kvList.list_complete;
|
|
631
|
+
scanCursor = kvList.cursor ?? kvList.keys.at(-1)?.name;
|
|
632
|
+
}
|
|
633
|
+
const hasMore = matches.length > limit;
|
|
634
|
+
const page = matches.slice(0, limit);
|
|
635
|
+
return {
|
|
636
|
+
data: page.map(({ run }) => run),
|
|
637
|
+
cursor: hasMore ? page.at(-1)?.key ?? null : null,
|
|
638
|
+
hasMore
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
events: {
|
|
643
|
+
async create(runId, data, _params) {
|
|
644
|
+
let effectiveRunId;
|
|
645
|
+
if (data.eventType === "run_created" && (!runId || runId === "")) effectiveRunId = `wrun_${ulid()}`;
|
|
646
|
+
else if (!runId) throw new WorkflowWorldError("runId is required for non-run_created events", { status: 400 });
|
|
647
|
+
else effectiveRunId = runId;
|
|
648
|
+
const stub = getRunDO(effectiveRunId);
|
|
649
|
+
let tokenHolder;
|
|
650
|
+
let hookReservation;
|
|
651
|
+
if (data.eventType === "hook_created") {
|
|
652
|
+
hookReservation = {
|
|
653
|
+
runId: effectiveRunId,
|
|
654
|
+
hookId: data.correlationId
|
|
655
|
+
};
|
|
656
|
+
const reservation = await env.WORKFLOW_INDEX.reserveHookToken(data.eventData.token, hookReservation);
|
|
657
|
+
tokenHolder = reservation.claimed ? null : reservation.holder;
|
|
658
|
+
}
|
|
659
|
+
let outcome;
|
|
660
|
+
try {
|
|
661
|
+
outcome = await stub.applyEvent({
|
|
662
|
+
runId: effectiveRunId,
|
|
663
|
+
data,
|
|
664
|
+
tokenHolder
|
|
665
|
+
});
|
|
666
|
+
} catch (error) {
|
|
667
|
+
if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
if (!outcome.ok) {
|
|
671
|
+
if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
|
|
672
|
+
throwOutcomeError(outcome, effectiveRunId, data);
|
|
673
|
+
}
|
|
674
|
+
if (outcome.run) {
|
|
675
|
+
const meta = JSON.stringify({
|
|
676
|
+
runId: effectiveRunId,
|
|
677
|
+
createdAt: outcome.run.createdAt.toISOString(),
|
|
678
|
+
status: outcome.run.status
|
|
679
|
+
});
|
|
680
|
+
await env.WORKFLOW_INDEX.put(workflowRunIndexKey(outcome.run), meta);
|
|
681
|
+
await env.WORKFLOW_INDEX.put(globalRunIndexKey(outcome.run), meta);
|
|
682
|
+
}
|
|
683
|
+
if (outcome.hookToIndex) {
|
|
684
|
+
const serialized = stringify(outcome.hookToIndex);
|
|
685
|
+
await env.WORKFLOW_INDEX.finalizeHookIndexes(outcome.hookToIndex.token, outcome.hookToIndex.hookId, serialized, hookOwner(outcome.hookToIndex));
|
|
686
|
+
} else if (hookReservation && data.eventType === "hook_created") await env.WORKFLOW_INDEX.releaseHookToken(data.eventData.token, hookReservation);
|
|
687
|
+
for (const released of outcome.releasedHooks) await env.WORKFLOW_INDEX.deleteHookIndexes(released.token, released.hookId, {
|
|
688
|
+
runId: effectiveRunId,
|
|
689
|
+
hookId: released.hookId
|
|
690
|
+
});
|
|
691
|
+
if (outcome.event?.correlationId) {
|
|
692
|
+
const correlationKey = `correlation:${encodeURIComponent(outcome.event.correlationId)}:${sortableTimestamp(outcome.event.createdAt)}:${outcome.event.eventId}:${effectiveRunId}`;
|
|
693
|
+
await env.WORKFLOW_INDEX.put(correlationKey, JSON.stringify({
|
|
694
|
+
runId: effectiveRunId,
|
|
695
|
+
eventId: outcome.event.eventId
|
|
696
|
+
}));
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
event: outcome.event,
|
|
700
|
+
run: outcome.run,
|
|
701
|
+
step: outcome.step,
|
|
702
|
+
hook: outcome.hook,
|
|
703
|
+
events: outcome.events
|
|
704
|
+
};
|
|
705
|
+
},
|
|
706
|
+
async get(runId, eventId, _params) {
|
|
707
|
+
const event = await getRunDO(runId).getEvent(eventId);
|
|
708
|
+
if (!event) throw new WorkflowWorldError(`Event not found: ${eventId}`, { status: 404 });
|
|
709
|
+
return parseEvent(event);
|
|
710
|
+
},
|
|
711
|
+
async list(params) {
|
|
712
|
+
const { runId } = params;
|
|
713
|
+
const limit = params?.pagination?.limit ?? 100;
|
|
714
|
+
const result = await getRunDO(runId).listEvents({
|
|
715
|
+
limit,
|
|
716
|
+
cursor: params?.pagination?.cursor || void 0,
|
|
717
|
+
sortOrder: params.pagination?.sortOrder || "asc"
|
|
718
|
+
});
|
|
719
|
+
return {
|
|
720
|
+
data: result.data.map(parseEvent),
|
|
721
|
+
cursor: result.cursor,
|
|
722
|
+
hasMore: result.hasMore
|
|
723
|
+
};
|
|
724
|
+
},
|
|
725
|
+
async listByCorrelationId(params) {
|
|
726
|
+
const limit = params.pagination?.limit ?? 100;
|
|
727
|
+
const prefix = `correlation:${encodeURIComponent(params.correlationId)}:`;
|
|
728
|
+
const listed = await env.WORKFLOW_INDEX.list({
|
|
729
|
+
prefix,
|
|
730
|
+
limit,
|
|
731
|
+
cursor: params.pagination?.cursor,
|
|
732
|
+
reverse: params.pagination?.sortOrder === "desc"
|
|
733
|
+
});
|
|
734
|
+
return {
|
|
735
|
+
data: (await Promise.all(listed.keys.map(async ({ name }) => {
|
|
736
|
+
const raw = await env.WORKFLOW_INDEX.get(name);
|
|
737
|
+
if (!raw) return null;
|
|
738
|
+
const { runId, eventId } = JSON.parse(raw);
|
|
739
|
+
return getRunDO(runId).getEvent(eventId);
|
|
740
|
+
}))).filter((event) => event !== null).map(parseEvent),
|
|
741
|
+
cursor: listed.list_complete ? null : listed.cursor ?? null,
|
|
742
|
+
hasMore: !listed.list_complete
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
steps: {
|
|
747
|
+
async get(runId, stepId, params) {
|
|
748
|
+
if (!runId) throw new WorkflowWorldError("runId is required for Cloudflare step lookup", { status: 400 });
|
|
749
|
+
const step = await getRunDO(runId).getStep(stepId);
|
|
750
|
+
if (!step) throw new WorkflowWorldError(`Step not found: ${stepId}`, { status: 404 });
|
|
751
|
+
return filterData(parseStep(step), params?.resolveData, ["input", "output"]);
|
|
752
|
+
},
|
|
753
|
+
async list(params) {
|
|
754
|
+
const { runId } = params;
|
|
755
|
+
const limit = params?.pagination?.limit ?? 20;
|
|
756
|
+
const result = await getRunDO(runId).listSteps({
|
|
757
|
+
limit,
|
|
758
|
+
cursor: params?.pagination?.cursor || void 0,
|
|
759
|
+
sortOrder: params?.pagination?.sortOrder ?? "asc"
|
|
760
|
+
});
|
|
761
|
+
return {
|
|
762
|
+
data: result.data.map((s) => filterData(parseStep(s), params?.resolveData, ["input", "output"])),
|
|
763
|
+
cursor: result.cursor,
|
|
764
|
+
hasMore: result.hasMore
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
hooks: {
|
|
769
|
+
async get(hookId, params) {
|
|
770
|
+
const raw = await env.WORKFLOW_INDEX.get(`hookid:${hookId}`);
|
|
771
|
+
if (!raw) throw new HookNotFoundError(hookId);
|
|
772
|
+
return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
|
|
773
|
+
},
|
|
774
|
+
async getByToken(token, params) {
|
|
775
|
+
const raw = await env.WORKFLOW_INDEX.get(`hook:${token}`);
|
|
776
|
+
if (!raw) throw new HookNotFoundError(token);
|
|
777
|
+
return filterHookData(parseHook(parse(raw)), params?.resolveData ?? "all");
|
|
778
|
+
},
|
|
779
|
+
async list(params) {
|
|
780
|
+
if (!params.runId) throw new WorkflowWorldError("runId is required for listing hooks", { status: 400 });
|
|
781
|
+
const runId = params.runId;
|
|
782
|
+
const limit = params?.pagination?.limit ?? 100;
|
|
783
|
+
const result = await getRunDO(runId).listHooks({
|
|
784
|
+
limit,
|
|
785
|
+
cursor: params?.pagination?.cursor || void 0,
|
|
786
|
+
sortOrder: params?.pagination?.sortOrder ?? "asc"
|
|
787
|
+
});
|
|
788
|
+
return {
|
|
789
|
+
data: result.data.map((h) => filterHookData(parseHook(h), params?.resolveData ?? "all")),
|
|
790
|
+
cursor: result.cursor,
|
|
791
|
+
hasMore: result.hasMore
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/streamer.ts
|
|
799
|
+
/** Default poll interval while waiting for new chunks on a live stream. */
|
|
800
|
+
const READ_POLL_MS = 100;
|
|
801
|
+
/** Chunks fetched per DO round-trip while reading. */
|
|
802
|
+
const READ_BATCH_SIZE = 32;
|
|
803
|
+
/** Default / maximum page sizes for getStreamChunks. */
|
|
804
|
+
const DEFAULT_CHUNK_PAGE_SIZE = 32;
|
|
805
|
+
const MAX_CHUNK_PAGE_SIZE = 32;
|
|
806
|
+
function toBytes(chunk) {
|
|
807
|
+
return typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
808
|
+
}
|
|
809
|
+
function createStreamer(config) {
|
|
810
|
+
const { env } = config;
|
|
811
|
+
const readPollMs = config.readPollMs ?? READ_POLL_MS;
|
|
812
|
+
const getStreamDO = (streamName) => {
|
|
813
|
+
const id = env.WORKFLOW_STREAMS.idFromName(`stream:${streamName}`);
|
|
814
|
+
return env.WORKFLOW_STREAMS.get(id);
|
|
815
|
+
};
|
|
816
|
+
const getRunRegistryDO = (runId) => {
|
|
817
|
+
const id = env.WORKFLOW_STREAMS.idFromName(`run-streams:${runId}`);
|
|
818
|
+
return env.WORKFLOW_STREAMS.get(id);
|
|
819
|
+
};
|
|
820
|
+
/** Per-isolate cache so each (runId, stream) pair registers only once. */
|
|
821
|
+
const registeredStreams = /* @__PURE__ */ new Set();
|
|
822
|
+
async function registerStreamForRun(name, runId) {
|
|
823
|
+
const cacheKey = `${runId}\0${name}`;
|
|
824
|
+
if (registeredStreams.has(cacheKey)) return;
|
|
825
|
+
await getRunRegistryDO(runId).registerStream(name);
|
|
826
|
+
registeredStreams.add(cacheKey);
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
async writeToStream(name, runId, chunk) {
|
|
830
|
+
await registerStreamForRun(name, await runId);
|
|
831
|
+
await getStreamDO(name).writeChunk(toBytes(chunk));
|
|
832
|
+
},
|
|
833
|
+
async closeStream(name, runId) {
|
|
834
|
+
await registerStreamForRun(name, await runId);
|
|
835
|
+
await getStreamDO(name).closeStream();
|
|
836
|
+
},
|
|
837
|
+
async readFromStream(name, startIndex = 0) {
|
|
838
|
+
const stub = getStreamDO(name);
|
|
839
|
+
let nextIndex;
|
|
840
|
+
if (startIndex < 0) {
|
|
841
|
+
const info = await stub.getInfo();
|
|
842
|
+
nextIndex = Math.max(0, info.tailIndex + 1 + startIndex);
|
|
843
|
+
} else nextIndex = startIndex;
|
|
844
|
+
let cancelled = false;
|
|
845
|
+
return new ReadableStream({
|
|
846
|
+
async pull(controller) {
|
|
847
|
+
while (true) {
|
|
848
|
+
if (cancelled) return;
|
|
849
|
+
const { chunks, done } = await stub.getChunks({
|
|
850
|
+
startIndex: nextIndex,
|
|
851
|
+
limit: READ_BATCH_SIZE
|
|
852
|
+
});
|
|
853
|
+
if (chunks.length > 0) {
|
|
854
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
855
|
+
nextIndex += chunks.length;
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (done) {
|
|
859
|
+
controller.close();
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
await new Promise((resolve) => setTimeout(resolve, readPollMs));
|
|
863
|
+
}
|
|
864
|
+
},
|
|
865
|
+
cancel() {
|
|
866
|
+
cancelled = true;
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
},
|
|
870
|
+
async listStreamsByRunId(runId) {
|
|
871
|
+
return getRunRegistryDO(runId).listStreams();
|
|
872
|
+
},
|
|
873
|
+
async getStreamChunks(name, _runId, options) {
|
|
874
|
+
const limit = Math.min(options?.limit ?? DEFAULT_CHUNK_PAGE_SIZE, MAX_CHUNK_PAGE_SIZE);
|
|
875
|
+
const startIndex = options?.cursor ? Number.parseInt(options.cursor, 10) : 0;
|
|
876
|
+
if (Number.isNaN(startIndex) || startIndex < 0) throw new Error(`Invalid stream cursor: ${options?.cursor}`);
|
|
877
|
+
const result = await getStreamDO(name).getChunks({
|
|
878
|
+
startIndex,
|
|
879
|
+
limit: limit + 1
|
|
880
|
+
});
|
|
881
|
+
const hasMore = result.chunks.length > limit;
|
|
882
|
+
return {
|
|
883
|
+
data: result.chunks.slice(0, limit).map((chunk, offset) => ({
|
|
884
|
+
index: startIndex + offset,
|
|
885
|
+
data: chunk
|
|
886
|
+
})),
|
|
887
|
+
cursor: hasMore ? String(startIndex + limit) : null,
|
|
888
|
+
hasMore,
|
|
889
|
+
done: result.done
|
|
890
|
+
};
|
|
891
|
+
},
|
|
892
|
+
async getStreamInfo(name, _runId) {
|
|
893
|
+
return getStreamDO(name).getInfo();
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
//#endregion
|
|
898
|
+
//#region src/index.ts
|
|
899
|
+
/**
|
|
900
|
+
* @ewhauser/world-celld — celld World implementation for the Vercel Workflow
|
|
901
|
+
* DevKit. Modeled on vinnymac/worlds packages/world-cloudflare/src/index.ts
|
|
902
|
+
* (Apache-2.0, see NOTICE).
|
|
903
|
+
*/
|
|
904
|
+
function createCelldWorld(config) {
|
|
905
|
+
const resolved = resolveConfig(config);
|
|
906
|
+
let env = resolved.env;
|
|
907
|
+
if (!env) {
|
|
908
|
+
const globalEnv = globalThis.CELLD_ENV;
|
|
909
|
+
if (globalEnv) env = globalEnv;
|
|
910
|
+
}
|
|
911
|
+
if (!env) {
|
|
912
|
+
if (resolved.fleetUrl) {
|
|
913
|
+
if (!resolved.secret) throw new Error("world-celld: config.secret (or CELLD_WORLD_SECRET) is required with fleetUrl");
|
|
914
|
+
env = createRemoteEnv({
|
|
915
|
+
fleetUrl: resolved.fleetUrl,
|
|
916
|
+
secret: resolved.secret,
|
|
917
|
+
timeoutMs: resolved.rpcTimeoutMs
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (!env) throw new Error("celld environment not configured. Provide config.fleetUrl + config.secret (or CELLD_FLEET_URL / CELLD_WORLD_SECRET), or pass config.env with WORKFLOW_DB, WORKFLOW_INDEX, WORKFLOW_QUEUE, WORKFLOW_STREAMS");
|
|
922
|
+
const storage = createStorage({
|
|
923
|
+
env: {
|
|
924
|
+
WORKFLOW_DB: env.WORKFLOW_DB,
|
|
925
|
+
WORKFLOW_INDEX: env.WORKFLOW_INDEX
|
|
926
|
+
},
|
|
927
|
+
deploymentId: resolved.deploymentId
|
|
928
|
+
});
|
|
929
|
+
const queue = createQueue({
|
|
930
|
+
env: { WORKFLOW_QUEUE: env.WORKFLOW_QUEUE },
|
|
931
|
+
deploymentId: resolved.deploymentId,
|
|
932
|
+
baseUrl: resolved.baseUrl,
|
|
933
|
+
queueShards: resolved.queueShards
|
|
934
|
+
});
|
|
935
|
+
const streamer = createStreamer({
|
|
936
|
+
env: { WORKFLOW_STREAMS: env.WORKFLOW_STREAMS },
|
|
937
|
+
readPollMs: resolved.readPollMs
|
|
938
|
+
});
|
|
939
|
+
return {
|
|
940
|
+
...storage,
|
|
941
|
+
...queue,
|
|
942
|
+
...streamer,
|
|
943
|
+
specVersion: SPEC_VERSION_CURRENT
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
export { createCelldWorld, createCelldWorld as createWorld };
|
|
948
|
+
|
|
949
|
+
//# sourceMappingURL=index.js.map
|