@ewhauser/world-celld 0.5.0 → 0.7.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/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { A as MAX_STREAM_BATCH_BYTES, B as decodeStreamWriteResult, C as boundedIntegerOption, F as MAX_STREAM_READ_CHUNKS, H as encodeStreamWriteBatch, I as MAX_STREAM_WRITE_CHUNKS, L as STREAM_BATCH_CONTENT_TYPE, M as MAX_STREAM_ERROR_BYTES, N as MAX_STREAM_LONG_POLL_MS, O as queueDelayDeadline, P as MAX_STREAM_READ_BYTES, R as decodeStreamReadResult, W as normalizeStreamError, b as MAX_QUEUE_DELIVERY_TIMEOUT_MS, d as rpcParse, f as rpcStringify, h as createWorkflowIndex, j as MAX_STREAM_CHUNK_BYTES, k as strictIntegerSetting, m as stringify, p as parse, r as nativeQueueDelaySeconds, u as compact, w as checkedQueueTimestampAdd, x as MAX_QUEUE_DERIVED_DEADLINE_HEADROOM_MS, y as MAX_FLEET_RPC_TIMEOUT_MS } from "./queue-protocol-CmJNMH_5.js";
2
- import { CreateEventSchema, EventSchema, HookSchema, MessageId, QueuePayloadSchema, SPEC_VERSION_CURRENT, StepSchema, WaitSchema, WorkflowRunSchema, isTerminalWorkflowRunStatus, parseQueueName } from "@workflow/world";
1
+ import { B as STREAM_BATCH_CONTENT_TYPE, C as MAX_QUEUE_DELIVERY_TIMEOUT_MS, D as checkedQueueTimestampAdd, E as boundedIntegerOption, F as MAX_STREAM_ERROR_BYTES, H as decodeStreamReadResult, I as MAX_STREAM_LONG_POLL_MS, J as isChunkLimitRejection, K as encodeStreamWriteBatch, L as MAX_STREAM_READ_BYTES, M as strictIntegerSetting, N as MAX_STREAM_BATCH_BYTES, P as MAX_STREAM_CHUNK_BYTES, R as MAX_STREAM_READ_CHUNKS, S as MAX_FLEET_RPC_TIMEOUT_MS, V as STREAM_CHUNK_LIMIT_HEADER, W as decodeStreamWriteResult, Y as normalizeStreamError, _ as createWorkflowIndex, a as nativeQueueDelaySeconds, f as compact, g as stringify, h as parse, j as queueDelayDeadline, m as rpcStringify, n as listRunsPage, p as rpcParse, v as runIndexMetadata, w as MAX_QUEUE_DERIVED_DEADLINE_HEADROOM_MS, z as MAX_STREAM_WRITE_CHUNKS } from "./run-list-B__jKuE7.js";
2
+ import { CreateEventSchema, EventSchema, HookSchema, MessageId, QueuePayloadSchema, SPEC_VERSION_CURRENT, StepSchema, WaitSchema, WorkflowRunSchema, parseQueueName } from "@workflow/world";
3
3
  import { EntityConflictError, HookNotFoundError, RunExpiredError, RunNotSupportedError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError } from "@workflow/errors";
4
4
  import { monotonicFactory } from "ulid";
5
5
  //#region src/config.ts
@@ -132,6 +132,23 @@ const READ_ATTEMPTS = 3;
132
132
  const MAX_WRITE_RESPONSE_BYTES = 64;
133
133
  const MAX_READ_RESPONSE_BYTES = MAX_STREAM_READ_BYTES + 65536;
134
134
  const MAX_ERROR_RESPONSE_BYTES = 65536;
135
+ /**
136
+ * Per-request chunk limit the fleet has advertised, per transport. Absent
137
+ * until a worker advertises one, so a client never sends an older worker more
138
+ * than the baseline it accepts; a response without the header drops back.
139
+ */
140
+ const advertisedChunkLimits = /* @__PURE__ */ new WeakMap();
141
+ function streamChunkLimit(transport) {
142
+ return advertisedChunkLimits.get(transport) ?? 32;
143
+ }
144
+ function resetStreamChunkLimit(transport) {
145
+ advertisedChunkLimits.delete(transport);
146
+ }
147
+ function recordStreamChunkLimit(transport, response) {
148
+ const advertised = Number(response.headers.get(STREAM_CHUNK_LIMIT_HEADER));
149
+ if (Number.isSafeInteger(advertised) && advertised > 32) advertisedChunkLimits.set(transport, Math.min(advertised, 512));
150
+ else advertisedChunkLimits.delete(transport);
151
+ }
135
152
  function delayMs(ms, signal) {
136
153
  if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
137
154
  if (signal.aborted) return Promise.reject(aborted(signal));
@@ -202,7 +219,7 @@ function streamUrl(transport, name) {
202
219
  async function writeStreamChunks(transport, name, runId, chunks) {
203
220
  const timeoutMs = resolveFleetTimeoutMs(transport);
204
221
  const body = encodeStreamWriteBatch(chunks);
205
- if (body.byteLength > 8389632) throw new Error("world-celld: encoded stream batch exceeds configured limit");
222
+ if (body.byteLength > 8391680) throw new Error("world-celld: encoded stream batch exceeds configured limit");
206
223
  const url = new URL(streamUrl(transport, name));
207
224
  url.searchParams.set("runId", runId);
208
225
  const doFetch = transport.fetchImpl ?? fetch;
@@ -221,6 +238,7 @@ async function writeStreamChunks(transport, name, runId, chunks) {
221
238
  throw new FleetTransportError(`world-celld: fleet unreachable at ${url.href}`, error);
222
239
  }
223
240
  if (!response.ok) throw await errorFromResponse(response);
241
+ recordStreamChunkLimit(transport, response);
224
242
  try {
225
243
  return decodeStreamWriteResult(await readBoundedResponse(response, MAX_WRITE_RESPONSE_BYTES));
226
244
  } catch (error) {
@@ -257,13 +275,16 @@ async function readStreamChunks(transport, name, request, signal) {
257
275
  await response.body?.cancel(aborted(signal)).catch(() => void 0);
258
276
  throw aborted(signal);
259
277
  }
260
- if (response.ok) try {
261
- return decodeStreamReadResult(await readBoundedResponse(response, MAX_READ_RESPONSE_BYTES));
262
- } catch (error) {
263
- if (signal?.aborted) throw aborted(signal);
264
- lastError = error instanceof FleetTransportError ? error : new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
265
- if (attempt < READ_ATTEMPTS) continue;
266
- throw lastError;
278
+ if (response.ok) {
279
+ recordStreamChunkLimit(transport, response);
280
+ try {
281
+ return decodeStreamReadResult(await readBoundedResponse(response, MAX_READ_RESPONSE_BYTES));
282
+ } catch (error) {
283
+ if (signal?.aborted) throw aborted(signal);
284
+ lastError = error instanceof FleetTransportError ? error : new FleetTransportError(`world-celld: malformed stream response from ${url.href}`, error);
285
+ if (attempt < READ_ATTEMPTS) continue;
286
+ throw lastError;
287
+ }
267
288
  }
268
289
  let error;
269
290
  try {
@@ -276,6 +297,12 @@ async function readStreamChunks(transport, name, request, signal) {
276
297
  lastError = error;
277
298
  continue;
278
299
  }
300
+ if (request.maxChunks > 32 && isChunkLimitRejection(error) && attempt < READ_ATTEMPTS) {
301
+ resetStreamChunkLimit(transport);
302
+ url.searchParams.set("maxChunks", String(32));
303
+ lastError = error;
304
+ continue;
305
+ }
279
306
  throw error;
280
307
  }
281
308
  throw lastError instanceof Error ? lastError : new FleetTransportError("world-celld: stream read failed", lastError);
@@ -335,6 +362,8 @@ function makeStreamNamespace(transport) {
335
362
  const control = makeStub(transport, "streams", name, STREAMS);
336
363
  control.writeChunks = (runId, chunks) => writeStreamChunks(transport, name, runId, chunks);
337
364
  control.readChunks = (request, signal) => readStreamChunks(transport, name, request, signal);
365
+ control.streamChunkLimit = () => streamChunkLimit(transport);
366
+ control.resetStreamChunkLimit = () => resetStreamChunkLimit(transport);
338
367
  return control;
339
368
  }
340
369
  };
@@ -382,6 +411,20 @@ const HOOK_IDS = {
382
411
  ],
383
412
  mutating: /* @__PURE__ */ new Set()
384
413
  };
414
+ /** Transports whose worker answered the fleet-side run listing with 404. */
415
+ const legacyRunListing = /* @__PURE__ */ new WeakSet();
416
+ async function listResolvedRuns(transport, request) {
417
+ if (legacyRunListing.has(transport)) return null;
418
+ try {
419
+ return await callFleetRoute(transport, "/v1/index/runs/list-resolved", [request], { idempotent: true });
420
+ } catch (error) {
421
+ if (error.status === 404) {
422
+ legacyRunListing.add(transport);
423
+ return null;
424
+ }
425
+ throw error;
426
+ }
427
+ }
385
428
  function createRemoteEnv(transport) {
386
429
  const runCatalog = makeNamespace(transport, "run-catalog", RUN_CATALOG);
387
430
  const hookTokens = makeNamespace(transport, "hook-tokens", HOOK_TOKENS);
@@ -402,6 +445,7 @@ function createRemoteEnv(transport) {
402
445
  publicationExpiresAt
403
446
  ], { idempotent: true }),
404
447
  listRuns: (options) => callFleetRoute(transport, "/v1/index/runs/list", [options], { idempotent: true }),
448
+ listResolvedRuns: (request) => listResolvedRuns(transport, request),
405
449
  expireRun: (request) => callFleetRoute(transport, "/v1/index/runs/expire", [request], { idempotent: true }),
406
450
  reserveHook: (token, owner) => callFleetRoute(transport, "/v1/index/hooks/reserve", [token, owner], { idempotent: true }),
407
451
  finalizeHookIndexes: (token, hookId, serializedHook, owner, reservation) => callFleetRoute(transport, "/v1/index/hooks/finalize", [
@@ -458,7 +502,7 @@ const debug = createDebugLogger("celld-world");
458
502
  * Vendored from vinnymac/worlds packages/world-cloudflare/src/queue.ts
459
503
  * (Apache-2.0, see NOTICE), modified for celld:
460
504
  *
461
- * - Production publishes a small pointer through celld v0.5.0 native Queues.
505
+ * - Production publishes a small pointer through celld v0.6.0 native Queues.
462
506
  * The deployed Queue consumer forwards deliveries to the same HTTP handler
463
507
  * used by the in-process test pump.
464
508
  * - Run-bearing message bodies live in the fleet object store. This preserves
@@ -828,6 +872,7 @@ function parseApplyEventOutcome(value) {
828
872
  for (const releasedHook of value.releasedHooks) if (!isRecord(releasedHook) || typeof releasedHook.hookId !== "string" || releasedHook.hookId.length === 0 || typeof releasedHook.token !== "string" || releasedHook.token.length === 0) malformedApplyEventOutcome("releasedHooks entries must contain hookId and token strings");
829
873
  const event = parseSuccessEntity(value, "event", EventSchema);
830
874
  const run = parseSuccessEntity(value, "run", WorkflowRunSchema);
875
+ const replayRun = parseSuccessEntity(value, "replayRun", WorkflowRunSchema);
831
876
  const step = parseSuccessEntity(value, "step", StepSchema);
832
877
  const hook = parseSuccessEntity(value, "hook", HookSchema);
833
878
  const wait = parseSuccessEntity(value, "wait", WaitSchema);
@@ -851,6 +896,7 @@ function parseApplyEventOutcome(value) {
851
896
  if (value.cursor !== void 0 && value.cursor !== null && typeof value.cursor !== "string") malformedApplyEventOutcome("cursor must be a string or null");
852
897
  if (value.hasMore !== void 0 && typeof value.hasMore !== "boolean") malformedApplyEventOutcome("hasMore must be a boolean");
853
898
  if (value.maxEvents !== void 0 && (typeof value.maxEvents !== "number" || !Number.isSafeInteger(value.maxEvents) || value.maxEvents < 0)) malformedApplyEventOutcome("maxEvents must be a non-negative safe integer");
899
+ for (const flag of ["runIndexPublished", "hookIndexesReleased"]) if (value[flag] !== void 0 && value[flag] !== true) malformedApplyEventOutcome(`${flag} must be true when present`);
854
900
  if (run !== void 0 && value.indexPublicationExpiresAt === void 0) malformedApplyEventOutcome("indexPublicationExpiresAt is required when run is present");
855
901
  if (value.indexPublicationExpiresAt !== void 0 && (typeof value.indexPublicationExpiresAt !== "number" || !Number.isFinite(value.indexPublicationExpiresAt))) malformedApplyEventOutcome("indexPublicationExpiresAt must be a finite number");
856
902
  return {
@@ -859,6 +905,7 @@ function parseApplyEventOutcome(value) {
859
905
  ...value,
860
906
  event,
861
907
  run,
908
+ replayRun,
862
909
  step,
863
910
  hook,
864
911
  wait,
@@ -951,19 +998,6 @@ function unwrapRead(outcome) {
951
998
  if (!outcome.ok) throw new RunExpiredError(outcome.message);
952
999
  return outcome.value;
953
1000
  }
954
- /** Bound cross-run fanout so large list pages cannot create an RPC burst. */
955
- const RUN_LIST_CONCURRENCY = 8;
956
- /**
957
- * Run statuses only move pending -> running -> terminal, and terminal statuses
958
- * are immutable. An older index value may therefore safely exclude a filter
959
- * only when it is already terminal, or when the caller asks for pending and
960
- * the index has advanced beyond pending. Earlier non-terminal metadata cannot
961
- * exclude a later status because a post-commit index write may need replay.
962
- */
963
- function indexStatusExcludes(indexed, requested) {
964
- if (requested === void 0 || indexed === requested) return false;
965
- return isTerminalWorkflowRunStatus(indexed) || requested === "pending";
966
- }
967
1001
  function createStorage(config) {
968
1002
  const { env } = config;
969
1003
  const cleanup = { retentionMs: config.runRetentionMs ?? 0 };
@@ -981,60 +1015,31 @@ function createStorage(config) {
981
1015
  runs: {
982
1016
  get: runsGet,
983
1017
  async list(params) {
984
- const limit = params?.pagination?.limit ?? 20;
985
- const prefix = params?.workflowName ? `run:${params.workflowName}:` : "runall:";
986
- const reverse = params?.pagination?.sortOrder === "desc";
987
- const matches = [];
988
- let scanCursor = params?.pagination?.cursor;
989
- let exhausted = false;
990
- while (matches.length <= limit && !exhausted) {
991
- const kvList = await env.WORKFLOW_INDEX.listRuns({
992
- prefix,
993
- limit: Math.min(1e3, Math.max(50, limit)),
994
- cursor: scanCursor,
995
- reverse
996
- });
997
- if (kvList.keys.length === 0) {
998
- exhausted = true;
999
- break;
1000
- }
1001
- const candidates = [];
1002
- for (const key of kvList.keys) {
1003
- const metadata = JSON.parse(key.value);
1004
- if (indexStatusExcludes(metadata.status, params?.status)) continue;
1005
- candidates.push({
1006
- key: key.name,
1007
- metadata
1008
- });
1009
- }
1010
- for (let offset = 0; offset < candidates.length && matches.length <= limit;) {
1011
- const needed = limit + 1 - matches.length;
1012
- const batch = candidates.slice(offset, offset + Math.min(RUN_LIST_CONCURRENCY, needed));
1013
- const resolved = await Promise.all(batch.map(async ({ key, metadata }) => {
1014
- try {
1015
- const run = await runsGet(metadata.runId, { resolveData: params?.resolveData });
1016
- return !params?.status || run.status === params.status ? {
1017
- key,
1018
- run
1019
- } : null;
1020
- } catch (error) {
1021
- if (!WorkflowRunNotFoundError.is(error) && !RunExpiredError.is(error)) throw error;
1022
- return null;
1023
- }
1024
- }));
1025
- for (const match of resolved) if (match) matches.push(match);
1026
- offset += batch.length;
1027
- }
1028
- exhausted = kvList.list_complete;
1029
- scanCursor = kvList.cursor ?? kvList.keys.at(-1)?.name;
1030
- }
1031
- const hasMore = matches.length > limit;
1032
- const page = matches.slice(0, limit);
1033
- return {
1034
- data: page.map(({ run }) => run),
1035
- cursor: hasMore ? page.at(-1)?.key ?? null : null,
1036
- hasMore
1018
+ const request = {
1019
+ workflowName: params?.workflowName,
1020
+ statuses: params?.status === void 0 ? void 0 : Array.isArray(params.status) ? params.status : [params.status],
1021
+ limit: params?.pagination?.limit ?? 20,
1022
+ cursor: params?.pagination?.cursor,
1023
+ sortOrder: params?.pagination?.sortOrder,
1024
+ resolveData: params?.resolveData
1025
+ };
1026
+ const fleetPage = Number.isSafeInteger(request.limit) && request.limit >= 1 && request.limit <= 1e3 ? await env.WORKFLOW_INDEX.listResolvedRuns?.(compact(request)) : void 0;
1027
+ if (fleetPage) return {
1028
+ data: fleetPage.data.map((run) => filterData(parseRun(run), params?.resolveData, ["input", "output"])),
1029
+ cursor: fleetPage.cursor,
1030
+ hasMore: fleetPage.hasMore
1037
1031
  };
1032
+ return await listRunsPage({
1033
+ listRuns: (options) => env.WORKFLOW_INDEX.listRuns(options),
1034
+ readRun: async (runId) => {
1035
+ try {
1036
+ return await runsGet(runId);
1037
+ } catch (error) {
1038
+ if (WorkflowRunNotFoundError.is(error) || RunExpiredError.is(error)) return null;
1039
+ throw error;
1040
+ }
1041
+ }
1042
+ }, request);
1038
1043
  }
1039
1044
  },
1040
1045
  events: {
@@ -1094,20 +1099,18 @@ function createStorage(config) {
1094
1099
  throwOutcomeError(parsedOutcome.outcome, effectiveRunId, data);
1095
1100
  }
1096
1101
  const outcome = parsedOutcome.outcome;
1097
- if (outcome.run) {
1098
- const meta = JSON.stringify({
1099
- runId: effectiveRunId,
1100
- createdAt: outcome.run.createdAt.toISOString(),
1101
- status: outcome.run.status
1102
- });
1102
+ if (outcome.run && !outcome.runIndexPublished) {
1103
1103
  if (outcome.indexPublicationExpiresAt === void 0) throw new Error("world-celld: authoritative run mutation omitted its index lease");
1104
- await env.WORKFLOW_INDEX.commitRun(outcome.run, meta, outcome.indexPublicationExpiresAt);
1104
+ await env.WORKFLOW_INDEX.commitRun(outcome.run, runIndexMetadata({
1105
+ ...outcome.run,
1106
+ runId: effectiveRunId
1107
+ }), outcome.indexPublicationExpiresAt);
1105
1108
  }
1106
1109
  if (outcome.hookToIndex) {
1107
1110
  const serialized = stringify(outcome.hookToIndex);
1108
1111
  await env.WORKFLOW_INDEX.finalizeHookIndexes(outcome.hookToIndex.token, outcome.hookToIndex.hookId, serialized, hookOwner(outcome.hookToIndex), hookAdmission?.reservation);
1109
1112
  } else if (hookAdmission) await env.WORKFLOW_INDEX.releaseHookReservation(hookAdmission.token, hookAdmission.owner, hookAdmission.reservation);
1110
- if (outcome.releasedHooks.length > 0) await env.WORKFLOW_INDEX.releaseHookIndexes({
1113
+ if (outcome.releasedHooks.length > 0 && !outcome.hookIndexesReleased) await env.WORKFLOW_INDEX.releaseHookIndexes({
1111
1114
  runId: effectiveRunId,
1112
1115
  hooks: outcome.releasedHooks
1113
1116
  });
@@ -1118,7 +1121,7 @@ function createStorage(config) {
1118
1121
  };
1119
1122
  return {
1120
1123
  event: outcome.event,
1121
- run: outcome.run,
1124
+ run: outcome.run ?? outcome.replayRun,
1122
1125
  step: outcome.step,
1123
1126
  hook: outcome.hook,
1124
1127
  wait: outcome.wait,
@@ -1275,18 +1278,28 @@ function createStreamer(config) {
1275
1278
  const chunks = input.map(toBytes);
1276
1279
  for (const chunk of chunks) if (chunk.byteLength > 1048576) throw new Error(`Stream chunk exceeds ${MAX_STREAM_CHUNK_BYTES} bytes`);
1277
1280
  await registerStreamForRun(name, resolvedRunId);
1278
- let batch = [];
1279
- let batchBytes = 0;
1280
- for (const chunk of chunks) {
1281
- if (batch.length > 0 && (batch.length === 32 || batchBytes + chunk.byteLength > 8388608)) {
1282
- await getStreamDO(name).writeChunks(resolvedRunId, batch);
1283
- batch = [];
1284
- batchBytes = 0;
1281
+ const stub = getStreamDO(name);
1282
+ let next = 0;
1283
+ while (next < chunks.length) {
1284
+ const chunkLimit = stub.streamChunkLimit?.() ?? 32;
1285
+ let end = next;
1286
+ let batchBytes = 0;
1287
+ while (end < chunks.length && end - next < chunkLimit && (end === next || batchBytes + chunks[end].byteLength <= 8388608)) {
1288
+ batchBytes += chunks[end].byteLength;
1289
+ end++;
1290
+ }
1291
+ const batch = chunks.slice(next, end);
1292
+ try {
1293
+ await stub.writeChunks(resolvedRunId, batch);
1294
+ } catch (error) {
1295
+ if (batch.length > 32 && isChunkLimitRejection(error)) {
1296
+ stub.resetStreamChunkLimit?.();
1297
+ continue;
1298
+ }
1299
+ throw error;
1285
1300
  }
1286
- batch.push(chunk);
1287
- batchBytes += chunk.byteLength;
1301
+ next = end;
1288
1302
  }
1289
- if (batch.length > 0) await getStreamDO(name).writeChunks(resolvedRunId, batch);
1290
1303
  };
1291
1304
  const writeToStream = (name, runId, chunk) => writeChunksToStream(name, runId, [chunk]);
1292
1305
  const closeStream = async (name, runId) => {
@@ -1324,7 +1337,7 @@ function createStreamer(config) {
1324
1337
  result = await stub.readChunks({
1325
1338
  runId,
1326
1339
  startIndex: nextIndex,
1327
- maxChunks: 32,
1340
+ maxChunks: stub.streamChunkLimit?.() ?? 32,
1328
1341
  maxBytes: MAX_STREAM_READ_BYTES,
1329
1342
  waitMs: streamLongPollMs
1330
1343
  }, abortController.signal);
@@ -1354,10 +1367,11 @@ function createStreamer(config) {
1354
1367
  const getStreamChunks = async (name, runId, options) => {
1355
1368
  const requestedLimit = options?.limit ?? DEFAULT_CHUNK_PAGE_SIZE;
1356
1369
  if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) throw new Error("Stream chunk limit must be a positive safe integer");
1357
- const limit = Math.min(requestedLimit, MAX_CHUNK_PAGE_SIZE);
1370
+ const stub = getStreamDO(name);
1371
+ const limit = Math.min(requestedLimit, stub.streamChunkLimit?.() ?? MAX_CHUNK_PAGE_SIZE);
1358
1372
  const startIndex = options?.cursor ? Number.parseInt(options.cursor, 10) : 0;
1359
1373
  if (Number.isNaN(startIndex) || startIndex < 0) throw new Error(`Invalid stream cursor: ${options?.cursor}`);
1360
- const result = await getStreamDO(name).readChunks({
1374
+ const result = await stub.readChunks({
1361
1375
  runId,
1362
1376
  startIndex,
1363
1377
  maxChunks: limit,