From ec9500e6541fbaa7289b2bd3ddb7199379bdad51 Mon Sep 17 00:00:00 2001 From: thesujai Date: Fri, 14 Aug 2026 12:52:33 +0530 Subject: [PATCH 1/5] feat: enhance DaytonaSandboxProvider with build failure handling --- packages/trueforge-core/src/core/index.ts | 2 +- .../core/sandbox/provider/DaytonaProvider.ts | 27 ++++++++++++++- .../trueforge/src/apis/sandboxProviders.ts | 3 ++ .../trueforge/src/runtime/sessionResources.ts | 1 + .../trueforge/src/sandbox/providerUtils.ts | 29 +++++++++++++++- .../tests/unit/apis/sandboxProviders.test.ts | 33 +++++++++++++++++++ 6 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 03be317a9..b9589dd89 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -143,7 +143,7 @@ export type { CodeModeTransport } from './sandbox/codeMode/CodeModeTransport'; export { CodeModeErrorSourceSchema, CodeModeReplySchema, CodeModeRequestSchema } from './sandbox/codeMode/types'; export type { CodeModeErrorSource, CodeModeReply, CodeModeRequest } from './sandbox/codeMode/types'; export { DaytonaSandboxProvider } from './sandbox/provider/DaytonaProvider'; -export type { DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider'; +export type { DaytonaBuildFailureHandler, DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider'; export { ensureExecSuccess, shellEscape } from './sandbox/provider/Provider'; export type { ExecErrorResult, diff --git a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts index 014d3ad81..fb2b23506 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts @@ -57,6 +57,8 @@ function httpUrlToWsUrl(url: string): string { return parsed.toString(); } +export type DaytonaBuildFailureHandler = (build: SandboxBuild) => Promise; + export interface DaytonaSandboxProviderOptions { /** Caller-owned Daytona SDK client (credentials / lifetime). */ client: Daytona; @@ -78,6 +80,7 @@ export interface DaytonaSandboxProviderOptions { natsBridgePort?: number; /** Defaults to 1 hour (same as the gateway's max agent execution time). */ previewUrlExpirySeconds?: number; + onBuildFailure: DaytonaBuildFailureHandler | undefined; logger: Logger; } @@ -94,6 +97,7 @@ export class DaytonaSandboxProvider implements SandboxProvider { private readonly fileMaxBytesForDownload: number; private readonly natsBridgePort: number; private readonly previewUrlExpirySeconds: number; + private readonly onBuildFailure: DaytonaBuildFailureHandler | undefined; private readonly logger: Logger; private readonly daytona: Daytona; private static readonly cachedSandboxes = new Map(); @@ -112,6 +116,7 @@ export class DaytonaSandboxProvider implements SandboxProvider { this.fileMaxBytesForDownload = options.fileMaxBytesForDownload; this.natsBridgePort = options.natsBridgePort ?? DEFAULT_SANDBOX_NATS_WS_PORT; this.previewUrlExpirySeconds = options.previewUrlExpirySeconds ?? DEFAULT_PREVIEW_URL_EXPIRY_SECONDS; + this.onBuildFailure = options.onBuildFailure; this.logger = options.logger.child({ module: 'DaytonaProvider' }); } @@ -276,7 +281,9 @@ export class DaytonaSandboxProvider implements SandboxProvider { // terminal (minutes on a cold image pull), but Daytona registers it on the first request, so // progress is observed via getImageBuildStatus. A concurrent-create conflict is harmless. void this.daytona.snapshot.create({ name: this.buildRef, image: this.imageUri }).catch((error: unknown) => { - this.logger.error(`Daytona snapshot create failed: name=${this.buildRef}`, extractErrorLogFields(error)); + const fields = extractErrorLogFields(error); + this.logger.error(`Daytona snapshot create failed: name=${this.buildRef}`, fields); + return this.reportBuildFailure(fields.error); }); return { status: 'pending', @@ -285,6 +292,24 @@ export class DaytonaSandboxProvider implements SandboxProvider { }; } + private async reportBuildFailure(reason: string): Promise { + if (!this.onBuildFailure) { + return; + } + try { + await this.onBuildFailure({ + status: 'failed', + reason: `Sandbox image build failed: ${reason}`, + metadata: { build_ref: this.buildRef, image_uri: this.imageUri }, + }); + } catch (error) { + this.logger.error( + `Reporting the Daytona snapshot create failure failed: name=${this.buildRef}`, + extractErrorLogFields(error), + ); + } + } + async getImageBuildStatus(): Promise { const snapshot = await this.getSnapshot(this.buildRef); // Read-only: a missing build reports pending; PUT /settings/sandbox-providers starts the build. diff --git a/packages/trueforge/src/apis/sandboxProviders.ts b/packages/trueforge/src/apis/sandboxProviders.ts index 8f4e982a0..2354be272 100644 --- a/packages/trueforge/src/apis/sandboxProviders.ts +++ b/packages/trueforge/src/apis/sandboxProviders.ts @@ -7,6 +7,7 @@ import { getSandboxProviderRoute, putSandboxProviderRoute } from '../routes/sand import { checkSnapshotStatus, isDaytonaAuthError, + persistBuildFailure, toDaytonaSandboxProvider, toSandboxStatus, } from '../sandbox/providerUtils'; @@ -79,6 +80,8 @@ export function createSandboxProvidersRouter(deps: SandboxProvider tenant_id: TENANT_ID, logger: deps.logger, ...(locked ? { build_metadata: locked.build_metadata } : {}), + onBuildFailure: build => + persistBuildFailure({ store: deps.sandboxProviderStore, tenant_id: TENANT_ID, build }), }); const built = toSandboxStatus( await withTimeout(provider.buildImage(), BUILD_REQUEST_TIMEOUT_MS, 'sandbox buildImage'), diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index ec0b4dd47..8949156ca 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -228,6 +228,7 @@ export async function resolveSandboxProvider({ tenant_id, logger, build_metadata: record.build_metadata, + onBuildFailure: undefined, }); } diff --git a/packages/trueforge/src/sandbox/providerUtils.ts b/packages/trueforge/src/sandbox/providerUtils.ts index 2770bf9cd..f1a8539b3 100644 --- a/packages/trueforge/src/sandbox/providerUtils.ts +++ b/packages/trueforge/src/sandbox/providerUtils.ts @@ -1,6 +1,11 @@ /** Daytona provider construction + persisted build-status refresh (see checkSnapshotStatus). */ import { Daytona, DaytonaError } from '@daytona/sdk'; -import { DaytonaSandboxProvider, SANDBOX_IMAGE_URI, type SandboxBuild } from '@truefoundry/trueforge-core/core'; +import { + DaytonaSandboxProvider, + SANDBOX_IMAGE_URI, + type DaytonaBuildFailureHandler, + type SandboxBuild, +} from '@truefoundry/trueforge-core/core'; import type { Logger } from 'winston'; import configuration from '../config'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -29,11 +34,13 @@ export function toDaytonaSandboxProvider({ tenant_id, logger, build_metadata, + onBuildFailure, }: { manifest: SandboxProviderManifest; tenant_id: string; logger: Logger; build_metadata?: SandboxBuildMetadata | null; + onBuildFailure: DaytonaBuildFailureHandler | undefined; }): DaytonaSandboxProvider { const { apiKey, ...settings } = toDaytonaSandboxProviderInput(manifest); return new DaytonaSandboxProvider({ @@ -43,10 +50,29 @@ export function toDaytonaSandboxProvider({ sandboxImage: build_metadata?.['image_uri'] ?? SANDBOX_IMAGE_URI, buildRef: build_metadata?.['build_ref'], fileMaxBytesForDownload: configuration.SANDBOX_FILE_MAX_BYTES_FOR_DOWNLOAD, + onBuildFailure, logger, }); } +/** + * Persists a build that Daytona rejected after `buildImage` already answered `pending`. + * Callers that kick off a build inside a transaction still get the right final row: that + * transaction holds the provider row lock, so this write lands after its `pending` upsert + * commits rather than being overwritten by it. + */ +export async function persistBuildFailure({ + store, + tenant_id, + build, +}: { + store: ISandboxProviderStore; + tenant_id: string; + build: SandboxBuild; +}): Promise { + await store.updateSandboxStatus({ tenant_id, ...toSandboxStatus(build) }); +} + /** Maps a core `SandboxBuild` onto the persisted/wire status shape (metadata passes through). */ export function toSandboxStatus(build: SandboxBuild): SandboxStatus { return { @@ -90,6 +116,7 @@ export async function checkSnapshotStatus({ tenant_id, logger, build_metadata: record.build_metadata, + onBuildFailure: build => persistBuildFailure({ store, tenant_id, build }), }); let build: SandboxBuild; if (record.status === 'ready') { diff --git a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts index 4afc39067..a7cf04c21 100644 --- a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts @@ -42,6 +42,16 @@ const readyBuild: SandboxBuild = { reason: null, metadata: { build_ref: 'trueforge-build-029ea5ff', image_uri: IMAGE_URI }, }; +const pendingBuild: SandboxBuild = { + status: 'pending', + reason: 'Sandbox image build started.', + metadata: readyBuild.metadata, +}; +const failedBuild: SandboxBuild = { + status: 'failed', + reason: 'Sandbox image build failed: Access denied', + metadata: readyBuild.metadata, +}; const expectedStatus = { status: 'ready' as const, status_reason: null, @@ -174,6 +184,29 @@ describe('sandboxProviders router', () => { expect((await router.request('/')).status).toBe(404); }); + it('PUT persists failed once the backgrounded build is rejected', async () => { + const { settingsRouter: router, sandboxProviderStore: store } = await createRouters(); + let reportFailure: ((build: SandboxBuild) => Promise) | undefined; + mockProviderFactory.mockImplementation((options: { onBuildFailure?: (build: SandboxBuild) => Promise }) => { + reportFailure = options.onBuildFailure; + return stubProvider({ buildImage: jest.fn().mockResolvedValue(pendingBuild) }); + }); + + const put = await router.request('/', putInit(putBody)); + expect(put.status).toBe(200); + expect(await put.json()).toEqual({ + data: { ...putBodyWire, status: 'pending', status_reason: pendingBuild.reason }, + }); + + // Daytona rejects the create only after buildImage has already answered `pending`. + expect(reportFailure).toBeDefined(); + await reportFailure?.(failedBuild); + + const stored = await store.getSandboxProvider(TENANT_ID); + expect(stored?.status).toBe('failed'); + expect(stored?.status_reason).toBe(failedBuild.reason); + }); + it('PUT rejects invalid bodies at the Zod layer', async () => { const { auth: _auth, ...withoutAuth } = putBody; const missingAuth = await settingsRouter.request('/', putInit(withoutAuth)); From a5e3078adf074fe8203372117acf2f06ebde4380 Mon Sep 17 00:00:00 2001 From: thesujai Date: Fri, 14 Aug 2026 23:10:42 +0530 Subject: [PATCH 2/5] feat: improve Daytona build failure handling and persistency --- packages/trueforge-core/src/core/index.ts | 2 +- .../core/sandbox/provider/DaytonaProvider.ts | 106 ++++++++++++------ .../daytonaSnapshotRegistration.test.ts | 80 +++++++++++++ .../trueforge/src/apis/sandboxProviders.ts | 6 +- .../trueforge/src/runtime/sessionResources.ts | 1 - .../trueforge/src/sandbox/providerUtils.ts | 55 ++++----- .../tests/unit/apis/sandboxProviders.test.ts | 33 ------ 7 files changed, 175 insertions(+), 108 deletions(-) create mode 100644 packages/trueforge-core/tests/core/sandbox/daytonaSnapshotRegistration.test.ts diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index b9589dd89..03be317a9 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -143,7 +143,7 @@ export type { CodeModeTransport } from './sandbox/codeMode/CodeModeTransport'; export { CodeModeErrorSourceSchema, CodeModeReplySchema, CodeModeRequestSchema } from './sandbox/codeMode/types'; export type { CodeModeErrorSource, CodeModeReply, CodeModeRequest } from './sandbox/codeMode/types'; export { DaytonaSandboxProvider } from './sandbox/provider/DaytonaProvider'; -export type { DaytonaBuildFailureHandler, DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider'; +export type { DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider'; export { ensureExecSuccess, shellEscape } from './sandbox/provider/Provider'; export type { ExecErrorResult, diff --git a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts index fb2b23506..77fc569fa 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts @@ -17,6 +17,8 @@ import { DEFAULT_PREVIEW_URL_EXPIRY_SECONDS, DEFAULT_SANDBOX_NATS_WS_PORT } from import type { ExecResult, SandboxBuild, SandboxExecParams, SandboxFileInfo, SandboxProvider } from './Provider'; const SANDBOX_NOT_FOUND_STATUS = 404; +/** Another replica already registered this build name; its create is the one that counts. */ +const SNAPSHOT_CONFLICT_STATUS = 409; const SANDBOX_STATE_STARTED = 'started'; const BUILD_STATE_ACTIVE = 'active'; @@ -25,6 +27,12 @@ const BUILD_STATE_ERROR = 'error'; const BUILD_STATE_BUILD_FAILED = 'build_failed'; const IMAGE_BUILD_NAME_PREFIX = 'trueforge-build-'; +/** Same default the Daytona SDK applies when `DaytonaConfig.apiUrl` is omitted. */ +const DEFAULT_DAYTONA_API_URL = 'https://app.daytona.io/api'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} /** * Digest portion of a container image reference (the tag/digest after the final `:`) @@ -57,11 +65,16 @@ function httpUrlToWsUrl(url: string): string { return parsed.toString(); } -export type DaytonaBuildFailureHandler = (build: SandboxBuild) => Promise; - export interface DaytonaSandboxProviderOptions { /** Caller-owned Daytona SDK client (credentials / lifetime). */ client: Daytona; + /** + * Same API key the client was built with; used for the register-only snapshot POST because the + * SDK's `snapshot.create` polls to a terminal state instead of returning once registered. + */ + apiKey: string; + /** Daytona API base URL (including `/api`). Defaults to the SDK's public cloud endpoint. */ + apiUrl?: string | undefined; tenantName: string; /** Release-owned sandbox image reference; built into a Daytona snapshot and cloned per sandbox. */ sandboxImage: string; @@ -80,7 +93,6 @@ export interface DaytonaSandboxProviderOptions { natsBridgePort?: number; /** Defaults to 1 hour (same as the gateway's max agent execution time). */ previewUrlExpirySeconds?: number; - onBuildFailure: DaytonaBuildFailureHandler | undefined; logger: Logger; } @@ -97,7 +109,8 @@ export class DaytonaSandboxProvider implements SandboxProvider { private readonly fileMaxBytesForDownload: number; private readonly natsBridgePort: number; private readonly previewUrlExpirySeconds: number; - private readonly onBuildFailure: DaytonaBuildFailureHandler | undefined; + private readonly apiKey: string; + private readonly apiUrl: string; private readonly logger: Logger; private readonly daytona: Daytona; private static readonly cachedSandboxes = new Map(); @@ -106,6 +119,8 @@ export class DaytonaSandboxProvider implements SandboxProvider { constructor(options: DaytonaSandboxProviderOptions) { this.daytona = options.client; + this.apiKey = options.apiKey; + this.apiUrl = options.apiUrl ?? DEFAULT_DAYTONA_API_URL; this.tenantName = options.tenantName; this.imageUri = options.sandboxImage; this.buildRef = options.buildRef ?? deriveImageBuildName(imageDigest(options.sandboxImage)); @@ -116,7 +131,6 @@ export class DaytonaSandboxProvider implements SandboxProvider { this.fileMaxBytesForDownload = options.fileMaxBytesForDownload; this.natsBridgePort = options.natsBridgePort ?? DEFAULT_SANDBOX_NATS_WS_PORT; this.previewUrlExpirySeconds = options.previewUrlExpirySeconds ?? DEFAULT_PREVIEW_URL_EXPIRY_SECONDS; - this.onBuildFailure = options.onBuildFailure; this.logger = options.logger.child({ module: 'DaytonaProvider' }); } @@ -248,7 +262,7 @@ export class DaytonaSandboxProvider implements SandboxProvider { } } - private toBuild(state: Snapshot['state'], errorReason: string | null): SandboxBuild { + private toBuild(state: string, errorReason: string | null): SandboxBuild { const metadata = { build_ref: this.buildRef, image_uri: this.imageUri }; switch (state) { case BUILD_STATE_ACTIVE: @@ -262,6 +276,45 @@ export class DaytonaSandboxProvider implements SandboxProvider { } } + /** + * Registers the snapshot and returns its initial state without waiting for the build. + * + * The SDK's `snapshot.create` issues this same POST and then polls until the snapshot is active + * or failed (minutes on a cold image pull). Configure only needs the registration result, so + * credential errors surface on the request while build progress stays observable via `getSnapshot`. + */ + private async registerSnapshot(): Promise<{ state: string; errorReason: string | null }> { + let response: Response; + try { + response = await fetch(`${this.apiUrl}/snapshots`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: this.buildRef, imageName: this.imageUri }), + }); + } catch (error) { + throw new Error('Daytona snapshot registration request failed.', { cause: error }); + } + + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = + isRecord(body) && typeof body['message'] === 'string' + ? body['message'] + : `Daytona snapshot registration failed (${response.status})`; + throw new DaytonaError(message, response.status); + } + if (!isRecord(body) || typeof body['state'] !== 'string') { + throw new DaytonaError("Failed to register snapshot. Daytona didn't return a snapshot state."); + } + return { + state: body['state'], + errorReason: typeof body['errorReason'] === 'string' ? body['errorReason'] : null, + }; + } + async buildImage(): Promise { const existing = await this.getSnapshot(this.buildRef); if (existing) { @@ -277,36 +330,23 @@ export class DaytonaSandboxProvider implements SandboxProvider { // re-saving settings (which calls buildImage) could never retry. Drop it, then recreate. await this.deleteFailedBuild(existing); } - // Fire the create in the background: the SDK's snapshot.create polls until the snapshot is - // terminal (minutes on a cold image pull), but Daytona registers it on the first request, so - // progress is observed via getImageBuildStatus. A concurrent-create conflict is harmless. - void this.daytona.snapshot.create({ name: this.buildRef, image: this.imageUri }).catch((error: unknown) => { - const fields = extractErrorLogFields(error); - this.logger.error(`Daytona snapshot create failed: name=${this.buildRef}`, fields); - return this.reportBuildFailure(fields.error); - }); - return { - status: 'pending', - reason: 'Sandbox image build started.', - metadata: { build_ref: this.buildRef, image_uri: this.imageUri }, - }; - } - private async reportBuildFailure(reason: string): Promise { - if (!this.onBuildFailure) { - return; - } try { - await this.onBuildFailure({ - status: 'failed', - reason: `Sandbox image build failed: ${reason}`, - metadata: { build_ref: this.buildRef, image_uri: this.imageUri }, - }); + const registered = await this.registerSnapshot(); + // Registration returns pending almost always; map whatever Daytona sent so a fast + // active/error still surfaces correctly without a follow-up GET. + return this.toBuild(registered.state, registered.errorReason); } catch (error) { - this.logger.error( - `Reporting the Daytona snapshot create failure failed: name=${this.buildRef}`, - extractErrorLogFields(error), - ); + // A losing concurrent create is not a build failure: the winner owns the deterministic name. + if (error instanceof DaytonaError && error.statusCode === SNAPSHOT_CONFLICT_STATUS) { + this.logger.info(`Daytona snapshot already created concurrently: name=${this.buildRef}`); + return { + status: 'pending', + reason: 'Sandbox image build started by another server replica.', + metadata: { build_ref: this.buildRef, image_uri: this.imageUri }, + }; + } + throw error; } } diff --git a/packages/trueforge-core/tests/core/sandbox/daytonaSnapshotRegistration.test.ts b/packages/trueforge-core/tests/core/sandbox/daytonaSnapshotRegistration.test.ts new file mode 100644 index 000000000..26414fb24 --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/daytonaSnapshotRegistration.test.ts @@ -0,0 +1,80 @@ +import { Daytona, DaytonaError } from '@daytona/sdk'; +import { DaytonaSandboxProvider } from '../../../src/core/sandbox/provider/DaytonaProvider'; +import { makeSilentLogger } from '../harnessMocks'; + +const NOT_FOUND_STATUS = 404; +const CONFLICT_STATUS = 409; +const FORBIDDEN_STATUS = 403; +const API_URL = 'https://daytona.test/api'; + +/** + * Builds a provider whose snapshot lookup reports "not built yet" so `buildImage` always reaches + * the register-only POST. + */ +function makeProvider(): DaytonaSandboxProvider { + // useDeprecatedPolling keeps the constructor from opening the event-stream WebSocket. + const client = new Daytona({ apiKey: 'dtn-test', useDeprecatedPolling: true }); + jest.spyOn(client.snapshot, 'get').mockRejectedValue(new DaytonaError('not found', NOT_FOUND_STATUS)); + + return new DaytonaSandboxProvider({ + client, + apiKey: 'dtn-test', + apiUrl: API_URL, + tenantName: 'test-tenant', + sandboxImage: 'registry.example.com/sandbox:029ea5ff', + timeoutMs: 1000, + autoStopIntervalInMinutes: 5, + autoArchiveIntervalInMinutes: 60, + autoDeleteIntervalInMinutes: 7200, + fileMaxBytesForDownload: 1024, + logger: makeSilentLogger(), + }); +} + +function mockFetch({ status, body }: { status: number; body: unknown }): jest.SpiedFunction { + return jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })); +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('DaytonaSandboxProvider register-only snapshot create', () => { + it('awaits the register POST and returns pending without polling to active', async () => { + const fetchMock = mockFetch({ + status: 200, + body: { id: 'snap-1', name: 'trueforge-build-029ea5ff', state: 'pending', errorReason: null }, + }); + + const build = await makeProvider().buildImage(); + + expect(build.status).toBe('pending'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe(`${API_URL}/snapshots`); + const init = fetchMock.mock.calls[0]?.[1]; + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ + name: 'trueforge-build-029ea5ff', + imageName: 'registry.example.com/sandbox:029ea5ff', + }); + }); + + it('treats a concurrent-create conflict as pending, not a thrown failure', async () => { + mockFetch({ status: CONFLICT_STATUS, body: { statusCode: CONFLICT_STATUS, message: 'Conflict' } }); + + const build = await makeProvider().buildImage(); + + expect(build.status).toBe('pending'); + }); + + it('throws on Access denied so PUT can map it to 422', async () => { + mockFetch({ status: FORBIDDEN_STATUS, body: { statusCode: FORBIDDEN_STATUS, message: 'Access denied' } }); + + await expect(makeProvider().buildImage()).rejects.toMatchObject({ + message: 'Access denied', + statusCode: FORBIDDEN_STATUS, + }); + }); +}); diff --git a/packages/trueforge/src/apis/sandboxProviders.ts b/packages/trueforge/src/apis/sandboxProviders.ts index 2354be272..0470eeac4 100644 --- a/packages/trueforge/src/apis/sandboxProviders.ts +++ b/packages/trueforge/src/apis/sandboxProviders.ts @@ -7,7 +7,6 @@ import { getSandboxProviderRoute, putSandboxProviderRoute } from '../routes/sand import { checkSnapshotStatus, isDaytonaAuthError, - persistBuildFailure, toDaytonaSandboxProvider, toSandboxStatus, } from '../sandbox/providerUtils'; @@ -15,7 +14,7 @@ import type { PutSandboxProviderRequest, SandboxProviderManifest } from '../sche import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction'; import { TENANT_ID } from './sessions'; -/** Cap the Daytona build kickoff so a slow/unreachable provider can't hold the request (or DB txn) open. */ +/** Cap the Daytona register round-trip so a slow/unreachable provider can't hold the request (or DB txn) open. */ const BUILD_REQUEST_TIMEOUT_MS = 3_000; export interface SandboxProvidersRouterDeps { @@ -80,9 +79,8 @@ export function createSandboxProvidersRouter(deps: SandboxProvider tenant_id: TENANT_ID, logger: deps.logger, ...(locked ? { build_metadata: locked.build_metadata } : {}), - onBuildFailure: build => - persistBuildFailure({ store: deps.sandboxProviderStore, tenant_id: TENANT_ID, build }), }); + // Awaits register-only POST (~1s). Auth failures throw here → 422 below; progress is on GET. const built = toSandboxStatus( await withTimeout(provider.buildImage(), BUILD_REQUEST_TIMEOUT_MS, 'sandbox buildImage'), ); diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 8949156ca..ec0b4dd47 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -228,7 +228,6 @@ export async function resolveSandboxProvider({ tenant_id, logger, build_metadata: record.build_metadata, - onBuildFailure: undefined, }); } diff --git a/packages/trueforge/src/sandbox/providerUtils.ts b/packages/trueforge/src/sandbox/providerUtils.ts index f1a8539b3..196e55b72 100644 --- a/packages/trueforge/src/sandbox/providerUtils.ts +++ b/packages/trueforge/src/sandbox/providerUtils.ts @@ -1,14 +1,9 @@ /** Daytona provider construction + persisted build-status refresh (see checkSnapshotStatus). */ import { Daytona, DaytonaError } from '@daytona/sdk'; -import { - DaytonaSandboxProvider, - SANDBOX_IMAGE_URI, - type DaytonaBuildFailureHandler, - type SandboxBuild, -} from '@truefoundry/trueforge-core/core'; +import { DaytonaSandboxProvider, SANDBOX_IMAGE_URI, type SandboxBuild } from '@truefoundry/trueforge-core/core'; import type { Logger } from 'winston'; import configuration from '../config'; -import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; +import type { ISandboxProviderStore, SandboxProviderRecord } from '../db/sandboxProviderStore'; import { toDaytonaSandboxProviderInput, type SandboxBuildMetadata, @@ -34,45 +29,25 @@ export function toDaytonaSandboxProvider({ tenant_id, logger, build_metadata, - onBuildFailure, }: { manifest: SandboxProviderManifest; tenant_id: string; logger: Logger; build_metadata?: SandboxBuildMetadata | null; - onBuildFailure: DaytonaBuildFailureHandler | undefined; }): DaytonaSandboxProvider { const { apiKey, ...settings } = toDaytonaSandboxProviderInput(manifest); return new DaytonaSandboxProvider({ client: new Daytona({ apiKey }), + apiKey, ...settings, tenantName: tenant_id, sandboxImage: build_metadata?.['image_uri'] ?? SANDBOX_IMAGE_URI, buildRef: build_metadata?.['build_ref'], fileMaxBytesForDownload: configuration.SANDBOX_FILE_MAX_BYTES_FOR_DOWNLOAD, - onBuildFailure, logger, }); } -/** - * Persists a build that Daytona rejected after `buildImage` already answered `pending`. - * Callers that kick off a build inside a transaction still get the right final row: that - * transaction holds the provider row lock, so this write lands after its `pending` upsert - * commits rather than being overwritten by it. - */ -export async function persistBuildFailure({ - store, - tenant_id, - build, -}: { - store: ISandboxProviderStore; - tenant_id: string; - build: SandboxBuild; -}): Promise { - await store.updateSandboxStatus({ tenant_id, ...toSandboxStatus(build) }); -} - /** Maps a core `SandboxBuild` onto the persisted/wire status shape (metadata passes through). */ export function toSandboxStatus(build: SandboxBuild): SandboxStatus { return { @@ -82,6 +57,14 @@ export function toSandboxStatus(build: SandboxBuild): SandboxStatus { }; } +function sandboxStatusFromRecord(record: SandboxProviderRecord): SandboxStatus { + return { + status: record.status, + status_reason: record.status_reason, + build_metadata: record.build_metadata, + }; +} + // Daytona deactivates idle snapshots after 14 days; revalidate at 13 to stay a day ahead. const READY_REVALIDATE_INTERVAL_MS = 13 * 24 * 60 * 60 * 1000; @@ -99,11 +82,7 @@ export async function checkSnapshotStatus({ return undefined; } - const persisted: SandboxStatus = { - status: record.status, - status_reason: record.status_reason, - build_metadata: record.build_metadata, - }; + const persisted = sandboxStatusFromRecord(record); const readyIsFresh = record.status === 'ready' && Date.now() - Date.parse(record.updated_at) < READY_REVALIDATE_INTERVAL_MS; @@ -116,7 +95,6 @@ export async function checkSnapshotStatus({ tenant_id, logger, build_metadata: record.build_metadata, - onBuildFailure: build => persistBuildFailure({ store, tenant_id, build }), }); let build: SandboxBuild; if (record.status === 'ready') { @@ -126,6 +104,11 @@ export async function checkSnapshotStatus({ build = await provider.getImageBuildStatus(); } const next = toSandboxStatus(build); - await store.updateSandboxStatus({ tenant_id, ...next }); - return next; + // GET is a reader: it only persists terminal states (ready/failed) observed from Daytona. + // `pending` is transient and re-derived on every read, so never write it back. + if (next.status === 'pending') { + return next; + } + const updated = await store.updateSandboxStatus({ tenant_id, ...next }); + return updated ? sandboxStatusFromRecord(updated) : next; } diff --git a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts index a7cf04c21..4afc39067 100644 --- a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts @@ -42,16 +42,6 @@ const readyBuild: SandboxBuild = { reason: null, metadata: { build_ref: 'trueforge-build-029ea5ff', image_uri: IMAGE_URI }, }; -const pendingBuild: SandboxBuild = { - status: 'pending', - reason: 'Sandbox image build started.', - metadata: readyBuild.metadata, -}; -const failedBuild: SandboxBuild = { - status: 'failed', - reason: 'Sandbox image build failed: Access denied', - metadata: readyBuild.metadata, -}; const expectedStatus = { status: 'ready' as const, status_reason: null, @@ -184,29 +174,6 @@ describe('sandboxProviders router', () => { expect((await router.request('/')).status).toBe(404); }); - it('PUT persists failed once the backgrounded build is rejected', async () => { - const { settingsRouter: router, sandboxProviderStore: store } = await createRouters(); - let reportFailure: ((build: SandboxBuild) => Promise) | undefined; - mockProviderFactory.mockImplementation((options: { onBuildFailure?: (build: SandboxBuild) => Promise }) => { - reportFailure = options.onBuildFailure; - return stubProvider({ buildImage: jest.fn().mockResolvedValue(pendingBuild) }); - }); - - const put = await router.request('/', putInit(putBody)); - expect(put.status).toBe(200); - expect(await put.json()).toEqual({ - data: { ...putBodyWire, status: 'pending', status_reason: pendingBuild.reason }, - }); - - // Daytona rejects the create only after buildImage has already answered `pending`. - expect(reportFailure).toBeDefined(); - await reportFailure?.(failedBuild); - - const stored = await store.getSandboxProvider(TENANT_ID); - expect(stored?.status).toBe('failed'); - expect(stored?.status_reason).toBe(failedBuild.reason); - }); - it('PUT rejects invalid bodies at the Zod layer', async () => { const { auth: _auth, ...withoutAuth } = putBody; const missingAuth = await settingsRouter.request('/', putInit(withoutAuth)); From e2804df89c6257e524531762b486ec1ce05b18e8 Mon Sep 17 00:00:00 2001 From: thesujai Date: Mon, 17 Aug 2026 09:27:56 +0530 Subject: [PATCH 3/5] refactor: remove outdated comments in sandboxProviders and providerUtils for clarity --- packages/trueforge/src/apis/sandboxProviders.ts | 1 - packages/trueforge/src/sandbox/providerUtils.ts | 5 ----- 2 files changed, 6 deletions(-) diff --git a/packages/trueforge/src/apis/sandboxProviders.ts b/packages/trueforge/src/apis/sandboxProviders.ts index 0470eeac4..13b54b362 100644 --- a/packages/trueforge/src/apis/sandboxProviders.ts +++ b/packages/trueforge/src/apis/sandboxProviders.ts @@ -80,7 +80,6 @@ export function createSandboxProvidersRouter(deps: SandboxProvider logger: deps.logger, ...(locked ? { build_metadata: locked.build_metadata } : {}), }); - // Awaits register-only POST (~1s). Auth failures throw here → 422 below; progress is on GET. const built = toSandboxStatus( await withTimeout(provider.buildImage(), BUILD_REQUEST_TIMEOUT_MS, 'sandbox buildImage'), ); diff --git a/packages/trueforge/src/sandbox/providerUtils.ts b/packages/trueforge/src/sandbox/providerUtils.ts index 196e55b72..9189f7742 100644 --- a/packages/trueforge/src/sandbox/providerUtils.ts +++ b/packages/trueforge/src/sandbox/providerUtils.ts @@ -104,11 +104,6 @@ export async function checkSnapshotStatus({ build = await provider.getImageBuildStatus(); } const next = toSandboxStatus(build); - // GET is a reader: it only persists terminal states (ready/failed) observed from Daytona. - // `pending` is transient and re-derived on every read, so never write it back. - if (next.status === 'pending') { - return next; - } const updated = await store.updateSandboxStatus({ tenant_id, ...next }); return updated ? sandboxStatusFromRecord(updated) : next; } From 1be0367fb207b3c7330eda2692433e32455d2bae Mon Sep 17 00:00:00 2001 From: thesujai Date: Mon, 17 Aug 2026 09:40:09 +0530 Subject: [PATCH 4/5] fix: stringify Daytona response status in error message Co-authored-by: Cursor --- .../trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts index 77fc569fa..540ab56e4 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts @@ -303,7 +303,7 @@ export class DaytonaSandboxProvider implements SandboxProvider { const message = isRecord(body) && typeof body['message'] === 'string' ? body['message'] - : `Daytona snapshot registration failed (${response.status})`; + : `Daytona snapshot registration failed (${String(response.status)})`; throw new DaytonaError(message, response.status); } if (!isRecord(body) || typeof body['state'] !== 'string') { From 1b16acfd9b4431f4a6c224c06f0cbc958aa91f75 Mon Sep 17 00:00:00 2001 From: thesujai Date: Mon, 17 Aug 2026 10:59:29 +0530 Subject: [PATCH 5/5] Add changeset for Daytona snapshot register status fix. Co-authored-by: Cursor --- .../20260817052904-daytona-snapshot-register-status.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/20260817052904-daytona-snapshot-register-status.md diff --git a/.changeset/20260817052904-daytona-snapshot-register-status.md b/.changeset/20260817052904-daytona-snapshot-register-status.md new file mode 100644 index 000000000..c2ab94b93 --- /dev/null +++ b/.changeset/20260817052904-daytona-snapshot-register-status.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-core': patch +'@truefoundry/trueforge': patch +--- + +Await Daytona snapshot registration on sandbox provider configure so auth failures return 422 instead of a false pending status, and keep GET status refreshes persisted.