deepline 0.1.307 → 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 (24) hide show
  1. package/dist/bundling-sources/sdk/src/play.ts +26 -0
  2. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  3. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +3 -0
  4. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +66 -11
  5. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +13 -5
  6. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +12 -3
  7. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +21 -9
  8. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +2 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/runtime-constants.ts +5 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-compute-usage.ts +17 -2
  11. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runtime-limits.ts +102 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +56 -9
  13. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +56 -0
  14. package/dist/bundling-sources/shared_libs/plays/contracts.ts +6 -0
  15. package/dist/cli/index.js +174 -11
  16. package/dist/cli/index.mjs +174 -11
  17. package/dist/index.d.mts +16 -0
  18. package/dist/index.d.ts +16 -0
  19. package/dist/index.js +11 -1
  20. package/dist/index.mjs +11 -1
  21. package/dist/plays/bundle-play-file.d.mts +8 -0
  22. package/dist/plays/bundle-play-file.d.ts +8 -0
  23. package/dist/plays/bundle-play-file.mjs +42 -0
  24. package/package.json +1 -1
@@ -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.307',
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;
@@ -1,3 +1,5 @@
1
+ import { MAX_PLAY_SANDBOX_RUNTIME_LIMITS } from './sandbox-runtime-limits';
2
+
1
3
  /** Maximum active user-code runtime for a standard play, in seconds. */
2
4
  export const STANDARD_PLAY_RUNTIME_LIMIT_SECONDS = 30 * 60;
3
5
  export const STANDARD_PLAY_RUNTIME_LIMIT_LABEL = '30 minutes';
@@ -22,7 +24,9 @@ export const PLAY_RUNNER_STARTUP_GRACE_SECONDS = 4 * 60;
22
24
  export const PLAY_RUNNER_TIMEOUT_SECONDS = 40 * 60;
23
25
 
24
26
  /** TTL for workflow executor tokens, in seconds. */
25
- export const WORKFLOW_EXECUTOR_TOKEN_TTL_SECONDS = PLAY_RUNNER_TIMEOUT_SECONDS;
27
+ export const WORKFLOW_EXECUTOR_TOKEN_TTL_SECONDS =
28
+ MAX_PLAY_SANDBOX_RUNTIME_LIMITS.timeoutSeconds +
29
+ (PLAY_RUNNER_TIMEOUT_SECONDS - STANDARD_PLAY_RUNTIME_LIMIT_SECONDS);
26
30
 
27
31
  /**
28
32
  * Absurd run-claim lease window, in seconds. The scheduler expires a claim whose
@@ -22,6 +22,7 @@ export function resolveDaytonaRuntimeComputeItems(input: {
22
22
  cpu: number | null;
23
23
  memoryGiB: number | null;
24
24
  diskGiB: number | null;
25
+ maxBillingDurationSeconds: number | null;
25
26
  }
26
27
  >();
27
28
  const items: ComputeBillingItem[] = [];
@@ -38,6 +39,9 @@ export function resolveDaytonaRuntimeComputeItems(input: {
38
39
  const billingStartedAt = finiteNonNegative(value.billingStartedAt);
39
40
  if (billingStartedAt === null) continue;
40
41
  const billingEndedAt = finiteNonNegative(value.billingEndedAt);
42
+ const maxBillingDurationSeconds = finiteNonNegative(
43
+ value.maxBillingDurationSeconds,
44
+ );
41
45
  const existing = resourcesBySandboxId.get(sandboxId);
42
46
  resourcesBySandboxId.set(sandboxId, {
43
47
  startedAt: Math.min(
@@ -46,16 +50,27 @@ export function resolveDaytonaRuntimeComputeItems(input: {
46
50
  ),
47
51
  endedAt:
48
52
  billingEndedAt === null
49
- ? existing?.endedAt ?? null
53
+ ? (existing?.endedAt ?? null)
50
54
  : Math.max(existing?.endedAt ?? billingEndedAt, billingEndedAt),
51
55
  cpu: finiteNonNegative(value.cpu) ?? existing?.cpu ?? null,
52
56
  memoryGiB:
53
57
  finiteNonNegative(value.memoryGiB) ?? existing?.memoryGiB ?? null,
54
58
  diskGiB: finiteNonNegative(value.diskGiB) ?? existing?.diskGiB ?? null,
59
+ maxBillingDurationSeconds:
60
+ maxBillingDurationSeconds ??
61
+ existing?.maxBillingDurationSeconds ??
62
+ null,
55
63
  });
56
64
  }
57
65
  for (const [sandboxId, resource] of resourcesBySandboxId) {
58
- const endedAt = resource.endedAt ?? input.endedAt;
66
+ const observedEndedAt = resource.endedAt ?? input.endedAt;
67
+ const endedAt =
68
+ resource.maxBillingDurationSeconds === null
69
+ ? observedEndedAt
70
+ : Math.min(
71
+ observedEndedAt,
72
+ resource.startedAt + resource.maxBillingDurationSeconds * 1_000,
73
+ );
59
74
  items.push(
60
75
  resolveDaytonaSandboxComputeItem({
61
76
  itemId: `daytona:${sandboxId}`,
@@ -0,0 +1,102 @@
1
+ export type PlaySandboxRuntimeLimits = {
2
+ timeoutSeconds: number;
3
+ memoryGiB: number;
4
+ cpu: number;
5
+ diskGiB: number;
6
+ };
7
+
8
+ export const STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS: PlaySandboxRuntimeLimits = {
9
+ timeoutSeconds: 30 * 60,
10
+ memoryGiB: 1,
11
+ cpu: 1,
12
+ diskGiB: 3,
13
+ };
14
+
15
+ // Product-wide ceilings. Organisation entitlements may lower these, but a
16
+ // customer-authored Play can never ask Daytona for an unbounded resource.
17
+ export const MAX_PLAY_SANDBOX_RUNTIME_LIMITS: PlaySandboxRuntimeLimits = {
18
+ timeoutSeconds: 4 * 60 * 60,
19
+ memoryGiB: 16,
20
+ cpu: 4,
21
+ diskGiB: 50,
22
+ };
23
+
24
+ export type PlaySandboxRuntimeDeclaration = {
25
+ timeout?: string;
26
+ memory?: string;
27
+ cpu?: number;
28
+ disk?: string;
29
+ };
30
+
31
+ function parsePositiveInteger(value: string, unit: string): number | null {
32
+ const match = new RegExp(`^(\\d+)\\s*${unit}$`, 'i').exec(value.trim());
33
+ if (!match) return null;
34
+ const parsed = Number(match[1]);
35
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
36
+ }
37
+
38
+ function parseTimeout(value: string): number | null {
39
+ const match = /^(\d+)\s*([mh])$/i.exec(value.trim());
40
+ if (!match) return null;
41
+ const amount = Number(match[1]);
42
+ if (!Number.isSafeInteger(amount) || amount <= 0) return null;
43
+ return amount * (match[2].toLowerCase() === 'h' ? 3600 : 60);
44
+ }
45
+
46
+ export function resolvePlaySandboxRuntimeLimits(
47
+ declaration: PlaySandboxRuntimeDeclaration | null | undefined,
48
+ ): PlaySandboxRuntimeLimits {
49
+ const base = STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS;
50
+ if (!declaration) return { ...base };
51
+ const timeoutSeconds = declaration.timeout
52
+ ? parseTimeout(declaration.timeout)
53
+ : base.timeoutSeconds;
54
+ const memoryGiB = declaration.memory
55
+ ? parsePositiveInteger(declaration.memory, 'GiB')
56
+ : base.memoryGiB;
57
+ const diskGiB = declaration.disk
58
+ ? parsePositiveInteger(declaration.disk, 'GiB')
59
+ : base.diskGiB;
60
+ const cpu = declaration.cpu ?? base.cpu;
61
+ if (
62
+ timeoutSeconds === null ||
63
+ memoryGiB === null ||
64
+ diskGiB === null ||
65
+ !Number.isSafeInteger(cpu) ||
66
+ cpu <= 0
67
+ ) {
68
+ throw new Error(
69
+ 'Invalid runtime sandbox declaration. Use timeout like "90m" or "2h", memory/disk like "4GiB", and a positive integer cpu.',
70
+ );
71
+ }
72
+ const resolved = { timeoutSeconds, memoryGiB, cpu, diskGiB };
73
+ for (const key of ['memoryGiB', 'cpu', 'diskGiB'] as const) {
74
+ if (resolved[key] < base[key]) {
75
+ throw new Error(
76
+ `Requested runtime ${key}=${resolved[key]} is below the supported minimum ${base[key]}.`,
77
+ );
78
+ }
79
+ }
80
+ for (const key of Object.keys(
81
+ resolved,
82
+ ) as (keyof PlaySandboxRuntimeLimits)[]) {
83
+ if (resolved[key] > MAX_PLAY_SANDBOX_RUNTIME_LIMITS[key]) {
84
+ throw new Error(
85
+ `Requested runtime ${key}=${resolved[key]} exceeds this organisation's maximum ${MAX_PLAY_SANDBOX_RUNTIME_LIMITS[key]}.`,
86
+ );
87
+ }
88
+ }
89
+ return resolved;
90
+ }
91
+
92
+ export function hasNonStandardPlaySandboxRuntimeLimits(
93
+ value: PlaySandboxRuntimeLimits,
94
+ ): boolean {
95
+ return Object.keys(value).some(
96
+ (key) =>
97
+ value[key as keyof PlaySandboxRuntimeLimits] !==
98
+ STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS[
99
+ key as keyof PlaySandboxRuntimeLimits
100
+ ],
101
+ );
102
+ }