deepline 0.3.148 → 0.3.150

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.
@@ -36,7 +36,7 @@ import type { ToolResultBilling } from '../../shared_libs/plays/tool-result-type
36
36
  * @module
37
37
  */
38
38
  import { resolveConfig } from './config.js';
39
- import { DeeplineError } from './errors.js';
39
+ import { DeeplineError, ToolExecutionError } from './errors.js';
40
40
  import { HttpClient } from './http.js';
41
41
  import { PRODUCT_NOTIFICATION_SLACK_OAUTH_SCOPES } from '../../shared_libs/product-notifications/contract.js';
42
42
  import {
@@ -222,6 +222,114 @@ const MONITOR_NON_RETRYABLE_MUTATION_OPTIONS = {
222
222
  exactUrlOnly: true,
223
223
  };
224
224
  const RAW_V2_EXECUTE_RESPONSE_CONTRACT = RAW_V2_TOOL_RESPONSE_CONTRACT;
225
+ const DEFAULT_EXECUTION_RECOVERY_TIMEOUT_MS = 15 * 60 * 1_000;
226
+
227
+ function validateExecutionIdempotencyKey(key: string): void {
228
+ if (
229
+ typeof key !== 'string' ||
230
+ key.length < 1 ||
231
+ key.length > 200 ||
232
+ !/^[A-Za-z0-9._:-]+$/.test(key)
233
+ ) {
234
+ throw new DeeplineError(
235
+ 'Execution idempotency keys must be 1–200 ASCII letters, digits, dots, underscores, colons, or hyphens.',
236
+ undefined,
237
+ 'IDEMPOTENCY_KEY_INVALID',
238
+ );
239
+ }
240
+ }
241
+
242
+ function isExecutionInProgressResponse(value: unknown): boolean {
243
+ return (
244
+ typeof value === 'object' &&
245
+ value !== null &&
246
+ typeof (value as { executionRecovery?: { state?: unknown } })
247
+ .executionRecovery === 'object' &&
248
+ (value as { executionRecovery: { state?: unknown } }).executionRecovery
249
+ .state === 'running'
250
+ );
251
+ }
252
+
253
+ function getExecutionRecoveryState(
254
+ value: unknown,
255
+ ): ExecutionRecovery['state'] | null {
256
+ if (
257
+ typeof value !== 'object' ||
258
+ value === null ||
259
+ typeof (value as { executionRecovery?: { state?: unknown } })
260
+ .executionRecovery !== 'object'
261
+ ) {
262
+ return null;
263
+ }
264
+ const state = (value as { executionRecovery: { state?: unknown } })
265
+ .executionRecovery.state;
266
+ return state === 'running' ||
267
+ state === 'completed' ||
268
+ state === 'outcome_unknown'
269
+ ? state
270
+ : null;
271
+ }
272
+
273
+ function isRecoverableExecutionAttemptError(error: unknown): boolean {
274
+ if (error instanceof ToolExecutionError) {
275
+ return (
276
+ error.code === 'EXECUTION_IN_PROGRESS' ||
277
+ (error.origin === 'deepline' &&
278
+ error.category === 'network' &&
279
+ error.networkScope === 'client_to_deepline')
280
+ );
281
+ }
282
+ return (
283
+ error instanceof DeeplineError &&
284
+ (error.code === 'EXECUTION_IN_PROGRESS' ||
285
+ (error.code?.startsWith('NETWORK_') === true &&
286
+ error.statusCode === undefined))
287
+ );
288
+ }
289
+
290
+ function isRecoverableExecutionLookupError(error: unknown): boolean {
291
+ if (isRecoverableExecutionAttemptError(error)) return true;
292
+ return (
293
+ error instanceof DeeplineError &&
294
+ (error.statusCode === 429 ||
295
+ (error.statusCode !== undefined && error.statusCode >= 500))
296
+ );
297
+ }
298
+
299
+ function timeoutWithinRecoveryBudget(
300
+ requestTimeoutMs: number | undefined,
301
+ defaultRequestTimeoutMs: number,
302
+ remainingMs: number,
303
+ ): number {
304
+ return Math.min(requestTimeoutMs ?? defaultRequestTimeoutMs, remainingMs);
305
+ }
306
+
307
+ function executionRecoveryError(input: {
308
+ toolId: string;
309
+ idempotencyKey: string;
310
+ code:
311
+ | 'EXECUTION_IN_PROGRESS'
312
+ | 'EXECUTION_OUTCOME_UNKNOWN'
313
+ | 'EXECUTION_RECOVERY_TIMEOUT';
314
+ message: string;
315
+ }): ToolExecutionError {
316
+ return new ToolExecutionError(input.message, {
317
+ toolId: input.toolId,
318
+ provider: null,
319
+ operation: input.toolId,
320
+ code: input.code,
321
+ origin: 'deepline',
322
+ category:
323
+ input.code === 'EXECUTION_OUTCOME_UNKNOWN' ? 'unknown' : 'conflict',
324
+ retryable: input.code !== 'EXECUTION_OUTCOME_UNKNOWN',
325
+ statusCode: null,
326
+ requestId: null,
327
+ retryAfterMs: null,
328
+ networkKind: null,
329
+ networkScope: null,
330
+ publicDetails: { idempotencyKey: input.idempotencyKey },
331
+ });
332
+ }
225
333
  const COMPILE_MANIFEST_RETRY_DELAYS_MS = [250, 1_000];
226
334
  const REGISTER_PLAY_ARTIFACTS_COMPILE_CONCURRENCY = 3;
227
335
  const REGISTER_PLAY_ARTIFACTS_MAX_BATCH_COUNT = 3;
@@ -547,6 +655,40 @@ type ExecuteToolRawOptions = {
547
655
  metadata?: Record<string, unknown>;
548
656
  timeout?: number;
549
657
  maxRetries?: number;
658
+ /** Reuse this stable key to recover or retry an ambiguous execution. */
659
+ idempotencyKey?: string;
660
+ /** Generate a key and enable recoverable execution before dispatch. */
661
+ recover?: boolean;
662
+ /** Called and awaited with the key before the first network request. */
663
+ onExecution?: (execution: { idempotencyKey: string }) => void | Promise<void>;
664
+ /** Maximum time to reconnect to an in-progress keyed execution. Defaults to 15 minutes. */
665
+ recoveryTimeoutMs?: number;
666
+ };
667
+
668
+ /** Durable state returned for a keyed tool execution. */
669
+ export type ExecutionRecovery = {
670
+ /** Stable caller key used to resume or replay this execution. */
671
+ idempotencyKey: string;
672
+ /** Whether the execution is still running, completed, or has an unknown provider outcome. */
673
+ state: 'running' | 'completed' | 'outcome_unknown';
674
+ /** Whether this response came from an existing durable execution. */
675
+ replayed: boolean;
676
+ /** ISO timestamp after which the completed execution can no longer be replayed. */
677
+ expiresAt?: string;
678
+ };
679
+
680
+ /** Durable lookup result for a keyed tool execution. */
681
+ export type ExecutionByKeyResult = {
682
+ /** Recovery state and the key that owns the execution. */
683
+ executionRecovery: ExecutionRecovery;
684
+ /** Provider tool associated with the execution. */
685
+ toolId: string;
686
+ /** Original server-owned billing request ID, allocated before provider dispatch. */
687
+ requestId?: string;
688
+ /** HTTP status saved with the original response, when it was terminal. */
689
+ responseStatus?: number;
690
+ /** Saved execution response, when one is available for replay. */
691
+ response?: unknown;
550
692
  };
551
693
 
552
694
  /**
@@ -3121,6 +3263,41 @@ export class DeeplineClient {
3121
3263
  input: Record<string, unknown>,
3122
3264
  options?: ExecuteToolRawOptions,
3123
3265
  ): Promise<ToolExecution<TData, TMeta>> {
3266
+ // Snapshot JSON input before the caller's awaited onExecution callback and
3267
+ // before any retry, so every POST is byte-equivalent in meaning.
3268
+ const inputSnapshot = JSON.parse(JSON.stringify(input)) as Record<
3269
+ string,
3270
+ unknown
3271
+ >;
3272
+ const metadataSnapshot = options?.metadata
3273
+ ? (JSON.parse(JSON.stringify(options.metadata)) as Record<
3274
+ string,
3275
+ unknown
3276
+ >)
3277
+ : undefined;
3278
+ const idempotencyKey =
3279
+ options?.idempotencyKey ??
3280
+ (options?.recover ? crypto.randomUUID() : undefined);
3281
+ if (idempotencyKey !== undefined) {
3282
+ validateExecutionIdempotencyKey(idempotencyKey);
3283
+ if (
3284
+ options?.recoveryTimeoutMs !== undefined &&
3285
+ (!Number.isFinite(options.recoveryTimeoutMs) ||
3286
+ options.recoveryTimeoutMs < 0)
3287
+ ) {
3288
+ throw new DeeplineError(
3289
+ 'recoveryTimeoutMs must be a finite, non-negative number.',
3290
+ undefined,
3291
+ 'IDEMPOTENCY_KEY_INVALID',
3292
+ );
3293
+ }
3294
+ await options?.onExecution?.({ idempotencyKey });
3295
+ const timeoutMs =
3296
+ options?.timeout ?? resolveToolExecuteTimeoutMs(toolId, inputSnapshot);
3297
+ await this.lookupExecutionByKey(idempotencyKey, timeoutMs);
3298
+ }
3299
+ const timeoutMs =
3300
+ options?.timeout ?? resolveToolExecuteTimeoutMs(toolId, inputSnapshot);
3124
3301
  const headers = {
3125
3302
  [EXECUTE_RESPONSE_CONTRACT_HEADER]: RAW_V2_EXECUTE_RESPONSE_CONTRACT,
3126
3303
  [TOOL_EXECUTION_ERROR_SCHEMA_HEADER]: String(
@@ -3130,22 +3307,180 @@ export class DeeplineClient {
3130
3307
  ? { [INCLUDE_TOOL_METADATA_HEADER]: 'true' }
3131
3308
  : {}),
3132
3309
  [EXECUTE_RESPONSE_INTENT_HEADER]: options?.responseIntent ?? 'raw',
3310
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
3133
3311
  };
3134
- const response = await this.http.post<ToolExecution<TData, TMeta>>(
3135
- `/api/v2/integrations/${encodeURIComponent(toolId)}/execute`,
3136
- {
3137
- payload: input,
3138
- ...(options?.metadata ? { metadata: options.metadata } : {}),
3139
- },
3140
- headers,
3312
+ const request = (requestTimeoutMs: number | undefined) =>
3313
+ this.http.post<ToolExecution<TData, TMeta>>(
3314
+ `/api/v2/integrations/${encodeURIComponent(toolId)}/execute`,
3315
+ {
3316
+ payload: inputSnapshot,
3317
+ ...(metadataSnapshot ? { metadata: metadataSnapshot } : {}),
3318
+ },
3319
+ headers,
3320
+ {
3321
+ timeout: requestTimeoutMs,
3322
+ maxRetries: idempotencyKey ? 0 : (options?.maxRetries ?? 0),
3323
+ exactUrlOnly: true,
3324
+ toolId,
3325
+ idempotencyKey,
3326
+ },
3327
+ );
3328
+ let response: ToolExecution<TData, TMeta>;
3329
+ let recoveryDeadline: number | null = null;
3330
+ const recoveryTimeoutMs =
3331
+ options?.recoveryTimeoutMs ?? DEFAULT_EXECUTION_RECOVERY_TIMEOUT_MS;
3332
+ let retryDelayMs = 1_000;
3333
+ const recoveryTimeoutError = () =>
3334
+ executionRecoveryError({
3335
+ toolId,
3336
+ idempotencyKey: idempotencyKey!,
3337
+ code: 'EXECUTION_RECOVERY_TIMEOUT',
3338
+ message: `Execution recovery for ${idempotencyKey} did not finish before the recovery timeout. Inspect client.executions.getByKey() and resume with the same idempotency key.`,
3339
+ });
3340
+ const waitForRecoveryRetry = async (deadline: number) => {
3341
+ const remainingMs = deadline - Date.now();
3342
+ if (remainingMs <= 0) throw recoveryTimeoutError();
3343
+ await new Promise((resolve) =>
3344
+ setTimeout(resolve, Math.min(retryDelayMs, remainingMs)),
3345
+ );
3346
+ retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
3347
+ };
3348
+ const lookupDuringRecovery = async () => {
3349
+ while (true) {
3350
+ const remainingMs = recoveryDeadline! - Date.now();
3351
+ if (remainingMs <= 0) throw recoveryTimeoutError();
3352
+ try {
3353
+ return await this.lookupExecutionByKey(
3354
+ idempotencyKey!,
3355
+ timeoutWithinRecoveryBudget(
3356
+ timeoutMs,
3357
+ this.config.timeout,
3358
+ remainingMs,
3359
+ ),
3360
+ );
3361
+ } catch (error) {
3362
+ if (!isRecoverableExecutionLookupError(error)) throw error;
3363
+ await waitForRecoveryRetry(recoveryDeadline!);
3364
+ }
3365
+ }
3366
+ };
3367
+ while (true) {
3368
+ const remainingMs =
3369
+ recoveryDeadline === null ? null : recoveryDeadline - Date.now();
3370
+ if (remainingMs !== null && remainingMs <= 0) {
3371
+ throw recoveryTimeoutError();
3372
+ }
3373
+ try {
3374
+ response = await request(
3375
+ remainingMs === null
3376
+ ? timeoutMs
3377
+ : timeoutWithinRecoveryBudget(
3378
+ timeoutMs,
3379
+ this.config.timeout,
3380
+ remainingMs,
3381
+ ),
3382
+ );
3383
+ if (getExecutionRecoveryState(response) === 'outcome_unknown') {
3384
+ if (!idempotencyKey) break;
3385
+ throw executionRecoveryError({
3386
+ toolId,
3387
+ idempotencyKey,
3388
+ code: 'EXECUTION_OUTCOME_UNKNOWN',
3389
+ message: `The outcome of execution ${idempotencyKey} is unknown. Do not retry with a new key; inspect client.executions.getByKey().`,
3390
+ });
3391
+ }
3392
+ if (!idempotencyKey || !isExecutionInProgressResponse(response)) {
3393
+ break;
3394
+ }
3395
+ if (recoveryDeadline === null) {
3396
+ recoveryDeadline = Date.now() + recoveryTimeoutMs;
3397
+ }
3398
+ } catch (error) {
3399
+ if (
3400
+ idempotencyKey &&
3401
+ error instanceof DeeplineError &&
3402
+ error.code === 'EXECUTION_OUTCOME_UNKNOWN'
3403
+ ) {
3404
+ throw executionRecoveryError({
3405
+ toolId,
3406
+ idempotencyKey,
3407
+ code: 'EXECUTION_OUTCOME_UNKNOWN',
3408
+ message: error.message,
3409
+ });
3410
+ }
3411
+ if (!idempotencyKey || !isRecoverableExecutionAttemptError(error)) {
3412
+ throw error;
3413
+ }
3414
+ if (recoveryDeadline === null) {
3415
+ recoveryDeadline = Date.now() + recoveryTimeoutMs;
3416
+ }
3417
+ }
3418
+ await waitForRecoveryRetry(recoveryDeadline!);
3419
+ const recovered = await lookupDuringRecovery();
3420
+ if (getExecutionRecoveryState(recovered) === 'outcome_unknown') {
3421
+ throw executionRecoveryError({
3422
+ toolId,
3423
+ idempotencyKey,
3424
+ code: 'EXECUTION_OUTCOME_UNKNOWN',
3425
+ message: `The outcome of execution ${idempotencyKey} is unknown. Do not retry with a new key; inspect client.executions.getByKey().`,
3426
+ });
3427
+ }
3428
+ }
3429
+ const materialized = materializeToolExecutionResponse(response);
3430
+ return idempotencyKey ? { ...materialized, idempotencyKey } : materialized;
3431
+ }
3432
+
3433
+ /** Read the durable state of a keyed tool execution. */
3434
+ async getExecutionByKey(
3435
+ idempotencyKey: string,
3436
+ ): Promise<ExecutionByKeyResult> {
3437
+ validateExecutionIdempotencyKey(idempotencyKey);
3438
+ const result = await this.lookupExecutionByKey(idempotencyKey);
3439
+ if (!result) {
3440
+ throw new DeeplineError(
3441
+ `No execution exists for idempotency key ${idempotencyKey}.`,
3442
+ 404,
3443
+ 'EXECUTION_NOT_FOUND',
3444
+ );
3445
+ }
3446
+ return result;
3447
+ }
3448
+
3449
+ private async lookupExecutionByKey(
3450
+ idempotencyKey: string,
3451
+ timeoutMs?: number,
3452
+ ): Promise<ExecutionByKeyResult | null> {
3453
+ validateExecutionIdempotencyKey(idempotencyKey);
3454
+ let supported = false;
3455
+ const result = await this.http.get<ExecutionByKeyResult | null>(
3456
+ `/api/v2/executions/by-key/${encodeURIComponent(idempotencyKey)}`,
3141
3457
  {
3142
- timeout: options?.timeout ?? resolveToolExecuteTimeoutMs(toolId, input),
3143
- maxRetries: options?.maxRetries ?? 0,
3144
3458
  exactUrlOnly: true,
3145
- toolId,
3459
+ maxRetries: 0,
3460
+ timeout: timeoutMs,
3461
+ idempotencyKey,
3462
+ allowNotFound: true,
3463
+ onResponse: (response) => {
3464
+ supported =
3465
+ response.headers.get('X-Deepline-Idempotency-Supported') === 'true';
3466
+ },
3146
3467
  },
3147
3468
  );
3148
- return materializeToolExecutionResponse(response);
3469
+ if (!supported) {
3470
+ throw new DeeplineError(
3471
+ 'This Deepline server does not support recoverable tool executions; no tool was dispatched.',
3472
+ 422,
3473
+ 'IDEMPOTENCY_NOT_SUPPORTED',
3474
+ );
3475
+ }
3476
+ return result;
3477
+ }
3478
+
3479
+ /** Public recovery namespace. */
3480
+ get executions(): {
3481
+ getByKey: (key: string) => Promise<ExecutionByKeyResult>;
3482
+ } {
3483
+ return { getByKey: (key) => this.getExecutionByKey(key) };
3149
3484
  }
3150
3485
 
3151
3486
  /**
@@ -76,6 +76,9 @@ interface RequestOptions {
76
76
  preserveRateLimitResponse?: boolean;
77
77
  /** Enables endpoint-specific structured tool failure mapping. */
78
78
  toolId?: string;
79
+ onResponse?: (response: Response) => void;
80
+ allowNotFound?: boolean;
81
+ idempotencyKey?: string;
79
82
  }
80
83
 
81
84
  interface StreamOptions {
@@ -423,6 +426,12 @@ export class HttpClient {
423
426
  signal: controller.signal,
424
427
  });
425
428
 
429
+ options?.onResponse?.(response);
430
+ if (response.status === 404 && options?.allowNotFound) {
431
+ clearTimeout(timeoutId);
432
+ return null as T;
433
+ }
434
+
426
435
  clearTimeout(timeoutId);
427
436
 
428
437
  const body = await response.text();
@@ -626,6 +635,9 @@ export class HttpClient {
626
635
  : 'unavailable',
627
636
  networkScope: 'client_to_deepline',
628
637
  details: mappedNetworkError.details,
638
+ publicDetails: options.idempotencyKey
639
+ ? { idempotencyKey: options.idempotencyKey }
640
+ : null,
629
641
  },
630
642
  ),
631
643
  lastError,
@@ -652,7 +664,14 @@ export class HttpClient {
652
664
  path: string,
653
665
  options?: Pick<
654
666
  RequestOptions,
655
- 'retryApiErrors' | 'timeout' | 'maxRetries' | 'exactUrlOnly' | 'toolId'
667
+ | 'retryApiErrors'
668
+ | 'timeout'
669
+ | 'maxRetries'
670
+ | 'exactUrlOnly'
671
+ | 'toolId'
672
+ | 'onResponse'
673
+ | 'allowNotFound'
674
+ | 'idempotencyKey'
656
675
  >,
657
676
  ): Promise<T> {
658
677
  return this.request<T>(path, {
@@ -777,7 +796,14 @@ export class HttpClient {
777
796
  headers?: Record<string, string>,
778
797
  options?: Pick<
779
798
  RequestOptions,
780
- 'retryApiErrors' | 'timeout' | 'maxRetries' | 'exactUrlOnly' | 'toolId'
799
+ | 'retryApiErrors'
800
+ | 'timeout'
801
+ | 'maxRetries'
802
+ | 'exactUrlOnly'
803
+ | 'toolId'
804
+ | 'onResponse'
805
+ | 'allowNotFound'
806
+ | 'idempotencyKey'
781
807
  >,
782
808
  ): Promise<T> {
783
809
  return this.request<T>(path, {
@@ -128,6 +128,8 @@ export type {
128
128
  RunsTailOptions,
129
129
  StopAllRunsOptions,
130
130
  ToolExecution,
131
+ ExecutionRecovery,
132
+ ExecutionByKeyResult,
131
133
  } from './client.js';
132
134
 
133
135
  // ——— Monitors framework ———
@@ -288,6 +290,8 @@ export type {
288
290
  DeeplinePlaysNamespace,
289
291
  DeeplinePlayRuntimeContext,
290
292
  DeeplineToolsNamespace,
293
+ DeeplineToolExecuteResult,
294
+ DeeplineExecutionsNamespace,
291
295
  DefinedPlay,
292
296
  ColumnMap,
293
297
  ColumnResolver,
@@ -109,7 +109,11 @@ import type {
109
109
  ToolDefinition,
110
110
  ToolMetadata,
111
111
  } from './types.js';
112
- import type { ToolExecution } from './client.js';
112
+ import type {
113
+ ExecutionByKeyResult,
114
+ ExecutionRecovery,
115
+ ToolExecution,
116
+ } from './client.js';
113
117
  import type {
114
118
  DurableCallStaleAfterSeconds,
115
119
  PlayAuthoringBindings,
@@ -660,7 +664,26 @@ export type DeeplineToolsNamespace = {
660
664
  execute(
661
665
  toolId: string,
662
666
  input: Record<string, unknown>,
663
- ): Promise<ToolExecuteResult>;
667
+ options?: {
668
+ idempotencyKey?: string;
669
+ recover?: boolean;
670
+ onExecution?: (execution: {
671
+ idempotencyKey: string;
672
+ }) => void | Promise<void>;
673
+ recoveryTimeoutMs?: number;
674
+ },
675
+ ): Promise<DeeplineToolExecuteResult>;
676
+ };
677
+
678
+ export type DeeplineToolExecuteResult = ToolExecuteResult & {
679
+ /** Stable key used to recover this direct tool execution, when enabled. */
680
+ idempotencyKey?: string;
681
+ executionRecovery?: ExecutionRecovery;
682
+ };
683
+
684
+ export type DeeplineExecutionsNamespace = {
685
+ /** Look up a recoverable tool execution by its stable idempotency key. */
686
+ getByKey(idempotencyKey: string): Promise<ExecutionByKeyResult>;
664
687
  };
665
688
 
666
689
  /**
@@ -1084,10 +1107,19 @@ export class DeeplineContext {
1084
1107
  execute: async (
1085
1108
  toolId: string,
1086
1109
  input: Record<string, unknown>,
1087
- ): Promise<ToolExecuteResult> => {
1110
+ options?: {
1111
+ idempotencyKey?: string;
1112
+ recover?: boolean;
1113
+ onExecution?: (execution: {
1114
+ idempotencyKey: string;
1115
+ }) => void | Promise<void>;
1116
+ recoveryTimeoutMs?: number;
1117
+ },
1118
+ ): Promise<DeeplineToolExecuteResult> => {
1088
1119
  const response = await this.client.executeTool(toolId, input, {
1089
1120
  includeToolMetadata: true,
1090
1121
  responseIntent: 'dataset',
1122
+ ...options,
1091
1123
  });
1092
1124
  return toolExecutionEnvelopeToResult(toolId, response, {
1093
1125
  client: this.client,
@@ -1097,6 +1129,11 @@ export class DeeplineContext {
1097
1129
  };
1098
1130
  }
1099
1131
 
1132
+ /** Durable state for recoverable direct tool executions. */
1133
+ get executions(): DeeplineExecutionsNamespace {
1134
+ return { getByKey: (key) => this.client.getExecutionByKey(key) };
1135
+ }
1136
+
1100
1137
  /**
1101
1138
  * Play discovery and named-play handles.
1102
1139
  *
@@ -1506,7 +1543,7 @@ function toolExecutionEnvelopeToResult(
1506
1543
  client: DeeplineClient;
1507
1544
  input: Record<string, unknown>;
1508
1545
  },
1509
- ): ToolExecuteResult {
1546
+ ): DeeplineToolExecuteResult {
1510
1547
  const raw = response.toolResponse?.raw ?? null;
1511
1548
  const rawV2 = response.toolResponse?.rawV2;
1512
1549
  const view = response.toolResponse?.view;
@@ -1516,7 +1553,7 @@ function toolExecutionEnvelopeToResult(
1516
1553
  : null;
1517
1554
  const toolMetadata = isRecord(metadata) ? metadata : {};
1518
1555
 
1519
- return attachSdkQueryResultDatasetResult(
1556
+ const result: DeeplineToolExecuteResult = attachSdkQueryResultDatasetResult(
1520
1557
  fallbackToolId,
1521
1558
  createToolExecuteResult({
1522
1559
  status:
@@ -1553,6 +1590,19 @@ function toolExecutionEnvelopeToResult(
1553
1590
  }),
1554
1591
  options,
1555
1592
  );
1593
+ const executionRecovery = isRecord(response.executionRecovery)
1594
+ ? (response.executionRecovery as unknown as ExecutionRecovery)
1595
+ : undefined;
1596
+ // `createToolExecuteResult` installs `toolOutput` and `_metadata` as
1597
+ // non-enumerable compatibility properties. Mutating the wrapper preserves
1598
+ // those aliases while adding recovery fields to the same public result.
1599
+ if (typeof response.idempotencyKey === 'string') {
1600
+ result.idempotencyKey = response.idempotencyKey;
1601
+ }
1602
+ if (executionRecovery) {
1603
+ result.executionRecovery = executionRecovery;
1604
+ }
1605
+ return result;
1556
1606
  }
1557
1607
 
1558
1608
  export function defineInput<TInput>(
@@ -200,7 +200,7 @@ export const SDK_RELEASE = {
200
200
  // getters keep their established compatibility behavior.
201
201
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
202
202
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
203
- version: '0.3.148',
203
+ version: '0.3.150',
204
204
  updateSummary:
205
205
  'Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.',
206
206
  packageCapabilities: {
@@ -190,6 +190,7 @@ export const QUEUE_ITEM_STATES = [
190
190
  'ready',
191
191
  'delayed',
192
192
  'running',
193
+ 'blocked',
193
194
  'held',
194
195
  'quarantined',
195
196
  'archived',
@@ -367,8 +367,39 @@ export type QueueRetirementCandidate = {
367
367
  readonly itemId: string;
368
368
  readonly bucket: 'blocked' | 'deadLettered';
369
369
  readonly ageMs: number;
370
+ /** Native generation. An absent value must never suppress unresolved work. */
371
+ readonly sourceRevision?: string;
370
372
  };
371
373
 
374
+ /** The runtime item reader and bounded health observer must derive the same
375
+ * opaque revision from the same native row, or an old operator disposition
376
+ * could hide a later failure of the same item identity. */
377
+ export function runtimeQueueItemRevision(fields: {
378
+ updatedAt?: unknown;
379
+ createdAt?: unknown;
380
+ state?: unknown;
381
+ status?: unknown;
382
+ leaseExpiresAt?: unknown;
383
+ failedAt?: unknown;
384
+ }): string {
385
+ const timestamp = (value: unknown): string | undefined => {
386
+ if (value instanceof Date && Number.isFinite(value.getTime())) {
387
+ return value.toISOString();
388
+ }
389
+ if (typeof value === 'string' || typeof value === 'number') {
390
+ const date = new Date(value);
391
+ if (Number.isFinite(date.getTime())) return date.toISOString();
392
+ }
393
+ return undefined;
394
+ };
395
+ return [
396
+ timestamp(fields.updatedAt) ?? timestamp(fields.createdAt) ?? '',
397
+ String(fields.state ?? fields.status ?? ''),
398
+ timestamp(fields.leaseExpiresAt) ?? '',
399
+ timestamp(fields.failedAt) ?? '',
400
+ ].join(':');
401
+ }
402
+
372
403
  /**
373
404
  * Remove operator-retired identities from a bounded source sample. The source
374
405
  * still owns every row; the disposition overlay only changes what operators
@@ -35,6 +35,12 @@ export type QueueItemConnectorRegisterOptions = {
35
35
  readonly replaceUnavailable?: boolean;
36
36
  };
37
37
 
38
+ function isUnavailableRegistration(
39
+ registration: QueueItemConnectorRegistration,
40
+ ): boolean {
41
+ return registration.connector.id.endsWith('-unavailable');
42
+ }
43
+
38
44
  /**
39
45
  * The registry is the routing boundary: the control service looks up a queue
40
46
  * by its stable public id and never switches on a native table or provider.
@@ -72,7 +78,7 @@ export class QueueItemConnectorRegistry {
72
78
  if (
73
79
  existing &&
74
80
  options.replaceUnavailable &&
75
- !existing.connector.id.endsWith('-unavailable')
81
+ !isUnavailableRegistration(existing)
76
82
  ) {
77
83
  throw new Error(
78
84
  `Queue connector ${connector.queueId} is already owned by ${existing.connector.id}; replacement is not allowed.`,
@@ -90,6 +96,12 @@ export class QueueItemConnectorRegistry {
90
96
  return this.registrations.has(queueId);
91
97
  }
92
98
 
99
+ /** True only after the owner has replaced the safe unavailable placeholder. */
100
+ hasOwnerConnector(queueId: string): boolean {
101
+ const registration = this.registrations.get(queueId);
102
+ return registration !== undefined && !isUnavailableRegistration(registration);
103
+ }
104
+
93
105
  list(): readonly QueueItemConnectorRegistration[] {
94
106
  return [...this.registrations.values()];
95
107
  }