deepline 0.2.0 → 0.2.2

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 (32) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +17 -3
  2. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  3. package/dist/bundling-sources/shared_libs/observability/scheduled-job-errors.ts +95 -0
  4. package/dist/bundling-sources/shared_libs/play-runtime/backend.ts +19 -0
  5. package/dist/bundling-sources/shared_libs/play-runtime/modal-runtime-config.ts +104 -0
  6. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +4 -1
  7. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +177 -10
  8. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-modal-fallback.ts +218 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +26 -7
  10. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +34 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/modal.ts +380 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/index.ts +28 -3
  13. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +240 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +28 -1
  15. package/dist/bundling-sources/shared_libs/play-runtime/runtime-environment.ts +17 -2
  16. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sandbox-placement-policy.ts +188 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-compute-usage.ts +77 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +10 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/worker-api-types.ts +39 -0
  21. package/dist/cli/index.js +50 -26
  22. package/dist/cli/index.mjs +50 -26
  23. package/dist/index.d.mts +5 -3
  24. package/dist/index.d.ts +5 -3
  25. package/dist/index.js +50 -5
  26. package/dist/index.mjs +50 -5
  27. package/dist/plays/bundle-play-file.d.mts +2 -2
  28. package/dist/plays/bundle-play-file.d.ts +2 -2
  29. package/dist/plays/bundle-play-file.mjs +7 -1
  30. package/dist/{tool-execution-error-YDz7UMl-.d.mts → tool-execution-error-4-rhemLQ.d.mts} +7 -1
  31. package/dist/{tool-execution-error-YDz7UMl-.d.ts → tool-execution-error-4-rhemLQ.d.ts} +7 -1
  32. package/package.json +1 -1
@@ -187,7 +187,7 @@ function resolvePlayRunRuntimeSelection(
187
187
  const runtime = normalizePlayRuntimeSelection(request.runtime);
188
188
  if (!runtime) {
189
189
  throw new DeeplineError(
190
- 'runtime must be exactly { environment: "preview", namespace } with namespace matching ^[a-z][a-z0-9-]{0,30}$.',
190
+ 'runtime must be { environment: "preview", namespace, backend?: "daytona" | "modal" } with namespace matching ^[a-z][a-z0-9-]{0,30}$.',
191
191
  undefined,
192
192
  'INVALID_RUNTIME_SELECTION',
193
193
  );
@@ -229,7 +229,18 @@ function resolvePlayRunRuntimeSelection(
229
229
  'INVALID_RUNTIME_NAMESPACE',
230
230
  );
231
231
  }
232
- return { environment, namespace };
232
+ const configuredBackend = process.env.DEEPLINE_PLAY_RUNNER_BACKEND?.trim();
233
+ if (!configuredBackend) {
234
+ return { environment, namespace };
235
+ }
236
+ if (configuredBackend !== 'daytona' && configuredBackend !== 'modal') {
237
+ throw new DeeplineError(
238
+ `DEEPLINE_PLAY_RUNNER_BACKEND must be daytona or modal for preview runtime selection. Received "${configuredBackend}".`,
239
+ undefined,
240
+ 'INVALID_RUNTIME_BACKEND',
241
+ );
242
+ }
243
+ return { environment, namespace, backend: configuredBackend };
233
244
  }
234
245
 
235
246
  function runtimeSelectionHeaders(
@@ -733,7 +744,10 @@ export type MonitorUpdateChangeSummary = {
733
744
  };
734
745
  upstream: {
735
746
  resource_replaced: boolean;
736
- strategy: 'unchanged' | 'create_then_delete_previous';
747
+ strategy:
748
+ | 'unchanged'
749
+ | 'create_then_delete_previous'
750
+ | 'deferred_until_reactivation';
737
751
  };
738
752
  };
739
753
  export type MonitorUpdateResult = {
@@ -160,7 +160,7 @@ export const SDK_RELEASE = {
160
160
  // 0.2.0 makes Dataset Handles uniformly async-only after 0.1.320 briefly
161
161
  // exposed storage-dependent synchronous access. This deliberate minor
162
162
  // release keeps lazy paging semantics independent of row residency.
163
- version: '0.2.0',
163
+ version: '0.2.2',
164
164
  contracts: {
165
165
  api: {
166
166
  name: 'sdk-http-api',
@@ -0,0 +1,95 @@
1
+ import { redactTelemetryText } from './redaction';
2
+
3
+ const MAX_ERROR_SUMMARY_LENGTH = 900;
4
+
5
+ const SECRET_ASSIGNMENT =
6
+ /\b((?:[A-Za-z][A-Za-z0-9_-]*[_-])?(?:token|secret|password|api[_ -]?key|access[_ -]?key))\b(\s*[:=]\s*)([^\s,;]+)/gi;
7
+ const AUTHORIZATION_VALUE =
8
+ /\bauthorization\b(\s*[:=]\s*)(?:Bearer\s+)?[^\s,;|]+/gi;
9
+ const BEARER_TOKEN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
10
+ const SLACK_WEBHOOK =
11
+ /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/gi;
12
+ const URL_SECRET = /([?&](?:token|secret|key|signature|sig|code)=)[^&\s]+/gi;
13
+ const BARE_CREDENTIAL_PATTERNS = [
14
+ /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/gi,
15
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/gi,
16
+ /\b(?:xaat|xain)-[A-Za-z0-9_-]{16,}\b/gi,
17
+ /\bsk-ant-(?:api|oat)[A-Za-z0-9_-]{8,}\b/gi,
18
+ /\bxox(?:a|b|p|r|s)-[A-Za-z0-9-]{10,}\b/gi,
19
+ ] as const;
20
+
21
+ export type ScheduledJobFailure = {
22
+ jobId: string;
23
+ jobName: string;
24
+ scheduler: string;
25
+ error: unknown;
26
+ runUrl?: string | null;
27
+ stage?: string | null;
28
+ attempt?: number | null;
29
+ occurredAt?: string;
30
+ test?: boolean;
31
+ };
32
+
33
+ export function sanitizeScheduledJobError(
34
+ error: unknown,
35
+ maxLength = MAX_ERROR_SUMMARY_LENGTH,
36
+ ): string {
37
+ const raw =
38
+ error instanceof Error
39
+ ? `${error.name}: ${error.message}`
40
+ : typeof error === 'string'
41
+ ? error
42
+ : safeJson(error);
43
+ let compact = redactTelemetryText(raw)
44
+ .replace(/\u001b\[[0-9;]*m/g, '')
45
+ .replace(/\r/g, '')
46
+ .split('\n')
47
+ .map((line) => line.trim())
48
+ .filter(Boolean)
49
+ .join(' | ')
50
+ .replace(
51
+ AUTHORIZATION_VALUE,
52
+ (_match, separator) => `Authorization${separator}[REDACTED]`,
53
+ )
54
+ .replace(BEARER_TOKEN, 'Bearer [REDACTED]')
55
+ .replace(SLACK_WEBHOOK, '[REDACTED_SLACK_WEBHOOK]')
56
+ .replace(SECRET_ASSIGNMENT, (_match, label, separator) => {
57
+ return `${label}${separator}[REDACTED]`;
58
+ })
59
+ .replace(URL_SECRET, '$1[REDACTED]')
60
+ .replace(/\s+/g, ' ')
61
+ .trim();
62
+ for (const pattern of BARE_CREDENTIAL_PATTERNS) {
63
+ compact = compact.replace(pattern, '[REDACTED_CREDENTIAL]');
64
+ }
65
+ if (!compact) return 'No error detail was captured.';
66
+ return compact.length <= maxLength
67
+ ? compact
68
+ : `${compact.slice(0, Math.max(0, maxLength - 1))}…`;
69
+ }
70
+
71
+ export function formatScheduledJobFailure(input: ScheduledJobFailure): string {
72
+ const title = input.test
73
+ ? '[TEST] Scheduled job failure alert'
74
+ : 'Scheduled job failed';
75
+ return [
76
+ title,
77
+ `Job: ${input.jobName} (${input.jobId})`,
78
+ `Scheduler: ${input.scheduler}`,
79
+ input.stage ? `Stage: ${input.stage}` : null,
80
+ input.attempt ? `Attempt: ${input.attempt}` : null,
81
+ `Error: ${sanitizeScheduledJobError(input.error)}`,
82
+ `Occurred: ${input.occurredAt ?? new Date().toISOString()}`,
83
+ input.runUrl ? `Diagnostics: ${input.runUrl}` : null,
84
+ ]
85
+ .filter((line): line is string => Boolean(line))
86
+ .join('\n');
87
+ }
88
+
89
+ function safeJson(value: unknown): string {
90
+ try {
91
+ return JSON.stringify(value) ?? String(value);
92
+ } catch {
93
+ return String(value);
94
+ }
95
+ }
@@ -1,6 +1,7 @@
1
1
  export const PLAY_RUNTIME_BACKENDS = {
2
2
  localProcess: 'local_process',
3
3
  daytona: 'daytona',
4
+ modal: 'modal',
4
5
  } as const;
5
6
 
6
7
  export type PlayRuntimeBackendId =
@@ -39,6 +40,11 @@ export const PLAY_BACKEND_DESCRIPTORS: Record<
39
40
  artifactKind: PLAY_ARTIFACT_KINDS.cjsNode20,
40
41
  label: 'Daytona sandbox',
41
42
  },
43
+ [PLAY_RUNTIME_BACKENDS.modal]: {
44
+ id: PLAY_RUNTIME_BACKENDS.modal,
45
+ artifactKind: PLAY_ARTIFACT_KINDS.cjsNode20,
46
+ label: 'Modal sandbox',
47
+ },
42
48
  };
43
49
 
44
50
  export function describePlayBackend(
@@ -65,6 +71,10 @@ export function normalizePlayRuntimeBackend(
65
71
  return PLAY_RUNTIME_BACKENDS.daytona;
66
72
  }
67
73
 
74
+ if (normalized === PLAY_RUNTIME_BACKENDS.modal) {
75
+ return PLAY_RUNTIME_BACKENDS.modal;
76
+ }
77
+
68
78
  if (normalized === PLAY_RUNTIME_BACKENDS.localProcess) {
69
79
  return PLAY_RUNTIME_BACKENDS.localProcess;
70
80
  }
@@ -118,3 +128,12 @@ export function isPlayRuntimeBackendId(
118
128
  Object.values(PLAY_RUNTIME_BACKENDS).includes(value as PlayRuntimeBackendId)
119
129
  );
120
130
  }
131
+
132
+ export function isManagedSandboxRuntimeBackend(
133
+ value: string | null | undefined,
134
+ ): boolean {
135
+ return (
136
+ value === PLAY_RUNTIME_BACKENDS.daytona ||
137
+ value === PLAY_RUNTIME_BACKENDS.modal
138
+ );
139
+ }
@@ -0,0 +1,104 @@
1
+ import type { ModalClient } from 'modal';
2
+ import { isIsolatedRuntimeSchedulerSchema } from './runtime-scheduler-topology';
3
+ import {
4
+ STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS,
5
+ validatePlaySandboxRuntimeLimits,
6
+ type PlaySandboxRuntimeLimits,
7
+ } from './sandbox-runtime-limits';
8
+
9
+ export const MODAL_RUNNER_APP_NAME = 'deepline-play-runner';
10
+ export const MODAL_RUNNER_IMAGE = 'node:20-bookworm-slim';
11
+ export const MODAL_RUNNER_WORKDIR = '/root/deepline';
12
+
13
+ // Modal bills one physical core as two vCPUs. This matches the standard
14
+ // Deepline one-vCPU / one-GiB sandbox.
15
+ export const MODAL_SANDBOX_CPU_CORES = 0.5;
16
+ export const MODAL_SANDBOX_MEMORY_MIB = 1024;
17
+
18
+ export type ModalRequiredConfig = {
19
+ client: ModalClient;
20
+ appName: string;
21
+ image: string;
22
+ workdir: string;
23
+ outboundCidrAllowlist: string[] | null;
24
+ limits: PlaySandboxRuntimeLimits;
25
+ };
26
+
27
+ export type ModalClientConfig = Omit<
28
+ ModalRequiredConfig,
29
+ 'outboundCidrAllowlist' | 'limits'
30
+ >;
31
+
32
+ function requiredEnv(
33
+ env: NodeJS.ProcessEnv,
34
+ name: 'MODAL_TOKEN_ID' | 'MODAL_TOKEN_SECRET',
35
+ ): string {
36
+ const value = env[name]?.trim();
37
+ if (!value) throw new Error(`Missing required Modal configuration: ${name}.`);
38
+ return value;
39
+ }
40
+
41
+ function cidrAllowlist(raw: string | null | undefined): string[] | null {
42
+ const values = (raw ?? '')
43
+ .split(',')
44
+ .map((value) => value.trim())
45
+ .filter(Boolean)
46
+ .filter((value) => !value.includes(':'));
47
+ return values.length > 0 ? values : null;
48
+ }
49
+
50
+ export function resolveModalSandboxNetworkPolicy(input: {
51
+ runtimeSchedulerSchema: string | null | undefined;
52
+ env?: NodeJS.ProcessEnv;
53
+ }): { outboundCidrAllowlist: string[] | null } {
54
+ const env = input.env ?? process.env;
55
+ const outboundCidrAllowlist = cidrAllowlist(
56
+ env.DEEPLINE_DAYTONA_NETWORK_ALLOW_LIST,
57
+ );
58
+ if (
59
+ !outboundCidrAllowlist &&
60
+ env.NODE_ENV === 'production' &&
61
+ !isIsolatedRuntimeSchedulerSchema(input.runtimeSchedulerSchema)
62
+ ) {
63
+ throw new Error(
64
+ 'DEEPLINE_DAYTONA_NETWORK_ALLOW_LIST is required for the Modal capacity fallback in production. Refusing to run customer code with unrestricted outbound network access.',
65
+ );
66
+ }
67
+ return { outboundCidrAllowlist };
68
+ }
69
+
70
+ export async function loadModalRequiredConfig(input: {
71
+ env?: NodeJS.ProcessEnv;
72
+ runtimeSchedulerSchema?: string | null;
73
+ limits?: PlaySandboxRuntimeLimits | null;
74
+ }): Promise<ModalRequiredConfig> {
75
+ const env = input.env ?? process.env;
76
+ const clientConfig = await loadModalClientConfig({ env });
77
+ const { outboundCidrAllowlist } = resolveModalSandboxNetworkPolicy({
78
+ env,
79
+ runtimeSchedulerSchema: input.runtimeSchedulerSchema,
80
+ });
81
+ return {
82
+ ...clientConfig,
83
+ outboundCidrAllowlist,
84
+ limits: validatePlaySandboxRuntimeLimits(
85
+ input.limits ?? { ...STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS },
86
+ ),
87
+ };
88
+ }
89
+
90
+ export async function loadModalClientConfig(
91
+ input: { env?: NodeJS.ProcessEnv } = {},
92
+ ): Promise<ModalClientConfig> {
93
+ const env = input.env ?? process.env;
94
+ const tokenId = requiredEnv(env, 'MODAL_TOKEN_ID');
95
+ const tokenSecret = requiredEnv(env, 'MODAL_TOKEN_SECRET');
96
+ const { ModalClient } = await import('modal');
97
+ return {
98
+ client: new ModalClient({ tokenId, tokenSecret }),
99
+ appName:
100
+ env.DEEPLINE_MODAL_RUNNER_APP_NAME?.trim() || MODAL_RUNNER_APP_NAME,
101
+ image: env.DEEPLINE_MODAL_RUNNER_IMAGE?.trim() || MODAL_RUNNER_IMAGE,
102
+ workdir: env.DEEPLINE_MODAL_RUNNER_WORKDIR?.trim() || MODAL_RUNNER_WORKDIR,
103
+ };
104
+ }
@@ -231,10 +231,13 @@ export type PlayRunnerEvent =
231
231
  };
232
232
 
233
233
  export type PlayRunnerRuntimeTiming = {
234
- backend: 'daytona';
234
+ backend: 'daytona' | 'modal';
235
235
  daytonaCreateMs?: number;
236
236
  daytonaUploadMs?: number;
237
237
  daytonaExecuteMs?: number;
238
+ modalCreateMs?: number;
239
+ modalUploadMs?: number;
240
+ modalExecuteMs?: number;
238
241
  };
239
242
 
240
243
  /**
@@ -9,9 +9,13 @@ import {
9
9
  STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS,
10
10
  validatePlaySandboxRuntimeLimits,
11
11
  } from '@shared_libs/play-runtime/sandbox-runtime-limits';
12
+ import type { PlayRunnerRuntimeLifecycleEvent } from '../types';
12
13
 
13
14
  const DAYTONA_CREATE_TIMEOUT_SECONDS = 10;
14
15
  const DAYTONA_CREATE_RETRY_DELAYS_MS = [0, 500, 1_500] as const;
16
+ const DAYTONA_TIMED_OUT_CREATE_RECONCILE_DELAYS_MS = [
17
+ 0, 250, 750, 1_500, 3_000,
18
+ ] as const;
15
19
  // Explicit runner deadline + scheduler GC own the normal lifecycle. Daytona's
16
20
  // inactivity stop is a wider crash backstop measured from sandbox creation, so
17
21
  // setup time cannot consume the terminal-flush grace.
@@ -38,6 +42,10 @@ export type DaytonaStageEmitter = (
38
42
  extra?: Record<string, unknown>,
39
43
  ) => void;
40
44
 
45
+ type DaytonaCreateCallObserver = (
46
+ event: PlayRunnerRuntimeLifecycleEvent,
47
+ ) => Promise<void>;
48
+
41
49
  export type AcquiredDaytonaSandbox = {
42
50
  sandbox: DaytonaSandbox;
43
51
  daytonaOrganizationId: string;
@@ -54,12 +62,63 @@ export type OneShotDaytonaSandboxLifecycle = {
54
62
  dispose: () => Promise<void>;
55
63
  };
56
64
 
65
+ export type DaytonaSandboxAcquisitionUnavailableReason =
66
+ | 'daytona_total_cpu_limit_exceeded'
67
+ | 'daytona_sandbox_start_timeout';
68
+
69
+ /**
70
+ * A typed, pre-customer-code acquisition rejection. Only this error may move
71
+ * managed placement to another provider.
72
+ */
73
+ export class DaytonaSandboxAcquisitionUnavailableError extends Error {
74
+ readonly reason: DaytonaSandboxAcquisitionUnavailableReason;
75
+
76
+ constructor(
77
+ reason: DaytonaSandboxAcquisitionUnavailableReason,
78
+ message: string,
79
+ ) {
80
+ super(message);
81
+ this.name = 'DaytonaSandboxAcquisitionUnavailableError';
82
+ this.reason = reason;
83
+ }
84
+ }
85
+
86
+ export function resolveDaytonaSandboxAcquisitionUnavailableReason(
87
+ errors: readonly string[],
88
+ ): DaytonaSandboxAcquisitionUnavailableReason | null {
89
+ if (
90
+ errors.length > 0 &&
91
+ errors.every((error) =>
92
+ /Total CPU limit exceeded\.\s*Maximum allowed:\s*\d+/i.test(error),
93
+ )
94
+ ) {
95
+ return 'daytona_total_cpu_limit_exceeded';
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function isDaytonaSandboxStartTimeout(error: string): boolean {
101
+ return /Failed to create and start sandbox within 10 seconds\. Operation timed out\./.test(
102
+ error,
103
+ );
104
+ }
105
+
57
106
  type DaytonaCreateResult = {
58
107
  sandbox: DaytonaSandbox;
59
108
  attempt: number;
60
109
  attemptElapsedMs: number;
61
110
  };
62
111
 
112
+ function daytonaCreateErrorClass(
113
+ error: unknown,
114
+ ): 'timeout' | 'capacity' | 'rate_limit' | 'other' {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ if (/timed out|timeout/i.test(message)) return 'timeout';
117
+ if (/total cpu limit|capacity/i.test(message)) return 'capacity';
118
+ if (/too many requests|rate limit|429/i.test(message)) return 'rate_limit';
119
+ return 'other';
120
+ }
121
+
63
122
  async function rejectAcquiredSandbox(
64
123
  sandbox: DaytonaSandbox,
65
124
  reason: string,
@@ -177,6 +236,7 @@ async function createOneShotDaytonaSandbox(input: {
177
236
  daytona: DaytonaClient;
178
237
  orgId: string;
179
238
  context: DaytonaExecutionContext;
239
+ sandboxName: string;
180
240
  }): Promise<DaytonaSandbox> {
181
241
  const limits = validatePlaySandboxRuntimeLimits(
182
242
  input.context.sandboxRuntimeLimits ?? {
@@ -205,8 +265,13 @@ async function createOneShotDaytonaSandbox(input: {
205
265
  });
206
266
 
207
267
  const commonParams = {
268
+ name: input.sandboxName,
208
269
  labels,
270
+ // This is also the provider-owned backstop for an HTTP create timeout
271
+ // whose named sandbox never becomes lookup-addressable to this process:
272
+ // Daytona deletes an ephemeral sandbox immediately when it stops.
209
273
  ephemeral: true,
274
+ autoDeleteInterval: 0,
210
275
  autoStopInterval: Math.ceil(
211
276
  (limits.timeoutSeconds + PLAY_RUNNER_TIMEOUT_SECONDS - 30 * 60) / 60,
212
277
  ),
@@ -224,16 +289,47 @@ async function createOneShotDaytonaSandbox(input: {
224
289
  });
225
290
  }
226
291
 
292
+ async function reconcileAndDeleteTimedOutDaytonaSandbox(input: {
293
+ daytona: DaytonaClient;
294
+ sandboxName: string;
295
+ }): Promise<boolean> {
296
+ if (!input.daytona.get) return false;
297
+ for (const delayMs of DAYTONA_TIMED_OUT_CREATE_RECONCILE_DELAYS_MS) {
298
+ if (delayMs > 0) {
299
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
300
+ }
301
+ let sandbox: DaytonaSandbox;
302
+ try {
303
+ sandbox = await input.daytona.get(input.sandboxName);
304
+ } catch {
305
+ continue;
306
+ }
307
+ try {
308
+ await sandbox.delete(30);
309
+ } catch (error) {
310
+ throw new Error(
311
+ `Daytona timed-out sandbox ${sandbox.id} was reconciled by name but could not be deleted. Modal fallback suppressed.`,
312
+ { cause: error },
313
+ );
314
+ }
315
+ return true;
316
+ }
317
+ return false;
318
+ }
319
+
227
320
  async function createRetriedOneShotDaytonaSandbox(input: {
228
321
  daytona: DaytonaClient;
229
322
  orgId: string;
230
323
  context: DaytonaExecutionContext;
231
324
  emitStage: DaytonaStageEmitter;
325
+ observeCreateCall?: DaytonaCreateCallObserver;
326
+ nextProviderAttempt: () => number;
232
327
  startedAt: number;
233
328
  }): Promise<DaytonaCreateResult> {
234
329
  const errors: string[] = [];
235
330
  for (const [index, delayMs] of DAYTONA_CREATE_RETRY_DELAYS_MS.entries()) {
236
- const attempt = index + 1;
331
+ const attempt = input.nextProviderAttempt();
332
+ const sandboxName = `dl-${crypto.randomUUID()}`;
237
333
  if (delayMs > 0) {
238
334
  input.emitStage('create:retry', {
239
335
  attempt: index,
@@ -244,18 +340,26 @@ async function createRetriedOneShotDaytonaSandbox(input: {
244
340
  await new Promise((resolve) => setTimeout(resolve, delayMs));
245
341
  }
246
342
  const attemptStartedAt = Date.now();
343
+ await input.observeCreateCall?.({
344
+ type: 'daytona_create_call_started',
345
+ occurredAtMs: attemptStartedAt,
346
+ providerAttempt: attempt,
347
+ });
348
+ let sandbox: DaytonaSandbox;
247
349
  try {
248
- const sandbox = await createOneShotDaytonaSandbox({
350
+ sandbox = await createOneShotDaytonaSandbox({
249
351
  daytona: input.daytona,
250
352
  orgId: input.orgId,
251
353
  context: input.context,
354
+ sandboxName,
252
355
  });
253
- return {
254
- sandbox,
255
- attempt,
256
- attemptElapsedMs: Date.now() - attemptStartedAt,
257
- };
258
356
  } catch (error) {
357
+ await input.observeCreateCall?.({
358
+ type: 'daytona_create_call_failed',
359
+ occurredAtMs: Date.now(),
360
+ providerAttempt: attempt,
361
+ errorClass: daytonaCreateErrorClass(error),
362
+ });
259
363
  const message = error instanceof Error ? error.message : String(error);
260
364
  errors.push(message);
261
365
  input.emitStage('create:attempt_failed', {
@@ -270,11 +374,65 @@ async function createRetriedOneShotDaytonaSandbox(input: {
270
374
  attempt,
271
375
  error: message,
272
376
  });
377
+ if (isDaytonaSandboxStartTimeout(message)) {
378
+ const deleted = await reconcileAndDeleteTimedOutDaytonaSandbox({
379
+ daytona: input.daytona,
380
+ sandboxName,
381
+ });
382
+ if (deleted) {
383
+ throw new DaytonaSandboxAcquisitionUnavailableError(
384
+ 'daytona_sandbox_start_timeout',
385
+ `${message} Timed-out Daytona sandbox was reconciled and deleted before fallback.`,
386
+ );
387
+ }
388
+ throw new Error(
389
+ `${message} Daytona did not return a cleanup-addressable sandbox identity; Modal fallback suppressed. The named ephemeral sandbox retains Daytona's auto-delete-on-stop backstop.`,
390
+ { cause: error },
391
+ );
392
+ }
393
+ continue;
394
+ }
395
+ // The outcome must be attributed immediately after Daytona acknowledges
396
+ // creation, before resource-policy validation can reject it. Otherwise a
397
+ // created-but-rejected sandbox is indistinguishable from an unknown
398
+ // create result. Do not turn a failed *post-create* journal write into a
399
+ // new provider create: the sandbox is already real. The missing durable
400
+ // outcome is deliberately loud in worker logs and makes the capture gate
401
+ // fail as incomplete telemetry, while the normal resource ledger still
402
+ // records the known sandbox for cleanup below.
403
+ const acquiredAt = Date.now();
404
+ try {
405
+ await input.observeCreateCall?.({
406
+ type: 'daytona_create_call_succeeded',
407
+ occurredAtMs: acquiredAt,
408
+ providerAttempt: attempt,
409
+ sandboxId: sandbox.id,
410
+ });
411
+ } catch {
412
+ console.error('[play-runner.daytona.create_lifecycle_event_unrecorded]', {
413
+ workflowId: input.context.workflowId ?? null,
414
+ runId: input.context.runId ?? null,
415
+ attempt,
416
+ sandboxId: sandbox.id,
417
+ eventType: 'daytona_create_call_succeeded',
418
+ });
273
419
  }
420
+ return {
421
+ sandbox,
422
+ attempt,
423
+ attemptElapsedMs: acquiredAt - attemptStartedAt,
424
+ };
274
425
  }
275
- throw new Error(
276
- `Daytona sandbox create failed across ${errors.length} bounded attempts: ${errors.join('; ')}`,
277
- );
426
+ const message = `Daytona sandbox create failed across ${errors.length} bounded attempts: ${errors.join('; ')}`;
427
+ const fallbackReason =
428
+ resolveDaytonaSandboxAcquisitionUnavailableReason(errors);
429
+ if (fallbackReason) {
430
+ throw new DaytonaSandboxAcquisitionUnavailableError(
431
+ fallbackReason,
432
+ message,
433
+ );
434
+ }
435
+ throw new Error(message);
278
436
  }
279
437
 
280
438
  async function acquireOneShotDaytonaSandbox(input: {
@@ -282,6 +440,8 @@ async function acquireOneShotDaytonaSandbox(input: {
282
440
  orgId: string;
283
441
  context: DaytonaExecutionContext;
284
442
  emitStage: DaytonaStageEmitter;
443
+ observeCreateCall?: DaytonaCreateCallObserver;
444
+ nextProviderAttempt: () => number;
285
445
  startedAt: number;
286
446
  }): Promise<AcquiredDaytonaSandbox> {
287
447
  const limits = validatePlaySandboxRuntimeLimits(
@@ -361,11 +521,13 @@ export function createOneShotDaytonaSandboxLifecycle(input: {
361
521
  daytona: DaytonaClient;
362
522
  context: DaytonaExecutionContext;
363
523
  emitStage: DaytonaStageEmitter;
524
+ observeCreateCall?: DaytonaCreateCallObserver;
364
525
  startedAt?: number;
365
526
  }): OneShotDaytonaSandboxLifecycle {
366
527
  const orgId = validateDaytonaExecutionContext(input.context);
367
528
  const startedAt = input.startedAt ?? Date.now();
368
529
  let disposed = false;
530
+ let providerAttempt = 0;
369
531
  const acquiredSandboxes = new Map<string, AcquiredDaytonaSandbox>();
370
532
  let latestAcquiredSandboxPromise: Promise<AcquiredDaytonaSandbox>;
371
533
  const createFreshSandbox = () => {
@@ -374,6 +536,11 @@ export function createOneShotDaytonaSandboxLifecycle(input: {
374
536
  orgId,
375
537
  context: input.context,
376
538
  emitStage: input.emitStage,
539
+ observeCreateCall: input.observeCreateCall,
540
+ nextProviderAttempt: () => {
541
+ providerAttempt += 1;
542
+ return providerAttempt;
543
+ },
377
544
  startedAt,
378
545
  }).then((acquired) => {
379
546
  acquiredSandboxes.set(acquired.sandbox.id, acquired);