deepline 0.1.20 → 0.1.22

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,20 +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;
90
- const WORKFLOW_POOL_READY_MAX_AGE_MS = 20_000;
162
+ const WORKFLOW_POOL_READY_MAX_AGE_MS = 7 * 60_000;
91
163
 
92
164
  interface DedupEnv {
93
165
  PLAY_DEDUP: DurableObjectNamespace;
@@ -97,6 +169,8 @@ export class PlayDedup implements DurableObject {
97
169
  // Promises waiting on /await endpoints. Keyed by `${toolId}:${inputHash}`.
98
170
  // Resolved by /publish; rejected on timeout.
99
171
  private waiters: Map<string, Set<(value: unknown) => void>> = new Map();
172
+ private runEventWaiters: Set<(value: CoordinatorRunEvent) => void> =
173
+ new Set();
100
174
 
101
175
  constructor(
102
176
  private readonly state: DurableObjectState,
@@ -111,6 +185,10 @@ export class PlayDedup implements DurableObject {
111
185
  return `${COORDINATOR_TRACE_KEY_PREFIX}${String(entry.ts).padStart(13, '0')}:${crypto.randomUUID().slice(0, 8)}`;
112
186
  }
113
187
 
188
+ private runEventKey(event: CoordinatorRunEvent): string {
189
+ return `${COORDINATOR_RUN_EVENT_KEY_PREFIX}${String(event.seq).padStart(13, '0')}`;
190
+ }
191
+
114
192
  private waiterKey(toolId: string, inputHash: string): string {
115
193
  return `${toolId}:${inputHash}`;
116
194
  }
@@ -131,18 +209,22 @@ export class PlayDedup implements DurableObject {
131
209
  return await this.handleDebug(req);
132
210
  case '/pool-add':
133
211
  return await this.handlePoolAdd(req);
134
- case '/pool-lease':
135
- return await this.handlePoolLease(req);
212
+ case '/pool-claim':
213
+ return await this.handlePoolClaim(req);
136
214
  case '/pool-count':
137
215
  return await this.handlePoolCount(req);
138
216
  case '/pool-list':
139
217
  return await this.handlePoolList(req);
140
218
  case '/pool-promote':
141
219
  return await this.handlePoolPromote(req);
220
+ case '/pool-ready':
221
+ return await this.handlePoolReady(req);
142
222
  case '/pool-delete':
143
223
  return await this.handlePoolDelete(req);
144
224
  case '/pool-map-run':
145
225
  return await this.handlePoolMapRun(req);
226
+ case '/pool-block-run':
227
+ return await this.handlePoolBlockRun(req);
146
228
  case '/pool-resolve-run':
147
229
  return await this.handlePoolResolveRun(req);
148
230
  case '/pool-clear':
@@ -151,6 +233,14 @@ export class PlayDedup implements DurableObject {
151
233
  return await this.handleTraceAdd(req);
152
234
  case '/trace-list':
153
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();
154
244
  default:
155
245
  return new Response('not found', { status: 404 });
156
246
  }
@@ -168,6 +258,7 @@ export class PlayDedup implements DurableObject {
168
258
  // be garbage-collected by CF's hot pool.
169
259
  await this.state.storage.deleteAll();
170
260
  this.waiters.clear();
261
+ this.runEventWaiters.clear();
171
262
  }
172
263
 
173
264
  private async handleLookupOrClaim(req: Request): Promise<Response> {
@@ -339,17 +430,28 @@ export class PlayDedup implements DurableObject {
339
430
  return new URL(req.url).searchParams.get('version')?.trim() ?? '';
340
431
  }
341
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
+
342
441
  private isReadyWorkflowPoolEntry(
343
442
  value: { key: string; entry: WorkflowPoolEntry | undefined },
344
443
  version: string,
345
444
  now: number,
445
+ minReadyAgeMs = 0,
346
446
  ): value is ReadyWorkflowPoolEntryRecord {
347
447
  return (
348
448
  value.entry !== undefined &&
349
449
  value.entry.version === version &&
350
450
  value.entry.expiresAt > now &&
451
+ value.entry.state === 'ready' &&
351
452
  value.entry.readyAt !== null &&
352
- now - value.entry.readyAt <= WORKFLOW_POOL_READY_MAX_AGE_MS
453
+ now - value.entry.readyAt <= WORKFLOW_POOL_READY_MAX_AGE_MS &&
454
+ now - value.entry.readyAt >= minReadyAgeMs
353
455
  );
354
456
  }
355
457
 
@@ -370,7 +472,8 @@ export class PlayDedup implements DurableObject {
370
472
  if (
371
473
  !entry ||
372
474
  entry.expiresAt <= now ||
373
- (entry.readyAt !== null &&
475
+ (entry.state === 'ready' &&
476
+ entry.readyAt !== null &&
374
477
  now - entry.readyAt > WORKFLOW_POOL_READY_MAX_AGE_MS) ||
375
478
  (version && entry.version !== version)
376
479
  ) {
@@ -420,18 +523,34 @@ export class PlayDedup implements DurableObject {
420
523
  body.ttlMs > 0
421
524
  ? Math.min(body.ttlMs, WORKFLOW_POOL_DEFAULT_TTL_MS)
422
525
  : WORKFLOW_POOL_DEFAULT_TTL_MS;
423
- const writes: Record<string, WorkflowPoolEntry> = {};
424
- for (const id of ids) {
425
- writes[`${WORKFLOW_POOL_KEY_PREFIX}${id}`] = {
426
- id,
427
- version,
428
- createdAt: now,
429
- readyAt: ready ? readyAt : null,
430
- expiresAt: now + ttlMs,
431
- };
432
- }
433
526
  await this.state.blockConcurrencyWhile(async () => {
434
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
+ }
435
554
  if (Object.keys(writes).length > 0) {
436
555
  await this.state.storage.put(writes);
437
556
  }
@@ -441,30 +560,72 @@ export class PlayDedup implements DurableObject {
441
560
  });
442
561
  }
443
562
 
444
- private async handlePoolLease(req: Request): Promise<Response> {
563
+ private async handlePoolClaim(req: Request): Promise<Response> {
445
564
  const version = this.workflowPoolVersion(req);
446
565
  if (!version) {
447
566
  return new Response('version is required', { status: 400 });
448
567
  }
449
- 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 };
450
578
  await this.state.blockConcurrencyWhile(async () => {
451
579
  const now = Date.now();
452
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
+ }
453
593
  const entries = await this.state.storage.list<WorkflowPoolEntry>({
454
594
  prefix: WORKFLOW_POOL_KEY_PREFIX,
455
595
  });
596
+ counts = this.countWorkflowPoolEntries(
597
+ entries,
598
+ version,
599
+ now,
600
+ minReadyAgeMs,
601
+ );
456
602
  const sorted = [...entries.entries()]
457
603
  .map(([key, entry]) => ({ key, entry }))
458
- .filter((entry) => this.isReadyWorkflowPoolEntry(entry, version, now))
459
- // Lease the freshest ready instance. Older waiting Workflows are more
460
- // likely to be hibernated, so "pooled" otherwise still pays a wake tax.
604
+ .filter((entry) =>
605
+ this.isReadyWorkflowPoolEntry(entry, version, now, minReadyAgeMs),
606
+ )
461
607
  .sort((a, b) => b.entry.readyAt - a.entry.readyAt);
462
608
  const selected = sorted[0];
463
609
  if (!selected) return;
464
- leasedId = selected.entry.id;
610
+ claimedId = selected.entry.id;
465
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
+ };
466
627
  });
467
- return new Response(JSON.stringify({ id: leasedId }), {
628
+ return new Response(JSON.stringify({ id: claimedId, ...counts }), {
468
629
  headers: { 'content-type': 'application/json' },
469
630
  });
470
631
  }
@@ -474,27 +635,45 @@ export class PlayDedup implements DurableObject {
474
635
  if (!version) {
475
636
  return new Response('version is required', { status: 400 });
476
637
  }
638
+ const minReadyAgeMs = this.workflowPoolMinReadyAgeMs(req);
477
639
  const now = Date.now();
478
640
  await this.gcWorkflowPool(now, version);
479
641
  const entries = await this.state.storage.list<WorkflowPoolEntry>({
480
642
  prefix: WORKFLOW_POOL_KEY_PREFIX,
481
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 {
482
661
  let available = 0;
483
662
  let warming = 0;
484
663
  for (const entry of entries.values()) {
485
664
  if (entry.version !== version) continue;
486
665
  if (
666
+ entry.state !== 'ready' ||
487
667
  entry.readyAt === null ||
488
- now - entry.readyAt > WORKFLOW_POOL_READY_MAX_AGE_MS
668
+ now - entry.readyAt > WORKFLOW_POOL_READY_MAX_AGE_MS ||
669
+ now - entry.readyAt < minReadyAgeMs
489
670
  ) {
490
671
  warming += 1;
491
672
  } else {
492
673
  available += 1;
493
674
  }
494
675
  }
495
- return new Response(JSON.stringify({ available, warming }), {
496
- headers: { 'content-type': 'application/json' },
497
- });
676
+ return { available, warming };
498
677
  }
499
678
 
500
679
  private async handlePoolList(req: Request): Promise<Response> {
@@ -512,6 +691,7 @@ export class PlayDedup implements DurableObject {
512
691
  .filter((entry) => entry.version === version)
513
692
  .map((entry) => ({
514
693
  id: entry.id,
694
+ state: entry.state,
515
695
  createdAt: entry.createdAt,
516
696
  readyAt: entry.readyAt,
517
697
  expiresAt: entry.expiresAt,
@@ -548,6 +728,7 @@ export class PlayDedup implements DurableObject {
548
728
  }
549
729
  writes[key] = {
550
730
  ...entry,
731
+ state: 'ready',
551
732
  readyAt: now,
552
733
  };
553
734
  }
@@ -563,6 +744,38 @@ export class PlayDedup implements DurableObject {
563
744
  );
564
745
  }
565
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 }), {
775
+ headers: { 'content-type': 'application/json' },
776
+ });
777
+ }
778
+
566
779
  private async handlePoolDelete(req: Request): Promise<Response> {
567
780
  const body = (await req.json().catch(() => null)) as {
568
781
  ids?: unknown;
@@ -600,26 +813,112 @@ export class PlayDedup implements DurableObject {
600
813
  runId?: unknown;
601
814
  instanceId?: unknown;
602
815
  version?: unknown;
816
+ started?: unknown;
603
817
  } | null;
604
818
  const runId = typeof body?.runId === 'string' ? body.runId : '';
605
819
  const instanceId =
606
820
  typeof body?.instanceId === 'string' ? body.instanceId : '';
607
821
  const version =
608
822
  typeof body?.version === 'string' ? body.version.trim() : '';
823
+ const started = body?.started === true;
609
824
  if (!runId || !instanceId || !version) {
610
825
  return new Response('runId, instanceId, and version are required', {
611
826
  status: 400,
612
827
  });
613
828
  }
614
829
  const now = Date.now();
615
- await this.state.storage.put(`${WORKFLOW_POOL_RUN_KEY_PREFIX}${runId}`, {
616
- runId,
617
- instanceId,
618
- version,
619
- createdAt: now,
620
- expiresAt: now + WORKFLOW_POOL_RUN_MAPPING_TTL_MS,
621
- } satisfies WorkflowRunMapping);
622
- return new Response('{}', {
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, {
849
+ runId,
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,
911
+ version,
912
+ createdAt: now,
913
+ expiresAt: now + WORKFLOW_POOL_RUN_MAPPING_TTL_MS,
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 }), {
623
922
  headers: { 'content-type': 'application/json' },
624
923
  });
625
924
  }
@@ -647,7 +946,10 @@ export class PlayDedup implements DurableObject {
647
946
  });
648
947
  }
649
948
  return new Response(
650
- JSON.stringify({ instanceId: mapping?.instanceId ?? null }),
949
+ JSON.stringify({
950
+ instanceId: mapping?.instanceId || null,
951
+ startedAt: mapping?.startedAt ?? null,
952
+ }),
651
953
  { headers: { 'content-type': 'application/json' } },
652
954
  );
653
955
  }
@@ -732,4 +1034,206 @@ export class PlayDedup implements DurableObject {
732
1034
  { headers: { 'content-type': 'application/json' } },
733
1035
  );
734
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
+ }
735
1239
  }