Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/daytona-sandbox-create-on-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@truefoundry/trueforge-core': patch
'@truefoundry/trueforge': patch
---

Surface Daytona sandbox create failures through a host `onError` hook (authz still ignored for P1) instead of a provider-owned critical alert callback.
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ export interface DaytonaSandboxProviderOptions {
/** Defaults to 1 hour (same as the gateway's max agent execution time). */
previewUrlExpirySeconds?: number;
logger: Logger;
/** Host alert hook (e.g. Sentry P1). */
onCriticalAlert?: (error: Error) => void;
/**
* Host hook for fresh-create failures only (wired through getOrCreateSandbox).
* Other provider catch paths do not call this; the host decides what to alert on.
*/
onError?: (error: Error) => void;
Comment thread
cursor[bot] marked this conversation as resolved.
}

export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnvironment> {
Expand All @@ -99,7 +102,7 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv
private readonly apiKey: string;
private readonly apiUrl: string;
private readonly logger: Logger;
private readonly onCriticalAlert: ((error: Error) => void) | undefined;
private readonly onError: ((error: Error) => void) | undefined;
private readonly daytona: Daytona;
private static readonly cachedSandboxes = new Map<string, { sandbox: Sandbox; defaultTimeoutMs: number }>();
// De-dupes concurrent recovery attempts on the same sandbox to a single refreshData+start round-trip.
Expand All @@ -117,7 +120,7 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv
this.fileMaxBytesForDownload = options.fileMaxBytesForDownload;
this.natsBridgePort = options.natsBridgePort ?? DEFAULT_SANDBOX_NATS_WS_PORT;
this.previewUrlExpirySeconds = options.previewUrlExpirySeconds ?? DEFAULT_PREVIEW_URL_EXPIRY_SECONDS;
this.onCriticalAlert = options.onCriticalAlert;
this.onError = options.onError;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many places in this file has catch (error) {
Why should they not be piped to onError?

If they should not be piped then onError should be optional arg of getOrCreateSandbox?

this.logger = options.logger.child({ module: 'DaytonaProvider' });
}

Expand Down Expand Up @@ -340,30 +343,39 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv
throw new Error(SNAPSHOT_INACTIVE_USER_MESSAGE);
}

/** Fresh create: reject an inactive tip before calling Daytona create. */
private async createFreshSandbox(environment?: DaytonaSandboxEnvironment): Promise<Sandbox> {
/** New create: reject an inactive tip before calling Daytona create. */
private async createNewSandbox({
environment,
onError,
}: {
environment?: DaytonaSandboxEnvironment;
onError?: (error: Error) => void;
}): Promise<Sandbox> {
const env = this.requireEnvironment(environment);
try {
await this.rejectIfSnapshotInactive(env.snapshot_ref);
return await this.daytona.create(this.buildCreateParams(env));
} catch (error) {
if (error instanceof DaytonaError && (error.statusCode === 401 || error.statusCode === 403)) {
throw error;
}
const alertError = error instanceof Error ? error : new Error('Daytona sandbox create failed', { cause: error });
const createError = error instanceof Error ? error : new Error('Daytona sandbox create failed', { cause: error });
this.logger.error('Daytona sandbox create failed', {
snapshot_ref: env.snapshot_ref,
...extractErrorLogFields(alertError),
...extractErrorLogFields(createError),
});
this.onCriticalAlert?.(alertError);
throw error;
onError?.(createError);
throw createError;
}
}

private async getOrCreateSandbox(
sandboxId?: string,
environment?: DaytonaSandboxEnvironment,
): Promise<{ sandbox: Sandbox; defaultTimeoutMs: number }> {
private async getOrCreateSandbox({
sandboxId,
environment,
onError,
}: {
sandboxId?: string;
environment?: DaytonaSandboxEnvironment;
/** Only used on the fresh-create path; restore/exec paths omit this. */
onError?: (error: Error) => void;
}): Promise<{ sandbox: Sandbox; defaultTimeoutMs: number }> {
if (sandboxId) {
validateSandboxOwnedByTenant({ sandboxId, tenantName: this.tenantName });
const cached = DaytonaSandboxProvider.cachedSandboxes.get(sandboxId);
Expand All @@ -374,7 +386,7 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv

const sandbox = sandboxId
? await this.restoreExistingSandbox(sandboxId)
: await this.createFreshSandbox(environment);
: await this.createNewSandbox({ environment, onError });

const entry = { sandbox, defaultTimeoutMs: this.timeoutMs };
DaytonaSandboxProvider.cachedSandboxes.set(sandbox.name, entry);
Expand Down Expand Up @@ -476,7 +488,10 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv

async createSandbox(environment?: DaytonaSandboxEnvironment): Promise<{ sandboxId: string }> {
return context.with(suppressTracing(context.active()), async () => {
const { sandbox } = await this.getOrCreateSandbox(undefined, environment);
const { sandbox } = await this.getOrCreateSandbox({
environment,
onError: this.onError,
});
this.logger.debug(`Sandbox created: name=${sandbox.name}`);
return { sandboxId: sandbox.name };
});
Expand All @@ -486,7 +501,7 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv
return context.with(suppressTracing(context.active()), async (): Promise<ExecResult> => {
try {
return await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox, defaultTimeoutMs } = await this.getOrCreateSandbox(params.sandboxId);
const { sandbox, defaultTimeoutMs } = await this.getOrCreateSandbox({ sandboxId: params.sandboxId });
const response = await sandbox.process.executeCommand(
params.command,
params.cwd,
Expand Down Expand Up @@ -519,7 +534,7 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv
return context.with(suppressTracing(context.active()), async () => {
try {
return await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);
const { sandbox } = await this.getOrCreateSandbox({ sandboxId: params.sandboxId });

const info = await this.getFileInfo(sandbox, params.path);
if (info.isDir) {
Expand Down Expand Up @@ -548,7 +563,7 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv
return context.with(suppressTracing(context.active()), async () => {
try {
await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);
const { sandbox } = await this.getOrCreateSandbox({ sandboxId: params.sandboxId });
await sandbox.fs.uploadFile(params.content, params.remotePath);
});
} catch (e: unknown) {
Expand All @@ -563,7 +578,7 @@ export class DaytonaSandboxProvider implements SandboxProvider<DaytonaSandboxEnv
return context.with(suppressTracing(context.active()), async () => {
try {
return await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);
const { sandbox } = await this.getOrCreateSandbox({ sandboxId: params.sandboxId });
const signed = await sandbox.getSignedPreviewUrl(params.port, params.expiresInSeconds);
return signed.url;
});
Expand Down
5 changes: 4 additions & 1 deletion packages/trueforge/src/sandbox/providerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ export function toDaytonaSandboxProvider({
tenantName: tenant_id,
fileMaxBytesForDownload: configuration.SANDBOX_FILE_MAX_BYTES_FOR_DOWNLOAD,
logger,
onCriticalAlert: error => {
onError: error => {
if (isDaytonaAuthError(error) || isDaytonaPermissionError(error)) {
return;
}
captureCriticalException(error, {
tags: { module: 'DaytonaProvider', operation: 'sandboxCreate', priority: 'P1' },
});
Expand Down
Loading