deepline 0.0.1 → 0.1.1

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 (100) hide show
  1. package/README.md +324 -0
  2. package/dist/cli/index.js +6750 -503
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/cli/index.mjs +6735 -512
  5. package/dist/cli/index.mjs.map +1 -1
  6. package/dist/index.d.mts +2349 -32
  7. package/dist/index.d.ts +2349 -32
  8. package/dist/index.js +1631 -82
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +1617 -83
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/repo/apps/play-runner-workers/src/coordinator-entry.ts +3256 -0
  13. package/dist/repo/apps/play-runner-workers/src/dedup-do.ts +710 -0
  14. package/dist/repo/apps/play-runner-workers/src/entry.ts +5070 -0
  15. package/dist/repo/apps/play-runner-workers/src/runtime/README.md +21 -0
  16. package/dist/repo/apps/play-runner-workers/src/runtime/batching.ts +177 -0
  17. package/dist/repo/apps/play-runner-workers/src/runtime/execution-plan.ts +52 -0
  18. package/dist/repo/apps/play-runner-workers/src/runtime/tool-batch.ts +100 -0
  19. package/dist/repo/apps/play-runner-workers/src/runtime/tool-result.ts +184 -0
  20. package/dist/repo/sdk/src/cli/commands/auth.ts +482 -0
  21. package/dist/repo/sdk/src/cli/commands/billing.ts +188 -0
  22. package/dist/repo/sdk/src/cli/commands/csv.ts +123 -0
  23. package/dist/repo/sdk/src/cli/commands/db.ts +119 -0
  24. package/dist/repo/sdk/src/cli/commands/feedback.ts +40 -0
  25. package/dist/repo/sdk/src/cli/commands/org.ts +117 -0
  26. package/dist/repo/sdk/src/cli/commands/play.ts +3200 -0
  27. package/dist/repo/sdk/src/cli/commands/tools.ts +687 -0
  28. package/dist/repo/sdk/src/cli/dataset-stats.ts +341 -0
  29. package/dist/repo/sdk/src/cli/index.ts +138 -0
  30. package/dist/repo/sdk/src/cli/progress.ts +135 -0
  31. package/dist/repo/sdk/src/cli/trace.ts +61 -0
  32. package/dist/repo/sdk/src/cli/utils.ts +145 -0
  33. package/dist/repo/sdk/src/client.ts +1188 -0
  34. package/dist/repo/sdk/src/compat.ts +77 -0
  35. package/dist/repo/sdk/src/config.ts +285 -0
  36. package/dist/repo/sdk/src/errors.ts +125 -0
  37. package/dist/repo/sdk/src/http.ts +391 -0
  38. package/dist/repo/sdk/src/index.ts +139 -0
  39. package/dist/repo/sdk/src/play.ts +1330 -0
  40. package/dist/repo/sdk/src/plays/bundle-play-file.ts +133 -0
  41. package/dist/repo/sdk/src/plays/harness-stub.ts +210 -0
  42. package/dist/repo/sdk/src/plays/local-file-discovery.ts +326 -0
  43. package/dist/repo/sdk/src/tool-output.ts +489 -0
  44. package/dist/repo/sdk/src/types.ts +669 -0
  45. package/dist/repo/sdk/src/version.ts +2 -0
  46. package/dist/repo/sdk/src/worker-play-entry.ts +286 -0
  47. package/dist/repo/shared_libs/observability/node-tracing.ts +129 -0
  48. package/dist/repo/shared_libs/observability/tracing.ts +98 -0
  49. package/dist/repo/shared_libs/play-runtime/backend.ts +139 -0
  50. package/dist/repo/shared_libs/play-runtime/batch-runtime.ts +182 -0
  51. package/dist/repo/shared_libs/play-runtime/batching-types.ts +91 -0
  52. package/dist/repo/shared_libs/play-runtime/context.ts +3999 -0
  53. package/dist/repo/shared_libs/play-runtime/coordinator-headers.ts +78 -0
  54. package/dist/repo/shared_libs/play-runtime/ctx-contract.ts +250 -0
  55. package/dist/repo/shared_libs/play-runtime/ctx-types.ts +713 -0
  56. package/dist/repo/shared_libs/play-runtime/dataset-id.ts +10 -0
  57. package/dist/repo/shared_libs/play-runtime/db-session-crypto.ts +304 -0
  58. package/dist/repo/shared_libs/play-runtime/db-session.ts +462 -0
  59. package/dist/repo/shared_libs/play-runtime/dedup-backend.ts +0 -0
  60. package/dist/repo/shared_libs/play-runtime/default-batch-strategies.ts +124 -0
  61. package/dist/repo/shared_libs/play-runtime/execution-plan.ts +262 -0
  62. package/dist/repo/shared_libs/play-runtime/live-events.ts +214 -0
  63. package/dist/repo/shared_libs/play-runtime/live-state-contract.ts +50 -0
  64. package/dist/repo/shared_libs/play-runtime/map-execution-frame.ts +114 -0
  65. package/dist/repo/shared_libs/play-runtime/map-row-identity.ts +158 -0
  66. package/dist/repo/shared_libs/play-runtime/profiles.ts +90 -0
  67. package/dist/repo/shared_libs/play-runtime/progress-emitter.ts +172 -0
  68. package/dist/repo/shared_libs/play-runtime/protocol.ts +121 -0
  69. package/dist/repo/shared_libs/play-runtime/public-play-contract.ts +42 -0
  70. package/dist/repo/shared_libs/play-runtime/result-normalization.ts +33 -0
  71. package/dist/repo/shared_libs/play-runtime/runtime-actions.ts +208 -0
  72. package/dist/repo/shared_libs/play-runtime/runtime-api.ts +1873 -0
  73. package/dist/repo/shared_libs/play-runtime/runtime-constraints.ts +2 -0
  74. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-neon-serverless.ts +201 -0
  75. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-pg.ts +48 -0
  76. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver.ts +84 -0
  77. package/dist/repo/shared_libs/play-runtime/scheduler-backend.ts +174 -0
  78. package/dist/repo/shared_libs/play-runtime/static-pipeline-types.ts +147 -0
  79. package/dist/repo/shared_libs/play-runtime/suspension.ts +68 -0
  80. package/dist/repo/shared_libs/play-runtime/tool-batch-executor.ts +146 -0
  81. package/dist/repo/shared_libs/play-runtime/tool-result.ts +387 -0
  82. package/dist/repo/shared_libs/play-runtime/tracing.ts +31 -0
  83. package/dist/repo/shared_libs/play-runtime/waterfall-replay.ts +75 -0
  84. package/dist/repo/shared_libs/play-runtime/worker-api-types.ts +140 -0
  85. package/dist/repo/shared_libs/plays/artifact-transport.ts +14 -0
  86. package/dist/repo/shared_libs/plays/artifact-types.ts +49 -0
  87. package/dist/repo/shared_libs/plays/bundling/index.ts +1346 -0
  88. package/dist/repo/shared_libs/plays/compiler-manifest.ts +186 -0
  89. package/dist/repo/shared_libs/plays/contracts.ts +51 -0
  90. package/dist/repo/shared_libs/plays/dataset.ts +308 -0
  91. package/dist/repo/shared_libs/plays/definition.ts +264 -0
  92. package/dist/repo/shared_libs/plays/file-refs.ts +11 -0
  93. package/dist/repo/shared_libs/plays/rate-limit-scheduler.ts +206 -0
  94. package/dist/repo/shared_libs/plays/resolve-static-pipeline.ts +164 -0
  95. package/dist/repo/shared_libs/plays/row-identity.ts +302 -0
  96. package/dist/repo/shared_libs/plays/runtime-validation.ts +415 -0
  97. package/dist/repo/shared_libs/plays/static-pipeline.ts +560 -0
  98. package/dist/repo/shared_libs/temporal/constants.ts +39 -0
  99. package/dist/repo/shared_libs/temporal/preview-config.ts +153 -0
  100. package/package.json +14 -12
@@ -0,0 +1,710 @@
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
+ type DedupEntry =
20
+ | { status: 'in_flight'; claimedBy: string; claimedAt: number }
21
+ | { status: 'completed'; result: unknown; completedAt: number }
22
+ | { status: 'failed'; error: string; failedAt: number };
23
+
24
+ type LookupOrClaimRequest = {
25
+ runId: string;
26
+ toolId: string;
27
+ inputHashes: string[];
28
+ /** Identifier for the caller — used as `claimedBy` for in-flight entries. */
29
+ callerId: string;
30
+ };
31
+
32
+ type LookupOrClaimResponse = {
33
+ hits: Record<string, unknown>;
34
+ claimed: string[];
35
+ inFlight: string[];
36
+ };
37
+
38
+ type PublishRequest = {
39
+ runId: string;
40
+ toolId: string;
41
+ results: Array<{ inputHash: string; result: unknown }>;
42
+ };
43
+
44
+ type AwaitRequest = {
45
+ runId: string;
46
+ toolId: string;
47
+ inputHashes: string[];
48
+ timeoutMs: number;
49
+ };
50
+
51
+ type WorkflowPoolEntry = {
52
+ id: string;
53
+ version: string;
54
+ createdAt: number;
55
+ readyAt: number | null;
56
+ expiresAt: number;
57
+ };
58
+
59
+ type WorkflowRunMapping = {
60
+ runId: string;
61
+ instanceId: string;
62
+ version: string;
63
+ createdAt: number;
64
+ expiresAt: number;
65
+ };
66
+
67
+ type CoordinatorTraceEntry = {
68
+ ts: number;
69
+ source: 'coordinator' | 'dynamic_worker';
70
+ runId: string;
71
+ phase: string;
72
+ ms: number;
73
+ graphHash?: string | null;
74
+ [key: string]: unknown;
75
+ };
76
+
77
+ type ReadyWorkflowPoolEntryRecord = {
78
+ key: string;
79
+ entry: WorkflowPoolEntry & { readyAt: number };
80
+ };
81
+
82
+ const DEDUP_KEY_PREFIX = 'd:';
83
+ const WORKFLOW_POOL_KEY_PREFIX = 'p:';
84
+ const WORKFLOW_POOL_RUN_KEY_PREFIX = 'm:';
85
+ const COORDINATOR_TRACE_KEY_PREFIX = 't:';
86
+ const COORDINATOR_TRACE_MAX_ENTRIES = 200;
87
+ const FINISH_ALARM_DELAY_MS = 60_000; // self-evict 1 min after finish() called
88
+ const WORKFLOW_POOL_DEFAULT_TTL_MS = 8 * 60 * 1000;
89
+ const WORKFLOW_POOL_RUN_MAPPING_TTL_MS = 60 * 60 * 1000;
90
+
91
+ interface DedupEnv {
92
+ PLAY_DEDUP: DurableObjectNamespace;
93
+ }
94
+
95
+ export class PlayDedup implements DurableObject {
96
+ // Promises waiting on /await endpoints. Keyed by `${toolId}:${inputHash}`.
97
+ // Resolved by /publish; rejected on timeout.
98
+ private waiters: Map<string, Set<(value: unknown) => void>> = new Map();
99
+
100
+ constructor(
101
+ private readonly state: DurableObjectState,
102
+ private readonly env: DedupEnv,
103
+ ) {}
104
+
105
+ private storageKey(toolId: string, inputHash: string): string {
106
+ return `${DEDUP_KEY_PREFIX}${toolId}:${inputHash}`;
107
+ }
108
+
109
+ private traceKey(entry: CoordinatorTraceEntry): string {
110
+ return `${COORDINATOR_TRACE_KEY_PREFIX}${String(entry.ts).padStart(13, '0')}:${crypto.randomUUID().slice(0, 8)}`;
111
+ }
112
+
113
+ private waiterKey(toolId: string, inputHash: string): string {
114
+ return `${toolId}:${inputHash}`;
115
+ }
116
+
117
+ async fetch(req: Request): Promise<Response> {
118
+ const url = new URL(req.url);
119
+ try {
120
+ switch (url.pathname) {
121
+ case '/lookup-or-claim':
122
+ return await this.handleLookupOrClaim(req);
123
+ case '/publish':
124
+ return await this.handlePublish(req);
125
+ case '/await':
126
+ return await this.handleAwait(req);
127
+ case '/finish':
128
+ return await this.handleFinish(req);
129
+ case '/debug':
130
+ return await this.handleDebug(req);
131
+ case '/pool-add':
132
+ return await this.handlePoolAdd(req);
133
+ case '/pool-lease':
134
+ return await this.handlePoolLease(req);
135
+ case '/pool-count':
136
+ return await this.handlePoolCount(req);
137
+ case '/pool-list':
138
+ return await this.handlePoolList(req);
139
+ case '/pool-promote':
140
+ return await this.handlePoolPromote(req);
141
+ case '/pool-delete':
142
+ return await this.handlePoolDelete(req);
143
+ case '/pool-map-run':
144
+ return await this.handlePoolMapRun(req);
145
+ case '/pool-resolve-run':
146
+ return await this.handlePoolResolveRun(req);
147
+ case '/pool-clear':
148
+ return await this.handlePoolClear(req);
149
+ case '/trace-add':
150
+ return await this.handleTraceAdd(req);
151
+ case '/trace-list':
152
+ return await this.handleTraceList();
153
+ default:
154
+ return new Response('not found', { status: 404 });
155
+ }
156
+ } catch (error) {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ return new Response(JSON.stringify({ error: message }), {
159
+ status: 500,
160
+ headers: { 'content-type': 'application/json' },
161
+ });
162
+ }
163
+ }
164
+
165
+ async alarm(): Promise<void> {
166
+ // Fired after /finish was called. Evict storage and let the DO instance
167
+ // be garbage-collected by CF's hot pool.
168
+ await this.state.storage.deleteAll();
169
+ this.waiters.clear();
170
+ }
171
+
172
+ private async handleLookupOrClaim(req: Request): Promise<Response> {
173
+ const body = (await req.json()) as LookupOrClaimRequest;
174
+ const hits: Record<string, unknown> = {};
175
+ const claimed: string[] = [];
176
+ const inFlight: string[] = [];
177
+
178
+ // Atomic transaction: read all keys, decide outcome per hash, write claims
179
+ // back. DO storage transactions are serialized within the DO instance.
180
+ await this.state.blockConcurrencyWhile(async () => {
181
+ const keys = body.inputHashes.map((h) => this.storageKey(body.toolId, h));
182
+ const existing = (await this.state.storage.get<DedupEntry>(keys)) as Map<
183
+ string,
184
+ DedupEntry
185
+ >;
186
+ const now = Date.now();
187
+ const toWrite = new Map<string, DedupEntry>();
188
+
189
+ for (let i = 0; i < body.inputHashes.length; i += 1) {
190
+ const hash = body.inputHashes[i]!;
191
+ const key = keys[i]!;
192
+ const entry = existing.get(key);
193
+ if (!entry) {
194
+ // Claim it for the caller.
195
+ claimed.push(hash);
196
+ toWrite.set(key, {
197
+ status: 'in_flight',
198
+ claimedBy: body.callerId,
199
+ claimedAt: now,
200
+ });
201
+ } else if (entry.status === 'completed') {
202
+ hits[hash] = entry.result;
203
+ } else if (entry.status === 'failed') {
204
+ // Re-claim failed entries — the caller can retry.
205
+ claimed.push(hash);
206
+ toWrite.set(key, {
207
+ status: 'in_flight',
208
+ claimedBy: body.callerId,
209
+ claimedAt: now,
210
+ });
211
+ } else {
212
+ // Already in-flight by someone else; tell caller to await.
213
+ inFlight.push(hash);
214
+ }
215
+ }
216
+
217
+ if (toWrite.size > 0) {
218
+ await this.state.storage.put(Object.fromEntries(toWrite));
219
+ }
220
+ });
221
+
222
+ const response: LookupOrClaimResponse = { hits, claimed, inFlight };
223
+ return new Response(JSON.stringify(response), {
224
+ headers: { 'content-type': 'application/json' },
225
+ });
226
+ }
227
+
228
+ private async handlePublish(req: Request): Promise<Response> {
229
+ const body = (await req.json()) as PublishRequest;
230
+ const now = Date.now();
231
+ const writes: Record<string, DedupEntry> = {};
232
+ for (const { inputHash, result } of body.results) {
233
+ writes[this.storageKey(body.toolId, inputHash)] = {
234
+ status: 'completed',
235
+ result,
236
+ completedAt: now,
237
+ };
238
+ }
239
+ await this.state.storage.put(writes);
240
+
241
+ // Wake any awaiters.
242
+ for (const { inputHash, result } of body.results) {
243
+ const key = this.waiterKey(body.toolId, inputHash);
244
+ const set = this.waiters.get(key);
245
+ if (set) {
246
+ for (const resolve of set) resolve(result);
247
+ this.waiters.delete(key);
248
+ }
249
+ }
250
+
251
+ return new Response('{}', { headers: { 'content-type': 'application/json' } });
252
+ }
253
+
254
+ private async handleAwait(req: Request): Promise<Response> {
255
+ const body = (await req.json()) as AwaitRequest;
256
+ const results: Record<string, unknown> = {};
257
+ const stillWaiting: string[] = [];
258
+
259
+ // Synchronous read first.
260
+ const keys = body.inputHashes.map((h) => this.storageKey(body.toolId, h));
261
+ const initial = (await this.state.storage.get<DedupEntry>(keys)) as Map<
262
+ string,
263
+ DedupEntry
264
+ >;
265
+ for (let i = 0; i < body.inputHashes.length; i += 1) {
266
+ const hash = body.inputHashes[i]!;
267
+ const entry = initial.get(keys[i]!);
268
+ if (entry?.status === 'completed') {
269
+ results[hash] = entry.result;
270
+ } else {
271
+ stillWaiting.push(hash);
272
+ }
273
+ }
274
+
275
+ if (stillWaiting.length === 0) {
276
+ return new Response(JSON.stringify({ results }), {
277
+ headers: { 'content-type': 'application/json' },
278
+ });
279
+ }
280
+
281
+ // Long-poll. Register waiter promises; resolve via /publish or timeout.
282
+ const promises = stillWaiting.map(
283
+ (hash) =>
284
+ new Promise<{ hash: string; result: unknown }>((resolve, reject) => {
285
+ const wKey = this.waiterKey(body.toolId, hash);
286
+ if (!this.waiters.has(wKey)) this.waiters.set(wKey, new Set());
287
+ const onResolve = (result: unknown) => resolve({ hash, result });
288
+ this.waiters.get(wKey)!.add(onResolve);
289
+ setTimeout(() => {
290
+ this.waiters.get(wKey)?.delete(onResolve);
291
+ reject(new Error(`Dedup await timeout for ${hash}`));
292
+ }, body.timeoutMs);
293
+ }),
294
+ );
295
+
296
+ try {
297
+ const settled = await Promise.allSettled(promises);
298
+ for (const s of settled) {
299
+ if (s.status === 'fulfilled') {
300
+ results[s.value.hash] = s.value.result;
301
+ }
302
+ }
303
+ } catch {
304
+ // Individual timeouts are not fatal; return whatever resolved.
305
+ }
306
+
307
+ return new Response(JSON.stringify({ results }), {
308
+ headers: { 'content-type': 'application/json' },
309
+ });
310
+ }
311
+
312
+ private async handleFinish(_req: Request): Promise<Response> {
313
+ // Schedule alarm to evict after a grace period (any straggler awaits
314
+ // get a chance to resolve). Grace = 60s.
315
+ await this.state.storage.setAlarm(Date.now() + FINISH_ALARM_DELAY_MS);
316
+ return new Response('{}', { headers: { 'content-type': 'application/json' } });
317
+ }
318
+
319
+ private async handleDebug(_req: Request): Promise<Response> {
320
+ const all = await this.state.storage.list({ prefix: DEDUP_KEY_PREFIX });
321
+ const dump: Record<string, DedupEntry> = {};
322
+ for (const [k, v] of all) dump[k] = v as DedupEntry;
323
+ return new Response(
324
+ JSON.stringify({ size: all.size, entries: dump, waiters: this.waiters.size }),
325
+ { headers: { 'content-type': 'application/json' } },
326
+ );
327
+ }
328
+
329
+ private workflowPoolVersion(req: Request): string {
330
+ return new URL(req.url).searchParams.get('version')?.trim() ?? '';
331
+ }
332
+
333
+ private isReadyWorkflowPoolEntry(
334
+ value: { key: string; entry: WorkflowPoolEntry | undefined },
335
+ version: string,
336
+ now: number,
337
+ ): value is ReadyWorkflowPoolEntryRecord {
338
+ return (
339
+ value.entry !== undefined &&
340
+ value.entry.version === version &&
341
+ value.entry.expiresAt > now &&
342
+ value.entry.readyAt !== null
343
+ );
344
+ }
345
+
346
+ private async gcWorkflowPool(
347
+ now = Date.now(),
348
+ version?: string,
349
+ ): Promise<void> {
350
+ const [pool, mappings] = await Promise.all([
351
+ this.state.storage.list<WorkflowPoolEntry>({
352
+ prefix: WORKFLOW_POOL_KEY_PREFIX,
353
+ }),
354
+ this.state.storage.list<WorkflowRunMapping>({
355
+ prefix: WORKFLOW_POOL_RUN_KEY_PREFIX,
356
+ }),
357
+ ]);
358
+ const expiredKeys: string[] = [];
359
+ for (const [key, entry] of pool) {
360
+ if (
361
+ !entry ||
362
+ entry.expiresAt <= now ||
363
+ (version && entry.version !== version)
364
+ ) {
365
+ expiredKeys.push(key);
366
+ }
367
+ }
368
+ for (const [key, mapping] of mappings) {
369
+ if (
370
+ !mapping ||
371
+ mapping.expiresAt <= now ||
372
+ (version && mapping.version !== version)
373
+ ) {
374
+ expiredKeys.push(key);
375
+ }
376
+ }
377
+ if (expiredKeys.length > 0) {
378
+ await this.state.storage.delete(expiredKeys);
379
+ }
380
+ }
381
+
382
+ private async handlePoolAdd(req: Request): Promise<Response> {
383
+ const body = (await req.json().catch(() => null)) as
384
+ | {
385
+ ids?: unknown;
386
+ ttlMs?: unknown;
387
+ version?: unknown;
388
+ readyAt?: unknown;
389
+ ready?: unknown;
390
+ }
391
+ | null;
392
+ const version = typeof body?.version === 'string' ? body.version.trim() : '';
393
+ if (!version) {
394
+ return new Response('version is required', { status: 400 });
395
+ }
396
+ const ids = Array.isArray(body?.ids)
397
+ ? body.ids.filter(
398
+ (id): id is string => typeof id === 'string' && id.length > 0,
399
+ )
400
+ : [];
401
+ const now = Date.now();
402
+ const hasReadyAt =
403
+ typeof body?.readyAt === 'number' && Number.isFinite(body.readyAt);
404
+ const readyAt = hasReadyAt ? Math.max(0, body.readyAt as number) : now;
405
+ const ready = body?.ready === true || hasReadyAt;
406
+ const ttlMs =
407
+ typeof body?.ttlMs === 'number' &&
408
+ Number.isFinite(body.ttlMs) &&
409
+ body.ttlMs > 0
410
+ ? Math.min(body.ttlMs, WORKFLOW_POOL_DEFAULT_TTL_MS)
411
+ : WORKFLOW_POOL_DEFAULT_TTL_MS;
412
+ const writes: Record<string, WorkflowPoolEntry> = {};
413
+ for (const id of ids) {
414
+ writes[`${WORKFLOW_POOL_KEY_PREFIX}${id}`] = {
415
+ id,
416
+ version,
417
+ createdAt: now,
418
+ readyAt: ready ? readyAt : null,
419
+ expiresAt: now + ttlMs,
420
+ };
421
+ }
422
+ await this.state.blockConcurrencyWhile(async () => {
423
+ await this.gcWorkflowPool(now, version);
424
+ if (Object.keys(writes).length > 0) {
425
+ await this.state.storage.put(writes);
426
+ }
427
+ });
428
+ return new Response(JSON.stringify({ added: ids.length }), {
429
+ headers: { 'content-type': 'application/json' },
430
+ });
431
+ }
432
+
433
+ private async handlePoolLease(req: Request): Promise<Response> {
434
+ const version = this.workflowPoolVersion(req);
435
+ if (!version) {
436
+ return new Response('version is required', { status: 400 });
437
+ }
438
+ let leasedId: string | null = null;
439
+ await this.state.blockConcurrencyWhile(async () => {
440
+ const now = Date.now();
441
+ await this.gcWorkflowPool(now, version);
442
+ const entries = await this.state.storage.list<WorkflowPoolEntry>({
443
+ prefix: WORKFLOW_POOL_KEY_PREFIX,
444
+ });
445
+ const sorted = [...entries.entries()]
446
+ .map(([key, entry]) => ({ key, entry }))
447
+ .filter((entry) => this.isReadyWorkflowPoolEntry(entry, version, now))
448
+ .sort((a, b) => a.entry.readyAt - b.entry.readyAt);
449
+ const selected = sorted[0];
450
+ if (!selected) return;
451
+ leasedId = selected.entry.id;
452
+ await this.state.storage.delete(selected.key);
453
+ });
454
+ return new Response(JSON.stringify({ id: leasedId }), {
455
+ headers: { 'content-type': 'application/json' },
456
+ });
457
+ }
458
+
459
+ private async handlePoolCount(req: Request): Promise<Response> {
460
+ const version = this.workflowPoolVersion(req);
461
+ if (!version) {
462
+ return new Response('version is required', { status: 400 });
463
+ }
464
+ await this.gcWorkflowPool(Date.now(), version);
465
+ const entries = await this.state.storage.list<WorkflowPoolEntry>({
466
+ prefix: WORKFLOW_POOL_KEY_PREFIX,
467
+ });
468
+ let available = 0;
469
+ let warming = 0;
470
+ for (const entry of entries.values()) {
471
+ if (entry.version !== version) continue;
472
+ if (entry.readyAt === null) warming += 1;
473
+ else available += 1;
474
+ }
475
+ return new Response(JSON.stringify({ available, warming }), {
476
+ headers: { 'content-type': 'application/json' },
477
+ });
478
+ }
479
+
480
+ private async handlePoolList(req: Request): Promise<Response> {
481
+ const version = this.workflowPoolVersion(req);
482
+ if (!version) {
483
+ return new Response('version is required', { status: 400 });
484
+ }
485
+ await this.gcWorkflowPool(Date.now(), version);
486
+ const entries = await this.state.storage.list<WorkflowPoolEntry>({
487
+ prefix: WORKFLOW_POOL_KEY_PREFIX,
488
+ });
489
+ return new Response(
490
+ JSON.stringify({
491
+ entries: [...entries.values()]
492
+ .filter((entry) => entry.version === version)
493
+ .map((entry) => ({
494
+ id: entry.id,
495
+ createdAt: entry.createdAt,
496
+ readyAt: entry.readyAt,
497
+ expiresAt: entry.expiresAt,
498
+ })),
499
+ }),
500
+ { headers: { 'content-type': 'application/json' } },
501
+ );
502
+ }
503
+
504
+ private async handlePoolPromote(req: Request): Promise<Response> {
505
+ const body = (await req.json().catch(() => null)) as
506
+ | {
507
+ ids?: unknown;
508
+ version?: unknown;
509
+ }
510
+ | null;
511
+ const version = typeof body?.version === 'string' ? body.version.trim() : '';
512
+ if (!version) {
513
+ return new Response('version is required', { status: 400 });
514
+ }
515
+ const ids = Array.isArray(body?.ids)
516
+ ? body.ids.filter(
517
+ (id): id is string => typeof id === 'string' && id.length > 0,
518
+ )
519
+ : [];
520
+ const now = Date.now();
521
+ const writes: Record<string, WorkflowPoolEntry> = {};
522
+ await this.state.blockConcurrencyWhile(async () => {
523
+ await this.gcWorkflowPool(now, version);
524
+ for (const id of ids) {
525
+ const key = `${WORKFLOW_POOL_KEY_PREFIX}${id}`;
526
+ const entry = await this.state.storage.get<WorkflowPoolEntry>(key);
527
+ if (!entry || entry.version !== version || entry.expiresAt <= now) {
528
+ continue;
529
+ }
530
+ writes[key] = {
531
+ ...entry,
532
+ readyAt: now,
533
+ };
534
+ }
535
+ if (Object.keys(writes).length > 0) {
536
+ await this.state.storage.put(writes);
537
+ }
538
+ });
539
+ return new Response(JSON.stringify({ promoted: Object.keys(writes).length }), {
540
+ headers: { 'content-type': 'application/json' },
541
+ });
542
+ }
543
+
544
+ private async handlePoolDelete(req: Request): Promise<Response> {
545
+ const body = (await req.json().catch(() => null)) as
546
+ | {
547
+ ids?: unknown;
548
+ version?: unknown;
549
+ }
550
+ | null;
551
+ const version = typeof body?.version === 'string' ? body.version.trim() : '';
552
+ if (!version) {
553
+ return new Response('version is required', { status: 400 });
554
+ }
555
+ const ids = Array.isArray(body?.ids)
556
+ ? body.ids.filter(
557
+ (id): id is string => typeof id === 'string' && id.length > 0,
558
+ )
559
+ : [];
560
+ const keys: string[] = [];
561
+ await this.state.blockConcurrencyWhile(async () => {
562
+ await this.gcWorkflowPool(Date.now(), version);
563
+ for (const id of ids) {
564
+ const key = `${WORKFLOW_POOL_KEY_PREFIX}${id}`;
565
+ const entry = await this.state.storage.get<WorkflowPoolEntry>(key);
566
+ if (entry?.version === version) keys.push(key);
567
+ }
568
+ if (keys.length > 0) {
569
+ await this.state.storage.delete(keys);
570
+ }
571
+ });
572
+ return new Response(JSON.stringify({ deleted: keys.length }), {
573
+ headers: { 'content-type': 'application/json' },
574
+ });
575
+ }
576
+
577
+ private async handlePoolMapRun(req: Request): Promise<Response> {
578
+ const body = (await req.json().catch(() => null)) as
579
+ | {
580
+ runId?: unknown;
581
+ instanceId?: unknown;
582
+ version?: unknown;
583
+ }
584
+ | null;
585
+ const runId = typeof body?.runId === 'string' ? body.runId : '';
586
+ const instanceId =
587
+ typeof body?.instanceId === 'string' ? body.instanceId : '';
588
+ const version = typeof body?.version === 'string' ? body.version.trim() : '';
589
+ if (!runId || !instanceId || !version) {
590
+ return new Response('runId, instanceId, and version are required', {
591
+ status: 400,
592
+ });
593
+ }
594
+ const now = Date.now();
595
+ await this.state.storage.put(
596
+ `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`,
597
+ {
598
+ runId,
599
+ instanceId,
600
+ version,
601
+ createdAt: now,
602
+ expiresAt: now + WORKFLOW_POOL_RUN_MAPPING_TTL_MS,
603
+ } satisfies WorkflowRunMapping,
604
+ );
605
+ return new Response('{}', { headers: { 'content-type': 'application/json' } });
606
+ }
607
+
608
+ private async handlePoolResolveRun(req: Request): Promise<Response> {
609
+ const url = new URL(req.url);
610
+ const runId = url.searchParams.get('runId') ?? '';
611
+ const version = url.searchParams.get('version')?.trim() ?? '';
612
+ if (!runId) {
613
+ return new Response('runId is required', { status: 400 });
614
+ }
615
+ if (!version) {
616
+ return new Response('version is required', { status: 400 });
617
+ }
618
+ await this.gcWorkflowPool(Date.now(), version);
619
+ const mapping = await this.state.storage.get<WorkflowRunMapping>(
620
+ `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`,
621
+ );
622
+ if (mapping && mapping.version !== version) {
623
+ await this.state.storage.delete(`${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`);
624
+ return new Response(JSON.stringify({ instanceId: null }), {
625
+ headers: { 'content-type': 'application/json' },
626
+ });
627
+ }
628
+ return new Response(
629
+ JSON.stringify({ instanceId: mapping?.instanceId ?? null }),
630
+ { headers: { 'content-type': 'application/json' } },
631
+ );
632
+ }
633
+
634
+ private async handlePoolClear(req: Request): Promise<Response> {
635
+ const version = this.workflowPoolVersion(req);
636
+ const [pool, mappings] = await Promise.all([
637
+ this.state.storage.list<WorkflowPoolEntry>({
638
+ prefix: WORKFLOW_POOL_KEY_PREFIX,
639
+ }),
640
+ this.state.storage.list<WorkflowRunMapping>({
641
+ prefix: WORKFLOW_POOL_RUN_KEY_PREFIX,
642
+ }),
643
+ ]);
644
+ const keys = [
645
+ ...[...pool.entries()]
646
+ .filter(([, entry]) => !version || entry.version === version)
647
+ .map(([key]) => key),
648
+ ...[...mappings.entries()]
649
+ .filter(([, entry]) => !version || entry.version === version)
650
+ .map(([key]) => key),
651
+ ];
652
+ if (keys.length > 0) {
653
+ await this.state.storage.delete(keys);
654
+ }
655
+ return new Response(JSON.stringify({ deleted: keys.length }), {
656
+ headers: { 'content-type': 'application/json' },
657
+ });
658
+ }
659
+
660
+ private async handleTraceAdd(req: Request): Promise<Response> {
661
+ const body = (await req.json().catch(() => null)) as
662
+ | Partial<CoordinatorTraceEntry>
663
+ | null;
664
+ if (
665
+ !body ||
666
+ (body.source !== 'coordinator' && body.source !== 'dynamic_worker') ||
667
+ typeof body.runId !== 'string' ||
668
+ typeof body.phase !== 'string' ||
669
+ typeof body.ts !== 'number' ||
670
+ typeof body.ms !== 'number'
671
+ ) {
672
+ return new Response('invalid trace entry', { status: 400 });
673
+ }
674
+ const entry: CoordinatorTraceEntry = {
675
+ ...body,
676
+ source: body.source,
677
+ runId: body.runId,
678
+ phase: body.phase,
679
+ ts: body.ts,
680
+ ms: body.ms,
681
+ graphHash:
682
+ typeof body.graphHash === 'string' || body.graphHash === null
683
+ ? body.graphHash
684
+ : undefined,
685
+ };
686
+ await this.state.blockConcurrencyWhile(async () => {
687
+ await this.state.storage.put(this.traceKey(entry), entry);
688
+ const entries = await this.state.storage.list<CoordinatorTraceEntry>({
689
+ prefix: COORDINATOR_TRACE_KEY_PREFIX,
690
+ });
691
+ const overflow = entries.size - COORDINATOR_TRACE_MAX_ENTRIES;
692
+ if (overflow > 0) {
693
+ await this.state.storage.delete([...entries.keys()].slice(0, overflow));
694
+ }
695
+ });
696
+ return new Response('{}', { headers: { 'content-type': 'application/json' } });
697
+ }
698
+
699
+ private async handleTraceList(): Promise<Response> {
700
+ const entries = await this.state.storage.list<CoordinatorTraceEntry>({
701
+ prefix: COORDINATOR_TRACE_KEY_PREFIX,
702
+ });
703
+ return new Response(
704
+ JSON.stringify({
705
+ entries: [...entries.values()].sort((left, right) => left.ts - right.ts),
706
+ }),
707
+ { headers: { 'content-type': 'application/json' } },
708
+ );
709
+ }
710
+ }