deepline 0.3.236 → 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.
- package/dist/bundling-sources/sdk/src/client.ts +164 -295
- package/dist/bundling-sources/sdk/src/http.ts +1 -1
- package/dist/bundling-sources/sdk/src/index.ts +1 -0
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/runs/observe-transport.ts +5 -468
- package/dist/bundling-sources/sdk/src/runs/watch-polling.ts +220 -0
- package/dist/bundling-sources/sdk/src/stream-reconnect.ts +9 -12
- package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +4 -4
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +264 -120
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +219 -46
- package/dist/cli/index.js +687 -2108
- package/dist/cli/index.mjs +687 -2108
- package/dist/index.d.mts +77 -57
- package/dist/index.d.ts +77 -57
- package/dist/index.js +297 -1732
- package/dist/index.mjs +297 -1732
- package/dist/install-integrity.json +1 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
+
}
|