deepline 0.1.19 → 0.1.21

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.
@@ -48,17 +48,26 @@ type AwaitRequest = {
48
48
  timeoutMs: number;
49
49
  };
50
50
 
51
+ type WorkflowPoolEntryState = 'warming' | 'ready';
52
+
51
53
  type WorkflowPoolEntry = {
52
54
  id: string;
53
55
  version: string;
56
+ state: WorkflowPoolEntryState;
54
57
  createdAt: number;
55
58
  readyAt: number | null;
56
59
  expiresAt: number;
57
60
  };
58
61
 
62
+ type WorkflowRunMappingState = 'claimed' | 'started' | 'blocked';
63
+
59
64
  type WorkflowRunMapping = {
60
65
  runId: string;
61
66
  instanceId: string;
67
+ state: WorkflowRunMappingState;
68
+ blockedInstanceId?: string | null;
69
+ claimedAt?: number | null;
70
+ startedAt?: number | null;
62
71
  version: string;
63
72
  createdAt: number;
64
73
  expiresAt: number;
@@ -74,19 +83,83 @@ type CoordinatorTraceEntry = {
74
83
  [key: string]: unknown;
75
84
  };
76
85
 
86
+ type CoordinatorTerminalState = {
87
+ runId: string;
88
+ status: 'completed' | 'failed' | 'cancelled';
89
+ result?: unknown;
90
+ error?: string | null;
91
+ totalRows?: unknown;
92
+ durationMs?: unknown;
93
+ playName?: string | null;
94
+ completedAt: number;
95
+ };
96
+
97
+ type CoordinatorRunEvent =
98
+ | {
99
+ seq: number;
100
+ runId: string;
101
+ type: 'status';
102
+ status: string;
103
+ ts: number;
104
+ logs?: string[];
105
+ }
106
+ | {
107
+ seq: number;
108
+ runId: string;
109
+ type: 'log';
110
+ line: string;
111
+ ts: number;
112
+ }
113
+ | {
114
+ seq: number;
115
+ runId: string;
116
+ type: 'progress';
117
+ status: string;
118
+ ts: number;
119
+ logs?: string[];
120
+ activeNodeId?: string | null;
121
+ activeArtifactTableNamespace?: string | null;
122
+ updatedAt?: number | null;
123
+ }
124
+ | {
125
+ seq: number;
126
+ runId: string;
127
+ type: 'terminal';
128
+ status: 'completed' | 'failed' | 'cancelled';
129
+ ts: number;
130
+ result?: unknown;
131
+ error?: string | null;
132
+ totalRows?: unknown;
133
+ durationMs?: unknown;
134
+ playName?: string | null;
135
+ };
136
+
137
+ type OmitRunEventSequence<T> = T extends unknown ? Omit<T, 'seq'> : never;
138
+ type CoordinatorRunEventInput = OmitRunEventSequence<CoordinatorRunEvent>;
139
+
77
140
  type ReadyWorkflowPoolEntryRecord = {
78
141
  key: string;
79
- entry: WorkflowPoolEntry & { readyAt: number };
142
+ entry: WorkflowPoolEntry & { state: 'ready'; readyAt: number };
143
+ };
144
+
145
+ type WorkflowPoolCounts = {
146
+ available: number;
147
+ warming: number;
80
148
  };
81
149
 
82
150
  const DEDUP_KEY_PREFIX = 'd:';
83
151
  const WORKFLOW_POOL_KEY_PREFIX = 'p:';
84
152
  const WORKFLOW_POOL_RUN_KEY_PREFIX = 'm:';
85
153
  const COORDINATOR_TRACE_KEY_PREFIX = 't:';
154
+ const COORDINATOR_RUN_EVENT_KEY_PREFIX = 'e:';
155
+ const COORDINATOR_TERMINAL_KEY = 'terminal';
156
+ const COORDINATOR_RUN_EVENT_SEQUENCE_KEY = 'event-seq';
86
157
  const COORDINATOR_TRACE_MAX_ENTRIES = 200;
158
+ const COORDINATOR_RUN_EVENT_MAX_ENTRIES = 500;
87
159
  const FINISH_ALARM_DELAY_MS = 60_000; // self-evict 1 min after finish() called
88
160
  const WORKFLOW_POOL_DEFAULT_TTL_MS = 8 * 60 * 1000;
89
161
  const WORKFLOW_POOL_RUN_MAPPING_TTL_MS = 60 * 60 * 1000;
162
+ const WORKFLOW_POOL_READY_MAX_AGE_MS = 7 * 60_000;
90
163
 
91
164
  interface DedupEnv {
92
165
  PLAY_DEDUP: DurableObjectNamespace;
@@ -96,6 +169,8 @@ export class PlayDedup implements DurableObject {
96
169
  // Promises waiting on /await endpoints. Keyed by `${toolId}:${inputHash}`.
97
170
  // Resolved by /publish; rejected on timeout.
98
171
  private waiters: Map<string, Set<(value: unknown) => void>> = new Map();
172
+ private runEventWaiters: Set<(value: CoordinatorRunEvent) => void> =
173
+ new Set();
99
174
 
100
175
  constructor(
101
176
  private readonly state: DurableObjectState,
@@ -110,6 +185,10 @@ export class PlayDedup implements DurableObject {
110
185
  return `${COORDINATOR_TRACE_KEY_PREFIX}${String(entry.ts).padStart(13, '0')}:${crypto.randomUUID().slice(0, 8)}`;
111
186
  }
112
187
 
188
+ private runEventKey(event: CoordinatorRunEvent): string {
189
+ return `${COORDINATOR_RUN_EVENT_KEY_PREFIX}${String(event.seq).padStart(13, '0')}`;
190
+ }
191
+
113
192
  private waiterKey(toolId: string, inputHash: string): string {
114
193
  return `${toolId}:${inputHash}`;
115
194
  }
@@ -130,18 +209,22 @@ export class PlayDedup implements DurableObject {
130
209
  return await this.handleDebug(req);
131
210
  case '/pool-add':
132
211
  return await this.handlePoolAdd(req);
133
- case '/pool-lease':
134
- return await this.handlePoolLease(req);
212
+ case '/pool-claim':
213
+ return await this.handlePoolClaim(req);
135
214
  case '/pool-count':
136
215
  return await this.handlePoolCount(req);
137
216
  case '/pool-list':
138
217
  return await this.handlePoolList(req);
139
218
  case '/pool-promote':
140
219
  return await this.handlePoolPromote(req);
220
+ case '/pool-ready':
221
+ return await this.handlePoolReady(req);
141
222
  case '/pool-delete':
142
223
  return await this.handlePoolDelete(req);
143
224
  case '/pool-map-run':
144
225
  return await this.handlePoolMapRun(req);
226
+ case '/pool-block-run':
227
+ return await this.handlePoolBlockRun(req);
145
228
  case '/pool-resolve-run':
146
229
  return await this.handlePoolResolveRun(req);
147
230
  case '/pool-clear':
@@ -150,6 +233,14 @@ export class PlayDedup implements DurableObject {
150
233
  return await this.handleTraceAdd(req);
151
234
  case '/trace-list':
152
235
  return await this.handleTraceList();
236
+ case '/event-add':
237
+ return await this.handleRunEventAdd(req);
238
+ case '/event-list':
239
+ return await this.handleRunEventList(req);
240
+ case '/terminal-set':
241
+ return await this.handleTerminalSet(req);
242
+ case '/terminal-get':
243
+ return await this.handleTerminalGet();
153
244
  default:
154
245
  return new Response('not found', { status: 404 });
155
246
  }
@@ -167,6 +258,7 @@ export class PlayDedup implements DurableObject {
167
258
  // be garbage-collected by CF's hot pool.
168
259
  await this.state.storage.deleteAll();
169
260
  this.waiters.clear();
261
+ this.runEventWaiters.clear();
170
262
  }
171
263
 
172
264
  private async handleLookupOrClaim(req: Request): Promise<Response> {
@@ -248,7 +340,9 @@ export class PlayDedup implements DurableObject {
248
340
  }
249
341
  }
250
342
 
251
- return new Response('{}', { headers: { 'content-type': 'application/json' } });
343
+ return new Response('{}', {
344
+ headers: { 'content-type': 'application/json' },
345
+ });
252
346
  }
253
347
 
254
348
  private async handleAwait(req: Request): Promise<Response> {
@@ -313,7 +407,9 @@ export class PlayDedup implements DurableObject {
313
407
  // Schedule alarm to evict after a grace period (any straggler awaits
314
408
  // get a chance to resolve). Grace = 60s.
315
409
  await this.state.storage.setAlarm(Date.now() + FINISH_ALARM_DELAY_MS);
316
- return new Response('{}', { headers: { 'content-type': 'application/json' } });
410
+ return new Response('{}', {
411
+ headers: { 'content-type': 'application/json' },
412
+ });
317
413
  }
318
414
 
319
415
  private async handleDebug(_req: Request): Promise<Response> {
@@ -321,7 +417,11 @@ export class PlayDedup implements DurableObject {
321
417
  const dump: Record<string, DedupEntry> = {};
322
418
  for (const [k, v] of all) dump[k] = v as DedupEntry;
323
419
  return new Response(
324
- JSON.stringify({ size: all.size, entries: dump, waiters: this.waiters.size }),
420
+ JSON.stringify({
421
+ size: all.size,
422
+ entries: dump,
423
+ waiters: this.waiters.size,
424
+ }),
325
425
  { headers: { 'content-type': 'application/json' } },
326
426
  );
327
427
  }
@@ -330,16 +430,28 @@ export class PlayDedup implements DurableObject {
330
430
  return new URL(req.url).searchParams.get('version')?.trim() ?? '';
331
431
  }
332
432
 
433
+ private workflowPoolMinReadyAgeMs(req: Request): number {
434
+ const raw = Number(new URL(req.url).searchParams.get('minReadyAgeMs') ?? 0);
435
+ if (!Number.isFinite(raw) || raw <= 0) {
436
+ return 0;
437
+ }
438
+ return Math.min(Math.floor(raw), WORKFLOW_POOL_DEFAULT_TTL_MS);
439
+ }
440
+
333
441
  private isReadyWorkflowPoolEntry(
334
442
  value: { key: string; entry: WorkflowPoolEntry | undefined },
335
443
  version: string,
336
444
  now: number,
445
+ minReadyAgeMs = 0,
337
446
  ): value is ReadyWorkflowPoolEntryRecord {
338
447
  return (
339
448
  value.entry !== undefined &&
340
449
  value.entry.version === version &&
341
450
  value.entry.expiresAt > now &&
342
- value.entry.readyAt !== null
451
+ value.entry.state === 'ready' &&
452
+ value.entry.readyAt !== null &&
453
+ now - value.entry.readyAt <= WORKFLOW_POOL_READY_MAX_AGE_MS &&
454
+ now - value.entry.readyAt >= minReadyAgeMs
343
455
  );
344
456
  }
345
457
 
@@ -360,6 +472,9 @@ export class PlayDedup implements DurableObject {
360
472
  if (
361
473
  !entry ||
362
474
  entry.expiresAt <= now ||
475
+ (entry.state === 'ready' &&
476
+ entry.readyAt !== null &&
477
+ now - entry.readyAt > WORKFLOW_POOL_READY_MAX_AGE_MS) ||
363
478
  (version && entry.version !== version)
364
479
  ) {
365
480
  expiredKeys.push(key);
@@ -380,16 +495,15 @@ export class PlayDedup implements DurableObject {
380
495
  }
381
496
 
382
497
  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() : '';
498
+ const body = (await req.json().catch(() => null)) as {
499
+ ids?: unknown;
500
+ ttlMs?: unknown;
501
+ version?: unknown;
502
+ readyAt?: unknown;
503
+ ready?: unknown;
504
+ } | null;
505
+ const version =
506
+ typeof body?.version === 'string' ? body.version.trim() : '';
393
507
  if (!version) {
394
508
  return new Response('version is required', { status: 400 });
395
509
  }
@@ -409,18 +523,34 @@ export class PlayDedup implements DurableObject {
409
523
  body.ttlMs > 0
410
524
  ? Math.min(body.ttlMs, WORKFLOW_POOL_DEFAULT_TTL_MS)
411
525
  : 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
526
  await this.state.blockConcurrencyWhile(async () => {
423
527
  await this.gcWorkflowPool(now, version);
528
+ const writes: Record<string, WorkflowPoolEntry> = {};
529
+ const keys = ids.map((id) => `${WORKFLOW_POOL_KEY_PREFIX}${id}`);
530
+ const existing = (await this.state.storage.get<WorkflowPoolEntry>(
531
+ keys,
532
+ )) as Map<string, WorkflowPoolEntry>;
533
+ for (const id of ids) {
534
+ const key = `${WORKFLOW_POOL_KEY_PREFIX}${id}`;
535
+ const existingEntry = existing.get(key);
536
+ const existingReadyAt =
537
+ existingEntry?.version === version &&
538
+ existingEntry.expiresAt > now &&
539
+ existingEntry.state === 'ready' &&
540
+ existingEntry.readyAt !== null
541
+ ? existingEntry.readyAt
542
+ : null;
543
+ const nextReadyAt = ready ? readyAt : existingReadyAt;
544
+ writes[key] = {
545
+ id,
546
+ version,
547
+ state: nextReadyAt !== null ? 'ready' : 'warming',
548
+ createdAt:
549
+ existingEntry?.version === version ? existingEntry.createdAt : now,
550
+ readyAt: nextReadyAt,
551
+ expiresAt: now + ttlMs,
552
+ };
553
+ }
424
554
  if (Object.keys(writes).length > 0) {
425
555
  await this.state.storage.put(writes);
426
556
  }
@@ -430,28 +560,72 @@ export class PlayDedup implements DurableObject {
430
560
  });
431
561
  }
432
562
 
433
- private async handlePoolLease(req: Request): Promise<Response> {
563
+ private async handlePoolClaim(req: Request): Promise<Response> {
434
564
  const version = this.workflowPoolVersion(req);
435
565
  if (!version) {
436
566
  return new Response('version is required', { status: 400 });
437
567
  }
438
- let leasedId: string | null = null;
568
+ const body = (await req.json().catch(() => null)) as {
569
+ runId?: unknown;
570
+ } | null;
571
+ const runId = typeof body?.runId === 'string' ? body.runId : '';
572
+ if (!runId) {
573
+ return new Response('runId is required', { status: 400 });
574
+ }
575
+ const minReadyAgeMs = this.workflowPoolMinReadyAgeMs(req);
576
+ let claimedId: string | null = null;
577
+ let counts: WorkflowPoolCounts = { available: 0, warming: 0 };
439
578
  await this.state.blockConcurrencyWhile(async () => {
440
579
  const now = Date.now();
441
580
  await this.gcWorkflowPool(now, version);
581
+ const mappingKey = `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`;
582
+ const existingMapping =
583
+ await this.state.storage.get<WorkflowRunMapping>(mappingKey);
584
+ if (existingMapping?.version === version) {
585
+ if (
586
+ existingMapping.state === 'claimed' ||
587
+ existingMapping.state === 'started'
588
+ ) {
589
+ claimedId = existingMapping.instanceId || null;
590
+ }
591
+ return;
592
+ }
442
593
  const entries = await this.state.storage.list<WorkflowPoolEntry>({
443
594
  prefix: WORKFLOW_POOL_KEY_PREFIX,
444
595
  });
596
+ counts = this.countWorkflowPoolEntries(
597
+ entries,
598
+ version,
599
+ now,
600
+ minReadyAgeMs,
601
+ );
445
602
  const sorted = [...entries.entries()]
446
603
  .map(([key, entry]) => ({ key, entry }))
447
- .filter((entry) => this.isReadyWorkflowPoolEntry(entry, version, now))
448
- .sort((a, b) => a.entry.readyAt - b.entry.readyAt);
604
+ .filter((entry) =>
605
+ this.isReadyWorkflowPoolEntry(entry, version, now, minReadyAgeMs),
606
+ )
607
+ .sort((a, b) => b.entry.readyAt - a.entry.readyAt);
449
608
  const selected = sorted[0];
450
609
  if (!selected) return;
451
- leasedId = selected.entry.id;
610
+ claimedId = selected.entry.id;
452
611
  await this.state.storage.delete(selected.key);
612
+ await this.state.storage.put(mappingKey, {
613
+ runId,
614
+ instanceId: claimedId,
615
+ state: 'claimed',
616
+ blockedInstanceId: null,
617
+ claimedAt: now,
618
+ startedAt: null,
619
+ version,
620
+ createdAt: now,
621
+ expiresAt: now + WORKFLOW_POOL_RUN_MAPPING_TTL_MS,
622
+ } satisfies WorkflowRunMapping);
623
+ counts = {
624
+ available: Math.max(0, counts.available - 1),
625
+ warming: counts.warming,
626
+ };
453
627
  });
454
- return new Response(JSON.stringify({ id: leasedId }), {
628
+ return new Response(JSON.stringify({ id: claimedId, ...counts }), {
455
629
  headers: { 'content-type': 'application/json' },
456
630
  });
457
631
  }
@@ -461,20 +635,45 @@ export class PlayDedup implements DurableObject {
461
635
  if (!version) {
462
636
  return new Response('version is required', { status: 400 });
463
637
  }
464
- await this.gcWorkflowPool(Date.now(), version);
638
+ const minReadyAgeMs = this.workflowPoolMinReadyAgeMs(req);
639
+ const now = Date.now();
640
+ await this.gcWorkflowPool(now, version);
465
641
  const entries = await this.state.storage.list<WorkflowPoolEntry>({
466
642
  prefix: WORKFLOW_POOL_KEY_PREFIX,
467
643
  });
644
+ const counts = this.countWorkflowPoolEntries(
645
+ entries,
646
+ version,
647
+ now,
648
+ minReadyAgeMs,
649
+ );
650
+ return new Response(JSON.stringify(counts), {
651
+ headers: { 'content-type': 'application/json' },
652
+ });
653
+ }
654
+
655
+ private countWorkflowPoolEntries(
656
+ entries: Map<string, WorkflowPoolEntry>,
657
+ version: string,
658
+ now: number,
659
+ minReadyAgeMs: number,
660
+ ): WorkflowPoolCounts {
468
661
  let available = 0;
469
662
  let warming = 0;
470
663
  for (const entry of entries.values()) {
471
664
  if (entry.version !== version) continue;
472
- if (entry.readyAt === null) warming += 1;
473
- else available += 1;
665
+ if (
666
+ entry.state !== 'ready' ||
667
+ entry.readyAt === null ||
668
+ now - entry.readyAt > WORKFLOW_POOL_READY_MAX_AGE_MS ||
669
+ now - entry.readyAt < minReadyAgeMs
670
+ ) {
671
+ warming += 1;
672
+ } else {
673
+ available += 1;
674
+ }
474
675
  }
475
- return new Response(JSON.stringify({ available, warming }), {
476
- headers: { 'content-type': 'application/json' },
477
- });
676
+ return { available, warming };
478
677
  }
479
678
 
480
679
  private async handlePoolList(req: Request): Promise<Response> {
@@ -492,6 +691,7 @@ export class PlayDedup implements DurableObject {
492
691
  .filter((entry) => entry.version === version)
493
692
  .map((entry) => ({
494
693
  id: entry.id,
694
+ state: entry.state,
495
695
  createdAt: entry.createdAt,
496
696
  readyAt: entry.readyAt,
497
697
  expiresAt: entry.expiresAt,
@@ -502,13 +702,12 @@ export class PlayDedup implements DurableObject {
502
702
  }
503
703
 
504
704
  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() : '';
705
+ const body = (await req.json().catch(() => null)) as {
706
+ ids?: unknown;
707
+ version?: unknown;
708
+ } | null;
709
+ const version =
710
+ typeof body?.version === 'string' ? body.version.trim() : '';
512
711
  if (!version) {
513
712
  return new Response('version is required', { status: 400 });
514
713
  }
@@ -529,6 +728,7 @@ export class PlayDedup implements DurableObject {
529
728
  }
530
729
  writes[key] = {
531
730
  ...entry,
731
+ state: 'ready',
532
732
  readyAt: now,
533
733
  };
534
734
  }
@@ -536,19 +736,53 @@ export class PlayDedup implements DurableObject {
536
736
  await this.state.storage.put(writes);
537
737
  }
538
738
  });
539
- return new Response(JSON.stringify({ promoted: Object.keys(writes).length }), {
739
+ return new Response(
740
+ JSON.stringify({ promoted: Object.keys(writes).length }),
741
+ {
742
+ headers: { 'content-type': 'application/json' },
743
+ },
744
+ );
745
+ }
746
+
747
+ private async handlePoolReady(req: Request): Promise<Response> {
748
+ const body = (await req.json().catch(() => null)) as {
749
+ poolId?: unknown;
750
+ version?: unknown;
751
+ } | null;
752
+ const poolId = typeof body?.poolId === 'string' ? body.poolId : '';
753
+ const version =
754
+ typeof body?.version === 'string' ? body.version.trim() : '';
755
+ if (!poolId || !version) {
756
+ return new Response('poolId and version are required', { status: 400 });
757
+ }
758
+ const now = Date.now();
759
+ let ready = false;
760
+ await this.state.blockConcurrencyWhile(async () => {
761
+ await this.gcWorkflowPool(now, version);
762
+ const key = `${WORKFLOW_POOL_KEY_PREFIX}${poolId}`;
763
+ const entry = await this.state.storage.get<WorkflowPoolEntry>(key);
764
+ if (!entry || entry.version !== version || entry.expiresAt <= now) {
765
+ return;
766
+ }
767
+ await this.state.storage.put(key, {
768
+ ...entry,
769
+ state: 'ready',
770
+ readyAt: entry.readyAt ?? now,
771
+ });
772
+ ready = true;
773
+ });
774
+ return new Response(JSON.stringify({ ready }), {
540
775
  headers: { 'content-type': 'application/json' },
541
776
  });
542
777
  }
543
778
 
544
779
  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() : '';
780
+ const body = (await req.json().catch(() => null)) as {
781
+ ids?: unknown;
782
+ version?: unknown;
783
+ } | null;
784
+ const version =
785
+ typeof body?.version === 'string' ? body.version.trim() : '';
552
786
  if (!version) {
553
787
  return new Response('version is required', { status: 400 });
554
788
  }
@@ -575,34 +809,118 @@ export class PlayDedup implements DurableObject {
575
809
  }
576
810
 
577
811
  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;
812
+ const body = (await req.json().catch(() => null)) as {
813
+ runId?: unknown;
814
+ instanceId?: unknown;
815
+ version?: unknown;
816
+ started?: unknown;
817
+ } | null;
585
818
  const runId = typeof body?.runId === 'string' ? body.runId : '';
586
819
  const instanceId =
587
820
  typeof body?.instanceId === 'string' ? body.instanceId : '';
588
- const version = typeof body?.version === 'string' ? body.version.trim() : '';
821
+ const version =
822
+ typeof body?.version === 'string' ? body.version.trim() : '';
823
+ const started = body?.started === true;
589
824
  if (!runId || !instanceId || !version) {
590
825
  return new Response('runId, instanceId, and version are required', {
591
826
  status: 400,
592
827
  });
593
828
  }
594
829
  const now = Date.now();
595
- await this.state.storage.put(
596
- `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`,
597
- {
830
+ let mapped = true;
831
+ const key = `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`;
832
+ await this.state.blockConcurrencyWhile(async () => {
833
+ const existing = await this.state.storage.get<WorkflowRunMapping>(key);
834
+ if (
835
+ existing?.version === version &&
836
+ existing.state === 'blocked' &&
837
+ existing.blockedInstanceId === instanceId
838
+ ) {
839
+ mapped = false;
840
+ return;
841
+ }
842
+ const alreadyStarted =
843
+ existing?.version === version &&
844
+ existing.instanceId === instanceId &&
845
+ existing.state === 'started' &&
846
+ typeof existing.startedAt === 'number';
847
+ const startedAt = started || alreadyStarted ? now : null;
848
+ await this.state.storage.put(key, {
598
849
  runId,
599
850
  instanceId,
851
+ state: startedAt !== null ? 'started' : 'claimed',
852
+ blockedInstanceId: null,
853
+ claimedAt:
854
+ existing?.version === version && typeof existing.claimedAt === 'number'
855
+ ? existing.claimedAt
856
+ : now,
857
+ startedAt,
858
+ version,
859
+ createdAt:
860
+ existing?.version === version && existing.createdAt
861
+ ? existing.createdAt
862
+ : now,
863
+ expiresAt: now + WORKFLOW_POOL_RUN_MAPPING_TTL_MS,
864
+ } satisfies WorkflowRunMapping);
865
+ });
866
+ return new Response(JSON.stringify({ mapped }), {
867
+ headers: { 'content-type': 'application/json' },
868
+ });
869
+ }
870
+
871
+ private async handlePoolBlockRun(req: Request): Promise<Response> {
872
+ const body = (await req.json().catch(() => null)) as {
873
+ runId?: unknown;
874
+ instanceId?: unknown;
875
+ version?: unknown;
876
+ } | null;
877
+ const runId = typeof body?.runId === 'string' ? body.runId : '';
878
+ const instanceId =
879
+ typeof body?.instanceId === 'string' ? body.instanceId : '';
880
+ const version =
881
+ typeof body?.version === 'string' ? body.version.trim() : '';
882
+ if (!runId || !instanceId || !version) {
883
+ return new Response('runId, instanceId, and version are required', {
884
+ status: 400,
885
+ });
886
+ }
887
+ const now = Date.now();
888
+ const key = `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`;
889
+ let started = false;
890
+ await this.state.blockConcurrencyWhile(async () => {
891
+ const existing = await this.state.storage.get<WorkflowRunMapping>(key);
892
+ if (
893
+ existing?.version === version &&
894
+ existing.instanceId === instanceId &&
895
+ existing.state === 'started' &&
896
+ typeof existing.startedAt === 'number'
897
+ ) {
898
+ started = true;
899
+ return;
900
+ }
901
+ await this.state.storage.put(key, {
902
+ runId,
903
+ instanceId: '',
904
+ state: 'blocked',
905
+ blockedInstanceId: instanceId,
906
+ claimedAt:
907
+ existing?.version === version && typeof existing.claimedAt === 'number'
908
+ ? existing.claimedAt
909
+ : now,
910
+ startedAt: null,
600
911
  version,
601
912
  createdAt: now,
602
913
  expiresAt: now + WORKFLOW_POOL_RUN_MAPPING_TTL_MS,
603
- } satisfies WorkflowRunMapping,
604
- );
605
- return new Response('{}', { headers: { 'content-type': 'application/json' } });
914
+ } satisfies WorkflowRunMapping);
915
+ });
916
+ if (started) {
917
+ return new Response(JSON.stringify({ blocked: false, started: true }), {
918
+ headers: { 'content-type': 'application/json' },
919
+ });
920
+ }
921
+ return new Response(JSON.stringify({ blocked: true, started: false }), {
922
+ headers: { 'content-type': 'application/json' },
923
+ });
606
924
  }
607
925
 
608
926
  private async handlePoolResolveRun(req: Request): Promise<Response> {
@@ -620,13 +938,18 @@ export class PlayDedup implements DurableObject {
620
938
  `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`,
621
939
  );
622
940
  if (mapping && mapping.version !== version) {
623
- await this.state.storage.delete(`${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`);
941
+ await this.state.storage.delete(
942
+ `${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`,
943
+ );
624
944
  return new Response(JSON.stringify({ instanceId: null }), {
625
945
  headers: { 'content-type': 'application/json' },
626
946
  });
627
947
  }
628
948
  return new Response(
629
- JSON.stringify({ instanceId: mapping?.instanceId ?? null }),
949
+ JSON.stringify({
950
+ instanceId: mapping?.instanceId || null,
951
+ startedAt: mapping?.startedAt ?? null,
952
+ }),
630
953
  { headers: { 'content-type': 'application/json' } },
631
954
  );
632
955
  }
@@ -658,9 +981,9 @@ export class PlayDedup implements DurableObject {
658
981
  }
659
982
 
660
983
  private async handleTraceAdd(req: Request): Promise<Response> {
661
- const body = (await req.json().catch(() => null)) as
662
- | Partial<CoordinatorTraceEntry>
663
- | null;
984
+ const body = (await req
985
+ .json()
986
+ .catch(() => null)) as Partial<CoordinatorTraceEntry> | null;
664
987
  if (
665
988
  !body ||
666
989
  (body.source !== 'coordinator' && body.source !== 'dynamic_worker') ||
@@ -693,7 +1016,9 @@ export class PlayDedup implements DurableObject {
693
1016
  await this.state.storage.delete([...entries.keys()].slice(0, overflow));
694
1017
  }
695
1018
  });
696
- return new Response('{}', { headers: { 'content-type': 'application/json' } });
1019
+ return new Response('{}', {
1020
+ headers: { 'content-type': 'application/json' },
1021
+ });
697
1022
  }
698
1023
 
699
1024
  private async handleTraceList(): Promise<Response> {
@@ -702,9 +1027,213 @@ export class PlayDedup implements DurableObject {
702
1027
  });
703
1028
  return new Response(
704
1029
  JSON.stringify({
705
- entries: [...entries.values()].sort((left, right) => left.ts - right.ts),
1030
+ entries: [...entries.values()].sort(
1031
+ (left, right) => left.ts - right.ts,
1032
+ ),
706
1033
  }),
707
1034
  { headers: { 'content-type': 'application/json' } },
708
1035
  );
709
1036
  }
1037
+
1038
+ private async handleTerminalSet(req: Request): Promise<Response> {
1039
+ const body = (await req
1040
+ .json()
1041
+ .catch(() => null)) as Partial<CoordinatorTerminalState> | null;
1042
+ if (
1043
+ !body ||
1044
+ typeof body.runId !== 'string' ||
1045
+ (body.status !== 'completed' &&
1046
+ body.status !== 'failed' &&
1047
+ body.status !== 'cancelled')
1048
+ ) {
1049
+ return new Response('invalid terminal state', { status: 400 });
1050
+ }
1051
+ const state: CoordinatorTerminalState = {
1052
+ runId: body.runId,
1053
+ status: body.status,
1054
+ result: body.result,
1055
+ error: typeof body.error === 'string' ? body.error : null,
1056
+ totalRows: body.totalRows,
1057
+ durationMs: body.durationMs,
1058
+ playName: typeof body.playName === 'string' ? body.playName : null,
1059
+ completedAt:
1060
+ typeof body.completedAt === 'number' ? body.completedAt : Date.now(),
1061
+ };
1062
+ await this.state.storage.put(COORDINATOR_TERMINAL_KEY, state);
1063
+ const event = await this.storeRunEvent({
1064
+ runId: state.runId,
1065
+ type: 'terminal',
1066
+ status: state.status,
1067
+ result: state.result,
1068
+ error: state.error,
1069
+ totalRows: state.totalRows,
1070
+ durationMs: state.durationMs,
1071
+ playName: state.playName,
1072
+ ts: state.completedAt,
1073
+ });
1074
+ this.wakeRunEventWaiters(event);
1075
+ return new Response('{}', {
1076
+ headers: { 'content-type': 'application/json' },
1077
+ });
1078
+ }
1079
+
1080
+ private async handleTerminalGet(): Promise<Response> {
1081
+ const state = await this.state.storage.get<CoordinatorTerminalState>(
1082
+ COORDINATOR_TERMINAL_KEY,
1083
+ );
1084
+ return new Response(JSON.stringify({ state: state ?? null }), {
1085
+ headers: { 'content-type': 'application/json' },
1086
+ });
1087
+ }
1088
+
1089
+ private async storeRunEvent(
1090
+ input: CoordinatorRunEventInput,
1091
+ ): Promise<CoordinatorRunEvent> {
1092
+ let event: CoordinatorRunEvent | null = null;
1093
+ await this.state.blockConcurrencyWhile(async () => {
1094
+ const current =
1095
+ (await this.state.storage.get<number>(
1096
+ COORDINATOR_RUN_EVENT_SEQUENCE_KEY,
1097
+ )) ?? 0;
1098
+ const seq = current + 1;
1099
+ event = { ...input, seq } as CoordinatorRunEvent;
1100
+ await this.state.storage.put({
1101
+ [COORDINATOR_RUN_EVENT_SEQUENCE_KEY]: seq,
1102
+ [this.runEventKey(event)]: event,
1103
+ });
1104
+ const entries = await this.state.storage.list<CoordinatorRunEvent>({
1105
+ prefix: COORDINATOR_RUN_EVENT_KEY_PREFIX,
1106
+ });
1107
+ const overflow = entries.size - COORDINATOR_RUN_EVENT_MAX_ENTRIES;
1108
+ if (overflow > 0) {
1109
+ await this.state.storage.delete([...entries.keys()].slice(0, overflow));
1110
+ }
1111
+ });
1112
+ if (!event) {
1113
+ throw new Error('failed to store run event');
1114
+ }
1115
+ return event;
1116
+ }
1117
+
1118
+ private wakeRunEventWaiters(event: CoordinatorRunEvent): void {
1119
+ for (const resolve of this.runEventWaiters) {
1120
+ resolve(event);
1121
+ }
1122
+ this.runEventWaiters.clear();
1123
+ }
1124
+
1125
+ private async handleRunEventAdd(req: Request): Promise<Response> {
1126
+ const body = (await req.json().catch(() => null)) as
1127
+ | Partial<CoordinatorRunEvent>
1128
+ | null;
1129
+ if (!body || typeof body.runId !== 'string') {
1130
+ return new Response('invalid run event', { status: 400 });
1131
+ }
1132
+ let eventInput: CoordinatorRunEventInput | null = null;
1133
+ const ts = typeof body.ts === 'number' ? body.ts : Date.now();
1134
+ if (body.type === 'log' && typeof body.line === 'string') {
1135
+ eventInput = {
1136
+ runId: body.runId,
1137
+ type: 'log',
1138
+ line: body.line,
1139
+ ts,
1140
+ };
1141
+ } else if (body.type === 'status' && typeof body.status === 'string') {
1142
+ eventInput = {
1143
+ runId: body.runId,
1144
+ type: 'status',
1145
+ status: body.status,
1146
+ ts,
1147
+ logs: Array.isArray(body.logs)
1148
+ ? body.logs.filter((line): line is string => typeof line === 'string')
1149
+ : undefined,
1150
+ };
1151
+ } else if (body.type === 'progress' && typeof body.status === 'string') {
1152
+ eventInput = {
1153
+ runId: body.runId,
1154
+ type: 'progress',
1155
+ status: body.status,
1156
+ ts,
1157
+ logs: Array.isArray(body.logs)
1158
+ ? body.logs.filter((line): line is string => typeof line === 'string')
1159
+ : undefined,
1160
+ activeNodeId:
1161
+ typeof body.activeNodeId === 'string' ? body.activeNodeId : null,
1162
+ activeArtifactTableNamespace:
1163
+ typeof body.activeArtifactTableNamespace === 'string'
1164
+ ? body.activeArtifactTableNamespace
1165
+ : null,
1166
+ updatedAt: typeof body.updatedAt === 'number' ? body.updatedAt : null,
1167
+ };
1168
+ } else if (
1169
+ body.type === 'terminal' &&
1170
+ (body.status === 'completed' ||
1171
+ body.status === 'failed' ||
1172
+ body.status === 'cancelled')
1173
+ ) {
1174
+ eventInput = {
1175
+ runId: body.runId,
1176
+ type: 'terminal',
1177
+ status: body.status,
1178
+ ts,
1179
+ result: body.result,
1180
+ error: typeof body.error === 'string' ? body.error : null,
1181
+ totalRows: body.totalRows,
1182
+ durationMs: body.durationMs,
1183
+ playName: typeof body.playName === 'string' ? body.playName : null,
1184
+ };
1185
+ }
1186
+ if (!eventInput) {
1187
+ return new Response('invalid run event', { status: 400 });
1188
+ }
1189
+ const event = await this.storeRunEvent(eventInput);
1190
+ this.wakeRunEventWaiters(event);
1191
+ return new Response(JSON.stringify({ event }), {
1192
+ headers: { 'content-type': 'application/json' },
1193
+ });
1194
+ }
1195
+
1196
+ private async handleRunEventList(req: Request): Promise<Response> {
1197
+ const url = new URL(req.url);
1198
+ const afterSeq = Math.max(
1199
+ 0,
1200
+ Math.floor(Number(url.searchParams.get('afterSeq') ?? '0')),
1201
+ );
1202
+ const timeoutMs = Math.min(
1203
+ Math.max(Number(url.searchParams.get('timeoutMs') ?? '0'), 0),
1204
+ 30_000,
1205
+ );
1206
+ const readEvents = async () => {
1207
+ const entries = await this.state.storage.list<CoordinatorRunEvent>({
1208
+ prefix: COORDINATOR_RUN_EVENT_KEY_PREFIX,
1209
+ });
1210
+ return [...entries.values()]
1211
+ .filter((event) => event.seq > afterSeq)
1212
+ .sort((left, right) => left.seq - right.seq);
1213
+ };
1214
+ let events = await readEvents();
1215
+ if (events.length === 0 && timeoutMs > 0) {
1216
+ await new Promise<void>((resolve) => {
1217
+ let settled = false;
1218
+ const finish = () => {
1219
+ if (settled) return;
1220
+ settled = true;
1221
+ this.runEventWaiters.delete(onEvent);
1222
+ clearTimeout(timeout);
1223
+ resolve();
1224
+ };
1225
+ const onEvent = () => finish();
1226
+ const timeout = setTimeout(finish, timeoutMs);
1227
+ this.runEventWaiters.add(onEvent);
1228
+ });
1229
+ events = await readEvents();
1230
+ }
1231
+ const latestSeq =
1232
+ (await this.state.storage.get<number>(
1233
+ COORDINATOR_RUN_EVENT_SEQUENCE_KEY,
1234
+ )) ?? afterSeq;
1235
+ return new Response(JSON.stringify({ events, latestSeq }), {
1236
+ headers: { 'content-type': 'application/json' },
1237
+ });
1238
+ }
710
1239
  }