deepline 0.1.179 → 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.
@@ -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>,
@@ -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
 
@@ -642,6 +642,98 @@ function decodeBase64Bytes(value: string): Uint8Array {
642
642
  return bytes;
643
643
  }
644
644
 
645
+ /**
646
+ * A staged-file upload target minted by POST /api/v2/plays/files/stage/mint.
647
+ * When `alreadyStaged` is true the server already holds the content-addressed
648
+ * object and `uploadUrl` is null; otherwise the client PUTs the body to
649
+ * `uploadUrl` with `uploadHeaders`.
650
+ */
651
+ type MintStagedFileUpload = {
652
+ logicalPath: string;
653
+ contentHash: string;
654
+ ref: PlayStagedFileRef;
655
+ alreadyStaged: boolean;
656
+ uploadUrl: string | null;
657
+ uploadHeaders: Record<string, string> | null;
658
+ uploadExpiresAt: string | null;
659
+ };
660
+
661
+ // Legacy multipart staging proxies the body through the serverless function,
662
+ // which caps request bodies near 4.5MB. Stay conservatively below that so the
663
+ // fail-loud guidance fires before the platform returns an opaque 413.
664
+ const STAGE_LEGACY_MULTIPART_MAX_BYTES = 4_000_000;
665
+
666
+ const STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS = 3;
667
+
668
+ function stagedUploadIdentity(logicalPath: string, contentHash: string): string {
669
+ return `${contentHash}:${logicalPath}`;
670
+ }
671
+
672
+ function isStagedUploadMintUnsupported(error: unknown): boolean {
673
+ // A server without the mint route returns 404. Treat only that as
674
+ // "unsupported, fall back"; every other failure propagates.
675
+ return error instanceof DeeplineError && error.statusCode === 404;
676
+ }
677
+
678
+ function formatMegabytes(bytes: number): string {
679
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
680
+ }
681
+
682
+ async function uploadStagedFileToPresignedUrl(input: {
683
+ url: string;
684
+ headers: Record<string, string>;
685
+ body: Uint8Array;
686
+ logicalPath: string;
687
+ }): Promise<void> {
688
+ // Wrap the bytes in a fresh Blob so the body is a BodyInit and the upload is
689
+ // re-runnable across retries (a consumed stream would not be).
690
+ const arrayBuffer = input.body.buffer.slice(
691
+ input.body.byteOffset,
692
+ input.body.byteOffset + input.body.byteLength,
693
+ ) as ArrayBuffer;
694
+ let lastError: unknown;
695
+ for (
696
+ let attempt = 1;
697
+ attempt <= STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS;
698
+ attempt += 1
699
+ ) {
700
+ try {
701
+ const response = await fetch(input.url, {
702
+ method: 'PUT',
703
+ headers: input.headers,
704
+ body: new Blob([arrayBuffer]),
705
+ });
706
+ if (response.ok) {
707
+ return;
708
+ }
709
+ const text = await response.text().catch(() => '');
710
+ lastError = new DeeplineError(
711
+ `Direct storage upload of ${input.logicalPath} failed: ${response.status} ${text.slice(0, 200)}`,
712
+ response.status,
713
+ 'STAGED_FILE_UPLOAD_FAILED',
714
+ { logicalPath: input.logicalPath },
715
+ );
716
+ // 4xx (other than throttling) will not recover on retry.
717
+ if (response.status < 500 && response.status !== 429) {
718
+ break;
719
+ }
720
+ } catch (error) {
721
+ lastError = error;
722
+ }
723
+ if (attempt < STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS) {
724
+ await sleep(250 * attempt);
725
+ }
726
+ }
727
+ throw lastError instanceof Error
728
+ ? lastError
729
+ : new DeeplineError(
730
+ `Direct storage upload of ${input.logicalPath} failed.`,
731
+ undefined,
732
+ 'STAGED_FILE_UPLOAD_FAILED',
733
+ { logicalPath: input.logicalPath },
734
+ );
735
+ }
736
+
645
737
  function readStringArray(value: unknown): string[] {
646
738
  return Array.isArray(value)
647
739
  ? value.filter((line): line is string => typeof line === 'string')
@@ -1265,6 +1357,34 @@ export class DeeplineClient {
1265
1357
  });
1266
1358
  }
1267
1359
 
1360
+ /**
1361
+ * Re-establish this workspace's tenant storage contract: role/DB connect
1362
+ * grants plus materialized table grants. Org-admin only. Use when a run fails
1363
+ * with WORKSPACE_STORAGE_NOT_READY.
1364
+ */
1365
+ async repairIngestionStorage(input?: { provider?: string }): Promise<{
1366
+ connection_grants: { runtime_role: string; customer_db_role: string };
1367
+ repaired: unknown[];
1368
+ count: number;
1369
+ }> {
1370
+ // forbiddenAsApiError: the repair route returns a meaningful 403 for
1371
+ // non-org-admin callers. Without it, http.ts collapses 403 into a generic
1372
+ // AuthError (statusCode 401) and the CLI's "requires org admin" branch
1373
+ // could never fire.
1374
+ return this.http.post<{
1375
+ connection_grants: { runtime_role: string; customer_db_role: string };
1376
+ repaired: unknown[];
1377
+ count: number;
1378
+ }>(
1379
+ '/api/v2/ingestion/repair',
1380
+ {
1381
+ ...(input?.provider ? { provider: input.provider } : {}),
1382
+ },
1383
+ undefined,
1384
+ { forbiddenAsApiError: true },
1385
+ );
1386
+ }
1387
+
1268
1388
  // ——————————————————————————————————————————————————————————
1269
1389
  // Plays — submission and lifecycle
1270
1390
  // ——————————————————————————————————————————————————————————
@@ -1829,6 +1949,123 @@ export class DeeplineClient {
1829
1949
  bytes: number;
1830
1950
  }>,
1831
1951
  ): Promise<PlayStagedFileRef[]> {
1952
+ // Primary path: mint presigned R2 PUT targets and upload each file body
1953
+ // straight to storage. This bypasses the ~4.5MB serverless request-body
1954
+ // limit that the legacy multipart proxy hits (413
1955
+ // FUNCTION_PAYLOAD_TOO_LARGE), so large enriched CSVs stage and resume.
1956
+ let uploads: MintStagedFileUpload[];
1957
+ try {
1958
+ uploads = await this.mintStagedPlayFileUploads(
1959
+ files.map((file) => ({
1960
+ logicalPath: file.logicalPath,
1961
+ contentHash: file.contentHash,
1962
+ contentType: file.contentType,
1963
+ bytes: file.bytes,
1964
+ })),
1965
+ );
1966
+ } catch (error) {
1967
+ if (isStagedUploadMintUnsupported(error)) {
1968
+ // The connected server predates direct-to-storage staging. Fall back to
1969
+ // the legacy multipart proxy, but only for files small enough to clear
1970
+ // the serverless body limit; larger files fail loud with guidance.
1971
+ return this.stagePlayFilesViaMultipart(files);
1972
+ }
1973
+ throw error;
1974
+ }
1975
+
1976
+ const uploadByIdentity = new Map<string, MintStagedFileUpload>();
1977
+ for (const upload of uploads) {
1978
+ uploadByIdentity.set(
1979
+ stagedUploadIdentity(upload.logicalPath, upload.contentHash),
1980
+ upload,
1981
+ );
1982
+ }
1983
+
1984
+ await Promise.all(
1985
+ files.map(async (file) => {
1986
+ const upload = uploadByIdentity.get(
1987
+ stagedUploadIdentity(file.logicalPath, file.contentHash),
1988
+ );
1989
+ if (!upload) {
1990
+ throw new DeeplineError(
1991
+ `The staging server did not return an upload target for ${file.logicalPath}.`,
1992
+ undefined,
1993
+ 'STAGED_FILE_MINT_INCOMPLETE',
1994
+ );
1995
+ }
1996
+ if (upload.alreadyStaged || !upload.uploadUrl) {
1997
+ return;
1998
+ }
1999
+ await uploadStagedFileToPresignedUrl({
2000
+ url: upload.uploadUrl,
2001
+ headers: upload.uploadHeaders ?? {
2002
+ 'content-type': file.contentType,
2003
+ },
2004
+ body: decodeBase64Bytes(file.contentBase64),
2005
+ logicalPath: file.logicalPath,
2006
+ });
2007
+ }),
2008
+ );
2009
+
2010
+ return files.map((file) => {
2011
+ const upload = uploadByIdentity.get(
2012
+ stagedUploadIdentity(file.logicalPath, file.contentHash),
2013
+ );
2014
+ if (!upload) {
2015
+ throw new DeeplineError(
2016
+ `The staging server did not return an upload target for ${file.logicalPath}.`,
2017
+ undefined,
2018
+ 'STAGED_FILE_MINT_INCOMPLETE',
2019
+ );
2020
+ }
2021
+ return upload.ref;
2022
+ });
2023
+ }
2024
+
2025
+ /**
2026
+ * Mint short-lived presigned upload targets for staged play files.
2027
+ *
2028
+ * Internal primitive used by {@link stagePlayFiles}. The server returns an
2029
+ * already-staged ref (no upload needed) for content-addressed files it
2030
+ * already holds, or a presigned PUT URL the caller uploads the body to.
2031
+ */
2032
+ async mintStagedPlayFileUploads(
2033
+ files: Array<{
2034
+ logicalPath: string;
2035
+ contentHash: string;
2036
+ contentType: string;
2037
+ bytes: number;
2038
+ }>,
2039
+ ): Promise<MintStagedFileUpload[]> {
2040
+ const response = await this.http.post<{
2041
+ uploads: MintStagedFileUpload[];
2042
+ }>('/api/v2/plays/files/stage/mint', { files });
2043
+ return response.uploads ?? [];
2044
+ }
2045
+
2046
+ private async stagePlayFilesViaMultipart(
2047
+ files: Array<{
2048
+ logicalPath: string;
2049
+ contentBase64: string;
2050
+ contentHash: string;
2051
+ contentType: string;
2052
+ bytes: number;
2053
+ }>,
2054
+ ): Promise<PlayStagedFileRef[]> {
2055
+ for (const file of files) {
2056
+ if (file.bytes > STAGE_LEGACY_MULTIPART_MAX_BYTES) {
2057
+ throw new DeeplineError(
2058
+ `Cannot stage ${file.logicalPath} (${formatMegabytes(file.bytes)}): the connected Deepline server does not support direct-to-storage uploads, and this file exceeds the ~4.5MB request-body limit the legacy upload path is subject to. Update the Deepline server (or target code.deepline.com) to stage files this large.`,
2059
+ 413,
2060
+ 'STAGED_FILE_TOO_LARGE_FOR_LEGACY_UPLOAD',
2061
+ {
2062
+ logicalPath: file.logicalPath,
2063
+ bytes: file.bytes,
2064
+ maxBytes: STAGE_LEGACY_MULTIPART_MAX_BYTES,
2065
+ },
2066
+ );
2067
+ }
2068
+ }
1832
2069
  const buildFormData = () => {
1833
2070
  const formData = new FormData();
1834
2071
  formData.set(
@@ -38,6 +38,8 @@ import {
38
38
  const MAX_DIAGNOSTIC_HEADER_LENGTH = 120;
39
39
  const COWORK_NETWORK_HINT =
40
40
  'Claude Cowork appears to be running Deepline in a network-restricted sandbox. In Claude Desktop, open Settings > Capabilities, turn on Allow network egress, and set Domain allowlist to All domains for the Cowork session.';
41
+ const REQUEST_TIMEOUT_MARKER = Symbol('deeplineRequestTimeout');
42
+ const REQUEST_ABORT_MARKER = Symbol('deeplineRequestAbort');
41
43
 
42
44
  interface RequestOptions {
43
45
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
@@ -72,6 +74,83 @@ interface StreamOptions {
72
74
  signal?: AbortSignal;
73
75
  }
74
76
 
77
+ type RequestAbortTaggedError = Error & {
78
+ [REQUEST_TIMEOUT_MARKER]?: number;
79
+ [REQUEST_ABORT_MARKER]?: true;
80
+ };
81
+
82
+ function normalizeRequestAbortError(
83
+ error: unknown,
84
+ input: { timeoutMs: number; timedOut: boolean },
85
+ ): Error {
86
+ const normalized = error instanceof Error ? error : new Error(String(error));
87
+ const tagged = normalized as RequestAbortTaggedError;
88
+ if (input.timedOut) {
89
+ tagged[REQUEST_TIMEOUT_MARKER] = input.timeoutMs;
90
+ } else if (isAbortLikeError(normalized)) {
91
+ tagged[REQUEST_ABORT_MARKER] = true;
92
+ }
93
+ return normalized;
94
+ }
95
+
96
+ function isAbortLikeError(error: Error): boolean {
97
+ const name = error.name.toLowerCase();
98
+ const message = error.message.toLowerCase();
99
+ return (
100
+ name === 'aborterror' ||
101
+ message.includes('operation was aborted') ||
102
+ message === 'aborted'
103
+ );
104
+ }
105
+
106
+ function mapNetworkError(
107
+ baseUrl: string,
108
+ error: Error | null,
109
+ targetLabel: string,
110
+ ): {
111
+ message: string;
112
+ code: string;
113
+ details?: Record<string, unknown>;
114
+ } {
115
+ const tagged = error as RequestAbortTaggedError | null;
116
+ const timeoutMs = tagged?.[REQUEST_TIMEOUT_MARKER];
117
+ if (typeof timeoutMs === 'number') {
118
+ return {
119
+ message: `Request to ${targetLabel} timed out after ${timeoutMs}ms while waiting for a response.`,
120
+ code: 'NETWORK_TIMEOUT',
121
+ details: { timeoutMs, target: targetLabel },
122
+ };
123
+ }
124
+ if (tagged?.[REQUEST_ABORT_MARKER]) {
125
+ return {
126
+ message: `Request to ${targetLabel} was aborted before it completed.`,
127
+ code: 'NETWORK_ABORTED',
128
+ };
129
+ }
130
+ return {
131
+ message: error?.message
132
+ ? `Unable to connect to ${baseUrl}. ${error.message}`
133
+ : `Unable to connect to ${baseUrl}. Is the computer able to access the url?`,
134
+ code: 'NETWORK_ERROR',
135
+ };
136
+ }
137
+
138
+ function describeRequestTarget(path: string): string {
139
+ const toolExecuteMatch = path.match(
140
+ /^\/api\/v2\/integrations\/([^/?#]+)\/execute(?:[/?#]|$)/,
141
+ );
142
+ if (toolExecuteMatch) {
143
+ return providerLabelFromToolId(decodeURIComponent(toolExecuteMatch[1]));
144
+ }
145
+ return 'Deepline';
146
+ }
147
+
148
+ function providerLabelFromToolId(toolId: string): string {
149
+ const normalized = toolId.trim().toLowerCase();
150
+ const provider = normalized.split(/[_:./-]+/)[0];
151
+ return provider || normalized || 'provider';
152
+ }
153
+
75
154
  /**
76
155
  * Low-level HTTP client used internally by {@link DeeplineClient}.
77
156
  *
@@ -246,10 +325,12 @@ export class HttpClient {
246
325
 
247
326
  for (const candidateUrl of candidateUrls) {
248
327
  const controller = new AbortController();
249
- const timeoutId = setTimeout(
250
- () => controller.abort(),
251
- options?.timeout ?? this.config.timeout,
252
- );
328
+ const timeoutMs = options?.timeout ?? this.config.timeout;
329
+ let requestTimedOut = false;
330
+ const timeoutId = setTimeout(() => {
331
+ requestTimedOut = true;
332
+ controller.abort();
333
+ }, timeoutMs);
253
334
 
254
335
  try {
255
336
  const response = await fetch(candidateUrl, {
@@ -375,7 +456,10 @@ export class HttpClient {
375
456
  lastError = error;
376
457
  break;
377
458
  }
378
- lastError = error instanceof Error ? error : new Error(String(error));
459
+ lastError = normalizeRequestAbortError(error, {
460
+ timeoutMs,
461
+ timedOut: requestTimedOut,
462
+ });
379
463
  }
380
464
  }
381
465
 
@@ -385,10 +469,17 @@ export class HttpClient {
385
469
  if (lastError instanceof DeeplineError) {
386
470
  throw lastError;
387
471
  }
388
- const errorMessage = lastError?.message
389
- ? `Unable to connect to ${baseUrl}. ${lastError.message}`
390
- : `Unable to connect to ${baseUrl}. Is the computer able to access the url?`;
391
- throw new DeeplineError(withCoworkNetworkHint(errorMessage));
472
+ const mappedNetworkError = mapNetworkError(
473
+ baseUrl,
474
+ lastError,
475
+ describeRequestTarget(path),
476
+ );
477
+ throw new DeeplineError(
478
+ withCoworkNetworkHint(mappedNetworkError.message),
479
+ undefined,
480
+ mappedNetworkError.code,
481
+ mappedNetworkError.details,
482
+ );
392
483
  }
393
484
 
394
485
  /**
@@ -105,10 +105,10 @@ export const SDK_RELEASE = {
105
105
  // 0.1.154 removes the short-lived generated enrich StepOptions recompute
106
106
  // fields shipped in 0.1.153.
107
107
  // 0.1.175 ships loud `deepline enrich` empty-waterfall reporting.
108
- version: '0.1.179',
108
+ version: '0.1.180',
109
109
  apiContract: '2026-06-dataset-handle-results-hard-cutover',
110
110
  supportPolicy: {
111
- latest: '0.1.179',
111
+ latest: '0.1.180',
112
112
  minimumSupported: '0.1.53',
113
113
  deprecatedBelow: '0.1.53',
114
114
  commandMinimumSupported: [
@@ -26,9 +26,60 @@ export interface CompiledRequestBatch<TRequest> {
26
26
  splitResults: (value: unknown) => Array<unknown | null>;
27
27
  }
28
28
 
29
+ /**
30
+ * Plan chunk boundaries. A chunk closes when it reaches `batchSize` units OR,
31
+ * when a `maxChunkWeight` + `weightOf` exposure cap is supplied, when its
32
+ * cumulative weight reaches that cap — whichever comes first. A single unit that
33
+ * alone exceeds either limit still forms its own chunk (batches are atomic and
34
+ * never split). The weight cap is the persistence-latch dispatch-wave bound:
35
+ * without it a large `batchSize` (provider-pacing hint) would let a whole
36
+ * chunk's provider calls bill before the latch can gate the rest.
37
+ */
38
+ function planExecutionChunks<TRequest>(
39
+ requests: TRequest[],
40
+ batchSize: number,
41
+ maxChunkWeight: number | undefined,
42
+ weightOf: ((request: TRequest) => number) | undefined,
43
+ ): TRequest[][] {
44
+ const maxUnits = Math.max(1, Math.floor(batchSize));
45
+ const weightCap =
46
+ maxChunkWeight != null && weightOf != null
47
+ ? Math.max(1, Math.floor(maxChunkWeight))
48
+ : null;
49
+ const chunks: TRequest[][] = [];
50
+ let current: TRequest[] = [];
51
+ let weight = 0;
52
+ for (const request of requests) {
53
+ if (
54
+ current.length > 0 &&
55
+ (current.length >= maxUnits ||
56
+ (weightCap != null && weight >= weightCap))
57
+ ) {
58
+ chunks.push(current);
59
+ current = [];
60
+ weight = 0;
61
+ }
62
+ current.push(request);
63
+ if (weightCap != null && weightOf != null) {
64
+ weight += Math.max(1, Math.floor(weightOf(request)));
65
+ }
66
+ }
67
+ if (current.length > 0) chunks.push(current);
68
+ return chunks;
69
+ }
70
+
29
71
  export async function executeChunkedRequests<TRequest, TResult>(input: {
30
72
  requests: TRequest[];
31
73
  batchSize: number;
74
+ /**
75
+ * Optional exposure cap: the maximum cumulative `weightOf` a single chunk may
76
+ * dispatch before the caller's per-unit latch check runs again. Set to
77
+ * PROVIDER_DISPATCH_WAVE_SIZE by the batch dispatch paths so a persistence
78
+ * failure prevents the rest of a large batch group instead of billing it all.
79
+ */
80
+ maxChunkWeight?: number;
81
+ /** Per-request weight toward `maxChunkWeight` (e.g. batch member count). */
82
+ weightOf?: (request: TRequest) => number;
32
83
  execute: (request: TRequest) => Promise<TResult>;
33
84
  /**
34
85
  * Loud per-request failure hook. A rejected request is recorded as a
@@ -43,8 +94,13 @@ export async function executeChunkedRequests<TRequest, TResult>(input: {
43
94
  }): Promise<Array<ChunkExecutionResult<TRequest, TResult>>> {
44
95
  const results: Array<ChunkExecutionResult<TRequest, TResult>> = [];
45
96
 
46
- for (let start = 0; start < input.requests.length; start += input.batchSize) {
47
- const chunk = input.requests.slice(start, start + input.batchSize);
97
+ const chunks = planExecutionChunks(
98
+ input.requests,
99
+ input.batchSize,
100
+ input.maxChunkWeight,
101
+ input.weightOf,
102
+ );
103
+ for (const chunk of chunks) {
48
104
  let notifyChain: Promise<void> = Promise.resolve();
49
105
  const notify = async (
50
106
  entry: ChunkExecutionResult<TRequest, TResult>,