deepline 0.1.306 → 0.1.308

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 (27) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +11 -24
  2. package/dist/bundling-sources/sdk/src/errors.ts +3 -1
  3. package/dist/bundling-sources/sdk/src/http.ts +16 -24
  4. package/dist/bundling-sources/sdk/src/play.ts +26 -0
  5. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  6. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +3 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +66 -11
  8. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +13 -5
  9. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +12 -3
  10. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +21 -9
  11. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +2 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/runtime-constants.ts +5 -1
  13. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-compute-usage.ts +17 -2
  14. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runtime-limits.ts +102 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +56 -9
  16. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +56 -0
  17. package/dist/bundling-sources/shared_libs/plays/contracts.ts +6 -0
  18. package/dist/cli/index.js +197 -42
  19. package/dist/cli/index.mjs +197 -42
  20. package/dist/index.d.mts +19 -1
  21. package/dist/index.d.ts +19 -1
  22. package/dist/index.js +33 -30
  23. package/dist/index.mjs +33 -30
  24. package/dist/plays/bundle-play-file.d.mts +8 -0
  25. package/dist/plays/bundle-play-file.d.ts +8 -0
  26. package/dist/plays/bundle-play-file.mjs +42 -0
  27. package/package.json +1 -1
@@ -1785,7 +1785,6 @@ export class DeeplineClient {
1785
1785
  },
1786
1786
  headers,
1787
1787
  {
1788
- forbiddenAsApiError: true,
1789
1788
  timeout: options?.timeout ?? resolveToolExecuteTimeoutMs(toolId, input),
1790
1789
  maxRetries: options?.maxRetries ?? 0,
1791
1790
  exactUrlOnly: true,
@@ -1848,17 +1847,11 @@ export class DeeplineClient {
1848
1847
  async repairIngestionStorage(input?: {
1849
1848
  provider?: string;
1850
1849
  }): Promise<IngestionStorageRepairResult> {
1851
- // forbiddenAsApiError: the repair route returns a meaningful 403 for
1852
- // non-org-admin callers. Without it, http.ts collapses 403 into a generic
1853
- // AuthError (statusCode 401) and the CLI's "requires org admin" branch
1854
- // could never fire.
1855
1850
  return this.http.post<IngestionStorageRepairResult>(
1856
1851
  '/api/v2/ingestion/repair',
1857
1852
  {
1858
1853
  ...(input?.provider ? { provider: input.provider } : {}),
1859
1854
  },
1860
- undefined,
1861
- { forbiddenAsApiError: true },
1862
1855
  );
1863
1856
  }
1864
1857
 
@@ -2080,7 +2073,7 @@ export class DeeplineClient {
2080
2073
  compilerManifest,
2081
2074
  },
2082
2075
  undefined,
2083
- { forbiddenAsApiError: true, retryApiErrors: true },
2076
+ { retryApiErrors: true },
2084
2077
  );
2085
2078
  }
2086
2079
 
@@ -2124,7 +2117,7 @@ export class DeeplineClient {
2124
2117
  '/api/v2/plays/artifacts',
2125
2118
  { artifacts },
2126
2119
  undefined,
2127
- { forbiddenAsApiError: true, retryApiErrors: true },
2120
+ { retryApiErrors: true },
2128
2121
  );
2129
2122
  }
2130
2123
  const compiledArtifacts = await mapWithConcurrency(
@@ -2166,7 +2159,7 @@ export class DeeplineClient {
2166
2159
  artifacts: chunk,
2167
2160
  },
2168
2161
  undefined,
2169
- { forbiddenAsApiError: true, retryApiErrors: true },
2162
+ { retryApiErrors: true },
2170
2163
  ),
2171
2164
  );
2172
2165
  }
@@ -3612,8 +3605,6 @@ export class DeeplineClient {
3612
3605
  return this.http.post<PublishPlayVersionResult>(
3613
3606
  `/api/v2/plays/${encodedName}/live`,
3614
3607
  request,
3615
- undefined,
3616
- { forbiddenAsApiError: true },
3617
3608
  );
3618
3609
  }
3619
3610
 
@@ -3935,8 +3926,7 @@ export class DeeplineClient {
3935
3926
  * `client.monitors.status()`.
3936
3927
  */
3937
3928
  async getMonitorsAccess(): Promise<MonitorsAccessStatus> {
3938
- // No forbiddenAsApiError: the endpoint answers 200 in both the granted and
3939
- // denied cases, so a 403 would be a real auth failure, not a denial body.
3929
+ // The endpoint answers 200 in both the granted and denied cases.
3940
3930
  const payload = await this.http.request<MonitorsAccessStatus>(
3941
3931
  '/api/v2/monitors/access',
3942
3932
  { method: 'GET' },
@@ -3989,7 +3979,7 @@ export class DeeplineClient {
3989
3979
  const suffix = query ? `?${query}` : '';
3990
3980
  return this.http.request<MonitorsAvailableResult>(
3991
3981
  `/api/v2/monitors/tools${suffix}`,
3992
- { method: 'GET', forbiddenAsApiError: true },
3982
+ { method: 'GET' },
3993
3983
  );
3994
3984
  }
3995
3985
 
@@ -4000,7 +3990,6 @@ export class DeeplineClient {
4000
3990
  return this.http.request<MonitorCheckResult>('/api/v2/monitors/check', {
4001
3991
  method: 'POST',
4002
3992
  body: definition,
4003
- forbiddenAsApiError: true,
4004
3993
  });
4005
3994
  }
4006
3995
 
@@ -4019,13 +4008,11 @@ export class DeeplineClient {
4019
4008
  return this.http.request<MonitorDeployResult>('/api/v2/monitors/check', {
4020
4009
  method: 'POST',
4021
4010
  body: definition,
4022
- forbiddenAsApiError: true,
4023
4011
  });
4024
4012
  }
4025
4013
  return this.http.request<MonitorDeployResult>('/api/v2/monitors/deploy', {
4026
4014
  method: 'POST',
4027
4015
  body: definition,
4028
- forbiddenAsApiError: true,
4029
4016
  });
4030
4017
  }
4031
4018
 
@@ -4045,7 +4032,7 @@ export class DeeplineClient {
4045
4032
  const suffix = query ? `?${query}` : '';
4046
4033
  return this.http.request<MonitorsListResult>(
4047
4034
  `/api/v2/monitors/deployed${suffix}`,
4048
- { method: 'GET', forbiddenAsApiError: true },
4035
+ { method: 'GET' },
4049
4036
  );
4050
4037
  }
4051
4038
 
@@ -4053,7 +4040,7 @@ export class DeeplineClient {
4053
4040
  async getMonitor(key: string): Promise<MonitorDetail> {
4054
4041
  return this.http.request<MonitorDetail>(
4055
4042
  `/api/v2/monitors/deployed/${encodeURIComponent(key)}`,
4056
- { method: 'GET', forbiddenAsApiError: true },
4043
+ { method: 'GET' },
4057
4044
  );
4058
4045
  }
4059
4046
 
@@ -4061,7 +4048,7 @@ export class DeeplineClient {
4061
4048
  async getMonitorDependents(key: string): Promise<MonitorDependents> {
4062
4049
  return this.http.request<MonitorDependents>(
4063
4050
  `/api/v2/monitors/deployed/${encodeURIComponent(key)}/dependents`,
4064
- { method: 'GET', forbiddenAsApiError: true },
4051
+ { method: 'GET' },
4065
4052
  );
4066
4053
  }
4067
4054
 
@@ -4072,7 +4059,7 @@ export class DeeplineClient {
4072
4059
  ): Promise<MonitorUpdateResult> {
4073
4060
  return this.http.request<MonitorUpdateResult>(
4074
4061
  `/api/v2/monitors/deployed/${encodeURIComponent(key)}`,
4075
- { method: 'PATCH', body: patch, forbiddenAsApiError: true },
4062
+ { method: 'PATCH', body: patch },
4076
4063
  );
4077
4064
  }
4078
4065
 
@@ -4091,7 +4078,7 @@ export class DeeplineClient {
4091
4078
  const query = params.toString();
4092
4079
  return this.http.request<MonitorDeleteResult>(
4093
4080
  `/api/v2/monitors/deployed/${encodeURIComponent(key)}${query ? `?${query}` : ''}`,
4094
- { method: 'DELETE', forbiddenAsApiError: true },
4081
+ { method: 'DELETE' },
4095
4082
  );
4096
4083
  }
4097
4084
 
@@ -4106,7 +4093,7 @@ export class DeeplineClient {
4106
4093
  const query = options?.dryRun ? '?dry_run=true' : '';
4107
4094
  return this.http.request<MonitorReactivateResult>(
4108
4095
  `/api/v2/monitors/deployed/${encodeURIComponent(key)}/reactivate${query}`,
4109
- { method: 'POST', body: {}, forbiddenAsApiError: true },
4096
+ { method: 'POST', body: {} },
4110
4097
  );
4111
4098
  }
4112
4099
 
@@ -30,7 +30,9 @@ export {
30
30
  /**
31
31
  * Thrown when the API rejects the request due to an invalid or missing API key.
32
32
  *
33
- * This maps to HTTP 401/403 responses. The SDK never retries auth errors
33
+ * This maps to HTTP 401 responses. HTTP 403 means the caller was authenticated
34
+ * but lacks permission, so the SDK preserves the server's API error instead.
35
+ * The SDK never retries auth errors —
34
36
  * they fail immediately.
35
37
  *
36
38
  * Fix: run `deepline auth register` to obtain a valid key, or pass one via
@@ -9,7 +9,7 @@
9
9
  * - **Max attempts**: `1 + maxRetries` (default: 4 total attempts)
10
10
  * - **Backoff**: exponential — 1s, 2s, 4s, 8s, ... capped at 30s
11
11
  * - **Retryable**: network errors, timeouts, HTTP 429 (rate limit)
12
- * - **Not retryable**: HTTP 401/403 (auth errors), other 4xx API errors
12
+ * - **Not retryable**: HTTP 401 (auth error), HTTP 403 and other 4xx API errors
13
13
  *
14
14
  * ## Localhost failover
15
15
  *
@@ -61,14 +61,6 @@ interface RequestOptions {
61
61
  headers?: Record<string, string>;
62
62
  /** Per-request timeout override in milliseconds. */
63
63
  timeout?: number;
64
- /**
65
- * Treat HTTP 403 as a regular API error instead of a generic {@link AuthError}.
66
- * Use for endpoints that return 403 with a meaningful server error message
67
- * (e.g. feature-flagged-off billing subscription checkout) so the server
68
- * message is preserved and surfaced loudly. A provider-originated HTTP 401
69
- * remains a regular API error when its response envelope says so.
70
- */
71
- forbiddenAsApiError?: boolean;
72
64
  /**
73
65
  * Retry HTTP 408/425/5xx JSON API responses. Use only for idempotent requests;
74
66
  * most mutating API calls must fail loudly after the first server response.
@@ -437,11 +429,10 @@ export class HttpClient {
437
429
  }
438
430
 
439
431
  if (
440
- (response.status === 401 &&
441
- !isProviderOriginatedHttpError(parsed)) ||
442
- (response.status === 403 && !options?.forbiddenAsApiError)
432
+ response.status === 401 &&
433
+ !isProviderOriginatedHttpError(parsed)
443
434
  ) {
444
- throw new AuthError();
435
+ throw new AuthError(apiErrorMessage(parsed, response.status));
445
436
  }
446
437
  if (!response.ok) {
447
438
  const retryableApiError =
@@ -576,10 +567,17 @@ export class HttpClient {
576
567
  * @typeParam T - Expected response body type
577
568
  * @param path - API path (e.g. `"/api/v2/tools"`)
578
569
  */
579
- async get<T = unknown>(path: string): Promise<T> {
570
+ async get<T = unknown>(
571
+ path: string,
572
+ options?: Pick<
573
+ RequestOptions,
574
+ 'retryApiErrors' | 'timeout' | 'maxRetries' | 'exactUrlOnly' | 'toolId'
575
+ >,
576
+ ): Promise<T> {
580
577
  return this.request<T>(path, {
581
578
  method: 'GET',
582
579
  retryApiErrors: true,
580
+ ...options,
583
581
  });
584
582
  }
585
583
 
@@ -614,11 +612,10 @@ export class HttpClient {
614
612
  const body = await response.text();
615
613
  const parsed = parseResponseBody(body);
616
614
  if (
617
- (response.status === 401 &&
618
- !isProviderOriginatedHttpError(parsed)) ||
619
- response.status === 403
615
+ response.status === 401 &&
616
+ !isProviderOriginatedHttpError(parsed)
620
617
  ) {
621
- throw new AuthError();
618
+ throw new AuthError(apiErrorMessage(parsed, response.status));
622
619
  }
623
620
  const htmlError = detectHtmlErrorBody(
624
621
  body,
@@ -681,12 +678,7 @@ export class HttpClient {
681
678
  headers?: Record<string, string>,
682
679
  options?: Pick<
683
680
  RequestOptions,
684
- | 'forbiddenAsApiError'
685
- | 'retryApiErrors'
686
- | 'timeout'
687
- | 'maxRetries'
688
- | 'exactUrlOnly'
689
- | 'toolId'
681
+ 'retryApiErrors' | 'timeout' | 'maxRetries' | 'exactUrlOnly' | 'toolId'
690
682
  >,
691
683
  ): Promise<T> {
692
684
  return this.request<T>(path, {
@@ -198,6 +198,17 @@ export type PlayBindings = {
198
198
  /** Stop the run before a billed action would push total run credits above this cap. */
199
199
  maxCreditsPerRun?: number;
200
200
  };
201
+ /** Requested sandbox envelope; the server enforces the final allowed limits. */
202
+ runtime?: {
203
+ /** Duration such as `"90m"` or `"2h"`. */
204
+ timeout?: string;
205
+ /** Memory such as `"4GiB"`. */
206
+ memory?: string;
207
+ /** Whole vCPU count. */
208
+ cpu?: number;
209
+ /** Disk such as `"10GiB"`. */
210
+ disk?: string;
211
+ };
201
212
  /** Webhook trigger with optional HMAC signature verification. */
202
213
  webhook?: {
203
214
  hmac?: {
@@ -1193,6 +1204,8 @@ export type DefinePlayConfig<TInput, TOutput extends PlayReturnObject> = {
1193
1204
  bindings?: PlayBindings;
1194
1205
  /** Billing options. */
1195
1206
  billing?: PlayBindings['billing'];
1207
+ /** Requested sandbox envelope; the server enforces the final allowed limits. */
1208
+ runtime?: PlayBindings['runtime'];
1196
1209
  /** Runtime compatibility override. Omit for the current typed contract. */
1197
1210
  compatibility?: PlayBindings['compatibility'];
1198
1211
  };
@@ -1354,6 +1367,8 @@ export type DefinedPlay<TInput, TOutput extends PlayReturnObject> = ((
1354
1367
  DeeplineNamedPlay<TInput, TOutput> & {
1355
1368
  /** Optional trigger bindings (cron, webhook). */
1356
1369
  readonly bindings?: PlayBindings;
1370
+ /** Requested sandbox envelope. */
1371
+ readonly runtime?: PlayBindings['runtime'];
1357
1372
  /** Runtime compatibility explicitly selected by the author. */
1358
1373
  readonly compatibility?: PlayBindings['compatibility'];
1359
1374
  /** The play's name (same as `.name`). */
@@ -1366,6 +1381,7 @@ type PlayMetadata = {
1366
1381
  bindings?: PlayBindings;
1367
1382
  inputSchema?: Record<string, unknown>;
1368
1383
  billing?: PlayBindings['billing'];
1384
+ runtime?: PlayBindings['runtime'];
1369
1385
  compatibility?: PlayBindings['compatibility'];
1370
1386
  };
1371
1387
 
@@ -2124,6 +2140,7 @@ export function definePlay<TInput, TOutput extends PlayReturnObject>(
2124
2140
  description: maybeBindings?.description,
2125
2141
  inputSchema: undefined,
2126
2142
  billing: maybeBindings?.billing,
2143
+ runtime: maybeBindings?.runtime,
2127
2144
  compatibility: maybeBindings?.compatibility,
2128
2145
  }
2129
2146
  : {
@@ -2133,6 +2150,7 @@ export function definePlay<TInput, TOutput extends PlayReturnObject>(
2133
2150
  description: nameOrConfig.description,
2134
2151
  inputSchema: nameOrConfig.input.schema,
2135
2152
  billing: nameOrConfig.billing,
2153
+ runtime: nameOrConfig.runtime ?? nameOrConfig.bindings?.runtime,
2136
2154
  compatibility:
2137
2155
  nameOrConfig.compatibility ?? nameOrConfig.bindings?.compatibility,
2138
2156
  };
@@ -2142,6 +2160,7 @@ export function definePlay<TInput, TOutput extends PlayReturnObject>(
2142
2160
  const description = config.description?.trim();
2143
2161
  const billing = config.billing;
2144
2162
  const inputSchema = config.inputSchema;
2163
+ const runtime = config.runtime;
2145
2164
  const compatibility = config.compatibility;
2146
2165
  if (typeof fn !== 'function') {
2147
2166
  throw new Error('definePlay(...) requires an async run function.');
@@ -2176,6 +2195,7 @@ export function definePlay<TInput, TOutput extends PlayReturnObject>(
2176
2195
  ...(bindings ? { bindings } : {}),
2177
2196
  ...(inputSchema ? { inputSchema } : {}),
2178
2197
  ...(billing ? { billing } : {}),
2198
+ ...(runtime ? { runtime } : {}),
2179
2199
  ...(compatibility ? { compatibility } : {}),
2180
2200
  };
2181
2201
  const play = fn as DefinedPlay<TInput, TOutput>;
@@ -2200,6 +2220,12 @@ export function definePlay<TInput, TOutput extends PlayReturnObject>(
2200
2220
  configurable: false,
2201
2221
  writable: false,
2202
2222
  });
2223
+ Object.defineProperty(play, 'runtime', {
2224
+ value: runtime,
2225
+ enumerable: true,
2226
+ configurable: false,
2227
+ writable: false,
2228
+ });
2203
2229
 
2204
2230
  Object.defineProperty(play, 'compatibility', {
2205
2231
  value: compatibility,
@@ -157,7 +157,7 @@ export const SDK_RELEASE = {
157
157
  // 0.1.253 makes play-page browser opening opt-in and retires --no-open.
158
158
  // 0.1.254 removes the internal operations tree from the published SDK CLI.
159
159
  // Operators use the checkout-local deepline-admin binary instead.
160
- version: '0.1.306',
160
+ version: '0.1.308',
161
161
  contracts: {
162
162
  api: {
163
163
  name: 'sdk-http-api',
@@ -11,6 +11,7 @@ import type { PlayLiveEventSource } from './live-events';
11
11
  import type { PreloadedRuntimeDbSession } from './db-session';
12
12
  import type { PacingRule } from './governor/rate-state-backend';
13
13
  import type { GovernanceSnapshot } from './governor/governor';
14
+ import type { PlaySandboxRuntimeLimits } from './sandbox-runtime-limits';
14
15
  import type { PlayRunInputPayload } from './play-input';
15
16
  import type { PlayRunFailureDetails } from './run-failure';
16
17
  import type { ToolExecutionErrorSchemaVersion } from '../tool-execution-error';
@@ -169,6 +170,8 @@ export interface PlayRunnerContextConfig {
169
170
  */
170
171
  runnerPushExecution?: RunnerPushExecutionConfig | null;
171
172
  governance?: GovernanceSnapshot | null;
173
+ /** Immutable resource envelope resolved at launch. */
174
+ sandboxRuntimeLimits?: PlaySandboxRuntimeLimits | null;
172
175
  }
173
176
 
174
177
  /**
@@ -5,20 +5,17 @@ import type {
5
5
  } from '@shared_libs/play-runtime/protocol';
6
6
  import { isIsolatedRuntimeSchedulerSchema } from '@shared_libs/play-runtime/runtime-scheduler-topology';
7
7
  import { PLAY_RUNNER_TIMEOUT_SECONDS } from '@shared_libs/play-runtime/runtime-constants';
8
+ import { resolvePlaySandboxRuntimeLimits } from '@shared_libs/play-runtime/sandbox-runtime-limits';
8
9
 
9
10
  const DAYTONA_CREATE_TIMEOUT_SECONDS = 10;
10
11
  const DAYTONA_CREATE_RETRY_DELAYS_MS = [0, 500, 1_500] as const;
11
12
  // Explicit runner deadline + scheduler GC own the normal lifecycle. Daytona's
12
13
  // inactivity stop is a wider crash backstop measured from sandbox creation, so
13
14
  // setup time cannot consume the terminal-flush grace.
14
- const DAYTONA_AUTO_STOP_INTERVAL_MINUTES = Math.ceil(
15
- PLAY_RUNNER_TIMEOUT_SECONDS / 60,
16
- );
17
15
  const DAYTONA_AUTO_ARCHIVE_INTERVAL_MINUTES = 15;
18
16
  const DAYTONA_SANDBOX_LABEL_SOURCE = 'deepline-play-runner';
19
- // The sandbox must be created at its final resource boundary. Do not add a
20
- // resize call here: sandbox acquisition is launch-critical, and every extra
21
- // control-plane round trip raises cold-start latency.
17
+ // Daytona's default image is the fast path. Non-standard resources are applied
18
+ // during acquisition, before customer code and before the billing clock starts.
22
19
  export const DAYTONA_SANDBOX_CPU = 1;
23
20
  export const DAYTONA_SANDBOX_MEMORY_GIB = 1;
24
21
  export const DAYTONA_SANDBOX_DISK_GIB = 3;
@@ -176,6 +173,16 @@ async function createOneShotDaytonaSandbox(input: {
176
173
  orgId: string;
177
174
  context: DaytonaExecutionContext;
178
175
  }): Promise<DaytonaSandbox> {
176
+ const limits = resolvePlaySandboxRuntimeLimits(
177
+ input.context.sandboxRuntimeLimits
178
+ ? {
179
+ timeout: `${input.context.sandboxRuntimeLimits.timeoutSeconds / 60}m`,
180
+ memory: `${input.context.sandboxRuntimeLimits.memoryGiB}GiB`,
181
+ cpu: input.context.sandboxRuntimeLimits.cpu,
182
+ disk: `${input.context.sandboxRuntimeLimits.diskGiB}GiB`,
183
+ }
184
+ : null,
185
+ );
179
186
  const orgId = normalizeLabelValue(input.orgId);
180
187
  const workflowId = normalizeLabelValue(input.context.workflowId);
181
188
  const playId = normalizeLabelValue(input.context.playId);
@@ -203,7 +210,9 @@ async function createOneShotDaytonaSandbox(input: {
203
210
  {
204
211
  labels,
205
212
  ephemeral: true,
206
- autoStopInterval: DAYTONA_AUTO_STOP_INTERVAL_MINUTES,
213
+ autoStopInterval: Math.ceil(
214
+ (limits.timeoutSeconds + PLAY_RUNNER_TIMEOUT_SECONDS - 30 * 60) / 60,
215
+ ),
207
216
  autoArchiveInterval: DAYTONA_AUTO_ARCHIVE_INTERVAL_MINUTES,
208
217
  // A non-empty `networkAllowList` IS the "block all egress except these
209
218
  // CIDRs" control; Daytona rejects create when `networkBlockAll: true` is
@@ -277,6 +286,16 @@ async function acquireOneShotDaytonaSandbox(input: {
277
286
  emitStage: DaytonaStageEmitter;
278
287
  startedAt: number;
279
288
  }): Promise<AcquiredDaytonaSandbox> {
289
+ const limits = resolvePlaySandboxRuntimeLimits(
290
+ input.context.sandboxRuntimeLimits
291
+ ? {
292
+ timeout: `${input.context.sandboxRuntimeLimits.timeoutSeconds / 60}m`,
293
+ memory: `${input.context.sandboxRuntimeLimits.memoryGiB}GiB`,
294
+ cpu: input.context.sandboxRuntimeLimits.cpu,
295
+ disk: `${input.context.sandboxRuntimeLimits.diskGiB}GiB`,
296
+ }
297
+ : null,
298
+ );
280
299
  input.emitStage('create:start');
281
300
  const result = await createRetriedOneShotDaytonaSandbox(input);
282
301
  const granted = {
@@ -285,15 +304,51 @@ async function acquireOneShotDaytonaSandbox(input: {
285
304
  diskGiB: result.sandbox.disk,
286
305
  gpu: result.sandbox.gpu ?? 0,
287
306
  };
307
+ // Daytona's SDK only accepts resources when a custom image is supplied.
308
+ // We deliberately retain the provider's fast default image, then resize
309
+ // before any customer code or billing window begins.
310
+ if (
311
+ typeof result.sandbox.resize === 'function' &&
312
+ (granted.cpu !== limits.cpu ||
313
+ granted.memoryGiB !== limits.memoryGiB ||
314
+ granted.diskGiB !== limits.diskGiB)
315
+ ) {
316
+ try {
317
+ if (granted.diskGiB !== limits.diskGiB) {
318
+ await result.sandbox.stop(60);
319
+ await result.sandbox.resize({
320
+ cpu: limits.cpu,
321
+ memory: limits.memoryGiB,
322
+ disk: limits.diskGiB,
323
+ });
324
+ await result.sandbox.start(60);
325
+ } else {
326
+ await result.sandbox.resize({
327
+ cpu: limits.cpu,
328
+ memory: limits.memoryGiB,
329
+ disk: limits.diskGiB,
330
+ });
331
+ }
332
+ } catch (error) {
333
+ const message = error instanceof Error ? error.message : String(error);
334
+ return await rejectAcquiredSandbox(
335
+ result.sandbox,
336
+ `Daytona sandbox resize failed before execution: ${message}.`,
337
+ );
338
+ }
339
+ granted.cpu = result.sandbox.cpu;
340
+ granted.memoryGiB = result.sandbox.memory;
341
+ granted.diskGiB = result.sandbox.disk;
342
+ }
288
343
  if (
289
- granted.cpu !== DAYTONA_SANDBOX_CPU ||
290
- granted.memoryGiB !== DAYTONA_SANDBOX_MEMORY_GIB ||
291
- granted.diskGiB !== DAYTONA_SANDBOX_DISK_GIB ||
344
+ granted.cpu !== limits.cpu ||
345
+ granted.memoryGiB !== limits.memoryGiB ||
346
+ granted.diskGiB !== limits.diskGiB ||
292
347
  granted.gpu !== DAYTONA_SANDBOX_GPU
293
348
  ) {
294
349
  await rejectAcquiredSandbox(
295
350
  result.sandbox,
296
- `Daytona sandbox resource boundary mismatch: expected cpu=${DAYTONA_SANDBOX_CPU} memoryGiB=${DAYTONA_SANDBOX_MEMORY_GIB} diskGiB=${DAYTONA_SANDBOX_DISK_GIB} gpu=${DAYTONA_SANDBOX_GPU}, granted cpu=${granted.cpu} memoryGiB=${granted.memoryGiB} diskGiB=${granted.diskGiB} gpu=${granted.gpu}`,
351
+ `Daytona sandbox resource boundary mismatch: expected cpu=${limits.cpu} memoryGiB=${limits.memoryGiB} diskGiB=${limits.diskGiB} gpu=${DAYTONA_SANDBOX_GPU}, granted cpu=${granted.cpu} memoryGiB=${granted.memoryGiB} diskGiB=${granted.diskGiB} gpu=${granted.gpu}`,
297
352
  );
298
353
  }
299
354
  const configuredOrganizationId =
@@ -120,7 +120,11 @@ function nodeMaterializePayloadCommand(input: {
120
120
  * Exported (with the builder) for the unit test that executes the script
121
121
  * against a real local HTTP server.
122
122
  */
123
- export function buildDaytonaCrashTerminalPusherSource(): string {
123
+ export function buildDaytonaCrashTerminalPusherSource(
124
+ runtimeLimitSeconds = STANDARD_PLAY_RUNTIME_LIMIT_SECONDS,
125
+ ): string {
126
+ const runtimeLimitMessage = `The play reached its configured ${runtimeLimitSeconds} second runtime limit and was stopped.`;
127
+ const runtimeLimitDetail = `${runtimeLimitMessage} Completed row state was preserved; run a smaller batch or continue from the persisted rows.`;
124
128
  return `
125
129
  const fs = require('node:fs');
126
130
  const configPath = process.argv[2];
@@ -264,7 +268,7 @@ async function main() {
264
268
  const sandboxOom =
265
269
  sandboxKilled && (outputHasExplicitOomSignature() || cgroupOomKillObserved());
266
270
  const synthesizedError = runtimeLimitExceeded
267
- ? 'RUNTIME_LIMIT_EXCEEDED: The play reached its 30 minute runtime limit and was stopped.'
271
+ ? 'RUNTIME_LIMIT_EXCEEDED: ' + ${JSON.stringify(runtimeLimitMessage)}
268
272
  : sandboxOom
269
273
  ? 'RUNTIME_SANDBOX_OOM: ' + ${JSON.stringify(RUNTIME_SANDBOX_OOM_MESSAGE)}
270
274
  : sandboxKilled
@@ -280,7 +284,7 @@ async function main() {
280
284
  ? [{
281
285
  code: 'RUNTIME_LIMIT_EXCEEDED',
282
286
  phase: 'runtime',
283
- message: 'The play reached its 30 minute runtime limit and was stopped. Completed row state was preserved; run a smaller batch or continue from the persisted rows.',
287
+ message: ${JSON.stringify(runtimeLimitDetail)},
284
288
  retryable: false,
285
289
  cause: synthesizedError,
286
290
  }]
@@ -498,6 +502,9 @@ export async function stageDaytonaRunnerPayload(input: {
498
502
  materializedFiles: remoteMaterializedFiles,
499
503
  context: remoteRuntimeContextForDaytona(input.config.context),
500
504
  };
505
+ const runtimeLimitSeconds =
506
+ input.config.context.sandboxRuntimeLimits?.timeoutSeconds ??
507
+ STANDARD_PLAY_RUNTIME_LIMIT_SECONDS;
501
508
 
502
509
  const envelopeUpload = input.bundlePromise.then((bundle) =>
503
510
  timedDaytonaUpload({
@@ -511,7 +518,8 @@ export async function stageDaytonaRunnerPayload(input: {
511
518
  artifactBundledCode: compactedArtifact.bundledCode,
512
519
  artifactSourceMap: input.config.artifact.sourceMap,
513
520
  config: remoteConfig,
514
- crashPusherCode: buildDaytonaCrashTerminalPusherSource(),
521
+ crashPusherCode:
522
+ buildDaytonaCrashTerminalPusherSource(runtimeLimitSeconds),
515
523
  }),
516
524
  }),
517
525
  );
@@ -542,7 +550,7 @@ export async function stageDaytonaRunnerPayload(input: {
542
550
  shellQuote(configPath),
543
551
  shellQuote(runtimeStartedPath),
544
552
  shellQuote(runtimeCompletedPath),
545
- String(STANDARD_PLAY_RUNTIME_LIMIT_SECONDS * 1_000),
553
+ String(runtimeLimitSeconds * 1_000),
546
554
  String(PLAY_RUNNER_STARTUP_GRACE_SECONDS * 1_000),
547
555
  String(PLAY_RUNNER_TERMINAL_GRACE_SECONDS * 1_000),
548
556
  shellQuote(runtimeLimitMarkerPath),
@@ -744,6 +744,9 @@ export const daytonaPlayRunnerBackend: PlayRunnerBackend = {
744
744
  const bundlePromise = preparedExecution.bundlePromise;
745
745
  const sandboxLifecycle = preparedExecution.sandboxLifecycle;
746
746
  const startedAt = sandboxLifecycle.startedAt;
747
+ const executionTimeoutSeconds =
748
+ config.context.sandboxRuntimeLimits?.timeoutSeconds ??
749
+ STANDARD_PLAY_RUNTIME_LIMIT_SECONDS;
747
750
  const runtimeTiming: PlayRunnerRuntimeTiming = { backend: 'daytona' };
748
751
  const sandboxCleanup = createDaytonaSandboxCleanupManager();
749
752
  const withCleanup = (result: PlayRunnerResult): PlayRunnerResult => {
@@ -794,6 +797,10 @@ export const daytonaPlayRunnerBackend: PlayRunnerBackend = {
794
797
  daytonaOrganizationId: acquired.daytonaOrganizationId,
795
798
  billingStartedAt: acquired.billingStartedAt,
796
799
  billingEndedAt,
800
+ maxBillingDurationSeconds:
801
+ (config.context.sandboxRuntimeLimits?.timeoutSeconds ??
802
+ STANDARD_PLAY_RUNTIME_LIMIT_SECONDS) +
803
+ PLAY_RUNNER_TERMINAL_GRACE_SECONDS,
797
804
  cpu: typeof sandbox.cpu === 'number' ? sandbox.cpu : null,
798
805
  memoryGiB: typeof sandbox.memory === 'number' ? sandbox.memory : null,
799
806
  diskGiB: typeof sandbox.disk === 'number' ? sandbox.disk : null,
@@ -976,7 +983,7 @@ export const daytonaPlayRunnerBackend: PlayRunnerBackend = {
976
983
 
977
984
  emitDaytonaStage(callbacks, config.context, 'execute:start', {
978
985
  sandboxId: sandbox.id,
979
- timeoutSeconds: DAYTONA_EXECUTE_TIMEOUT_SECONDS,
986
+ timeoutSeconds: executionTimeoutSeconds,
980
987
  mode: 'session_detached',
981
988
  });
982
989
  // Push execution (B2-final, park-and-wake): start the runner command
@@ -1012,7 +1019,7 @@ export const daytonaPlayRunnerBackend: PlayRunnerBackend = {
1012
1019
  sessionId: start.sessionId,
1013
1020
  cmdId: start.cmdId,
1014
1021
  runnerAttempt,
1015
- ceilingSeconds: DAYTONA_EXECUTE_TIMEOUT_SECONDS,
1022
+ ceilingSeconds: executionTimeoutSeconds,
1016
1023
  terminalGraceSeconds: PLAY_RUNNER_TERMINAL_GRACE_SECONDS,
1017
1024
  outputPath: stagedPayload.outputPath,
1018
1025
  exitCodePath: stagedPayload.exitCodePath,
@@ -1031,7 +1038,9 @@ export const daytonaPlayRunnerBackend: PlayRunnerBackend = {
1031
1038
  exitCodePath: stagedPayload.exitCodePath,
1032
1039
  runtimeCompletedPath: stagedPayload.runtimeCompletedPath,
1033
1040
  startedAtMs: Date.now(),
1034
- ceilingMs: DAYTONA_DETACHED_CEILING_SECONDS * 1_000,
1041
+ ceilingMs:
1042
+ (executionTimeoutSeconds + PLAY_RUNNER_TERMINAL_GRACE_SECONDS) *
1043
+ 1_000,
1035
1044
  },
1036
1045
  logs: [],
1037
1046
  stats: {},
@@ -20,10 +20,7 @@ import {
20
20
  recordComputeBillingItemViaAppRuntime,
21
21
  type WorkerRuntimeApiContext,
22
22
  } from '@shared_libs/play-runtime/app-runtime-api';
23
- import {
24
- STANDARD_PLAY_RUNTIME_LIMIT_LABEL,
25
- STANDARD_PLAY_RUNTIME_LIMIT_SECONDS,
26
- } from '@shared_libs/play-runtime/runtime-constants';
23
+ import { STANDARD_PLAY_RUNTIME_LIMIT_SECONDS } from '@shared_libs/play-runtime/runtime-constants';
27
24
 
28
25
  interface CommandExecutionResult {
29
26
  exitCode: number;
@@ -44,7 +41,9 @@ const LOCAL_PROCESS_COMPUTE_MEMORY_GIB = 1;
44
41
  const LOCAL_PROCESS_COMPUTE_DISK_GIB = 3;
45
42
  export const LOCAL_PROCESS_CANCEL_GRACE_MS = 2_000;
46
43
  const LOCAL_PROCESS_CANCELLED_ERROR = 'Local play runner cancelled';
47
- const STANDARD_PLAY_RUNTIME_LIMIT_ERROR = `Based on this plan, max runtime is ${STANDARD_PLAY_RUNTIME_LIMIT_LABEL}. Use smaller batches; ask for runtime.`;
44
+ function localProcessRuntimeLimitError(runtimeLimitSeconds: number): string {
45
+ return `Configured max runtime is ${runtimeLimitSeconds} seconds. Use smaller batches or increase runtime.timeout.`;
46
+ }
48
47
 
49
48
  export const LOCAL_PROCESS_COMPUTE_PROFILE = {
50
49
  source: LOCAL_PROCESS_COMPUTE_SOURCE,
@@ -192,16 +191,28 @@ async function runCommand(
192
191
  });
193
192
  }
194
193
 
195
- async function runCommandWithStandardRuntimeLimit(
194
+ export function resolveLocalProcessRuntimeLimitSeconds(
195
+ context: PlayRunnerExecutionConfig['context'],
196
+ ): number {
197
+ return (
198
+ context.sandboxRuntimeLimits?.timeoutSeconds ??
199
+ STANDARD_PLAY_RUNTIME_LIMIT_SECONDS
200
+ );
201
+ }
202
+
203
+ async function runCommandWithRuntimeLimit(
196
204
  command: string,
197
205
  args: string[],
206
+ runtimeLimitSeconds: number,
198
207
  options?: SpawnOptionsWithoutStdio,
199
208
  callbacks?: StreamedCommandCallbacks,
200
209
  ): Promise<CommandExecutionResult> {
201
210
  const runtimeLimitController = new AbortController();
202
211
  const runtimeDeadlineTimer = setTimeout(() => {
203
- runtimeLimitController.abort(STANDARD_PLAY_RUNTIME_LIMIT_ERROR);
204
- }, STANDARD_PLAY_RUNTIME_LIMIT_SECONDS * 1000);
212
+ runtimeLimitController.abort(
213
+ localProcessRuntimeLimitError(runtimeLimitSeconds),
214
+ );
215
+ }, runtimeLimitSeconds * 1000);
205
216
  runtimeDeadlineTimer.unref?.();
206
217
 
207
218
  const onExternalCancel = () => {
@@ -413,9 +424,10 @@ export const localProcessPlayRunnerBackend: PlayRunnerBackend = {
413
424
  },
414
425
  },
415
426
  async (span) => {
416
- const result = await runCommandWithStandardRuntimeLimit(
427
+ const result = await runCommandWithRuntimeLimit(
417
428
  'node',
418
429
  [runnerPath, configPath],
430
+ resolveLocalProcessRuntimeLimitSeconds(config.context),
419
431
  {
420
432
  cwd: workspaceRoot,
421
433
  env: {
@@ -17,6 +17,8 @@ export type PlayRunnerRuntimeResource = {
17
17
  daytonaOrganizationId?: string;
18
18
  billingStartedAt: number;
19
19
  billingEndedAt?: number | null;
20
+ /** Customer liability ceiling for this physical sandbox. */
21
+ maxBillingDurationSeconds?: number | null;
20
22
  terminalReason?: RuntimeResourceTerminalReason | null;
21
23
  terminalAt?: number | null;
22
24
  cpu?: number | null;