@ewhauser/world-celld 0.8.1 → 0.9.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/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as validateStreamReadRequest, B as MAX_STREAM_READ_BYTES, C as HOOK_CLAIM_LEASE_MS, I as MAX_STREAM_BATCH_BYTES, J as encodeStreamReadResult, K as decodeStreamWriteBatch, L as MAX_STREAM_CHUNK_BYTES, M as isPositiveSafeInteger, N as isRecord$1, O as MAX_RUN_INDEX_PUBLICATION_LIFETIME_MS, Q as normalizeStreamError, U as STREAM_BATCH_CONTENT_TYPE, W as STREAM_CHUNK_LIMIT_HEADER, X as encodeStreamWriteResult, _ as rpcStringify, a as QUEUE_PUBLICATION_LEASE_MS, b as createWorkflowIndex, d as queuePayloadObjectKey, et as validateStreamWriteChunks, f as queuePayloadRegistryKey, g as rpcParse, h as compact, i as QUEUE_PAYLOAD_REGISTRY_PREFIX, j as isNonNegativeSafeInteger, l as queueClaimName, m as validateNativeQueueSendOptions, n as listRunsPage, o as QUEUE_RESERVATION_GRACE_MS, p as validateNativeQueueEnvelope, r as NATIVE_QUEUE_MAX_MESSAGE_BYTES, s as isInlineRunBody, t as MAX_RUN_LIST_LIMIT, u as queueOrphanName, v as parse, w as LIFECYCLE_COMPACTION_RETRY_MS, x as runIndexMetadata, z as MAX_STREAM_LONG_POLL_MS } from "./run-list-DGsdv6Jf.js";
1
+ import { $ as normalizeStreamError, B as MAX_STREAM_LONG_POLL_MS, C as HOOK_CLAIM_LEASE_MS, F as queueDelayDeadline, G as STREAM_CHUNK_LIMIT_HEADER, L as MAX_STREAM_BATCH_BYTES, M as isNonNegativeSafeInteger, N as isPositiveSafeInteger, O as MAX_RUN_INDEX_PUBLICATION_LIFETIME_MS, P as isRecord$1, R as MAX_STREAM_CHUNK_BYTES, V as MAX_STREAM_READ_BYTES, W as STREAM_BATCH_CONTENT_TYPE, Y as encodeStreamReadResult, Z as encodeStreamWriteResult, _ as rpcStringify, a as QUEUE_PUBLICATION_LEASE_MS, b as createWorkflowIndex, c as nativeQueueDelaySeconds, d as queuePayloadObjectKey, et as validateStreamReadRequest, f as queuePayloadRegistryKey, g as rpcParse, h as compact, i as QUEUE_PAYLOAD_REGISTRY_PREFIX, k as QUEUE_CLAIM_STALE_MS, l as queueClaimName, m as validateNativeQueueSendOptions, n as listRunsPage, o as QUEUE_RESERVATION_GRACE_MS, p as validateNativeQueueEnvelope, q as decodeStreamWriteBatch, r as NATIVE_QUEUE_MAX_MESSAGE_BYTES, s as isInlineRunBody, t as MAX_RUN_LIST_LIMIT, tt as validateStreamWriteChunks, u as queueOrphanName, v as parse, w as LIFECYCLE_COMPACTION_RETRY_MS, x as runIndexMetadata } from "./run-list-C4B4SBYW.js";
2
2
  import { CreateEventSchema, EventSchema, HookSchema, SPEC_VERSION_CURRENT, StepSchema, WaitSchema, WorkflowRunSchema, applyAttributeChanges, eventIdToSlot, getMaxEventsPerRun, isChildEntityCreationEvent, isTerminalStepStatus, isTerminalWorkflowRunStatus, slotToEventId, validateAttributeChanges } from "@workflow/world";
3
3
  import http from "node:http";
4
4
  //#region src/testing/fake-cell.ts
@@ -1361,6 +1361,11 @@ async function authenticate(request, secret) {
1361
1361
  }
1362
1362
  //#endregion
1363
1363
  //#region src/worker/queue-claims.ts
1364
+ /**
1365
+ * Idempotency claims for Queue messages. A claim lives either in its own
1366
+ * claim cell (named by queue and idempotency key) or, for run-bearing
1367
+ * messages whose envelope says so, as an entry in the run's own cell.
1368
+ */
1364
1369
  function cellQueueClaim(cell) {
1365
1370
  return {
1366
1371
  claim: (messageId, staleMs) => cell.claimInflight({
@@ -1413,6 +1418,12 @@ function runQueueClaim(run, claimName) {
1413
1418
  })
1414
1419
  };
1415
1420
  }
1421
+ /** The claim a delivered envelope was reserved under, if it has an idempotency key. */
1422
+ function deliveryQueueClaim(envelope, runStub) {
1423
+ if (!envelope.idempotencyKey) return void 0;
1424
+ const claimName = queueClaimName(envelope.queueName, envelope.idempotencyKey);
1425
+ return envelope.claimScope === "run" && envelope.runId ? runQueueClaim(runStub(envelope.runId), claimName) : cellQueueClaim(runStub(claimName));
1426
+ }
1416
1427
  //#endregion
1417
1428
  //#region src/worker/index-validation.ts
1418
1429
  function invalid(message) {
@@ -4801,6 +4812,146 @@ var WorkflowRunDO = class extends DurableObject {
4801
4812
  }
4802
4813
  };
4803
4814
  //#endregion
4815
+ //#region src/worker/queue-delivery.ts
4816
+ /** Internal delivery contract. No Request/Response objects cross the RPC boundary. */
4817
+ const MAX_QUEUE_DELIVERY_RESPONSE_BYTES = 65536;
4818
+ const QUEUE_ORPHAN_GRACE_MS = 432e6;
4819
+ const PERMANENT_QUEUE_STATUSES = /* @__PURE__ */ new Set([
4820
+ 404,
4821
+ 409,
4822
+ 410,
4823
+ 422
4824
+ ]);
4825
+ async function readResponseText(response) {
4826
+ if (!response.body) return "";
4827
+ const reader = response.body.getReader();
4828
+ const decoder = new TextDecoder();
4829
+ let total = 0;
4830
+ let text = "";
4831
+ try {
4832
+ for (;;) {
4833
+ const { done, value } = await reader.read();
4834
+ if (done) break;
4835
+ total += value.byteLength;
4836
+ if (total > MAX_QUEUE_DELIVERY_RESPONSE_BYTES) {
4837
+ await reader.cancel("queue delivery response is too large").catch(() => void 0);
4838
+ throw new Error("workflow queue handler response exceeds configured limit");
4839
+ }
4840
+ text += decoder.decode(value, { stream: true });
4841
+ }
4842
+ return text + decoder.decode();
4843
+ } finally {
4844
+ reader.releaseLock();
4845
+ }
4846
+ }
4847
+ /** Validate at the service boundary; callers cannot bypass auth via RPC. */
4848
+ async function deliverQueueMessage(env, secret, input, attempt) {
4849
+ if (!env.WORLD_SECRET) throw new Error("WORLD_SECRET is not configured");
4850
+ if (typeof secret !== "string" || !secret || !await timingSafeEqual(secret, env.WORLD_SECRET)) throw new Error("Unauthorized Queue delivery");
4851
+ const envelope = validateNativeQueueEnvelope(input);
4852
+ if (!Number.isSafeInteger(attempt) || attempt < 1) throw new TypeError("queue delivery attempt must be a positive safe integer");
4853
+ if (!env.WORKFLOW_DB) throw new Error("missing binding: WORKFLOW_DB");
4854
+ const namespace = env.WORKFLOW_DB;
4855
+ const payloadStore = env.WORKFLOW_QUEUE_PAYLOADS;
4856
+ const runStub = (name) => namespace.get(namespace.idFromName(name));
4857
+ const claim = deliveryQueueClaim(envelope, runStub);
4858
+ let claimed = false;
4859
+ async function releaseClaim() {
4860
+ if (claimed) await claim.release(envelope.messageId);
4861
+ }
4862
+ async function unregisterPayload() {
4863
+ if (envelope.runId) {
4864
+ await runStub(envelope.runId).unregisterQueuePayload(envelope.messageId);
4865
+ await runStub(queueOrphanName(envelope.messageId)).cancelQueuePayloadOrphan(envelope.messageId);
4866
+ }
4867
+ }
4868
+ try {
4869
+ let admission;
4870
+ if (envelope.runId && !envelope.payloadKey && envelope.claimScope !== "run") {
4871
+ admission = runStub(envelope.runId).getQueueAdmission();
4872
+ admission.catch(() => void 0);
4873
+ }
4874
+ if (claim) {
4875
+ const result = await claim.claim(envelope.messageId, QUEUE_CLAIM_STALE_MS);
4876
+ if (result.expired) return { kind: "complete" };
4877
+ if (!result.claimed) {
4878
+ const now = Date.now();
4879
+ if (result.retryAt !== void 0 && result.retryAt > now) return {
4880
+ kind: "suspend",
4881
+ timeoutSeconds: Math.max(1, Math.ceil((result.retryAt - now) / 1e3))
4882
+ };
4883
+ return { kind: "complete" };
4884
+ }
4885
+ claimed = true;
4886
+ }
4887
+ if (admission && !(await admission).ok) {
4888
+ await claim?.complete(envelope.messageId);
4889
+ return { kind: "complete" };
4890
+ }
4891
+ let body = envelope.body;
4892
+ if (envelope.payloadKey) {
4893
+ if (!payloadStore) throw new Error("missing binding: WORKFLOW_QUEUE_PAYLOADS");
4894
+ body = await payloadStore.read(envelope.payloadKey) ?? void 0;
4895
+ if (body === void 0) {
4896
+ await unregisterPayload();
4897
+ await claim?.complete(envelope.messageId);
4898
+ return { kind: "complete" };
4899
+ }
4900
+ }
4901
+ const headers = {
4902
+ "content-type": "application/json",
4903
+ "x-vqs-queue-name": envelope.queueName,
4904
+ "x-vqs-message-id": envelope.messageId,
4905
+ "x-vqs-message-attempt": String(attempt)
4906
+ };
4907
+ if (env.WORKFLOW_CALLBACK_SECRET) headers["x-workflow-callback-secret"] = env.WORKFLOW_CALLBACK_SECRET;
4908
+ const callback = await fetch(`${envelope.targetBaseUrl.replace(/\/$/, "")}/.well-known/workflow/v1/flow`, {
4909
+ method: "POST",
4910
+ headers,
4911
+ body,
4912
+ signal: AbortSignal.timeout(3e5)
4913
+ });
4914
+ if (callback.ok || PERMANENT_QUEUE_STATUSES.has(callback.status)) {
4915
+ await callback.body?.cancel().catch(() => void 0);
4916
+ if (envelope.payloadKey) {
4917
+ await payloadStore.delete(envelope.payloadKey);
4918
+ await unregisterPayload();
4919
+ }
4920
+ await claim?.complete(envelope.messageId);
4921
+ return { kind: "complete" };
4922
+ }
4923
+ if (callback.status === 503) {
4924
+ const responseBody = await readResponseText(callback);
4925
+ const timeoutSeconds = JSON.parse(responseBody).timeoutSeconds;
4926
+ const notBefore = queueDelayDeadline(Date.now(), timeoutSeconds, 1, QUEUE_CLAIM_STALE_MS);
4927
+ if (notBefore === null) throw new Error("workflow queue suspension deadline is invalid");
4928
+ if (envelope.payloadKey && envelope.runId) await runStub(queueOrphanName(envelope.messageId)).scheduleQueuePayloadOrphan({
4929
+ messageId: envelope.messageId,
4930
+ runId: envelope.runId,
4931
+ key: envelope.payloadKey,
4932
+ expiresAt: notBefore + QUEUE_ORPHAN_GRACE_MS
4933
+ });
4934
+ if (claim) await claim.hold({
4935
+ messageId: envelope.messageId,
4936
+ retryAt: notBefore,
4937
+ expiresAt: notBefore + QUEUE_CLAIM_STALE_MS,
4938
+ reservationExpiresAt: notBefore + QUEUE_ORPHAN_GRACE_MS
4939
+ });
4940
+ return {
4941
+ kind: "suspend",
4942
+ timeoutSeconds
4943
+ };
4944
+ }
4945
+ await callback.body?.cancel().catch(() => void 0);
4946
+ await releaseClaim();
4947
+ claimed = false;
4948
+ return { kind: "retry" };
4949
+ } catch (error) {
4950
+ await releaseClaim();
4951
+ throw error;
4952
+ }
4953
+ }
4954
+ //#endregion
4804
4955
  //#region src/testing/http-harness.ts
4805
4956
  /**
4806
4957
  * Boots the real worker router over real cell classes (on FakeFleet state)
@@ -4809,6 +4960,7 @@ var WorkflowRunDO = class extends DurableObject {
4809
4960
  async function startHarness(options = {}) {
4810
4961
  const cellEnv = { ...options.cellEnv };
4811
4962
  const queueMessages = [];
4963
+ const queuePublications = [];
4812
4964
  const queuePayloads = /* @__PURE__ */ new Map();
4813
4965
  const queuePayloadBucket = {
4814
4966
  async put(key, value) {
@@ -4822,8 +4974,12 @@ async function startHarness(options = {}) {
4822
4974
  for (const key of Array.isArray(keys) ? keys : [keys]) queuePayloads.delete(key);
4823
4975
  }
4824
4976
  };
4825
- const nativeQueue = { async send(body) {
4977
+ const nativeQueue = { async send(body, sendOptions) {
4826
4978
  queueMessages.push(body);
4979
+ queuePublications.push({
4980
+ body,
4981
+ delaySeconds: sendOptions?.delaySeconds ?? 0
4982
+ });
4827
4983
  } };
4828
4984
  const fleet = new FakeFleet({
4829
4985
  runs: WorkflowRunDO,
@@ -4842,7 +4998,7 @@ async function startHarness(options = {}) {
4842
4998
  WORKFLOW_HOOK_IDS: fleet.namespace("hook-ids"),
4843
4999
  WORKFLOW_QUEUE_PAYLOADS: queuePayloadBucket
4844
5000
  });
4845
- const router = createRouter({
5001
+ const env = {
4846
5002
  WORKFLOW_DB: fleet.namespace("runs"),
4847
5003
  WORKFLOW_STREAMS: fleet.namespace("streams"),
4848
5004
  WORKFLOW_RUN_CATALOG: fleet.namespace("run-catalog"),
@@ -4851,7 +5007,8 @@ async function startHarness(options = {}) {
4851
5007
  WORKFLOW_QUEUE: nativeQueue,
4852
5008
  WORKFLOW_QUEUE_PAYLOADS: createQueuePayloadStore(queuePayloadBucket),
4853
5009
  WORLD_SECRET: options.secret
4854
- });
5010
+ };
5011
+ const router = createRouter(env);
4855
5012
  const server = http.createServer(async (req, res) => {
4856
5013
  const requestAbort = new AbortController();
4857
5014
  const abortRequest = () => {
@@ -4894,7 +5051,9 @@ async function startHarness(options = {}) {
4894
5051
  url: `http://127.0.0.1:${port}`,
4895
5052
  fleet,
4896
5053
  queueMessages,
5054
+ queuePublications,
4897
5055
  queuePayloads,
5056
+ deliverQueueMessage: (secret, envelope, attempt) => deliverQueueMessage(env, secret, envelope, attempt),
4898
5057
  close: () => new Promise((resolve, reject) => {
4899
5058
  server.close((err) => err ? reject(err) : resolve());
4900
5059
  server.closeAllConnections();
@@ -4902,11 +5061,148 @@ async function startHarness(options = {}) {
4902
5061
  };
4903
5062
  }
4904
5063
  //#endregion
5064
+ //#region src/worker/queue-consumer.ts
5065
+ function backoffSeconds(attempt) {
5066
+ return Math.min(60, 2 ** Math.min(attempt, 30));
5067
+ }
5068
+ async function republish(env, envelope, now = Date.now()) {
5069
+ await env.WORKFLOW_QUEUE.send(JSON.stringify(envelope), {
5070
+ contentType: "text",
5071
+ delaySeconds: nativeQueueDelaySeconds(envelope.notBefore, now)
5072
+ });
5073
+ }
5074
+ async function consume(message, env) {
5075
+ let envelope;
5076
+ try {
5077
+ if (typeof message.body !== "string") throw new TypeError("native Queue message body must be text");
5078
+ envelope = validateNativeQueueEnvelope(JSON.parse(message.body));
5079
+ } catch (error) {
5080
+ console.error("world-celld native Queue rejected malformed broker message", error);
5081
+ message.retry({ delaySeconds: backoffSeconds(message.attempts) });
5082
+ return;
5083
+ }
5084
+ const now = Date.now();
5085
+ if (envelope.notBefore !== void 0 && envelope.notBefore > now) {
5086
+ try {
5087
+ await republish(env, envelope, now);
5088
+ message.ack();
5089
+ } catch (error) {
5090
+ console.error("world-celld native Queue failed to continue a long delay", error);
5091
+ message.retry({ delaySeconds: backoffSeconds(message.attempts) });
5092
+ }
5093
+ return;
5094
+ }
5095
+ const attempt = (envelope.deliveryFailures ?? 0) + message.attempts;
5096
+ if (!env.WORLD_SECRET) {
5097
+ console.error("world-celld Queue consumer missing WORLD_SECRET");
5098
+ message.retry({ delaySeconds: backoffSeconds(attempt) });
5099
+ return;
5100
+ }
5101
+ let response;
5102
+ try {
5103
+ response = await env.WORLD_SERVICE.deliver(env.WORLD_SECRET, envelope, attempt);
5104
+ } catch (error) {
5105
+ console.error("world-celld Queue consumer could not reach the world service", error);
5106
+ message.retry({ delaySeconds: backoffSeconds(attempt) });
5107
+ return;
5108
+ }
5109
+ if (response?.kind === "complete") {
5110
+ message.ack();
5111
+ return;
5112
+ }
5113
+ if (response?.kind === "suspend") try {
5114
+ const notBefore = queueDelayDeadline(Date.now(), response.timeoutSeconds, 1, QUEUE_CLAIM_STALE_MS);
5115
+ const suspensionCount = (envelope.suspensionCount ?? 0) + 1;
5116
+ if (notBefore !== null && suspensionCount <= 256) {
5117
+ await republish(env, {
5118
+ ...envelope,
5119
+ notBefore,
5120
+ deliveryFailures: attempt - 1,
5121
+ suspensionCount
5122
+ });
5123
+ message.ack();
5124
+ return;
5125
+ }
5126
+ } catch (error) {
5127
+ console.error("world-celld Queue consumer received an invalid suspension response", error);
5128
+ }
5129
+ message.retry({ delaySeconds: backoffSeconds(attempt) });
5130
+ }
5131
+ var queue_consumer_default = { async queue(batch, env) {
5132
+ await Promise.all(batch.messages.map((message) => consume(message, env)));
5133
+ } };
5134
+ //#endregion
4905
5135
  //#region src/testing/index.ts
4906
5136
  /** Alarm-pump interval for the dev fleet (real time). */
4907
5137
  const PUMP_INTERVAL_MS = 25;
4908
5138
  async function startDevFleet(options = {}) {
4909
5139
  const harness = await startHarness(options);
5140
+ const pending = [];
5141
+ const deliveries = /* @__PURE__ */ new Set();
5142
+ let publicationCursor = 0;
5143
+ let stopped = false;
5144
+ const queueEnv = {
5145
+ WORKFLOW_QUEUE: { async send(body, sendOptions) {
5146
+ harness.queueMessages.push(body);
5147
+ harness.queuePublications.push({
5148
+ body,
5149
+ delaySeconds: sendOptions?.delaySeconds ?? 0
5150
+ });
5151
+ } },
5152
+ WORLD_SERVICE: { deliver: (secret, envelope, attempt) => harness.deliverQueueMessage(secret, envelope, attempt) },
5153
+ WORLD_SECRET: options.secret
5154
+ };
5155
+ function dispatch(message) {
5156
+ let settled = false;
5157
+ const delivery = queue_consumer_default.queue({ messages: [{
5158
+ body: message.body,
5159
+ attempts: message.attempts,
5160
+ ack() {
5161
+ settled = true;
5162
+ },
5163
+ retry(retryOptions) {
5164
+ settled = true;
5165
+ if (!stopped) pending.push({
5166
+ body: message.body,
5167
+ attempts: message.attempts + 1,
5168
+ dueAt: Date.now() + (retryOptions?.delaySeconds ?? 0) * 1e3
5169
+ });
5170
+ }
5171
+ }] }, queueEnv).then(() => {
5172
+ if (!settled) {
5173
+ console.error("[world-celld dev fleet] Queue delivery was not settled");
5174
+ if (!stopped) pending.push({
5175
+ body: message.body,
5176
+ attempts: message.attempts + 1,
5177
+ dueAt: Date.now() + 1e3
5178
+ });
5179
+ }
5180
+ }).catch((error) => {
5181
+ console.error("[world-celld dev fleet] Queue consumer failed", error);
5182
+ if (!settled && !stopped) pending.push({
5183
+ body: message.body,
5184
+ attempts: message.attempts + 1,
5185
+ dueAt: Date.now() + 1e3
5186
+ });
5187
+ });
5188
+ deliveries.add(delivery);
5189
+ delivery.finally(() => deliveries.delete(delivery));
5190
+ }
5191
+ const queuePump = setInterval(() => {
5192
+ if (stopped) return;
5193
+ while (publicationCursor < harness.queuePublications.length) {
5194
+ const publication = harness.queuePublications[publicationCursor++];
5195
+ pending.push({
5196
+ body: publication.body,
5197
+ attempts: 1,
5198
+ dueAt: Date.now() + publication.delaySeconds * 1e3
5199
+ });
5200
+ }
5201
+ const now = Date.now();
5202
+ for (let index = 0; index < pending.length;) if (pending[index].dueAt <= now) dispatch(pending.splice(index, 1)[0]);
5203
+ else index++;
5204
+ }, PUMP_INTERVAL_MS);
5205
+ queuePump.unref?.();
4910
5206
  let pumping = false;
4911
5207
  const pump = setInterval(async () => {
4912
5208
  if (pumping) return;
@@ -4924,7 +5220,10 @@ async function startDevFleet(options = {}) {
4924
5220
  return {
4925
5221
  ...harness,
4926
5222
  async stop() {
5223
+ stopped = true;
5224
+ clearInterval(queuePump);
4927
5225
  clearInterval(pump);
5226
+ await Promise.allSettled(deliveries);
4928
5227
  await harness.close();
4929
5228
  }
4930
5229
  };