diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index c78ef2c00f..d1ebbc7096 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -84,16 +84,21 @@ const manifest: PaperclipPluginManifestV1 = { }, autoStopInterval: { type: "number", - description: "Optional Daytona auto-stop interval in minutes. `0` disables auto-stop.", + description: + "Daytona auto-stop interval in minutes. `0` disables auto-stop. Defaults to 15 when unset.", + default: 15, }, autoArchiveInterval: { type: "number", - description: "Optional Daytona auto-archive interval in minutes. `0` uses Daytona's max interval.", + description: + "Daytona auto-archive interval in minutes. Stopped sandboxes still count against the storage quota until archived, so this defaults to 60 when unset. `0` uses Daytona's max interval.", + default: 60, }, autoDeleteInterval: { type: "number", description: - "Optional Daytona auto-delete interval in minutes. `-1` disables auto-delete and `0` deletes immediately after stop.", + "Daytona auto-delete interval in minutes. Backstop reaper for sandboxes nobody resumes; defaults to 10080 (7 days) when unset. `-1` disables auto-delete and `0` deletes immediately after stop.", + default: 10080, }, reuseLease: { type: "boolean", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index f594a07e27..fd8a3d24d9 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -115,6 +115,78 @@ describe("Daytona sandbox provider plugin", () => { }); }); + it("applies quota-safety auto-stop/archive/delete defaults when unset", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "daytona", + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(result).toMatchObject({ + ok: true, + normalizedConfig: { + autoStopInterval: 15, + autoArchiveInterval: 60, + autoDeleteInterval: 10080, + }, + }); + }); + + it("preserves an explicit 0/-1 to disable auto intervals", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "daytona", + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + autoStopInterval: 0, + autoArchiveInterval: 0, + autoDeleteInterval: -1, + reuseLease: true, + }, + }); + + expect(result).toMatchObject({ + ok: true, + normalizedConfig: { + autoStopInterval: 0, + autoArchiveInterval: 0, + autoDeleteInterval: -1, + }, + }); + }); + + it("forwards auto-archive/auto-delete defaults to the Daytona create call", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + config: { + image: "node:20", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + const [createParams] = mockCreate.mock.calls[0] as [Record]; + expect(createParams).toMatchObject({ + autoStopInterval: 15, + autoArchiveInterval: 60, + autoDeleteInterval: 10080, + }); + }); + it("rejects ambiguous or invalid config", async () => { await expect(plugin.definition.onEnvironmentValidateConfig?.({ driverKey: "daytona", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index fb367debe2..b004c05195 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -52,6 +52,21 @@ type WorkspaceSentinelResult = { const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-lease.json"; +// Quota-safety defaults (minutes). Daytona counts *stopped* sandboxes against +// the storage quota; only *archived* sandboxes move to cold object storage and +// stop counting. Without these, stopped/leaked sandboxes accumulate until the +// org quota fills. We apply sane defaults so every sandbox eventually leaves the +// quota on its own even when our own cleanup fails or never runs (crashed runs, +// failed lease destroys, orphaned probes). All three stay overridable per +// environment; an explicit 0/-1 in config is preserved. +// +// - autoStop: stop idle *running* sandboxes (frees CPU/RAM, starts the archive clock). +// - autoArchive: archive *stopped* sandboxes so they leave the disk quota. +// - autoDelete: backstop reaper for sandboxes nobody resumes. +const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15; +const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; +const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days + function parseOptionalString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -82,9 +97,9 @@ function parseDriverConfig(raw: Record): DaytonaDriverConfig { memory: parseOptionalNumber(raw.memory), disk: parseOptionalNumber(raw.disk), gpu: parseOptionalNumber(raw.gpu), - autoStopInterval: parseOptionalInteger(raw.autoStopInterval), - autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval), - autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval), + autoStopInterval: parseOptionalInteger(raw.autoStopInterval) ?? DEFAULT_AUTO_STOP_INTERVAL_MINUTES, + autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES, + autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES, reuseLease: raw.reuseLease === true, }; }