deepline 0.3.231 → 0.3.233

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.
@@ -0,0 +1,530 @@
1
+ import type { ToolResultBilling } from '../../shared_libs/plays/tool-result-types.js';
2
+ import { resolveTheirstackClientTimeoutMs } from '../../shared_libs/integration-contracts/theirstack/execution-policy.js';
3
+ import { resolveWaterfallClientTimeoutMs } from '../../shared_libs/integration-contracts/waterfall/execution-policy.js';
4
+ import {
5
+ BETTERCONTACT_LAUNCHER_CLIENT_TIMEOUT_MS,
6
+ usesExtendedBetterContactLauncherBudget,
7
+ } from '../../shared_libs/integration-contracts/bettercontact/execution-policy.js';
8
+ import {
9
+ TOOL_EXECUTION_ERROR_SCHEMA_HEADER,
10
+ TOOL_EXECUTION_ERROR_SCHEMA_VERSION,
11
+ } from '../../shared_libs/plays/tool-execution-error';
12
+ import {
13
+ legacyRawFromToolResponseRawV2,
14
+ providerMetaFromToolResponseRawV2,
15
+ RAW_V2_TOOL_RESPONSE_CONTRACT,
16
+ } from '../../shared_libs/plays/tool-response-contract.js';
17
+
18
+ import { DeeplineError, ToolExecutionError } from './errors.js';
19
+ import type { HttpClient } from './http.js';
20
+
21
+ const INCLUDE_TOOL_METADATA_HEADER = 'x-deepline-include-tool-metadata';
22
+ const EXECUTE_RESPONSE_CONTRACT_HEADER = 'x-deepline-execute-response-contract';
23
+ const EXECUTE_RESPONSE_INTENT_HEADER = 'x-deepline-execute-response-intent';
24
+ const RAW_V2_EXECUTE_RESPONSE_CONTRACT = RAW_V2_TOOL_RESPONSE_CONTRACT;
25
+ const DEFAULT_EXECUTION_RECOVERY_TIMEOUT_MS = 15 * 60 * 1_000;
26
+
27
+ function validateExecutionIdempotencyKey(key: string): void {
28
+ if (
29
+ typeof key !== 'string' ||
30
+ key.length < 1 ||
31
+ key.length > 200 ||
32
+ !/^[A-Za-z0-9._:-]+$/.test(key)
33
+ ) {
34
+ throw new DeeplineError(
35
+ 'Execution idempotency keys must be 1–200 ASCII letters, digits, dots, underscores, colons, or hyphens.',
36
+ undefined,
37
+ 'IDEMPOTENCY_KEY_INVALID',
38
+ );
39
+ }
40
+ }
41
+
42
+ function getExecutionRecoveryState(
43
+ value: unknown,
44
+ ): ExecutionRecovery['state'] | null {
45
+ if (!isRecord(value) || !isRecord(value.executionRecovery)) return null;
46
+ const state = value.executionRecovery.state;
47
+ return state === 'running' ||
48
+ state === 'completed' ||
49
+ state === 'outcome_unknown'
50
+ ? state
51
+ : null;
52
+ }
53
+
54
+ function isRecoverableExecutionAttemptError(error: unknown): boolean {
55
+ if (error instanceof ToolExecutionError) {
56
+ return (
57
+ error.code === 'EXECUTION_IN_PROGRESS' ||
58
+ (error.origin === 'deepline' &&
59
+ error.category === 'network' &&
60
+ error.networkScope === 'client_to_deepline')
61
+ );
62
+ }
63
+ return (
64
+ error instanceof DeeplineError &&
65
+ (error.code === 'EXECUTION_IN_PROGRESS' ||
66
+ (error.code?.startsWith('NETWORK_') === true &&
67
+ error.statusCode === undefined))
68
+ );
69
+ }
70
+
71
+ function isRecoverableExecutionLookupError(error: unknown): boolean {
72
+ if (isRecoverableExecutionAttemptError(error)) return true;
73
+ return (
74
+ error instanceof DeeplineError &&
75
+ (error.statusCode === 429 ||
76
+ (error.statusCode !== undefined && error.statusCode >= 500))
77
+ );
78
+ }
79
+
80
+ function timeoutWithinRecoveryBudget(
81
+ requestTimeoutMs: number | undefined,
82
+ defaultRequestTimeoutMs: number,
83
+ remainingMs: number,
84
+ ): number {
85
+ return Math.min(requestTimeoutMs ?? defaultRequestTimeoutMs, remainingMs);
86
+ }
87
+
88
+ function executionRecoveryError(input: {
89
+ toolId: string;
90
+ idempotencyKey: string;
91
+ code:
92
+ | 'EXECUTION_IN_PROGRESS'
93
+ | 'EXECUTION_OUTCOME_UNKNOWN'
94
+ | 'EXECUTION_RECOVERY_TIMEOUT';
95
+ message: string;
96
+ }): ToolExecutionError {
97
+ return new ToolExecutionError(input.message, {
98
+ toolId: input.toolId,
99
+ provider: null,
100
+ operation: input.toolId,
101
+ code: input.code,
102
+ origin: 'deepline',
103
+ category:
104
+ input.code === 'EXECUTION_OUTCOME_UNKNOWN' ? 'unknown' : 'conflict',
105
+ retryable: input.code !== 'EXECUTION_OUTCOME_UNKNOWN',
106
+ statusCode: null,
107
+ requestId: null,
108
+ retryAfterMs: null,
109
+ networkKind: null,
110
+ networkScope: null,
111
+ publicDetails: { idempotencyKey: input.idempotencyKey },
112
+ });
113
+ }
114
+ const DEEPLINEAGENT_EXECUTE_TIMEOUT_MS = 15 * 60 * 1000;
115
+
116
+ export type ExecuteToolRawOptions = {
117
+ includeToolMetadata?: boolean;
118
+ responseIntent?: 'dataset' | 'raw' | 'row_artifact';
119
+ metadata?: Record<string, unknown>;
120
+ timeout?: number;
121
+ maxRetries?: number;
122
+ /** Retry transient HTTP API responses when the caller has confirmed safety. */
123
+ retryApiErrors?: boolean;
124
+ /** Reuse this stable key to recover or retry an ambiguous execution. */
125
+ idempotencyKey?: string;
126
+ /** Generate a key and enable recoverable execution before dispatch. */
127
+ recover?: boolean;
128
+ /** Called and awaited with the key before the first network request. */
129
+ onExecution?: (execution: { idempotencyKey: string }) => void | Promise<void>;
130
+ /** Maximum time to reconnect to an in-progress keyed execution. Defaults to 15 minutes. */
131
+ recoveryTimeoutMs?: number;
132
+ /** The `search_ref` of the tools search that found this tool. */
133
+ searchRef?: string;
134
+ };
135
+
136
+ /** Durable state returned for a keyed tool execution. */
137
+ export type ExecutionRecovery = {
138
+ /** Stable caller key used to resume or replay this execution. */
139
+ idempotencyKey: string;
140
+ /** Whether the execution is still running, completed, or has an unknown provider outcome. */
141
+ state: 'running' | 'completed' | 'outcome_unknown';
142
+ /** Whether this response came from an existing durable execution. */
143
+ replayed: boolean;
144
+ /** ISO timestamp after which the completed execution can no longer be replayed. */
145
+ expiresAt?: string;
146
+ };
147
+
148
+ /** Durable lookup result for a keyed tool execution. */
149
+ export type ExecutionByKeyResult = {
150
+ /** Recovery state and the key that owns the execution. */
151
+ executionRecovery: ExecutionRecovery;
152
+ /** Provider tool associated with the execution. */
153
+ toolId: string;
154
+ /** Original server-owned billing request ID, allocated before provider dispatch. */
155
+ requestId?: string;
156
+ /** HTTP status saved with the original response, when it was terminal. */
157
+ responseStatus?: number;
158
+ /** Saved execution response, when one is available for replay. */
159
+ response?: unknown;
160
+ };
161
+
162
+ /**
163
+ * The server launches the Apify run, polls it for `payload.timeoutMs`, then
164
+ * reads its dataset before replying. Keep the client request alive through
165
+ * that final hand-off so a pollable run id is never lost to the SDK default
166
+ * timeout.
167
+ */
168
+ const APIFY_SYNC_DEFAULT_TIMEOUT_MS = 300_000;
169
+ const APIFY_SYNC_RESPONSE_GRACE_MS = 90_000;
170
+ function resolveToolExecuteTimeoutMs(
171
+ toolId: string,
172
+ input: Record<string, unknown>,
173
+ ): number | undefined {
174
+ const normalized = toolId.trim().toLowerCase();
175
+ if (normalized === 'apify_run_actor_sync') {
176
+ const requestedTimeoutMs = input.timeoutMs ?? APIFY_SYNC_DEFAULT_TIMEOUT_MS;
177
+ if (
178
+ typeof requestedTimeoutMs === 'number' &&
179
+ Number.isFinite(requestedTimeoutMs) &&
180
+ requestedTimeoutMs > 0
181
+ ) {
182
+ return Math.floor(requestedTimeoutMs) + APIFY_SYNC_RESPONSE_GRACE_MS;
183
+ }
184
+ }
185
+ // Provider-specific slow-request policies live in shared modules so the SDK
186
+ // and server classify the same payloads. If more providers need this, replace
187
+ // these individual checks with a shared policy registry, not a wider default.
188
+ const theirstackTimeoutMs = resolveTheirstackClientTimeoutMs(
189
+ normalized,
190
+ input,
191
+ );
192
+ if (theirstackTimeoutMs !== null) return theirstackTimeoutMs;
193
+ const waterfallTimeoutMs = resolveWaterfallClientTimeoutMs(normalized);
194
+ if (waterfallTimeoutMs !== null) return waterfallTimeoutMs;
195
+ if (usesExtendedBetterContactLauncherBudget(normalized)) {
196
+ return BETTERCONTACT_LAUNCHER_CLIENT_TIMEOUT_MS;
197
+ }
198
+ return normalized === 'deeplineagent' ||
199
+ normalized === 'deeplineagent_deeplineagent' ||
200
+ normalized === 'ai_inference' ||
201
+ normalized === 'deeplineagent_ai_inference' ||
202
+ normalized === 'ai_evaluate' ||
203
+ normalized === 'deeplineagent_ai_evaluate' ||
204
+ normalized === 'aiinference'
205
+ ? DEEPLINEAGENT_EXECUTE_TIMEOUT_MS
206
+ : undefined;
207
+ }
208
+
209
+ /**
210
+ * Standard provider/tool execution envelope returned by low-level SDK calls.
211
+ *
212
+ * `toolResponse.rawV2` contains the complete scrubbed provider response;
213
+ * `toolResponse.raw` is derived locally as the legacy provider-result projection. `extractedValues` and
214
+ * `extractedLists` contain Deepline-normalized getters when the tool exposes
215
+ * them. Billing fields are Deepline-facing and must not expose provider spend.
216
+ */
217
+ export type ToolExecution<TData = unknown, TMeta = Record<string, unknown>> = {
218
+ status: string;
219
+ job_id?: string;
220
+ meta?: Record<string, unknown>;
221
+ toolResponse: {
222
+ raw: TData;
223
+ rawV2?: unknown;
224
+ view?: 'data' | 'rawV2';
225
+ meta?: TMeta;
226
+ responseMeta?: TMeta;
227
+ };
228
+ extractedLists?: Record<string, unknown>;
229
+ extractedValues?: Record<string, unknown>;
230
+ billing?: ToolResultBilling;
231
+ [key: string]: unknown;
232
+ };
233
+
234
+ function isRecord(value: unknown): value is Record<string, unknown> {
235
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
236
+ }
237
+
238
+ /** Materialize raw-v2's rawV2-only wire response into the stable SDK result view. */
239
+ function materializeToolExecutionResponse<
240
+ TData = unknown,
241
+ TMeta = Record<string, unknown>,
242
+ >(response: ToolExecution<TData, TMeta>): ToolExecution<TData, TMeta> {
243
+ const toolResponse = response.toolResponse;
244
+ // An older V2 backend does not understand raw-v2 yet and therefore falls
245
+ // through to the historical `{ result: { data, meta? } }` response. New SDK
246
+ // clients must keep working during that server rollout window.
247
+ if (!toolResponse && isRecord(response.result)) {
248
+ const legacyResult = response.result;
249
+ if (Object.prototype.hasOwnProperty.call(legacyResult, 'data')) {
250
+ const legacyMeta = isRecord(legacyResult.meta)
251
+ ? (legacyResult.meta as TMeta)
252
+ : undefined;
253
+ return {
254
+ ...response,
255
+ toolResponse: {
256
+ raw: legacyResult.data as TData,
257
+ ...(legacyMeta ? { meta: legacyMeta } : {}),
258
+ },
259
+ };
260
+ }
261
+ }
262
+ if (
263
+ !toolResponse ||
264
+ Object.prototype.hasOwnProperty.call(toolResponse, 'raw')
265
+ ) {
266
+ return response;
267
+ }
268
+ const rawV2 = toolResponse.rawV2;
269
+ const view = toolResponse.view;
270
+ const providerMeta = providerMetaFromToolResponseRawV2(
271
+ rawV2,
272
+ view ?? 'rawV2',
273
+ ) as TMeta | undefined;
274
+ const responseMeta = isRecord(toolResponse.responseMeta)
275
+ ? (toolResponse.responseMeta as TMeta)
276
+ : undefined;
277
+ return {
278
+ ...response,
279
+ toolResponse: {
280
+ ...toolResponse,
281
+ raw: legacyRawFromToolResponseRawV2(
282
+ rawV2,
283
+ view ?? 'rawV2',
284
+ responseMeta as Record<string, unknown> | undefined,
285
+ ) as TData,
286
+ ...(toolResponse.meta || providerMeta || responseMeta
287
+ ? {
288
+ meta: {
289
+ ...(toolResponse.meta ?? {}),
290
+ ...(providerMeta ?? {}),
291
+ ...(responseMeta ?? {}),
292
+ } as TMeta,
293
+ }
294
+ : {}),
295
+ },
296
+ };
297
+ }
298
+
299
+ /** Owns tool dispatch, keyed recovery, and the stable SDK response projection. */
300
+ export class ToolCalls {
301
+ constructor(
302
+ private readonly http: HttpClient,
303
+ private readonly defaultRequestTimeoutMs: number,
304
+ ) {}
305
+
306
+ async call<TData = unknown, TMeta = Record<string, unknown>>(
307
+ toolId: string,
308
+ input: Record<string, unknown>,
309
+ options?: ExecuteToolRawOptions,
310
+ ): Promise<ToolExecution<TData, TMeta>> {
311
+ // Snapshot JSON input before the caller's awaited onExecution callback and
312
+ // before any retry, so every POST is byte-equivalent in meaning.
313
+ const inputSnapshot = JSON.parse(JSON.stringify(input)) as Record<
314
+ string,
315
+ unknown
316
+ >;
317
+ const metadataSnapshot = options?.metadata
318
+ ? (JSON.parse(JSON.stringify(options.metadata)) as Record<
319
+ string,
320
+ unknown
321
+ >)
322
+ : undefined;
323
+ const idempotencyKey =
324
+ options?.idempotencyKey ??
325
+ (options?.recover ? crypto.randomUUID() : undefined);
326
+ if (idempotencyKey !== undefined) {
327
+ validateExecutionIdempotencyKey(idempotencyKey);
328
+ if (
329
+ options?.recoveryTimeoutMs !== undefined &&
330
+ (!Number.isFinite(options.recoveryTimeoutMs) ||
331
+ options.recoveryTimeoutMs < 0)
332
+ ) {
333
+ throw new DeeplineError(
334
+ 'recoveryTimeoutMs must be a finite, non-negative number.',
335
+ undefined,
336
+ 'IDEMPOTENCY_KEY_INVALID',
337
+ );
338
+ }
339
+ await options?.onExecution?.({ idempotencyKey });
340
+ }
341
+ const timeoutMs =
342
+ options?.timeout ?? resolveToolExecuteTimeoutMs(toolId, inputSnapshot);
343
+ if (idempotencyKey !== undefined) {
344
+ await this.lookupExecutionByKey(idempotencyKey, timeoutMs);
345
+ }
346
+ const isApifySync = toolId.trim().toLowerCase() === 'apify_run_actor_sync';
347
+ const headers = {
348
+ [EXECUTE_RESPONSE_CONTRACT_HEADER]: RAW_V2_EXECUTE_RESPONSE_CONTRACT,
349
+ ...(isApifySync
350
+ ? { 'X-Deepline-Apify-Sync-Response-Budget': 'extended-v1' }
351
+ : {}),
352
+ [TOOL_EXECUTION_ERROR_SCHEMA_HEADER]: String(
353
+ TOOL_EXECUTION_ERROR_SCHEMA_VERSION,
354
+ ),
355
+ ...(options?.includeToolMetadata
356
+ ? { [INCLUDE_TOOL_METADATA_HEADER]: 'true' }
357
+ : {}),
358
+ [EXECUTE_RESPONSE_INTENT_HEADER]: options?.responseIntent ?? 'raw',
359
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
360
+ ...(options?.searchRef
361
+ ? { 'x-deepline-search-ref': options.searchRef }
362
+ : {}),
363
+ };
364
+ const request = (requestTimeoutMs: number | undefined) =>
365
+ this.http.post<ToolExecution<TData, TMeta>>(
366
+ `/api/v2/integrations/${encodeURIComponent(toolId)}/execute`,
367
+ {
368
+ payload: inputSnapshot,
369
+ ...(metadataSnapshot ? { metadata: metadataSnapshot } : {}),
370
+ },
371
+ headers,
372
+ {
373
+ timeout: requestTimeoutMs,
374
+ maxRetries: idempotencyKey ? 0 : (options?.maxRetries ?? 0),
375
+ retryApiErrors: options?.retryApiErrors === true,
376
+ exactUrlOnly: true,
377
+ toolId,
378
+ idempotencyKey,
379
+ },
380
+ );
381
+ let response: ToolExecution<TData, TMeta>;
382
+ let recoveryDeadline: number | null = null;
383
+ const recoveryTimeoutMs =
384
+ options?.recoveryTimeoutMs ?? DEFAULT_EXECUTION_RECOVERY_TIMEOUT_MS;
385
+ let retryDelayMs = 1_000;
386
+ const recoveryTimeoutError = () =>
387
+ executionRecoveryError({
388
+ toolId,
389
+ idempotencyKey: idempotencyKey!,
390
+ code: 'EXECUTION_RECOVERY_TIMEOUT',
391
+ message: `Execution recovery for ${idempotencyKey} did not finish before the recovery timeout. Inspect client.executions.getByKey() and resume with the same idempotency key.`,
392
+ });
393
+ const waitForRecoveryRetry = async (deadline: number) => {
394
+ const remainingMs = deadline - Date.now();
395
+ if (remainingMs <= 0) throw recoveryTimeoutError();
396
+ await new Promise((resolve) =>
397
+ setTimeout(resolve, Math.min(retryDelayMs, remainingMs)),
398
+ );
399
+ retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
400
+ };
401
+ const lookupDuringRecovery = async () => {
402
+ while (true) {
403
+ const remainingMs = recoveryDeadline! - Date.now();
404
+ if (remainingMs <= 0) throw recoveryTimeoutError();
405
+ try {
406
+ return await this.lookupExecutionByKey(
407
+ idempotencyKey!,
408
+ timeoutWithinRecoveryBudget(
409
+ timeoutMs,
410
+ this.defaultRequestTimeoutMs,
411
+ remainingMs,
412
+ ),
413
+ );
414
+ } catch (error) {
415
+ if (!isRecoverableExecutionLookupError(error)) throw error;
416
+ await waitForRecoveryRetry(recoveryDeadline!);
417
+ }
418
+ }
419
+ };
420
+ while (true) {
421
+ const remainingMs =
422
+ recoveryDeadline === null ? null : recoveryDeadline - Date.now();
423
+ if (remainingMs !== null && remainingMs <= 0) {
424
+ throw recoveryTimeoutError();
425
+ }
426
+ try {
427
+ response = await request(
428
+ remainingMs === null
429
+ ? timeoutMs
430
+ : timeoutWithinRecoveryBudget(
431
+ timeoutMs,
432
+ this.defaultRequestTimeoutMs,
433
+ remainingMs,
434
+ ),
435
+ );
436
+ const recoveryState = getExecutionRecoveryState(response);
437
+ if (recoveryState === 'outcome_unknown') {
438
+ if (!idempotencyKey) break;
439
+ throw executionRecoveryError({
440
+ toolId,
441
+ idempotencyKey,
442
+ code: 'EXECUTION_OUTCOME_UNKNOWN',
443
+ message: `The outcome of execution ${idempotencyKey} is unknown. Do not retry with a new key; inspect client.executions.getByKey().`,
444
+ });
445
+ }
446
+ if (!idempotencyKey || recoveryState !== 'running') {
447
+ break;
448
+ }
449
+ if (recoveryDeadline === null) {
450
+ recoveryDeadline = Date.now() + recoveryTimeoutMs;
451
+ }
452
+ } catch (error) {
453
+ if (
454
+ idempotencyKey &&
455
+ error instanceof DeeplineError &&
456
+ error.code === 'EXECUTION_OUTCOME_UNKNOWN'
457
+ ) {
458
+ throw executionRecoveryError({
459
+ toolId,
460
+ idempotencyKey,
461
+ code: 'EXECUTION_OUTCOME_UNKNOWN',
462
+ message: error.message,
463
+ });
464
+ }
465
+ if (!idempotencyKey || !isRecoverableExecutionAttemptError(error)) {
466
+ throw error;
467
+ }
468
+ if (recoveryDeadline === null) {
469
+ recoveryDeadline = Date.now() + recoveryTimeoutMs;
470
+ }
471
+ }
472
+ await waitForRecoveryRetry(recoveryDeadline!);
473
+ const recovered = await lookupDuringRecovery();
474
+ if (getExecutionRecoveryState(recovered) === 'outcome_unknown') {
475
+ throw executionRecoveryError({
476
+ toolId,
477
+ idempotencyKey,
478
+ code: 'EXECUTION_OUTCOME_UNKNOWN',
479
+ message: `The outcome of execution ${idempotencyKey} is unknown. Do not retry with a new key; inspect client.executions.getByKey().`,
480
+ });
481
+ }
482
+ }
483
+ const materialized = materializeToolExecutionResponse(response);
484
+ return idempotencyKey ? { ...materialized, idempotencyKey } : materialized;
485
+ }
486
+
487
+ /** Read the durable state of a keyed tool execution. */
488
+ async getByKey(idempotencyKey: string): Promise<ExecutionByKeyResult> {
489
+ validateExecutionIdempotencyKey(idempotencyKey);
490
+ const result = await this.lookupExecutionByKey(idempotencyKey);
491
+ if (!result) {
492
+ throw new DeeplineError(
493
+ `No execution exists for idempotency key ${idempotencyKey}.`,
494
+ 404,
495
+ 'EXECUTION_NOT_FOUND',
496
+ );
497
+ }
498
+ return result;
499
+ }
500
+
501
+ private async lookupExecutionByKey(
502
+ idempotencyKey: string,
503
+ timeoutMs?: number,
504
+ ): Promise<ExecutionByKeyResult | null> {
505
+ validateExecutionIdempotencyKey(idempotencyKey);
506
+ let supported = false;
507
+ const result = await this.http.get<ExecutionByKeyResult | null>(
508
+ `/api/v2/executions/by-key/${encodeURIComponent(idempotencyKey)}`,
509
+ {
510
+ exactUrlOnly: true,
511
+ maxRetries: 0,
512
+ timeout: timeoutMs,
513
+ idempotencyKey,
514
+ allowNotFound: true,
515
+ onResponse: (response) => {
516
+ supported =
517
+ response.headers.get('X-Deepline-Idempotency-Supported') === 'true';
518
+ },
519
+ },
520
+ );
521
+ if (!supported) {
522
+ throw new DeeplineError(
523
+ 'This Deepline server does not support recoverable tool executions; no tool was dispatched.',
524
+ 422,
525
+ 'IDEMPOTENCY_NOT_SUPPORTED',
526
+ );
527
+ }
528
+ return result;
529
+ }
530
+ }
@@ -212,3 +212,14 @@ function finiteNonNegative(value: unknown): number | undefined {
212
212
  ? value
213
213
  : undefined;
214
214
  }
215
+
216
+ /** One environment policy for scheduled-work execution and health reporting. */
217
+ export function scheduledWorkEnvironment(
218
+ env: Record<string, string | undefined> = process.env,
219
+ ): string {
220
+ if (env.DEEPLINE_ENV?.trim() === 'preview') return 'preview';
221
+ const deployment = env.CONVEX_DEPLOYMENT?.trim();
222
+ if (deployment?.startsWith('prod:')) return 'production';
223
+ if (deployment?.startsWith('dev:')) return 'development';
224
+ return env.DEEPLINE_ENV?.trim() || 'unknown';
225
+ }