deepline 0.1.242 → 0.1.244

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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +5 -25
  3. package/dist/bundling-sources/sdk/src/release.ts +23 -21
  4. package/dist/bundling-sources/sdk/src/types.ts +1 -1
  5. package/dist/bundling-sources/shared_libs/play-runtime/backend.ts +2 -21
  6. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -6
  7. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +6 -13
  8. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +9 -26
  9. package/dist/bundling-sources/shared_libs/play-runtime/internal-step-ids.ts +1 -2
  10. package/dist/bundling-sources/shared_libs/play-runtime/map-row-identity.ts +1 -1
  11. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +191 -29
  12. package/dist/bundling-sources/shared_libs/play-runtime/play-latency-trace.ts +1 -1
  13. package/dist/bundling-sources/shared_libs/play-runtime/providers.ts +1 -36
  14. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +2 -3
  15. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/index.ts +0 -7
  16. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +1 -12
  17. package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver-pg.ts +2 -3
  18. package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver.ts +5 -15
  19. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +6 -21
  20. package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +3 -4
  21. package/dist/bundling-sources/shared_libs/plays/artifact-kind-guard.ts +16 -29
  22. package/dist/bundling-sources/shared_libs/plays/artifact-types.ts +4 -8
  23. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +21 -712
  24. package/dist/bundling-sources/shared_libs/plays/bundling/limits.ts +1 -18
  25. package/dist/cli/index.js +39 -74
  26. package/dist/cli/index.mjs +39 -74
  27. package/dist/{compiler-manifest-CYcwzSOJ.d.ts → compiler-manifest-DlE7dnRm.d.mts} +2 -8
  28. package/dist/{compiler-manifest-CYcwzSOJ.d.mts → compiler-manifest-DlE7dnRm.d.ts} +2 -8
  29. package/dist/index.d.mts +2 -2
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +24 -22
  32. package/dist/index.mjs +24 -22
  33. package/dist/plays/bundle-play-file.d.mts +7 -22
  34. package/dist/plays/bundle-play-file.d.ts +7 -22
  35. package/dist/plays/bundle-play-file.mjs +26 -614
  36. package/package.json +2 -2
  37. package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +0 -4504
  38. package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +0 -1386
  39. package/dist/bundling-sources/apps/play-runner-workers/src/durable-object-deploy-handoff.ts +0 -30
  40. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +0 -8385
  41. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/README.md +0 -21
  42. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/batching.ts +0 -251
  43. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/coordinator-progress.ts +0 -289
  44. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/csv-rows.ts +0 -85
  45. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/customer-console.ts +0 -23
  46. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/dataset-handles.ts +0 -528
  47. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/harness-receipt-store.ts +0 -229
  48. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/ledger-event-batches.ts +0 -81
  49. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/live-progress.ts +0 -22
  50. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-chunk-plan.ts +0 -920
  51. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-latency-profile.ts +0 -90
  52. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/output-datasets.ts +0 -475
  53. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +0 -570
  54. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/result-dataset-persistence.ts +0 -50
  55. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/row-isolation.ts +0 -5
  56. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/run-work-dispatcher.ts +0 -519
  57. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-batch.ts +0 -107
  58. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +0 -2541
  59. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-http-errors.ts +0 -7
  60. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +0 -227
  61. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/work-budget.ts +0 -130
  62. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/worker-platform-budget.ts +0 -39
  63. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/workflow-preview.ts +0 -26
  64. package/dist/bundling-sources/apps/play-runner-workers/src/runtime-api-retry.ts +0 -23
  65. package/dist/bundling-sources/apps/play-runner-workers/src/workflow-instance-create.ts +0 -47
  66. package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry-state.ts +0 -167
  67. package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry.ts +0 -91
  68. package/dist/bundling-sources/sdk/src/plays/harness-stub.ts +0 -329
  69. package/dist/bundling-sources/sdk/src/worker-play-entry.ts +0 -240
  70. package/dist/bundling-sources/shared_libs/play-runtime/governor/coordinator-rate-state-backend.ts +0 -252
  71. package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver-neon-serverless.ts +0 -217
@@ -1,1386 +0,0 @@
1
- /**
2
- * PlayDedup Durable Object — cross-attempt idempotency cache for one play run.
3
- *
4
- * One DO instance per runId. Workers and queue-consumers fetch into it to:
5
- * - claim a hash to compute (atomic; first claimer wins)
6
- * - publish a result (unblocks awaiters)
7
- * - long-poll await for a result claimed by another caller
8
- *
9
- * Storage shape (DO storage):
10
- * key: `${toolId}:${inputHash}`
11
- * value: DedupEntry
12
- *
13
- * The whole DO is evicted via `/finish` at run end. Storage is single-region
14
- * but strongly-consistent within the DO instance — exactly what dedup needs.
15
- *
16
- * Latency: ~5ms per request (DO storage is sub-ms; HTTP add ~3-5ms).
17
- */
18
-
19
- import { sanitizeLiveLogLines } from './runtime/live-progress';
20
- import {
21
- DB_SESSION_MAX_TTL_SECONDS,
22
- type PreloadedRuntimeDbSession,
23
- } from '../../../shared_libs/play-runtime/db-session';
24
- import { createSecretRedactionContext } from '../../../shared_libs/play-runtime/secret-redaction';
25
-
26
- const durableStorageRedactor = createSecretRedactionContext();
27
-
28
- type DurableObjectStorage = {
29
- get<T>(key: string): Promise<T | undefined>;
30
- get<T>(keys: string[]): Promise<Map<string, T>>;
31
- put<T>(key: string, value: T): Promise<void>;
32
- put<T>(entries: Record<string, T>): Promise<void>;
33
- delete(key: string | string[]): Promise<unknown>;
34
- deleteAll(): Promise<void>;
35
- list<T>(options?: { prefix?: string }): Promise<Map<string, T>>;
36
- setAlarm(scheduledTime: number): Promise<void>;
37
- };
38
-
39
- type DurableObjectState = {
40
- storage: DurableObjectStorage;
41
- blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>;
42
- };
43
-
44
- type DurableObject = {
45
- fetch(request: Request): Promise<Response>;
46
- alarm?(): Promise<void>;
47
- };
48
-
49
- type DurableObjectId = {
50
- toString(): string;
51
- };
52
-
53
- type DurableObjectStub = {
54
- fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
55
- };
56
-
57
- type DurableObjectNamespace = {
58
- idFromName(name: string): DurableObjectId;
59
- get(id: DurableObjectId): DurableObjectStub;
60
- };
61
-
62
- type DedupEntry =
63
- | { status: 'in_flight'; claimedBy: string; claimedAt: number }
64
- | { status: 'completed'; result: unknown; completedAt: number }
65
- | { status: 'failed'; error: string; failedAt: number };
66
-
67
- type LookupOrClaimRequest = {
68
- runId: string;
69
- toolId: string;
70
- inputHashes: string[];
71
- /** Identifier for the caller — used as `claimedBy` for in-flight entries. */
72
- callerId: string;
73
- };
74
-
75
- type LookupOrClaimResponse = {
76
- hits: Record<string, unknown>;
77
- claimed: string[];
78
- inFlight: string[];
79
- };
80
-
81
- type PublishRequest = {
82
- runId: string;
83
- toolId: string;
84
- results: Array<{ inputHash: string; result: unknown }>;
85
- };
86
-
87
- type AwaitRequest = {
88
- runId: string;
89
- toolId: string;
90
- inputHashes: string[];
91
- timeoutMs: number;
92
- };
93
-
94
- type WorkflowRunRetryState = {
95
- runId: string;
96
- params: unknown;
97
- paramsRef?: unknown;
98
- paramsBytes?: number;
99
- retryAttempts: number;
100
- updatedAt: number;
101
- expiresAt: number;
102
- };
103
-
104
- type WorkflowInstanceState = {
105
- runId: string;
106
- instanceId: string;
107
- updatedAt: number;
108
- expiresAt: number;
109
- };
110
-
111
- type WorkflowSubmitClaimState = {
112
- runId: string;
113
- status: 'creating' | 'created';
114
- instanceId?: string;
115
- claimedAt: number;
116
- updatedAt: number;
117
- expiresAt: number;
118
- };
119
-
120
- type WorkflowDbSessionsState = {
121
- runId: string;
122
- sessions: PreloadedRuntimeDbSession[];
123
- storedAt: number;
124
- expiresAt: number;
125
- };
126
-
127
- type CoordinatorTraceEntry = {
128
- ts: number;
129
- source: 'coordinator' | 'dynamic_worker';
130
- runId: string;
131
- phase: string;
132
- ms: number;
133
- graphHash?: string | null;
134
- [key: string]: unknown;
135
- };
136
-
137
- type CoordinatorTerminalState = {
138
- runId: string;
139
- status: 'completed' | 'failed' | 'cancelled';
140
- result?: unknown;
141
- error?: string | null;
142
- totalRows?: unknown;
143
- durationMs?: unknown;
144
- playName?: string | null;
145
- liveLogs?: string[] | null;
146
- liveNodeProgress?: unknown;
147
- completedAt: number;
148
- };
149
-
150
- type CoordinatorRunEvent =
151
- | {
152
- seq: number;
153
- runId: string;
154
- type: 'status';
155
- status: string;
156
- ts: number;
157
- logs?: string[];
158
- }
159
- | {
160
- seq: number;
161
- runId: string;
162
- type: 'log';
163
- line: string;
164
- ts: number;
165
- }
166
- | {
167
- seq: number;
168
- runId: string;
169
- type: 'progress';
170
- status: string;
171
- ts: number;
172
- logs?: string[];
173
- activeNodeId?: string | null;
174
- activeArtifactTableNamespace?: string | null;
175
- updatedAt?: number | null;
176
- liveNodeProgress?: unknown;
177
- }
178
- | {
179
- seq: number;
180
- runId: string;
181
- type: 'terminal';
182
- status: 'completed' | 'failed' | 'cancelled';
183
- ts: number;
184
- result?: unknown;
185
- error?: string | null;
186
- totalRows?: unknown;
187
- durationMs?: unknown;
188
- playName?: string | null;
189
- liveLogs?: string[] | null;
190
- liveNodeProgress?: unknown;
191
- };
192
-
193
- type OmitRunEventSequence<T> = T extends unknown ? Omit<T, 'seq'> : never;
194
- type CoordinatorRunEventInput = OmitRunEventSequence<CoordinatorRunEvent>;
195
-
196
- /**
197
- * Per-(org,provider) rate-state for the distributed Rate State Backend.
198
- *
199
- * One PlayDedup DO instance is addressed per bucket via
200
- * `idFromName('rate:<orgId>:<provider>')`, so a single instance owns the rate
201
- * window for that bucket and is single-threaded — which is exactly why the
202
- * in-process algorithm from `InMemoryRateStateBackend` is correct here. The
203
- * coordinator RPCs `/rate-acquire` (lease N permits) and `/rate-penalize`
204
- * (Retry-After cooldown) into it; the esm_workers runtime never blocks per call
205
- * on a full round-trip because it leases small permit BLOCKS at a time.
206
- *
207
- * Rate windows are intentionally kept in DO memory (not durable storage): a
208
- * window is sub-second-to-seconds ephemeral state, the DO instance outlives any
209
- * single window, and persisting it would only add storage latency to the hot
210
- * path. This mirrors the in-memory backend and the Redis sliding-window limiter
211
- * (`src/lib/redis/customer-rate-limiter.ts`), which also keeps window state in a
212
- * volatile store with TTL rather than durable rows.
213
- */
214
- type RateRule = {
215
- ruleId: string;
216
- requestsPerWindow: number;
217
- windowMs: number;
218
- maxConcurrency: number | null;
219
- };
220
-
221
- type RateRuleWindowState = {
222
- windowStartedAt: number;
223
- startedInWindow: number;
224
- };
225
-
226
- type RateAcquireRequest = {
227
- bucketId: string;
228
- rules: RateRule[];
229
- /** How many permits the caller wants to lease in this round-trip. */
230
- requested: number;
231
- };
232
-
233
- type RateAcquireResponse = {
234
- /** Permits actually granted (0..requested). */
235
- granted: number;
236
- /** Suggested wait before the next acquire when granted === 0. */
237
- waitMs: number;
238
- };
239
-
240
- type RatePenalizeRequest = {
241
- bucketId: string;
242
- cooldownMs: number;
243
- };
244
-
245
- const RATE_MIN_WAIT_MS = 10;
246
- const RATE_STATE_KEY = (bucketId: string, ruleId: string) =>
247
- `${bucketId}::${ruleId}`;
248
-
249
- const DEDUP_KEY_PREFIX = 'd:';
250
- const WORKFLOW_RUN_RETRY_KEY_PREFIX = 'r:';
251
- const WORKFLOW_DB_SESSIONS_KEY = 'db-sessions';
252
- const COORDINATOR_TRACE_KEY_PREFIX = 't:';
253
- const COORDINATOR_RUN_EVENT_KEY_PREFIX = 'e:';
254
- const COORDINATOR_TERMINAL_KEY = 'terminal';
255
- const COORDINATOR_RUN_EVENT_SEQUENCE_KEY = 'event-seq';
256
- const COORDINATOR_TRACE_MAX_ENTRIES = 200;
257
- const COORDINATOR_RUN_EVENT_MAX_ENTRIES = 500;
258
- const FINISH_ALARM_DELAY_MS = 60_000; // self-evict 1 min after finish() called
259
- const WORKFLOW_RUN_STATE_TTL_MS = 60 * 60 * 1000;
260
- const WORKFLOW_RUN_RETRY_STATE_MAX_BYTES = 110_000;
261
- // Invariant: DB sessions must not expire before the workflow run/retry state.
262
- // hydrateWorkflowDbSessions re-reads sessions on every workflow re-entry, so a
263
- // shorter session TTL used to kill any run that ran longer than the session
264
- // window (10 min) with a misleading transport 410.
265
- const WORKFLOW_DB_SESSIONS_TTL_MS = WORKFLOW_RUN_STATE_TTL_MS;
266
- const WORKFLOW_DB_SESSIONS_MAX_TTL_MS = DB_SESSION_MAX_TTL_SECONDS * 1000;
267
-
268
- function jsonByteLength(value: unknown): number {
269
- return new TextEncoder().encode(JSON.stringify(value)).byteLength;
270
- }
271
-
272
- function assertEncryptedDbSessionsForStorage(
273
- sessions: PreloadedRuntimeDbSession[],
274
- ): void {
275
- for (const session of sessions) {
276
- if (session.session.postgresUrl) {
277
- throw new Error('preloaded db session storage rejects raw Postgres URLs');
278
- }
279
- if (!session.session.encryptedPostgresUrl) {
280
- throw new Error(
281
- 'preloaded db session storage requires encrypted Postgres URLs',
282
- );
283
- }
284
- }
285
- }
286
-
287
- interface DedupEnv {
288
- PLAY_DEDUP: DurableObjectNamespace;
289
- }
290
-
291
- export class PlayDedup implements DurableObject {
292
- // Promises waiting on /await endpoints. Keyed by `${toolId}:${inputHash}`.
293
- // Resolved by /publish; rejected on timeout.
294
- private waiters: Map<string, Set<(value: unknown) => void>> = new Map();
295
- private runEventWaiters: Set<(value: CoordinatorRunEvent) => void> =
296
- new Set();
297
- // Per-(bucketId, ruleId) request-window state for the rate-state backend.
298
- // In-memory by design — see the RateRule doc block. Single-threaded DO access
299
- // makes this the same sliding-window algorithm as InMemoryRateStateBackend.
300
- private rateWindows: Map<string, RateRuleWindowState> = new Map();
301
- // Per-bucket Retry-After cooldown floors (penalize()).
302
- private rateCooldownUntil: Map<string, number> = new Map();
303
-
304
- constructor(
305
- private readonly state: DurableObjectState,
306
- private readonly env: DedupEnv,
307
- ) {}
308
-
309
- private storageKey(toolId: string, inputHash: string): string {
310
- return `${DEDUP_KEY_PREFIX}${toolId}:${inputHash}`;
311
- }
312
-
313
- private traceKey(entry: CoordinatorTraceEntry): string {
314
- return `${COORDINATOR_TRACE_KEY_PREFIX}${String(entry.ts).padStart(13, '0')}:${crypto.randomUUID().slice(0, 8)}`;
315
- }
316
-
317
- private runEventKey(event: CoordinatorRunEvent): string {
318
- return `${COORDINATOR_RUN_EVENT_KEY_PREFIX}${String(event.seq).padStart(13, '0')}`;
319
- }
320
-
321
- private runEventKeyForSeq(seq: number): string {
322
- return `${COORDINATOR_RUN_EVENT_KEY_PREFIX}${String(seq).padStart(13, '0')}`;
323
- }
324
-
325
- private waiterKey(toolId: string, inputHash: string): string {
326
- return `${toolId}:${inputHash}`;
327
- }
328
-
329
- async fetch(req: Request): Promise<Response> {
330
- const url = new URL(req.url);
331
- try {
332
- switch (url.pathname) {
333
- case '/lookup-or-claim':
334
- return await this.handleLookupOrClaim(req);
335
- case '/publish':
336
- return await this.handlePublish(req);
337
- case '/await':
338
- return await this.handleAwait(req);
339
- case '/finish':
340
- return await this.handleFinish(req);
341
- case '/debug':
342
- return await this.handleDebug(req);
343
- case '/run-retry-state-put':
344
- return await this.handleRunRetryStatePut(req);
345
- case '/run-launch-state-put':
346
- return await this.handleRunLaunchStatePut(req);
347
- case '/workflow-instance-put':
348
- return await this.handleWorkflowInstancePut(req);
349
- case '/workflow-instance-get':
350
- return await this.handleWorkflowInstanceGet(req);
351
- case '/workflow-submit-claim':
352
- return await this.handleWorkflowSubmitClaim(req);
353
- case '/workflow-submit-release':
354
- return await this.handleWorkflowSubmitRelease(req);
355
- case '/run-retry-claim':
356
- return await this.handleRunRetryClaim(req);
357
- case '/db-sessions-put':
358
- return await this.handleDbSessionsPut(req);
359
- case '/db-sessions-get':
360
- return await this.handleDbSessionsGet(req);
361
- case '/trace-add':
362
- return await this.handleTraceAdd(req);
363
- case '/trace-list':
364
- return await this.handleTraceList();
365
- case '/event-add':
366
- return await this.handleRunEventAdd(req);
367
- case '/event-list':
368
- return await this.handleRunEventList(req);
369
- case '/terminal-set':
370
- return await this.handleTerminalSet(req);
371
- case '/terminal-get':
372
- return await this.handleTerminalGet();
373
- case '/rate-acquire':
374
- return await this.handleRateAcquire(req);
375
- case '/rate-penalize':
376
- return await this.handleRatePenalize(req);
377
- default:
378
- return new Response('not found', { status: 404 });
379
- }
380
- } catch (error) {
381
- const message = error instanceof Error ? error.message : String(error);
382
- return new Response(JSON.stringify({ error: message }), {
383
- status: 500,
384
- headers: { 'content-type': 'application/json' },
385
- });
386
- }
387
- }
388
-
389
- async alarm(): Promise<void> {
390
- // Fired after /finish was called. Evict storage and let the DO instance
391
- // be garbage-collected by Cloudflare.
392
- await this.state.storage.deleteAll();
393
- this.waiters.clear();
394
- this.runEventWaiters.clear();
395
- }
396
-
397
- private async handleLookupOrClaim(req: Request): Promise<Response> {
398
- const body = (await req.json()) as LookupOrClaimRequest;
399
- const hits: Record<string, unknown> = {};
400
- const claimed: string[] = [];
401
- const inFlight: string[] = [];
402
-
403
- // Atomic transaction: read all keys, decide outcome per hash, write claims
404
- // back. DO storage transactions are serialized within the DO instance.
405
- await this.state.blockConcurrencyWhile(async () => {
406
- const keys = body.inputHashes.map((h) => this.storageKey(body.toolId, h));
407
- const existing = (await this.state.storage.get<DedupEntry>(keys)) as Map<
408
- string,
409
- DedupEntry
410
- >;
411
- const now = Date.now();
412
- const toWrite = new Map<string, DedupEntry>();
413
-
414
- for (let i = 0; i < body.inputHashes.length; i += 1) {
415
- const hash = body.inputHashes[i]!;
416
- const key = keys[i]!;
417
- const entry = existing.get(key);
418
- if (!entry) {
419
- // Claim it for the caller.
420
- claimed.push(hash);
421
- toWrite.set(key, {
422
- status: 'in_flight',
423
- claimedBy: body.callerId,
424
- claimedAt: now,
425
- });
426
- } else if (entry.status === 'completed') {
427
- hits[hash] = entry.result;
428
- } else if (entry.status === 'failed') {
429
- // Re-claim failed entries — the caller can retry.
430
- claimed.push(hash);
431
- toWrite.set(key, {
432
- status: 'in_flight',
433
- claimedBy: body.callerId,
434
- claimedAt: now,
435
- });
436
- } else {
437
- // Already in-flight by someone else; tell caller to await.
438
- inFlight.push(hash);
439
- }
440
- }
441
-
442
- if (toWrite.size > 0) {
443
- await this.state.storage.put(Object.fromEntries(toWrite));
444
- }
445
- });
446
-
447
- const response: LookupOrClaimResponse = { hits, claimed, inFlight };
448
- return new Response(JSON.stringify(response), {
449
- headers: { 'content-type': 'application/json' },
450
- });
451
- }
452
-
453
- private async handlePublish(req: Request): Promise<Response> {
454
- const body = (await req.json()) as PublishRequest;
455
- const now = Date.now();
456
- const writes: Record<string, DedupEntry> = {};
457
- for (const { inputHash, result } of body.results) {
458
- writes[this.storageKey(body.toolId, inputHash)] = {
459
- status: 'completed',
460
- result,
461
- completedAt: now,
462
- };
463
- }
464
- await this.state.storage.put(writes);
465
-
466
- // Wake any awaiters.
467
- for (const { inputHash, result } of body.results) {
468
- const key = this.waiterKey(body.toolId, inputHash);
469
- const set = this.waiters.get(key);
470
- if (set) {
471
- for (const resolve of set) resolve(result);
472
- this.waiters.delete(key);
473
- }
474
- }
475
-
476
- return new Response('{}', {
477
- headers: { 'content-type': 'application/json' },
478
- });
479
- }
480
-
481
- private async handleAwait(req: Request): Promise<Response> {
482
- const body = (await req.json()) as AwaitRequest;
483
- const results: Record<string, unknown> = {};
484
- const stillWaiting: string[] = [];
485
-
486
- // Synchronous read first.
487
- const keys = body.inputHashes.map((h) => this.storageKey(body.toolId, h));
488
- const initial = (await this.state.storage.get<DedupEntry>(keys)) as Map<
489
- string,
490
- DedupEntry
491
- >;
492
- for (let i = 0; i < body.inputHashes.length; i += 1) {
493
- const hash = body.inputHashes[i]!;
494
- const entry = initial.get(keys[i]!);
495
- if (entry?.status === 'completed') {
496
- results[hash] = entry.result;
497
- } else {
498
- stillWaiting.push(hash);
499
- }
500
- }
501
-
502
- if (stillWaiting.length === 0) {
503
- return new Response(JSON.stringify({ results }), {
504
- headers: { 'content-type': 'application/json' },
505
- });
506
- }
507
-
508
- // Long-poll. Register waiter promises; resolve via /publish or timeout.
509
- const promises = stillWaiting.map(
510
- (hash) =>
511
- new Promise<{ hash: string; result: unknown }>((resolve, reject) => {
512
- const wKey = this.waiterKey(body.toolId, hash);
513
- if (!this.waiters.has(wKey)) this.waiters.set(wKey, new Set());
514
- const onResolve = (result: unknown) => resolve({ hash, result });
515
- this.waiters.get(wKey)!.add(onResolve);
516
- setTimeout(() => {
517
- this.waiters.get(wKey)?.delete(onResolve);
518
- reject(new Error(`Dedup await timeout for ${hash}`));
519
- }, body.timeoutMs);
520
- }),
521
- );
522
-
523
- try {
524
- const settled = await Promise.allSettled(promises);
525
- for (const s of settled) {
526
- if (s.status === 'fulfilled') {
527
- results[s.value.hash] = s.value.result;
528
- }
529
- }
530
- } catch {
531
- // Individual timeouts are not fatal; return whatever resolved.
532
- }
533
-
534
- return new Response(JSON.stringify({ results }), {
535
- headers: { 'content-type': 'application/json' },
536
- });
537
- }
538
-
539
- private async handleFinish(_req: Request): Promise<Response> {
540
- // Schedule alarm to evict after a grace period (any straggler awaits
541
- // get a chance to resolve). Grace = 60s.
542
- await this.state.storage.setAlarm(Date.now() + FINISH_ALARM_DELAY_MS);
543
- return new Response('{}', {
544
- headers: { 'content-type': 'application/json' },
545
- });
546
- }
547
-
548
- private async handleDebug(_req: Request): Promise<Response> {
549
- const all = await this.state.storage.list({ prefix: DEDUP_KEY_PREFIX });
550
- const dump: Record<string, DedupEntry> = {};
551
- for (const [k, v] of all) dump[k] = v as DedupEntry;
552
- return new Response(
553
- JSON.stringify({
554
- size: all.size,
555
- entries: dump,
556
- waiters: this.waiters.size,
557
- }),
558
- { headers: { 'content-type': 'application/json' } },
559
- );
560
- }
561
-
562
- private async handleRunRetryStatePut(req: Request): Promise<Response> {
563
- const body = (await req.json().catch(() => null)) as {
564
- runId?: unknown;
565
- params?: unknown;
566
- ttlMs?: unknown;
567
- } | null;
568
- const runId = typeof body?.runId === 'string' ? body.runId : '';
569
- if (!runId || !body || (!('params' in body) && !('paramsRef' in body))) {
570
- return new Response('runId and params or paramsRef are required', {
571
- status: 400,
572
- });
573
- }
574
- const now = Date.now();
575
- const ttlMs =
576
- typeof body.ttlMs === 'number' && Number.isFinite(body.ttlMs)
577
- ? Math.max(60_000, Math.min(body.ttlMs, WORKFLOW_RUN_STATE_TTL_MS))
578
- : WORKFLOW_RUN_STATE_TTL_MS;
579
- const key = `${WORKFLOW_RUN_RETRY_KEY_PREFIX}${runId}`;
580
- await this.state.blockConcurrencyWhile(async () => {
581
- const existing = await this.state.storage.get<WorkflowRunRetryState>(key);
582
- const retryState = {
583
- runId,
584
- params: 'params' in body ? body.params : null,
585
- paramsRef: 'paramsRef' in body ? body.paramsRef : null,
586
- paramsBytes:
587
- typeof (body as { paramsBytes?: unknown }).paramsBytes === 'number' &&
588
- Number.isFinite((body as { paramsBytes?: number }).paramsBytes)
589
- ? (body as { paramsBytes: number }).paramsBytes
590
- : undefined,
591
- retryAttempts:
592
- existing?.runId === runId &&
593
- typeof existing.retryAttempts === 'number'
594
- ? existing.retryAttempts
595
- : 0,
596
- updatedAt: now,
597
- expiresAt: now + ttlMs,
598
- } satisfies WorkflowRunRetryState;
599
- const bytes = jsonByteLength(retryState);
600
- if (bytes > WORKFLOW_RUN_RETRY_STATE_MAX_BYTES) {
601
- throw new Error(
602
- `workflow retry state too large: ${bytes} bytes exceeds ${WORKFLOW_RUN_RETRY_STATE_MAX_BYTES}`,
603
- );
604
- }
605
- await this.state.storage.put(key, retryState);
606
- });
607
- return new Response(JSON.stringify({ ok: true }), {
608
- headers: { 'content-type': 'application/json' },
609
- });
610
- }
611
-
612
- private async handleRunLaunchStatePut(req: Request): Promise<Response> {
613
- const body = (await req.json().catch(() => null)) as {
614
- runId?: unknown;
615
- params?: unknown;
616
- paramsRef?: unknown;
617
- paramsBytes?: unknown;
618
- sessions?: unknown;
619
- retryTtlMs?: unknown;
620
- dbSessionsTtlMs?: unknown;
621
- } | null;
622
- const runId = typeof body?.runId === 'string' ? body.runId : '';
623
- const sessions = Array.isArray(body?.sessions)
624
- ? (body.sessions as PreloadedRuntimeDbSession[])
625
- : null;
626
- if (
627
- !runId ||
628
- !body ||
629
- (!('params' in body) && !('paramsRef' in body)) ||
630
- !sessions
631
- ) {
632
- return new Response(
633
- 'runId, params or paramsRef, and sessions are required',
634
- {
635
- status: 400,
636
- },
637
- );
638
- }
639
- assertEncryptedDbSessionsForStorage(sessions);
640
- const now = Date.now();
641
- const retryTtlMs =
642
- typeof body.retryTtlMs === 'number' && Number.isFinite(body.retryTtlMs)
643
- ? Math.max(60_000, Math.min(body.retryTtlMs, WORKFLOW_RUN_STATE_TTL_MS))
644
- : WORKFLOW_RUN_STATE_TTL_MS;
645
- const dbSessionsTtlMs =
646
- typeof body.dbSessionsTtlMs === 'number' &&
647
- Number.isFinite(body.dbSessionsTtlMs) &&
648
- body.dbSessionsTtlMs > 0
649
- ? Math.min(
650
- Math.max(Math.floor(body.dbSessionsTtlMs), 60_000),
651
- WORKFLOW_DB_SESSIONS_MAX_TTL_MS,
652
- )
653
- : WORKFLOW_DB_SESSIONS_TTL_MS;
654
- const retryKey = `${WORKFLOW_RUN_RETRY_KEY_PREFIX}${runId}`;
655
- const dbSessionsState: WorkflowDbSessionsState = {
656
- runId,
657
- sessions,
658
- storedAt: now,
659
- expiresAt: now + dbSessionsTtlMs,
660
- };
661
- let retryStateExpiresAt = now + retryTtlMs;
662
- await this.state.blockConcurrencyWhile(async () => {
663
- const existing =
664
- await this.state.storage.get<WorkflowRunRetryState>(retryKey);
665
- const retryState = {
666
- runId,
667
- params: 'params' in body ? body.params : null,
668
- paramsRef: 'paramsRef' in body ? body.paramsRef : null,
669
- paramsBytes:
670
- typeof body.paramsBytes === 'number' &&
671
- Number.isFinite(body.paramsBytes)
672
- ? body.paramsBytes
673
- : undefined,
674
- retryAttempts:
675
- existing?.runId === runId &&
676
- typeof existing.retryAttempts === 'number'
677
- ? existing.retryAttempts
678
- : 0,
679
- updatedAt: now,
680
- expiresAt: retryStateExpiresAt,
681
- } satisfies WorkflowRunRetryState;
682
- const bytes = jsonByteLength(retryState);
683
- if (bytes > WORKFLOW_RUN_RETRY_STATE_MAX_BYTES) {
684
- throw new Error(
685
- `workflow retry state too large: ${bytes} bytes exceeds ${WORKFLOW_RUN_RETRY_STATE_MAX_BYTES}`,
686
- );
687
- }
688
- retryStateExpiresAt = retryState.expiresAt;
689
- await this.state.storage.put(retryKey, retryState);
690
- await this.state.storage.put(WORKFLOW_DB_SESSIONS_KEY, dbSessionsState);
691
- });
692
- return new Response(
693
- JSON.stringify({
694
- ok: true,
695
- sessionCount: sessions.length,
696
- retryExpiresAt: retryStateExpiresAt,
697
- dbSessionsExpiresAt: dbSessionsState.expiresAt,
698
- }),
699
- { headers: { 'content-type': 'application/json' } },
700
- );
701
- }
702
-
703
- private async handleRunRetryClaim(req: Request): Promise<Response> {
704
- const body = (await req.json().catch(() => null)) as {
705
- runId?: unknown;
706
- maxAttempts?: unknown;
707
- } | null;
708
- const runId = typeof body?.runId === 'string' ? body.runId : '';
709
- if (!runId) {
710
- return new Response('runId is required', { status: 400 });
711
- }
712
- const maxAttempts =
713
- typeof body?.maxAttempts === 'number' && Number.isFinite(body.maxAttempts)
714
- ? Math.max(0, Math.floor(body.maxAttempts))
715
- : 1;
716
- const now = Date.now();
717
- const key = `${WORKFLOW_RUN_RETRY_KEY_PREFIX}${runId}`;
718
- let response: Record<string, unknown> = {
719
- claimed: false,
720
- attempts: 0,
721
- params: null,
722
- };
723
- await this.state.blockConcurrencyWhile(async () => {
724
- const existing = await this.state.storage.get<WorkflowRunRetryState>(key);
725
- if (!existing || existing.expiresAt <= now) {
726
- response = { claimed: false, attempts: 0, params: null };
727
- return;
728
- }
729
- if (existing.retryAttempts >= maxAttempts) {
730
- response = {
731
- claimed: false,
732
- attempts: existing.retryAttempts,
733
- params: existing.params,
734
- paramsRef: existing.paramsRef ?? null,
735
- paramsBytes: existing.paramsBytes ?? null,
736
- };
737
- return;
738
- }
739
- const nextAttempts = existing.retryAttempts + 1;
740
- await this.state.storage.put(key, {
741
- ...existing,
742
- retryAttempts: nextAttempts,
743
- updatedAt: now,
744
- expiresAt: now + WORKFLOW_RUN_STATE_TTL_MS,
745
- } satisfies WorkflowRunRetryState);
746
- response = {
747
- claimed: true,
748
- attempts: nextAttempts,
749
- params: existing.params,
750
- paramsRef: existing.paramsRef ?? null,
751
- paramsBytes: existing.paramsBytes ?? null,
752
- };
753
- });
754
- return new Response(JSON.stringify(response), {
755
- headers: { 'content-type': 'application/json' },
756
- });
757
- }
758
-
759
- private async handleWorkflowInstancePut(req: Request): Promise<Response> {
760
- const body = (await req.json().catch(() => null)) as {
761
- runId?: unknown;
762
- instanceId?: unknown;
763
- ttlMs?: unknown;
764
- } | null;
765
- const runId = typeof body?.runId === 'string' ? body.runId : '';
766
- const instanceId =
767
- typeof body?.instanceId === 'string' ? body.instanceId : '';
768
- if (!runId || !instanceId) {
769
- return new Response('runId and instanceId are required', { status: 400 });
770
- }
771
- const now = Date.now();
772
- const ttlMs =
773
- typeof body?.ttlMs === 'number' &&
774
- Number.isFinite(body.ttlMs) &&
775
- body.ttlMs > 0
776
- ? Math.max(60_000, Math.min(body.ttlMs, WORKFLOW_RUN_STATE_TTL_MS))
777
- : WORKFLOW_RUN_STATE_TTL_MS;
778
- await this.state.storage.put({
779
- 'workflow-instance': {
780
- runId,
781
- instanceId,
782
- updatedAt: now,
783
- expiresAt: now + ttlMs,
784
- } satisfies WorkflowInstanceState,
785
- 'workflow-submit-claim': {
786
- runId,
787
- status: 'created',
788
- instanceId,
789
- claimedAt: now,
790
- updatedAt: now,
791
- expiresAt: now + ttlMs,
792
- } satisfies WorkflowSubmitClaimState,
793
- });
794
- return new Response(JSON.stringify({ ok: true }), {
795
- headers: { 'content-type': 'application/json' },
796
- });
797
- }
798
-
799
- private async handleWorkflowInstanceGet(req: Request): Promise<Response> {
800
- const runId = new URL(req.url).searchParams.get('runId') ?? '';
801
- if (!runId) {
802
- return new Response('runId is required', { status: 400 });
803
- }
804
- const state =
805
- await this.state.storage.get<WorkflowInstanceState>('workflow-instance');
806
- if (!state || state.runId !== runId) {
807
- return new Response(JSON.stringify({ instanceId: null }), {
808
- headers: { 'content-type': 'application/json' },
809
- });
810
- }
811
- if (state.expiresAt <= Date.now()) {
812
- await this.state.storage.delete('workflow-instance');
813
- return new Response(JSON.stringify({ instanceId: null }), {
814
- headers: { 'content-type': 'application/json' },
815
- });
816
- }
817
- return new Response(JSON.stringify({ instanceId: state.instanceId }), {
818
- headers: { 'content-type': 'application/json' },
819
- });
820
- }
821
-
822
- private async handleWorkflowSubmitClaim(req: Request): Promise<Response> {
823
- const body = (await req.json().catch(() => null)) as {
824
- runId?: unknown;
825
- ttlMs?: unknown;
826
- } | null;
827
- const runId = typeof body?.runId === 'string' ? body.runId : '';
828
- if (!runId) {
829
- return new Response('runId is required', { status: 400 });
830
- }
831
- const now = Date.now();
832
- const ttlMs =
833
- typeof body?.ttlMs === 'number' &&
834
- Number.isFinite(body.ttlMs) &&
835
- body.ttlMs > 0
836
- ? Math.max(60_000, Math.min(body.ttlMs, WORKFLOW_RUN_STATE_TTL_MS))
837
- : WORKFLOW_RUN_STATE_TTL_MS;
838
- let response: {
839
- claimed: boolean;
840
- status: WorkflowSubmitClaimState['status'];
841
- instanceId: string | null;
842
- } = { claimed: true, status: 'creating', instanceId: null };
843
- await this.state.blockConcurrencyWhile(async () => {
844
- const key = 'workflow-submit-claim';
845
- const existing =
846
- await this.state.storage.get<WorkflowSubmitClaimState>(key);
847
- if (existing?.runId === runId && existing.expiresAt > now) {
848
- response = {
849
- claimed: false,
850
- status: existing.status,
851
- instanceId: existing.instanceId ?? null,
852
- };
853
- return;
854
- }
855
- const state = {
856
- runId,
857
- status: 'creating',
858
- claimedAt: now,
859
- updatedAt: now,
860
- expiresAt: now + ttlMs,
861
- } satisfies WorkflowSubmitClaimState;
862
- await this.state.storage.put(key, state);
863
- });
864
- return new Response(JSON.stringify(response), {
865
- headers: { 'content-type': 'application/json' },
866
- });
867
- }
868
-
869
- private async handleWorkflowSubmitRelease(req: Request): Promise<Response> {
870
- const body = (await req.json().catch(() => null)) as {
871
- runId?: unknown;
872
- } | null;
873
- const runId = typeof body?.runId === 'string' ? body.runId : '';
874
- if (!runId) {
875
- return new Response('runId is required', { status: 400 });
876
- }
877
- let released = false;
878
- await this.state.blockConcurrencyWhile(async () => {
879
- const key = 'workflow-submit-claim';
880
- const existing =
881
- await this.state.storage.get<WorkflowSubmitClaimState>(key);
882
- if (existing?.runId === runId && existing.status === 'creating') {
883
- await this.state.storage.delete(key);
884
- released = true;
885
- }
886
- });
887
- return new Response(JSON.stringify({ ok: true, released }), {
888
- headers: { 'content-type': 'application/json' },
889
- });
890
- }
891
-
892
- private async handleDbSessionsPut(req: Request): Promise<Response> {
893
- const body = (await req.json().catch(() => null)) as {
894
- runId?: unknown;
895
- sessions?: unknown;
896
- ttlMs?: unknown;
897
- } | null;
898
- const runId = typeof body?.runId === 'string' ? body.runId : '';
899
- const sessions = Array.isArray(body?.sessions)
900
- ? (body.sessions as PreloadedRuntimeDbSession[])
901
- : null;
902
- if (!runId || !sessions) {
903
- return new Response('runId and sessions are required', { status: 400 });
904
- }
905
- assertEncryptedDbSessionsForStorage(sessions);
906
- const now = Date.now();
907
- const ttlMs =
908
- typeof body?.ttlMs === 'number' &&
909
- Number.isFinite(body.ttlMs) &&
910
- body.ttlMs > 0
911
- ? Math.min(
912
- Math.max(Math.floor(body.ttlMs), 60_000),
913
- WORKFLOW_DB_SESSIONS_MAX_TTL_MS,
914
- )
915
- : WORKFLOW_DB_SESSIONS_TTL_MS;
916
- const state: WorkflowDbSessionsState = {
917
- runId,
918
- sessions,
919
- storedAt: now,
920
- expiresAt: now + ttlMs,
921
- };
922
- await this.state.storage.put(WORKFLOW_DB_SESSIONS_KEY, state);
923
- return new Response(
924
- JSON.stringify({
925
- ok: true,
926
- sessionCount: sessions.length,
927
- expiresAt: state.expiresAt,
928
- }),
929
- { headers: { 'content-type': 'application/json' } },
930
- );
931
- }
932
-
933
- private async handleDbSessionsGet(req: Request): Promise<Response> {
934
- const url = new URL(req.url);
935
- const runId = url.searchParams.get('runId') ?? '';
936
- if (!runId) {
937
- return new Response('runId is required', { status: 400 });
938
- }
939
- const state = await this.state.storage.get<WorkflowDbSessionsState>(
940
- WORKFLOW_DB_SESSIONS_KEY,
941
- );
942
- if (!state || state.runId !== runId) {
943
- return new Response(JSON.stringify({ error: 'db sessions not found' }), {
944
- status: 404,
945
- headers: { 'content-type': 'application/json' },
946
- });
947
- }
948
- if (state.expiresAt <= Date.now()) {
949
- await this.state.storage.delete(WORKFLOW_DB_SESSIONS_KEY);
950
- return new Response(
951
- JSON.stringify({
952
- error:
953
- 'workflow db sessions expired: the run exceeded the session retention window before completing. This usually follows an earlier runtime persistence failure or a stalled run; re-run to resume.',
954
- }),
955
- {
956
- status: 410,
957
- headers: { 'content-type': 'application/json' },
958
- },
959
- );
960
- }
961
- assertEncryptedDbSessionsForStorage(state.sessions);
962
- return new Response(
963
- JSON.stringify({
964
- sessions: state.sessions,
965
- sessionCount: state.sessions.length,
966
- expiresAt: state.expiresAt,
967
- }),
968
- { headers: { 'content-type': 'application/json' } },
969
- );
970
- }
971
-
972
- private async handleTraceAdd(req: Request): Promise<Response> {
973
- const body = (await req
974
- .json()
975
- .catch(() => null)) as Partial<CoordinatorTraceEntry> | null;
976
- if (
977
- !body ||
978
- (body.source !== 'coordinator' && body.source !== 'dynamic_worker') ||
979
- typeof body.runId !== 'string' ||
980
- typeof body.phase !== 'string' ||
981
- typeof body.ts !== 'number' ||
982
- typeof body.ms !== 'number'
983
- ) {
984
- return new Response('invalid trace entry', { status: 400 });
985
- }
986
- const entry = durableStorageRedactor.redact({
987
- ...body,
988
- source: body.source,
989
- runId: body.runId,
990
- phase: body.phase,
991
- ts: body.ts,
992
- ms: body.ms,
993
- graphHash:
994
- typeof body.graphHash === 'string' || body.graphHash === null
995
- ? body.graphHash
996
- : undefined,
997
- }) as CoordinatorTraceEntry;
998
- await this.state.blockConcurrencyWhile(async () => {
999
- await this.state.storage.put(this.traceKey(entry), entry);
1000
- const entries = await this.state.storage.list<CoordinatorTraceEntry>({
1001
- prefix: COORDINATOR_TRACE_KEY_PREFIX,
1002
- });
1003
- const overflow = entries.size - COORDINATOR_TRACE_MAX_ENTRIES;
1004
- if (overflow > 0) {
1005
- await this.state.storage.delete([...entries.keys()].slice(0, overflow));
1006
- }
1007
- });
1008
- return new Response('{}', {
1009
- headers: { 'content-type': 'application/json' },
1010
- });
1011
- }
1012
-
1013
- private async handleTraceList(): Promise<Response> {
1014
- const entries = await this.state.storage.list<CoordinatorTraceEntry>({
1015
- prefix: COORDINATOR_TRACE_KEY_PREFIX,
1016
- });
1017
- return new Response(
1018
- JSON.stringify({
1019
- entries: [...entries.values()].sort(
1020
- (left, right) => left.ts - right.ts,
1021
- ),
1022
- }),
1023
- { headers: { 'content-type': 'application/json' } },
1024
- );
1025
- }
1026
-
1027
- private async handleTerminalSet(req: Request): Promise<Response> {
1028
- const body = (await req
1029
- .json()
1030
- .catch(() => null)) as Partial<CoordinatorTerminalState> | null;
1031
- if (
1032
- !body ||
1033
- typeof body.runId !== 'string' ||
1034
- (body.status !== 'completed' &&
1035
- body.status !== 'failed' &&
1036
- body.status !== 'cancelled')
1037
- ) {
1038
- return new Response('invalid terminal state', { status: 400 });
1039
- }
1040
- const state: CoordinatorTerminalState = {
1041
- runId: body.runId,
1042
- status: body.status,
1043
- result: body.result,
1044
- error:
1045
- typeof body.error === 'string' && body.error.trim()
1046
- ? body.error.trim()
1047
- : body.status === 'failed'
1048
- ? 'Cloudflare workflow failed before the runner reported an error.'
1049
- : null,
1050
- totalRows: body.totalRows,
1051
- durationMs: body.durationMs,
1052
- playName: typeof body.playName === 'string' ? body.playName : null,
1053
- liveLogs: sanitizeLiveLogLines(body.liveLogs),
1054
- liveNodeProgress: body.liveNodeProgress,
1055
- completedAt:
1056
- typeof body.completedAt === 'number' ? body.completedAt : Date.now(),
1057
- };
1058
- await this.state.storage.put(COORDINATOR_TERMINAL_KEY, state);
1059
- const event = await this.storeRunEvent({
1060
- runId: state.runId,
1061
- type: 'terminal',
1062
- status: state.status,
1063
- result: state.result,
1064
- error: state.error,
1065
- totalRows: state.totalRows,
1066
- durationMs: state.durationMs,
1067
- playName: state.playName,
1068
- liveLogs: state.liveLogs,
1069
- liveNodeProgress: state.liveNodeProgress,
1070
- ts: state.completedAt,
1071
- });
1072
- this.wakeRunEventWaiters(event);
1073
- return new Response('{}', {
1074
- headers: { 'content-type': 'application/json' },
1075
- });
1076
- }
1077
-
1078
- private async handleTerminalGet(): Promise<Response> {
1079
- const state = await this.state.storage.get<CoordinatorTerminalState>(
1080
- COORDINATOR_TERMINAL_KEY,
1081
- );
1082
- return new Response(JSON.stringify({ state: state ?? null }), {
1083
- headers: { 'content-type': 'application/json' },
1084
- });
1085
- }
1086
-
1087
- /**
1088
- * Lease up to `requested` request-window permits for `bucketId` under all
1089
- * `rules`. Single-threaded DO access means this is the same sliding-window
1090
- * math as InMemoryRateStateBackend.acquire, generalized to grant a BLOCK of
1091
- * permits at once so the runtime amortizes the round-trip across many calls.
1092
- *
1093
- * Concurrency rules (`maxConcurrency`) are NOT enforced here: simultaneous
1094
- * in-flight tracking across isolates needs a reliable release signal, which a
1095
- * dying isolate cannot guarantee. The Governor's global tool-concurrency
1096
- * semaphore is the cross-call concurrency backstop; this DO owns the
1097
- * cross-isolate REQUEST RATE, which is the throughput governor.
1098
- */
1099
- private async handleRateAcquire(req: Request): Promise<Response> {
1100
- const body = (await req
1101
- .json()
1102
- .catch(() => null)) as RateAcquireRequest | null;
1103
- if (
1104
- !body ||
1105
- typeof body.bucketId !== 'string' ||
1106
- !body.bucketId.trim() ||
1107
- !Array.isArray(body.rules)
1108
- ) {
1109
- return new Response('bucketId and rules are required', { status: 400 });
1110
- }
1111
- const requested =
1112
- typeof body.requested === 'number' && Number.isFinite(body.requested)
1113
- ? Math.max(1, Math.floor(body.requested))
1114
- : 1;
1115
- const result = this.computeRateAcquire(
1116
- body.bucketId,
1117
- body.rules,
1118
- requested,
1119
- );
1120
- return new Response(JSON.stringify(result), {
1121
- headers: { 'content-type': 'application/json' },
1122
- });
1123
- }
1124
-
1125
- private computeRateAcquire(
1126
- bucketId: string,
1127
- rules: RateRule[],
1128
- requested: number,
1129
- ): RateAcquireResponse {
1130
- const now = Date.now();
1131
- const usableRules = rules.filter(
1132
- (rule) =>
1133
- rule &&
1134
- typeof rule.ruleId === 'string' &&
1135
- typeof rule.requestsPerWindow === 'number' &&
1136
- rule.requestsPerWindow > 0 &&
1137
- typeof rule.windowMs === 'number',
1138
- );
1139
- if (usableRules.length === 0) {
1140
- return { granted: requested, waitMs: 0 };
1141
- }
1142
-
1143
- let waitMs = 0;
1144
- const cooldownUntil = this.rateCooldownUntil.get(bucketId) ?? 0;
1145
- if (cooldownUntil > now) waitMs = Math.max(waitMs, cooldownUntil - now);
1146
-
1147
- // The grant is the min remaining capacity across every rule's window. A
1148
- // single round-trip can never debit more than the tightest rule allows.
1149
- let grantable = requested;
1150
- for (const rule of usableRules) {
1151
- const state = this.getRateWindowState(bucketId, rule, now);
1152
- this.resetExpiredRateWindow(state, rule.windowMs, now);
1153
- const remaining = rule.requestsPerWindow - state.startedInWindow;
1154
- grantable = Math.min(grantable, Math.max(0, remaining));
1155
- if (remaining <= 0) {
1156
- waitMs = Math.max(
1157
- waitMs,
1158
- rule.windowMs > 0
1159
- ? state.windowStartedAt + rule.windowMs - now
1160
- : RATE_MIN_WAIT_MS,
1161
- );
1162
- }
1163
- }
1164
-
1165
- if (waitMs > 0 || grantable <= 0) {
1166
- return { granted: 0, waitMs: Math.max(RATE_MIN_WAIT_MS, waitMs) };
1167
- }
1168
-
1169
- for (const rule of usableRules) {
1170
- const state = this.getRateWindowState(bucketId, rule, now);
1171
- state.startedInWindow += grantable;
1172
- }
1173
- return { granted: grantable, waitMs: 0 };
1174
- }
1175
-
1176
- private async handleRatePenalize(req: Request): Promise<Response> {
1177
- const body = (await req
1178
- .json()
1179
- .catch(() => null)) as RatePenalizeRequest | null;
1180
- if (!body || typeof body.bucketId !== 'string' || !body.bucketId.trim()) {
1181
- return new Response('bucketId is required', { status: 400 });
1182
- }
1183
- const cooldownMs =
1184
- typeof body.cooldownMs === 'number' && Number.isFinite(body.cooldownMs)
1185
- ? Math.floor(body.cooldownMs)
1186
- : 0;
1187
- if (cooldownMs > 0) {
1188
- const until = Date.now() + cooldownMs;
1189
- const existing = this.rateCooldownUntil.get(body.bucketId) ?? 0;
1190
- this.rateCooldownUntil.set(body.bucketId, Math.max(existing, until));
1191
- }
1192
- return new Response('{}', {
1193
- headers: { 'content-type': 'application/json' },
1194
- });
1195
- }
1196
-
1197
- private getRateWindowState(
1198
- bucketId: string,
1199
- rule: RateRule,
1200
- now: number,
1201
- ): RateRuleWindowState {
1202
- const key = RATE_STATE_KEY(bucketId, rule.ruleId);
1203
- const existing = this.rateWindows.get(key);
1204
- if (existing) return existing;
1205
- const created: RateRuleWindowState = {
1206
- windowStartedAt: now,
1207
- startedInWindow: 0,
1208
- };
1209
- this.rateWindows.set(key, created);
1210
- return created;
1211
- }
1212
-
1213
- private resetExpiredRateWindow(
1214
- state: RateRuleWindowState,
1215
- windowMs: number,
1216
- now: number,
1217
- ): void {
1218
- if (windowMs <= 0) {
1219
- state.windowStartedAt = now;
1220
- state.startedInWindow = 0;
1221
- return;
1222
- }
1223
- if (now - state.windowStartedAt < windowMs) return;
1224
- const elapsed = Math.floor((now - state.windowStartedAt) / windowMs);
1225
- state.windowStartedAt += elapsed * windowMs;
1226
- state.startedInWindow = 0;
1227
- if (now - state.windowStartedAt >= windowMs) state.windowStartedAt = now;
1228
- }
1229
-
1230
- private async storeRunEvent(
1231
- input: CoordinatorRunEventInput,
1232
- ): Promise<CoordinatorRunEvent> {
1233
- let event: CoordinatorRunEvent | null = null;
1234
- await this.state.blockConcurrencyWhile(async () => {
1235
- const current =
1236
- (await this.state.storage.get<number>(
1237
- COORDINATOR_RUN_EVENT_SEQUENCE_KEY,
1238
- )) ?? 0;
1239
- const seq = current + 1;
1240
- event = { ...input, seq } as CoordinatorRunEvent;
1241
- await this.state.storage.put({
1242
- [COORDINATOR_RUN_EVENT_SEQUENCE_KEY]: seq,
1243
- [this.runEventKey(event)]: event,
1244
- });
1245
- const pruneSeq = seq - COORDINATOR_RUN_EVENT_MAX_ENTRIES;
1246
- if (pruneSeq > 0) {
1247
- await this.state.storage.delete(this.runEventKeyForSeq(pruneSeq));
1248
- }
1249
- });
1250
- if (!event) {
1251
- throw new Error('failed to store run event');
1252
- }
1253
- return event;
1254
- }
1255
-
1256
- private wakeRunEventWaiters(event: CoordinatorRunEvent): void {
1257
- for (const resolve of this.runEventWaiters) {
1258
- resolve(event);
1259
- }
1260
- this.runEventWaiters.clear();
1261
- }
1262
-
1263
- private async handleRunEventAdd(req: Request): Promise<Response> {
1264
- const body = (await req
1265
- .json()
1266
- .catch(() => null)) as Partial<CoordinatorRunEvent> | null;
1267
- if (!body || typeof body.runId !== 'string') {
1268
- return new Response('invalid run event', { status: 400 });
1269
- }
1270
- let eventInput: CoordinatorRunEventInput | null = null;
1271
- const ts = typeof body.ts === 'number' ? body.ts : Date.now();
1272
- if (body.type === 'log' && typeof body.line === 'string') {
1273
- eventInput = {
1274
- runId: body.runId,
1275
- type: 'log',
1276
- line: body.line,
1277
- ts,
1278
- };
1279
- } else if (body.type === 'status' && typeof body.status === 'string') {
1280
- eventInput = {
1281
- runId: body.runId,
1282
- type: 'status',
1283
- status: body.status,
1284
- ts,
1285
- logs: Array.isArray(body.logs)
1286
- ? body.logs.filter((line): line is string => typeof line === 'string')
1287
- : undefined,
1288
- };
1289
- } else if (body.type === 'progress' && typeof body.status === 'string') {
1290
- eventInput = {
1291
- runId: body.runId,
1292
- type: 'progress',
1293
- status: body.status,
1294
- ts,
1295
- logs: Array.isArray(body.logs)
1296
- ? body.logs.filter((line): line is string => typeof line === 'string')
1297
- : undefined,
1298
- activeNodeId:
1299
- typeof body.activeNodeId === 'string' ? body.activeNodeId : null,
1300
- activeArtifactTableNamespace:
1301
- typeof body.activeArtifactTableNamespace === 'string'
1302
- ? body.activeArtifactTableNamespace
1303
- : null,
1304
- updatedAt: typeof body.updatedAt === 'number' ? body.updatedAt : null,
1305
- liveNodeProgress: body.liveNodeProgress,
1306
- };
1307
- } else if (
1308
- body.type === 'terminal' &&
1309
- (body.status === 'completed' ||
1310
- body.status === 'failed' ||
1311
- body.status === 'cancelled')
1312
- ) {
1313
- eventInput = {
1314
- runId: body.runId,
1315
- type: 'terminal',
1316
- status: body.status,
1317
- ts,
1318
- result: body.result,
1319
- error: typeof body.error === 'string' ? body.error : null,
1320
- totalRows: body.totalRows,
1321
- durationMs: body.durationMs,
1322
- playName: typeof body.playName === 'string' ? body.playName : null,
1323
- };
1324
- }
1325
- if (!eventInput) {
1326
- return new Response('invalid run event', { status: 400 });
1327
- }
1328
- const event = await this.storeRunEvent(eventInput);
1329
- this.wakeRunEventWaiters(event);
1330
- return new Response(JSON.stringify({ event }), {
1331
- headers: { 'content-type': 'application/json' },
1332
- });
1333
- }
1334
-
1335
- private async handleRunEventList(req: Request): Promise<Response> {
1336
- const url = new URL(req.url);
1337
- const afterSeq = Math.max(
1338
- 0,
1339
- Math.floor(Number(url.searchParams.get('afterSeq') ?? '0')),
1340
- );
1341
- const timeoutMs = Math.min(
1342
- Math.max(Number(url.searchParams.get('timeoutMs') ?? '0'), 0),
1343
- 30_000,
1344
- );
1345
- const readLatestSeq = async () =>
1346
- (await this.state.storage.get<number>(
1347
- COORDINATOR_RUN_EVENT_SEQUENCE_KEY,
1348
- )) ?? afterSeq;
1349
- const readEvents = async (latestSeq: number) => {
1350
- const firstAvailableSeq = Math.max(
1351
- 1,
1352
- latestSeq - COORDINATOR_RUN_EVENT_MAX_ENTRIES + 1,
1353
- );
1354
- const startSeq = Math.max(afterSeq + 1, firstAvailableSeq);
1355
- if (latestSeq < startSeq) return [];
1356
- const keys: string[] = [];
1357
- for (let seq = startSeq; seq <= latestSeq; seq += 1) {
1358
- keys.push(this.runEventKeyForSeq(seq));
1359
- }
1360
- const entries = await this.state.storage.get<CoordinatorRunEvent>(keys);
1361
- return [...entries.values()].sort((left, right) => left.seq - right.seq);
1362
- };
1363
- let latestSeq = await readLatestSeq();
1364
- let events = await readEvents(latestSeq);
1365
- if (events.length === 0 && timeoutMs > 0) {
1366
- await new Promise<void>((resolve) => {
1367
- let settled = false;
1368
- const finish = () => {
1369
- if (settled) return;
1370
- settled = true;
1371
- this.runEventWaiters.delete(onEvent);
1372
- clearTimeout(timeout);
1373
- resolve();
1374
- };
1375
- const onEvent = () => finish();
1376
- const timeout = setTimeout(finish, timeoutMs);
1377
- this.runEventWaiters.add(onEvent);
1378
- });
1379
- latestSeq = await readLatestSeq();
1380
- events = await readEvents(latestSeq);
1381
- }
1382
- return new Response(JSON.stringify({ events, latestSeq }), {
1383
- headers: { 'content-type': 'application/json' },
1384
- });
1385
- }
1386
- }