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.
- package/dist/bundling-sources/sdk/src/client.ts +17 -3
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/observability/scheduled-job-errors.ts +95 -0
- package/dist/bundling-sources/shared_libs/play-runtime/backend.ts +19 -0
- package/dist/bundling-sources/shared_libs/play-runtime/modal-runtime-config.ts +104 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +4 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +177 -10
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-modal-fallback.ts +218 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +26 -7
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +34 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/modal.ts +380 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/index.ts +28 -3
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +240 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +28 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-environment.ts +17 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-sandbox-placement-policy.ts +188 -0
- package/dist/bundling-sources/shared_libs/play-runtime/sandbox-compute-usage.ts +77 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +10 -0
- package/dist/bundling-sources/shared_libs/play-runtime/worker-api-types.ts +39 -0
- package/dist/cli/index.js +50 -26
- package/dist/cli/index.mjs +50 -26
- package/dist/index.d.mts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +50 -5
- package/dist/index.mjs +50 -5
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/dist/plays/bundle-play-file.mjs +7 -1
- package/dist/{tool-execution-error-YDz7UMl-.d.mts → tool-execution-error-4-rhemLQ.d.mts} +7 -1
- package/dist/{tool-execution-error-YDz7UMl-.d.ts → tool-execution-error-4-rhemLQ.d.ts} +7 -1
- 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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
/**
|
package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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);
|