deepline 0.1.178 → 0.1.180

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +132 -7
  2. package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +25 -8
  3. package/dist/bundling-sources/apps/play-runner-workers/src/durable-object-deploy-handoff.ts +8 -2
  4. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +414 -196
  5. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/batching.ts +42 -2
  6. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/coordinator-progress.ts +289 -0
  7. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +112 -1
  8. package/dist/bundling-sources/sdk/src/client.ts +237 -0
  9. package/dist/bundling-sources/sdk/src/http.ts +100 -9
  10. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  11. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +10 -2
  12. package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +58 -2
  13. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +99 -25
  14. package/dist/bundling-sources/shared_libs/play-runtime/db-session.ts +7 -2
  15. package/dist/bundling-sources/shared_libs/play-runtime/persistence-latch.ts +167 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/receipt-salvage.ts +158 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +44 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +74 -30
  19. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +6 -0
  20. package/dist/cli/index.js +338 -13
  21. package/dist/cli/index.mjs +338 -13
  22. package/dist/index.d.mts +44 -0
  23. package/dist/index.d.ts +44 -0
  24. package/dist/index.js +229 -9
  25. package/dist/index.mjs +229 -9
  26. package/package.json +1 -1
@@ -19,17 +19,57 @@ export type ChunkExecutionResult<TRequest, TResult> = {
19
19
  error?: unknown;
20
20
  };
21
21
 
22
+ function planExecutionChunks<TRequest>(
23
+ requests: TRequest[],
24
+ batchSize: number,
25
+ maxChunkWeight: number | undefined,
26
+ weightOf: ((request: TRequest) => number) | undefined,
27
+ ): TRequest[][] {
28
+ const maxUnits = Math.max(1, Math.floor(batchSize));
29
+ const weightCap =
30
+ maxChunkWeight != null && weightOf != null
31
+ ? Math.max(1, Math.floor(maxChunkWeight))
32
+ : null;
33
+ const chunks: TRequest[][] = [];
34
+ let current: TRequest[] = [];
35
+ let weight = 0;
36
+ for (const request of requests) {
37
+ if (
38
+ current.length > 0 &&
39
+ (current.length >= maxUnits ||
40
+ (weightCap != null && weight >= weightCap))
41
+ ) {
42
+ chunks.push(current);
43
+ current = [];
44
+ weight = 0;
45
+ }
46
+ current.push(request);
47
+ if (weightCap != null && weightOf != null) {
48
+ weight += Math.max(1, Math.floor(weightOf(request)));
49
+ }
50
+ }
51
+ if (current.length > 0) chunks.push(current);
52
+ return chunks;
53
+ }
54
+
22
55
  export async function executeChunkedRequests<TRequest, TResult>(input: {
23
56
  requests: TRequest[];
24
57
  batchSize: number;
58
+ maxChunkWeight?: number;
59
+ weightOf?: (request: TRequest) => number;
25
60
  execute: (request: TRequest) => Promise<TResult>;
26
61
  onChunkComplete?: (
27
62
  results: Array<ChunkExecutionResult<TRequest, TResult>>,
28
63
  ) => void | Promise<void>;
29
64
  }): Promise<Array<ChunkExecutionResult<TRequest, TResult>>> {
30
65
  const results: Array<ChunkExecutionResult<TRequest, TResult>> = [];
31
- for (let start = 0; start < input.requests.length; start += input.batchSize) {
32
- const chunk = input.requests.slice(start, start + input.batchSize);
66
+ const chunks = planExecutionChunks(
67
+ input.requests,
68
+ input.batchSize,
69
+ input.maxChunkWeight,
70
+ input.weightOf,
71
+ );
72
+ for (const chunk of chunks) {
33
73
  let notifyChain: Promise<void> = Promise.resolve();
34
74
  const notify = async (
35
75
  entry: ChunkExecutionResult<TRequest, TResult>,
@@ -0,0 +1,289 @@
1
+ import { coordinatorRequestHeaders } from '../../../../shared_libs/play-runtime/coordinator-headers';
2
+ import type {
3
+ LiveNodeProgressMap,
4
+ LiveNodeProgressSnapshot,
5
+ } from './live-progress';
6
+
7
+ type CoordinatorProgressTraceExtra = {
8
+ status: 'ok' | 'failed' | 'skipped_no_url';
9
+ httpStatus?: number;
10
+ activeNodeId?: string | null;
11
+ activeArtifactTableNamespace?: string | null;
12
+ activeCompleted?: number | null;
13
+ activeTotal?: number | null;
14
+ activeMessage?: string | null;
15
+ };
16
+
17
+ type CoordinatorProgressTrace = {
18
+ phase: 'runner.coordinator_progress_publish';
19
+ ms: number;
20
+ extra: CoordinatorProgressTraceExtra;
21
+ };
22
+
23
+ export class CoordinatorProgressAuthorizationError extends Error {
24
+ constructor(
25
+ message: string,
26
+ readonly status: number,
27
+ ) {
28
+ super(message);
29
+ this.name = 'CoordinatorProgressAuthorizationError';
30
+ }
31
+ }
32
+
33
+ export function isCoordinatorProgressAuthorizationError(
34
+ error: unknown,
35
+ ): error is CoordinatorProgressAuthorizationError {
36
+ return (
37
+ error instanceof CoordinatorProgressAuthorizationError ||
38
+ (typeof error === 'object' &&
39
+ error !== null &&
40
+ 'name' in error &&
41
+ 'status' in error &&
42
+ (error as { name?: unknown }).name ===
43
+ 'CoordinatorProgressAuthorizationError' &&
44
+ typeof (error as { status?: unknown }).status === 'number')
45
+ );
46
+ }
47
+
48
+ function activeProgressFields(liveNodeProgress: LiveNodeProgressMap): {
49
+ activeNodeId: string | null;
50
+ activeProgress: LiveNodeProgressSnapshot | null;
51
+ activeArtifactTableNamespace: string | null;
52
+ activeCompleted: number | null;
53
+ activeTotal: number | null;
54
+ activeMessage: string | null;
55
+ } {
56
+ const activeEntry =
57
+ Object.entries(liveNodeProgress).find(
58
+ ([, progress]) => typeof progress.completedAt !== 'number',
59
+ ) ?? Object.entries(liveNodeProgress).at(-1);
60
+ const activeNodeId = activeEntry?.[0] ?? null;
61
+ const activeProgress = activeEntry?.[1] ?? null;
62
+ const activeArtifactTableNamespace =
63
+ typeof activeProgress?.artifactTableNamespace === 'string'
64
+ ? activeProgress.artifactTableNamespace
65
+ : null;
66
+ const activeCompleted =
67
+ typeof activeProgress?.completed === 'number'
68
+ ? activeProgress.completed
69
+ : null;
70
+ const activeTotal =
71
+ typeof activeProgress?.total === 'number' ? activeProgress.total : null;
72
+ const activeMessage =
73
+ typeof activeProgress?.message === 'string' ? activeProgress.message : null;
74
+ return {
75
+ activeNodeId,
76
+ activeProgress,
77
+ activeArtifactTableNamespace,
78
+ activeCompleted,
79
+ activeTotal,
80
+ activeMessage,
81
+ };
82
+ }
83
+
84
+ async function publishCoordinatorProgressEvent(input: {
85
+ runId: string;
86
+ coordinatorUrl?: string | null;
87
+ coordinatorInternalToken?: string | null;
88
+ occurredAt: number;
89
+ runLogBuffer: string[];
90
+ liveNodeProgress: LiveNodeProgressMap;
91
+ fetchFn: typeof fetch;
92
+ makeRequestId: () => string;
93
+ nowMs: () => number;
94
+ recordTrace: (trace: CoordinatorProgressTrace) => void;
95
+ }): Promise<void> {
96
+ const coordinatorUrl = input.coordinatorUrl?.trim();
97
+ if (!coordinatorUrl) {
98
+ input.recordTrace({
99
+ phase: 'runner.coordinator_progress_publish',
100
+ ms: 0,
101
+ extra: { status: 'skipped_no_url' },
102
+ });
103
+ return;
104
+ }
105
+
106
+ const publishStartedAt = input.nowMs();
107
+ const {
108
+ activeNodeId,
109
+ activeArtifactTableNamespace,
110
+ activeCompleted,
111
+ activeTotal,
112
+ activeMessage,
113
+ } = activeProgressFields(input.liveNodeProgress);
114
+
115
+ const response = await input.fetchFn(
116
+ `${coordinatorUrl.replace(/\/$/, '')}/dedup/${encodeURIComponent(
117
+ input.runId,
118
+ )}/event-add`,
119
+ {
120
+ method: 'POST',
121
+ headers: {
122
+ 'x-deepline-request-id': input.makeRequestId(),
123
+ ...coordinatorRequestHeaders({
124
+ runId: input.runId,
125
+ contentType: 'application/json',
126
+ internalToken: input.coordinatorInternalToken,
127
+ }),
128
+ },
129
+ body: JSON.stringify({
130
+ runId: input.runId,
131
+ type: 'progress',
132
+ status: 'running',
133
+ ts: input.occurredAt,
134
+ logs: input.runLogBuffer,
135
+ activeNodeId,
136
+ activeArtifactTableNamespace,
137
+ updatedAt: input.occurredAt,
138
+ liveNodeProgress: input.liveNodeProgress,
139
+ }),
140
+ },
141
+ );
142
+
143
+ if (response.status === 401 || response.status === 403) {
144
+ input.recordTrace({
145
+ phase: 'runner.coordinator_progress_publish',
146
+ ms: input.nowMs() - publishStartedAt,
147
+ extra: {
148
+ status: 'failed',
149
+ httpStatus: response.status,
150
+ activeNodeId,
151
+ activeArtifactTableNamespace,
152
+ activeCompleted,
153
+ activeTotal,
154
+ activeMessage,
155
+ },
156
+ });
157
+ throw new CoordinatorProgressAuthorizationError(
158
+ `Coordinator progress publish was rejected with ${response.status}; ` +
159
+ 'the play runner no longer has authority to update its coordinator state. ' +
160
+ 'Stopping before more provider work can execute with stale coordinator authority.',
161
+ response.status,
162
+ );
163
+ }
164
+
165
+ if (!response.ok) {
166
+ input.recordTrace({
167
+ phase: 'runner.coordinator_progress_publish',
168
+ ms: input.nowMs() - publishStartedAt,
169
+ extra: {
170
+ status: 'failed',
171
+ httpStatus: response.status,
172
+ activeNodeId,
173
+ activeArtifactTableNamespace,
174
+ activeCompleted,
175
+ activeTotal,
176
+ activeMessage,
177
+ },
178
+ });
179
+ throw new Error(`coordinator progress event failed ${response.status}`);
180
+ }
181
+
182
+ input.recordTrace({
183
+ phase: 'runner.coordinator_progress_publish',
184
+ ms: input.nowMs() - publishStartedAt,
185
+ extra: {
186
+ status: 'ok',
187
+ activeNodeId,
188
+ activeArtifactTableNamespace,
189
+ activeCompleted,
190
+ activeTotal,
191
+ activeMessage,
192
+ },
193
+ });
194
+ }
195
+
196
+ export function createCoordinatorProgressPublisher(input: {
197
+ runId: string;
198
+ coordinatorUrl?: string | null;
199
+ coordinatorInternalToken?: string | null;
200
+ abortController: AbortController;
201
+ getRunLogBuffer: () => string[];
202
+ getLiveNodeProgress: () => LiveNodeProgressMap;
203
+ recordTrace: (trace: CoordinatorProgressTrace) => void;
204
+ fetchFn?: typeof fetch;
205
+ makeRequestId: () => string;
206
+ nowMs: () => number;
207
+ heartbeatIntervalMs: number;
208
+ }): {
209
+ assertHealthy: () => void;
210
+ flush: (force: boolean) => Promise<void>;
211
+ fatalError: () => Error | null;
212
+ } {
213
+ let lastPublishAt = 0;
214
+ let publishInFlight: Promise<void> = Promise.resolve();
215
+ let publishQueueDepth = 0;
216
+ let fatalError: Error | null = null;
217
+ const abortSignal = input.abortController.signal;
218
+ const fetchFn = input.fetchFn ?? fetch;
219
+
220
+ const markFatal = (error: Error): void => {
221
+ fatalError ??= error;
222
+ if (!abortSignal.aborted) {
223
+ input.abortController.abort(error);
224
+ }
225
+ };
226
+
227
+ const assertHealthy = (): void => {
228
+ if (fatalError) {
229
+ throw fatalError;
230
+ }
231
+ };
232
+
233
+ const publish = async (occurredAt: number): Promise<void> => {
234
+ assertHealthy();
235
+ await publishCoordinatorProgressEvent({
236
+ runId: input.runId,
237
+ coordinatorUrl: input.coordinatorUrl,
238
+ coordinatorInternalToken: input.coordinatorInternalToken,
239
+ occurredAt,
240
+ runLogBuffer: input.getRunLogBuffer(),
241
+ liveNodeProgress: input.getLiveNodeProgress(),
242
+ fetchFn,
243
+ makeRequestId: input.makeRequestId,
244
+ nowMs: input.nowMs,
245
+ recordTrace: input.recordTrace,
246
+ });
247
+ };
248
+
249
+ const flush = (force: boolean): Promise<void> => {
250
+ if (fatalError) {
251
+ return force ? Promise.reject(fatalError) : Promise.resolve();
252
+ }
253
+
254
+ const now = input.nowMs();
255
+ if (!force && now - lastPublishAt < input.heartbeatIntervalMs) {
256
+ return Promise.resolve();
257
+ }
258
+ if (!force && publishQueueDepth > 0) {
259
+ return Promise.resolve();
260
+ }
261
+
262
+ lastPublishAt = now;
263
+ publishQueueDepth += 1;
264
+ const publishTask = publishInFlight
265
+ .catch(() => undefined)
266
+ .then(async () => {
267
+ try {
268
+ await publish(now);
269
+ } finally {
270
+ publishQueueDepth = Math.max(0, publishQueueDepth - 1);
271
+ }
272
+ });
273
+
274
+ const handledPublishTask = publishTask.catch((error) => {
275
+ if (isCoordinatorProgressAuthorizationError(error)) {
276
+ markFatal(error);
277
+ throw error;
278
+ }
279
+ });
280
+ publishInFlight = handledPublishTask.catch(() => undefined);
281
+ return force ? handledPublishTask : Promise.resolve();
282
+ };
283
+
284
+ return {
285
+ assertHealthy,
286
+ flush,
287
+ fatalError: () => fatalError,
288
+ };
289
+ }
@@ -29,6 +29,116 @@ export function canReclaimTimedOutWorkerToolReceipt(input: {
29
29
  return Boolean(currentRunId);
30
30
  }
31
31
 
32
+ /**
33
+ * Staleness floor for the dead-owner reclaim fast path. A `running` receipt
34
+ * left by a different run whose last write is older than this is treated as an
35
+ * orphan from a dead run — no live worker will ever complete it. Running
36
+ * receipts carry no heartbeat (updatedAt is frozen at claim time), so this is a
37
+ * conservative constant, not a heartbeat multiple.
38
+ */
39
+ export const RUNNING_RECEIPT_DEAD_OWNER_STALENESS_MS = 60_000;
40
+
41
+ /**
42
+ * Decide whether a `running` receipt should be reclaimed IMMEDIATELY, skipping
43
+ * the completion poll.
44
+ *
45
+ * The poll (`waitForCompletedRuntimeReceipt`) issues one service-binding
46
+ * subrequest per 250ms tick, up to the receipt's wait budget (~1320 ticks for a
47
+ * 5.5-minute default). On resume, a map can carry many receipts left `running`
48
+ * by a DEAD prior run; polling each one burns its full tick budget waiting on a
49
+ * receipt no live worker will complete, which blows Cloudflare's ~1000
50
+ * subrequests-per-invocation budget and kills the resume with
51
+ * "Too many subrequests by single Worker invocation" (prod resume deaths
52
+ * 20260703t230209 / 20260703t232104).
53
+ *
54
+ * A receipt is a dead-owner orphan when it is owned by a DIFFERENT run (a
55
+ * same-run `running` is this run replaying — its owner may still be live) and
56
+ * its last write is stale (or missing entirely — a live owner would carry a
57
+ * recent claim timestamp). Those reclaim immediately, matching the dispatch
58
+ * code's own contract ("on resume it reclaims and re-executes"). A live or
59
+ * freshly-claimed owner keeps the poll.
60
+ */
61
+ export function shouldReclaimRunningReceiptWithoutPolling(input: {
62
+ ownerRunId?: string | null;
63
+ currentRunId: string;
64
+ updatedAt?: string | null;
65
+ nowMs?: number;
66
+ stalenessFloorMs?: number;
67
+ }): boolean {
68
+ const currentRunId = input.currentRunId.trim();
69
+ const ownerRunId = input.ownerRunId?.trim() ?? '';
70
+ // Only a receipt owned by a different run can be a dead prior-run orphan.
71
+ if (!currentRunId || !ownerRunId || ownerRunId === currentRunId) return false;
72
+ const floorMs =
73
+ input.stalenessFloorMs ?? RUNNING_RECEIPT_DEAD_OWNER_STALENESS_MS;
74
+ // No freshness signal at all: treat as stale. A live owner would carry a
75
+ // recent claim timestamp; an orphan from a dead run may carry none.
76
+ if (!input.updatedAt) return true;
77
+ const updatedAtMs = Date.parse(input.updatedAt);
78
+ if (!Number.isFinite(updatedAtMs)) return true;
79
+ const now = input.nowMs ?? Date.now();
80
+ return now - updatedAtMs >= floorMs;
81
+ }
82
+
83
+ /** Metadata a `running` receipt carries about its owner and last write. */
84
+ export interface RunningReceiptOwnershipInput {
85
+ ownerRunId?: string | null;
86
+ currentRunId: string;
87
+ updatedAt?: string | null;
88
+ nowMs?: number;
89
+ stalenessFloorMs?: number;
90
+ }
91
+
92
+ /** Outcome of polling a `running` receipt for completion. */
93
+ export type RunningReceiptPollOutcome =
94
+ /** The poll saw the receipt complete and already resolved the group. */
95
+ | { kind: 'completed' }
96
+ /** The poll timed out; the receipt is still `running`. */
97
+ | { kind: 'timed_out'; error: unknown }
98
+ /** The poll failed for a non-timeout reason (e.g. the receipt failed). */
99
+ | { kind: 'errored'; error: unknown };
100
+
101
+ /**
102
+ * Orchestrate the resume-time handling of a `running` receipt: reclaim it
103
+ * immediately when it is a dead-owner orphan, otherwise poll for completion and
104
+ * only reclaim after the poll times out. Kept as a pure, dependency-injected
105
+ * function (the poll and reclaim side effects are passed in) so the
106
+ * "skip the poll for a dead owner" decision is unit-testable with a poll counter
107
+ * — the Worker scheduler that owns the real effects imports `cloudflare:workers`
108
+ * and cannot be loaded under vitest.
109
+ *
110
+ * Behavior (must match the scheduler's prior inline logic exactly):
111
+ * - dead-owner + reclaim-eligible → `reclaim` now, never polling;
112
+ * - otherwise `poll`; on `completed` the group is already resolved (return []);
113
+ * on a non-timeout error `reject` and stop; on timeout, `reclaim` if still
114
+ * eligible else `reject` with the timeout error.
115
+ */
116
+ export async function reclaimRunningReceiptGroupAfterWait<TClaimed>(input: {
117
+ ownership: RunningReceiptOwnershipInput;
118
+ timeoutError: unknown;
119
+ poll: () => Promise<RunningReceiptPollOutcome>;
120
+ reclaim: (rejectionOnFallthrough: unknown) => Promise<TClaimed[]>;
121
+ reject: (error: unknown) => void;
122
+ }): Promise<TClaimed[]> {
123
+ if (
124
+ shouldReclaimRunningReceiptWithoutPolling(input.ownership) &&
125
+ canReclaimTimedOutWorkerToolReceipt(input.ownership)
126
+ ) {
127
+ return await input.reclaim(input.timeoutError);
128
+ }
129
+ const outcome = await input.poll();
130
+ if (outcome.kind === 'completed') return [];
131
+ if (outcome.kind === 'errored') {
132
+ input.reject(outcome.error);
133
+ return [];
134
+ }
135
+ if (!canReclaimTimedOutWorkerToolReceipt(input.ownership)) {
136
+ input.reject(outcome.error);
137
+ return [];
138
+ }
139
+ return await input.reclaim(outcome.error);
140
+ }
141
+
32
142
  export function canReclaimFailedWorkerToolReceipt(input: {
33
143
  error?: string | null;
34
144
  }): boolean {
@@ -36,7 +146,8 @@ export function canReclaimFailedWorkerToolReceipt(input: {
36
146
  return Boolean(
37
147
  error &&
38
148
  (/\b(cancell?ed|aborted|terminate[d]?)\b/i.test(error) ||
39
- /\bmax runtime\b/i.test(error)),
149
+ /\bmax runtime\b/i.test(error) ||
150
+ /persistence failure|durable receipts? could not be marked/i.test(error)),
40
151
  );
41
152
  }
42
153