deepline 0.3.237 → 0.3.238

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.
@@ -1,45 +1,10 @@
1
- import { DeeplineError } from '../errors.js';
2
- import {
3
- buildPlayRunStatusSnapshot,
4
- diffPlayRunStreamEvents,
5
- isTerminalPlayRunLiveStatus,
6
- resolvePlayRunLogGap,
7
- EMPTY_PLAY_RUN_STREAM_DIFF_STATE,
8
- type LedgerBackedRunLike,
9
- type PlayRunStreamDiffState,
10
- type PlayRunStreamEvent,
11
- } from '../../../shared_libs/play-runtime/run-snapshot-stream.js';
12
-
13
1
  /**
14
- * Run Observe Transport (ADR-0008).
2
+ * Compatibility export retained for SDK callers that imported the old error.
3
+ * Run watching now uses authenticated REST snapshot polling and does not throw
4
+ * this error.
15
5
  *
16
- * Watches one Play Run by subscribing directly to the Convex Run Snapshot:
17
- *
18
- * 1. `POST /api/v2/runs/:runId/observe-grant` mints a short-lived Run
19
- * Observe Grant JWT (the Deepline app stays the only auth authority).
20
- * 2. A lazily-imported `convex/browser` ConvexClient authenticates with the
21
- * grant and subscribes to `runObservers.getPlayRunSnapshotForObserver`.
22
- * 3. Each snapshot update is run through the shared snapshot differ, so the
23
- * emitted `play.*` events are identical to the legacy SSE stream.
24
- *
25
- * Steady-state observation therefore holds no Vercel function open and never
26
- * consults the coordinator. The legacy SSE reconnect loop remains only as the
27
- * support-window fallback: callers catch
28
- * {@link RunObserveTransportUnavailableError} (grant endpoint missing on an
29
- * older/unconfigured server, or Convex unreachable) and fall back with a
30
- * single visible notice.
31
- */
32
-
33
- /** Grant response from POST /api/v2/runs/:runId/observe-grant. */
34
- export type RunObserveGrantResponse = {
35
- convexUrl: string;
36
- token: string;
37
- expiresAt: number;
38
- };
39
-
40
- /**
41
- * Bootstrap failure: this server/deployment cannot serve the subscription
42
- * transport. Callers fall back to the support-window SSE stream (ADR-0008).
6
+ * @deprecated The subscription transport has been removed. Use
7
+ * `client.watchRunEvents()` or `client.runs.watch()`.
43
8
  */
44
9
  export class RunObserveTransportUnavailableError extends Error {
45
10
  constructor(
@@ -50,431 +15,3 @@ export class RunObserveTransportUnavailableError extends Error {
50
15
  this.name = 'RunObserveTransportUnavailableError';
51
16
  }
52
17
  }
53
-
54
- type ObserveHttp = {
55
- post<T = unknown>(path: string, body: unknown): Promise<T>;
56
- };
57
-
58
- export type ObserveRunEventsOptions = {
59
- http: ObserveHttp;
60
- runId: string;
61
- signal?: AbortSignal;
62
- /**
63
- * Display-only notices (human mode): websocket reconnecting >10s, or a
64
- * non-terminal run with no snapshot update for >120s.
65
- */
66
- onNotice?: (message: string) => void;
67
- };
68
-
69
- /** How long to wait for the first snapshot before declaring Convex unreachable. */
70
- const OBSERVE_BOOTSTRAP_TIMEOUT_MS = 10_000;
71
- /** Surface a "reconnecting" notice when the websocket is down this long. */
72
- const OBSERVE_RECONNECT_NOTICE_MS = 10_000;
73
- /** Display-only staleness warning for non-terminal runs. */
74
- const OBSERVE_STALE_WARNING_MS = 120_000;
75
- const OBSERVE_WATCHDOG_TICK_MS = 5_000;
76
- /** Re-mint the grant slightly before expiry. */
77
- const GRANT_REFRESH_MARGIN_MS = 5 * 60_000;
78
- const BACKFILL_PAGE_LIMIT = 1000;
79
- const BACKFILL_MAX_PAGES = 30;
80
-
81
- const OBSERVER_SNAPSHOT_QUERY = 'runObservers:getPlayRunSnapshotForObserver';
82
- const OBSERVER_LOG_PAGE_QUERY = 'runObservers:getRunLogPageForObserver';
83
-
84
- function errorText(error: unknown): string {
85
- return error instanceof Error ? error.message : String(error);
86
- }
87
-
88
- async function mintRunObserveGrant(
89
- http: ObserveHttp,
90
- runId: string,
91
- ): Promise<RunObserveGrantResponse> {
92
- let response: unknown;
93
- try {
94
- response = await http.post(
95
- `/api/v2/runs/${encodeURIComponent(runId)}/observe-grant`,
96
- {},
97
- );
98
- } catch (error) {
99
- if (error instanceof DeeplineError) {
100
- // 401/403 are real auth failures on a server that HAS the endpoint —
101
- // surface them loudly. 404/405 mean an older server without the grant
102
- // route; 5xx/503 means the deployment cannot mint grants. Both of those
103
- // are bootstrap failures handled by the dated SSE fallback (ADR-0008).
104
- if (error.statusCode === 401 || error.statusCode === 403) {
105
- throw error;
106
- }
107
- throw new RunObserveTransportUnavailableError(
108
- `observe-grant endpoint unavailable (${error.statusCode ?? 'network'}): ${error.message}`,
109
- 'grant_endpoint_unavailable',
110
- );
111
- }
112
- throw new RunObserveTransportUnavailableError(
113
- `observe-grant request failed: ${errorText(error)}`,
114
- 'grant_request_failed',
115
- );
116
- }
117
- const grant = response as Partial<RunObserveGrantResponse> | null;
118
- if (
119
- !grant ||
120
- typeof grant.convexUrl !== 'string' ||
121
- !grant.convexUrl.trim() ||
122
- typeof grant.token !== 'string' ||
123
- !grant.token.trim() ||
124
- typeof grant.expiresAt !== 'number'
125
- ) {
126
- throw new RunObserveTransportUnavailableError(
127
- 'observe-grant endpoint returned an invalid grant payload.',
128
- 'grant_payload_invalid',
129
- );
130
- }
131
- return grant as RunObserveGrantResponse;
132
- }
133
-
134
- type ObserverRunDoc = LedgerBackedRunLike & { status: string };
135
-
136
- type ObserverRunLogPage = {
137
- runId: string;
138
- lastStoredSeq: number;
139
- entries: Array<{ seq: number; line: string }>;
140
- } | null;
141
-
142
- type QueueItem =
143
- | { kind: 'run'; run: ObserverRunDoc | null }
144
- | { kind: 'error'; error: unknown };
145
-
146
- /**
147
- * Log-gap backfill from the durable Run Log Stream (ADR-0009). Log line
148
- * bodies live in `playRunLogChunks` with exact absolute sequence numbers
149
- * (they are no longer persisted to `playRunEvents`), so the grant-scoped
150
- * `getRunLogPageForObserver` query returns precisely the missing seq range.
151
- * Returns null when the page read fails or the range is not fully stored —
152
- * the differ's loud `[stream] N log lines not retained` marker remains the
153
- * truth in that case.
154
- */
155
- async function backfillLogGap(input: {
156
- queryLogPage: (
157
- afterSeq: number,
158
- limit: number,
159
- ) => Promise<ObserverRunLogPage>;
160
- lastLogSeq: number;
161
- tailFirstSeq: number;
162
- }): Promise<string[] | null> {
163
- const lines: string[] = [];
164
- let cursor = input.lastLogSeq;
165
- for (let page = 0; page < BACKFILL_MAX_PAGES; page += 1) {
166
- if (cursor >= input.tailFirstSeq - 1) {
167
- break;
168
- }
169
- let logPage: ObserverRunLogPage;
170
- try {
171
- logPage = await input.queryLogPage(
172
- cursor,
173
- Math.min(BACKFILL_PAGE_LIMIT, input.tailFirstSeq - 1 - cursor),
174
- );
175
- } catch {
176
- return null;
177
- }
178
- const entries = (logPage?.entries ?? []).filter(
179
- (entry) => entry.seq > cursor && entry.seq < input.tailFirstSeq,
180
- );
181
- if (entries.length === 0) {
182
- break;
183
- }
184
- for (const entry of entries) {
185
- if (entry.seq !== cursor + 1) {
186
- // Stored seqs must be dense for the emitted payload's `firstSeq` to
187
- // describe a contiguous run; a hole (e.g. a legacy run whose chunk
188
- // storage starts mid-run) falls back to the differ's loud marker.
189
- return null;
190
- }
191
- lines.push(entry.line);
192
- cursor = entry.seq;
193
- }
194
- }
195
- if (cursor < input.tailFirstSeq - 1) {
196
- return null;
197
- }
198
- return lines;
199
- }
200
-
201
- /**
202
- * Subscribe to one run's snapshot and yield the same `play.*` live events the
203
- * SSE stream emits, ending after the terminal snapshot has been emitted.
204
- *
205
- * Failure contract:
206
- * - bootstrap failure → {@link RunObserveTransportUnavailableError} (caller
207
- * falls back to SSE with one notice)
208
- * - snapshot `null` → loud `RUN_NOT_FOUND`
209
- * - grant rejected after one re-mint → loud failure
210
- * - websocket drops mid-run → Convex client auto-reconnects; a display-only
211
- * "reconnecting" notice surfaces after 10s
212
- */
213
- export async function* observeRunEvents(
214
- options: ObserveRunEventsOptions,
215
- ): AsyncGenerator<PlayRunStreamEvent> {
216
- const { http, runId } = options;
217
- let grant = await mintRunObserveGrant(http, runId);
218
-
219
- let convexBrowser: typeof import('convex/browser');
220
- let convexServer: typeof import('convex/server');
221
- try {
222
- // Lazy: non-watch commands never load the Convex client.
223
- convexBrowser = await import('convex/browser');
224
- convexServer = await import('convex/server');
225
- } catch (error) {
226
- throw new RunObserveTransportUnavailableError(
227
- `convex client module unavailable: ${errorText(error)}`,
228
- 'convex_module_unavailable',
229
- );
230
- }
231
-
232
- let webSocketConstructor: typeof WebSocket | undefined;
233
- if (typeof WebSocket === 'undefined') {
234
- // Node 18/20 have no global WebSocket; use the ws shim.
235
- try {
236
- // Optional transport helper: thin SDK installs omit this and fall back to
237
- // the canonical SSE tail path instead of pulling a websocket stack.
238
- const wsModuleName = 'ws';
239
- const ws = (await import(wsModuleName)) as { default: unknown };
240
- webSocketConstructor = ws.default as typeof WebSocket;
241
- } catch (error) {
242
- throw new RunObserveTransportUnavailableError(
243
- `no WebSocket implementation available: ${errorText(error)}`,
244
- 'websocket_unavailable',
245
- );
246
- }
247
- }
248
-
249
- const snapshotQuery = convexServer.makeFunctionReference<'query'>(
250
- OBSERVER_SNAPSHOT_QUERY,
251
- );
252
- const logPageQuery = convexServer.makeFunctionReference<'query'>(
253
- OBSERVER_LOG_PAGE_QUERY,
254
- );
255
-
256
- const client = new convexBrowser.ConvexClient(grant.convexUrl, {
257
- ...(webSocketConstructor ? { webSocketConstructor } : {}),
258
- unsavedChangesWarning: false,
259
- });
260
-
261
- const queue: QueueItem[] = [];
262
- let wake: (() => void) | null = null;
263
- const push = (item: QueueItem) => {
264
- queue.push(item);
265
- wake?.();
266
- wake = null;
267
- };
268
-
269
- let lastForcedRefreshAt = 0;
270
- client.setAuth(async ({ forceRefreshToken }) => {
271
- const now = Date.now();
272
- if (!forceRefreshToken && grant.expiresAt - now > GRANT_REFRESH_MARGIN_MS) {
273
- return grant.token;
274
- }
275
- if (forceRefreshToken && now - lastForcedRefreshAt < 5_000) {
276
- // Convex rejected a token we just re-minted: one re-mint retry has
277
- // already happened, so fail loudly instead of looping silently.
278
- push({
279
- kind: 'error',
280
- error: new DeeplineError(
281
- `Run observe grant for ${runId} was rejected after a re-mint. ` +
282
- 'The server and Convex deployment disagree on the grant issuer/JWKS.',
283
- 401,
284
- 'RUN_OBSERVE_GRANT_REJECTED',
285
- ),
286
- });
287
- return null;
288
- }
289
- if (forceRefreshToken) {
290
- lastForcedRefreshAt = now;
291
- }
292
- try {
293
- grant = await mintRunObserveGrant(http, runId);
294
- return grant.token;
295
- } catch (error) {
296
- push({ kind: 'error', error });
297
- return null;
298
- }
299
- });
300
-
301
- const unsubscribe = client.onUpdate(
302
- snapshotQuery,
303
- { workflowId: runId },
304
- (run) => push({ kind: 'run', run: (run ?? null) as ObserverRunDoc | null }),
305
- (error) => push({ kind: 'error', error }),
306
- );
307
-
308
- let lastUpdateAt = Date.now();
309
- let lastStatusTerminal = false;
310
- let disconnectedSince: number | null = null;
311
- let warnedReconnecting = false;
312
- let warnedStale = false;
313
- const watchdog = setInterval(() => {
314
- const now = Date.now();
315
- try {
316
- const connectionState = client.connectionState();
317
- if (connectionState.isWebSocketConnected) {
318
- disconnectedSince = null;
319
- warnedReconnecting = false;
320
- } else {
321
- disconnectedSince ??= now;
322
- if (
323
- !warnedReconnecting &&
324
- now - disconnectedSince >= OBSERVE_RECONNECT_NOTICE_MS
325
- ) {
326
- warnedReconnecting = true;
327
- options.onNotice?.(
328
- `[observe] connection lost; reconnecting to live run ${runId}…`,
329
- );
330
- }
331
- }
332
- } catch {
333
- // connectionState is diagnostic only.
334
- }
335
- if (
336
- !lastStatusTerminal &&
337
- !warnedStale &&
338
- now - lastUpdateAt >= OBSERVE_STALE_WARNING_MS
339
- ) {
340
- warnedStale = true;
341
- options.onNotice?.(
342
- `[observe] no live updates for ${Math.round((now - lastUpdateAt) / 1000)}s; ` +
343
- `run ${runId} may be stalled (status checks continue)`,
344
- );
345
- }
346
- }, OBSERVE_WATCHDOG_TICK_MS);
347
- // Don't keep the process alive for the watchdog.
348
- watchdog.unref?.();
349
-
350
- const abortListener = () =>
351
- push({
352
- kind: 'error',
353
- error: new DeeplineError(
354
- 'Run observation aborted.',
355
- undefined,
356
- 'ABORTED',
357
- ),
358
- });
359
- options.signal?.addEventListener('abort', abortListener, { once: true });
360
-
361
- let diffState: PlayRunStreamDiffState = EMPTY_PLAY_RUN_STREAM_DIFF_STATE;
362
- const streamId = ['observe', runId].join(':');
363
- let sawFirstSnapshot = false;
364
-
365
- try {
366
- for (;;) {
367
- if (queue.length === 0) {
368
- const waitForItem = new Promise<void>((resolve) => {
369
- wake = resolve;
370
- });
371
- if (!sawFirstSnapshot) {
372
- // Convex unreachable is a bootstrap failure: no first result within
373
- // the bootstrap window means the caller should fall back to SSE.
374
- const timedOut = await Promise.race([
375
- waitForItem.then(() => false),
376
- new Promise<boolean>((resolve) =>
377
- setTimeout(() => resolve(true), OBSERVE_BOOTSTRAP_TIMEOUT_MS),
378
- ),
379
- ]);
380
- if (timedOut && queue.length === 0) {
381
- throw new RunObserveTransportUnavailableError(
382
- `no snapshot from Convex at ${grant.convexUrl} within ${OBSERVE_BOOTSTRAP_TIMEOUT_MS}ms`,
383
- 'convex_unreachable',
384
- );
385
- }
386
- } else {
387
- await waitForItem;
388
- }
389
- continue;
390
- }
391
-
392
- const item = queue.shift()!;
393
- if (item.kind === 'error') {
394
- if (options.signal?.aborted) {
395
- return;
396
- }
397
- throw item.error;
398
- }
399
-
400
- sawFirstSnapshot = true;
401
- lastUpdateAt = Date.now();
402
- warnedStale = false;
403
-
404
- if (item.run === null) {
405
- throw new DeeplineError(
406
- `Run ${runId} was not found (or is not visible to this grant).`,
407
- 404,
408
- 'RUN_NOT_FOUND',
409
- );
410
- }
411
-
412
- const snapshot = buildPlayRunStatusSnapshot({ run: item.run });
413
- lastStatusTerminal = isTerminalPlayRunLiveStatus(snapshot.status);
414
-
415
- // Backfill log lines that rotated out of the retained tail window from
416
- // the durable Run Log Stream before diffing, so the differ emits the
417
- // retained tail without its loud gap marker. First-connect to an
418
- // in-flight run (lastLogSeq === 0) is intentionally NOT backfilled:
419
- // watchers get the tail plus the marker pointing at `runs logs`.
420
- const gap = resolvePlayRunLogGap(snapshot, diffState.lastLogSeq);
421
- if (gap && diffState.lastLogSeq > 0) {
422
- const backfilled = await backfillLogGap({
423
- queryLogPage: (afterSeq, limit) =>
424
- client.query(logPageQuery, {
425
- workflowId: runId,
426
- afterSeq,
427
- limit,
428
- }) as Promise<ObserverRunLogPage>,
429
- lastLogSeq: diffState.lastLogSeq,
430
- tailFirstSeq: gap.tailFirstSeq,
431
- });
432
- if (backfilled && backfilled.length > 0) {
433
- yield {
434
- cursor: String(snapshot.updatedAt ?? Date.now()),
435
- streamId,
436
- scope: 'play',
437
- type: 'play.run.log',
438
- at: new Date().toISOString(),
439
- payload: {
440
- runId: snapshot.runId,
441
- lines: backfilled,
442
- source: 'worker',
443
- firstSeq: diffState.lastLogSeq + 1,
444
- totalLogCount: snapshot.totalLogCount,
445
- },
446
- };
447
- diffState = { ...diffState, lastLogSeq: gap.tailFirstSeq - 1 };
448
- }
449
- }
450
-
451
- const { events, next } = diffPlayRunStreamEvents({
452
- streamId,
453
- snapshot,
454
- previous: diffState,
455
- });
456
- diffState = next;
457
- // Snapshot event first, mirroring the SSE stream's ordering.
458
- const ordered = [
459
- ...events.filter((event) => event.type === 'play.run.snapshot'),
460
- ...events.filter((event) => event.type !== 'play.run.snapshot'),
461
- ];
462
- for (const event of ordered) {
463
- yield event;
464
- }
465
-
466
- if (lastStatusTerminal) {
467
- return;
468
- }
469
- }
470
- } finally {
471
- clearInterval(watchdog);
472
- options.signal?.removeEventListener('abort', abortListener);
473
- try {
474
- unsubscribe();
475
- } catch {
476
- // Already closed.
477
- }
478
- await client.close().catch(() => undefined);
479
- }
480
- }
@@ -0,0 +1,220 @@
1
+ import {
2
+ diffPlayRunStreamEvents,
3
+ EMPTY_PLAY_RUN_STREAM_DIFF_STATE,
4
+ isTerminalPlayRunLiveStatus,
5
+ resolvePlayRunLogGap,
6
+ type PlayRunLiveSnapshot,
7
+ type PlayRunStreamDiffState,
8
+ type PlayRunStreamEvent,
9
+ } from '../../../shared_libs/play-runtime/run-snapshot-stream.js';
10
+ import { isTransientPlayRunReadError } from '../stream-reconnect.js';
11
+
12
+ /** The watch endpoint is a small authenticated REST read, polled every two seconds. */
13
+ export const RUN_WATCH_POLL_INTERVAL_MS = 2_000;
14
+
15
+ type WatchHttp = {
16
+ get<T = unknown>(
17
+ path: string,
18
+ options?: { signal?: AbortSignal },
19
+ ): Promise<T>;
20
+ };
21
+
22
+ export type WatchRunEventsOptions = {
23
+ http: WatchHttp;
24
+ runId: string;
25
+ signal?: AbortSignal;
26
+ /** @internal Test seam; production callers use the fixed two-second cadence. */
27
+ pollIntervalMs?: number;
28
+ onNotice?: (message: string) => void;
29
+ onRetry?: (info: {
30
+ attempt: number;
31
+ delayMs: number;
32
+ reason: string;
33
+ }) => void;
34
+ };
35
+
36
+ type WatchSnapshotResponse = {
37
+ runId: string;
38
+ snapshot: PlayRunLiveSnapshot;
39
+ };
40
+
41
+ type WatchRunLogPage = {
42
+ entries: Array<{ seq: number; line: string }>;
43
+ };
44
+
45
+ function waitForNextPoll(ms: number, signal?: AbortSignal): Promise<boolean> {
46
+ if (signal?.aborted) {
47
+ return Promise.resolve(false);
48
+ }
49
+ return new Promise((resolve) => {
50
+ const finish = (elapsed: boolean) => {
51
+ clearTimeout(timeout);
52
+ signal?.removeEventListener('abort', onAbort);
53
+ resolve(elapsed);
54
+ };
55
+ const onAbort = () => finish(false);
56
+ const timeout = setTimeout(() => finish(true), ms);
57
+ signal?.addEventListener('abort', onAbort, { once: true });
58
+ });
59
+ }
60
+
61
+ function readSnapshotResponse(
62
+ response: unknown,
63
+ runId: string,
64
+ ): WatchSnapshotResponse {
65
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
66
+ throw new Error('Run watch returned an invalid snapshot response.');
67
+ }
68
+ const record = response as Record<string, unknown>;
69
+ const snapshot = record.snapshot;
70
+ if (
71
+ record.runId !== runId ||
72
+ !snapshot ||
73
+ typeof snapshot !== 'object' ||
74
+ Array.isArray(snapshot) ||
75
+ (snapshot as Record<string, unknown>).runId !== runId ||
76
+ typeof (snapshot as Record<string, unknown>).status !== 'string' ||
77
+ !Array.isArray((snapshot as Record<string, unknown>).logs) ||
78
+ !((snapshot as Record<string, unknown>).logs as unknown[]).every(
79
+ (line) => typeof line === 'string',
80
+ ) ||
81
+ typeof (snapshot as Record<string, unknown>).totalLogCount !== 'number' ||
82
+ !Array.isArray((snapshot as Record<string, unknown>).nodeStates)
83
+ ) {
84
+ throw new Error(`Run watch returned an invalid snapshot for ${runId}.`);
85
+ }
86
+ return { runId, snapshot: snapshot as PlayRunLiveSnapshot };
87
+ }
88
+
89
+ async function backfillRotatedLogTail(input: {
90
+ http: WatchHttp;
91
+ runId: string;
92
+ snapshot: PlayRunLiveSnapshot;
93
+ previous: PlayRunStreamDiffState;
94
+ signal?: AbortSignal;
95
+ }): Promise<PlayRunLiveSnapshot> {
96
+ // A late first attach intentionally shows the retained tail and its gap
97
+ // marker. Backfill only lines that rotated out after this watcher attached.
98
+ if (!input.previous.runSignature) {
99
+ return input.snapshot;
100
+ }
101
+ const gap = resolvePlayRunLogGap(input.snapshot, input.previous.lastLogSeq);
102
+ if (!gap) {
103
+ return input.snapshot;
104
+ }
105
+
106
+ const lines: string[] = [];
107
+ let cursor = input.previous.lastLogSeq;
108
+ const targetSeq = gap.tailFirstSeq - 1;
109
+ while (cursor < targetSeq) {
110
+ const limit = Math.min(1_000, targetSeq - cursor);
111
+ const page = await input.http.get<WatchRunLogPage>(
112
+ `/api/v2/runs/${encodeURIComponent(input.runId)}/logs?afterSeq=${cursor}&limit=${limit}`,
113
+ { signal: input.signal },
114
+ );
115
+ if (!page || !Array.isArray(page.entries) || page.entries.length === 0) {
116
+ return input.snapshot;
117
+ }
118
+ for (const entry of page.entries) {
119
+ if (
120
+ !entry ||
121
+ entry.seq !== cursor + 1 ||
122
+ typeof entry.line !== 'string'
123
+ ) {
124
+ return input.snapshot;
125
+ }
126
+ lines.push(entry.line);
127
+ cursor = entry.seq;
128
+ }
129
+ }
130
+ if (cursor !== targetSeq) {
131
+ return input.snapshot;
132
+ }
133
+ return { ...input.snapshot, logs: [...lines, ...input.snapshot.logs] };
134
+ }
135
+
136
+ /**
137
+ * Poll the authenticated run snapshot and emit the canonical `play.*` events.
138
+ * No Convex observer token, WebSocket, or server-held SSE connection is used.
139
+ */
140
+ export async function* watchRunEvents(
141
+ options: WatchRunEventsOptions,
142
+ ): AsyncGenerator<PlayRunStreamEvent> {
143
+ const { http, runId, signal } = options;
144
+ const path = `/api/v2/runs/${encodeURIComponent(runId)}?watchSnapshot=true`;
145
+ const streamId = `play-run-watch:${runId}`;
146
+ const pollIntervalMs = options.pollIntervalMs ?? RUN_WATCH_POLL_INTERVAL_MS;
147
+ let previous: PlayRunStreamDiffState = EMPTY_PLAY_RUN_STREAM_DIFF_STATE;
148
+ let consecutiveFailures = 0;
149
+ let outageNoticeSent = false;
150
+
151
+ while (!signal?.aborted) {
152
+ const pollStartedAt = Date.now();
153
+ let snapshot: PlayRunLiveSnapshot;
154
+ try {
155
+ const response = await http.get<unknown>(path, { signal });
156
+ snapshot = await backfillRotatedLogTail({
157
+ http,
158
+ runId,
159
+ snapshot: readSnapshotResponse(response, runId).snapshot,
160
+ previous,
161
+ signal,
162
+ });
163
+ } catch (error) {
164
+ if (signal?.aborted) {
165
+ return;
166
+ }
167
+ if (!isTransientPlayRunReadError(error)) {
168
+ throw error;
169
+ }
170
+ consecutiveFailures += 1;
171
+ const reason = error instanceof Error ? error.message : String(error);
172
+ if (!outageNoticeSent) {
173
+ options.onRetry?.({
174
+ attempt: consecutiveFailures,
175
+ delayMs: pollIntervalMs,
176
+ reason,
177
+ });
178
+ options.onNotice?.(
179
+ `[watch] run snapshot request failed; retrying every ${pollIntervalMs / 1_000}s (${reason})`,
180
+ );
181
+ outageNoticeSent = true;
182
+ }
183
+ const remaining = Math.max(
184
+ 0,
185
+ pollIntervalMs - (Date.now() - pollStartedAt),
186
+ );
187
+ if (!(await waitForNextPoll(remaining, signal))) {
188
+ return;
189
+ }
190
+ continue;
191
+ }
192
+
193
+ if (outageNoticeSent) {
194
+ options.onNotice?.('[watch] run snapshot polling recovered');
195
+ outageNoticeSent = false;
196
+ }
197
+ consecutiveFailures = 0;
198
+
199
+ const diff = diffPlayRunStreamEvents({
200
+ streamId,
201
+ snapshot,
202
+ previous,
203
+ });
204
+ previous = diff.next;
205
+ for (const event of diff.events) {
206
+ yield event;
207
+ }
208
+ if (isTerminalPlayRunLiveStatus(snapshot.status)) {
209
+ return;
210
+ }
211
+
212
+ const remaining = Math.max(
213
+ 0,
214
+ pollIntervalMs - (Date.now() - pollStartedAt),
215
+ );
216
+ if (!(await waitForNextPoll(remaining, signal))) {
217
+ return;
218
+ }
219
+ }
220
+ }