diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index c6ed21a0dd1..dded0044831 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -32,9 +32,9 @@ export const dynamic = 'force-dynamic' * reached, times out on connect or on `tools/list`, answers with a protocol or HTTP * error, or is cooling down after a recent failure is a fault of that server rather * than of Sim: it answers 422 (the request was valid; the dependency it names cannot - * serve it), the status this API family already uses for an upstream that cannot - * complete the operation, and logs at warn. Anything else keeps {@link categorizeError}'s - * status and logs at error only when that status is a 5xx. + * serve it), the status `oauth/start` answers when a server's authorization server + * refuses it, and logs at warn. The v2 surface keeps its documented 503. Anything else + * keeps {@link categorizeError}'s status and logs at error only when that status is a 5xx. */ function discoveryFailureResponse(requestId: string, error: unknown) { if (isMcpServerUnavailableError(error)) { diff --git a/apps/sim/lib/mothership/tools/server/media/generate-video.ts b/apps/sim/lib/mothership/tools/server/media/generate-video.ts index 680518350fe..71e2f216e40 100644 --- a/apps/sim/lib/mothership/tools/server/media/generate-video.ts +++ b/apps/sim/lib/mothership/tools/server/media/generate-video.ts @@ -68,12 +68,12 @@ export const generateVideoServerTool: BaseServerTool recordServiceCost('falai_video', cost.costDollars), }) - const outputPath = outputFile?.path || 'files/generated-video.mp4' - const mode = outputFile?.mode ?? 'create' - assertServerToolNotAborted(context) // The prompt is the only secret-bearing input; recording its provenance // keeps the written file model-readable (see generate-image). diff --git a/apps/sim/lib/mothership/tools/server/media/model-boundaries.test.ts b/apps/sim/lib/mothership/tools/server/media/model-boundaries.test.ts index 96f62494678..d1e9d401050 100644 --- a/apps/sim/lib/mothership/tools/server/media/model-boundaries.test.ts +++ b/apps/sim/lib/mothership/tools/server/media/model-boundaries.test.ts @@ -21,6 +21,7 @@ vi.mock('@google/genai', () => ({ })) vi.mock('@/lib/core/config/api-keys', () => ({ getRotatingApiKey: vi.fn(() => 'api-key') })) vi.mock('@/lib/mothership/vfs/resource-writer', () => ({ + validateCopilotWorkspaceFileWriteTarget: vi.fn(), writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/media/falai-audio', () => ({ generateFalAudio: mockGenerateFalAudio })) @@ -118,6 +119,7 @@ describe('Mothership media model boundaries', () => { generator().mockImplementationOnce( ({ signal }: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { + if (signal?.aborted) return reject(signal.reason) signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) }) ) diff --git a/apps/sim/lib/mothership/tools/server/media/write-target-precheck.integration.ts b/apps/sim/lib/mothership/tools/server/media/write-target-precheck.integration.ts index 2456b795998..f8505521287 100644 --- a/apps/sim/lib/mothership/tools/server/media/write-target-precheck.integration.ts +++ b/apps/sim/lib/mothership/tools/server/media/write-target-precheck.integration.ts @@ -181,4 +181,20 @@ describe('media generation output target precheck', () => { expect(written).toEqual([{ name: file }]) } ) + + it('refuses a taken default video target before generating or billing', async () => { + const { context } = await seedMovedFolder() + const first = await generateVideoServerTool.execute({ prompt: 'p' }, context) + expect(first.success, first.message).toBe(true) + mockGenerateFalVideo.mockClear() + const charges = vi.fn().mockResolvedValue(undefined) + + const second = await observeServiceCosts(charges, () => + generateVideoServerTool.execute({ prompt: 'p' }, context) + ) + + expect(second.success).toBe(false) + expect(mockGenerateFalVideo).not.toHaveBeenCalled() + expect(charges).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 5711a55b671..d6f07c4a21d 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -2273,6 +2273,31 @@ describe('repeated human review pauses', () => { }) } + it.each([ + { statusCode: 402, retryable: false, kind: 'user' }, + { statusCode: 404, retryable: false, kind: 'user' }, + { statusCode: 429, retryable: false, kind: 'user' }, + /** An unreadable usage ledger fails closed with a retryable 402: no verdict on the payer. */ + { statusCode: 402, retryable: true, kind: 'internal' }, + { statusCode: 500, retryable: false, kind: 'internal' }, + ])( + 'attributes a $statusCode (retryable: $retryable) preprocessing refusal as $kind', + async ({ statusCode, retryable, kind }) => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'log-1', deploymentVersionId: null }]) + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { message: 'refused', statusCode, retryable }, + }) + + const error = await runResumeExecution(createRepeatedReviewResumeArgs()).catch( + (thrown: unknown) => thrown + ) + + expect(error).toMatchObject({ name: 'ResumeAdmissionError', statusCode }) + expect(classifyFailure(error)).toBe(kind) + } + ) + it('keeps the next pause snapshot attached to the log that admission claimed', async () => { mockCompletedResumeRun() diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index b8bfe1f744c..a4963191ec4 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -1554,18 +1554,25 @@ export class PauseResumeManager { if (!preprocessingResult.success) { const errorMessage = preprocessingResult.error?.message || 'Preprocessing check failed for resume execution' - logger.error('Resume preprocessing failed', { + const refusal = new ResumeAdmissionError( + errorMessage, + preprocessingResult.error?.statusCode ?? 500, + preprocessingResult.error?.retryable ?? false + ) + /** + * A final 4xx is a verdict on the run (usage limit, rate limit, archived workflow). A + * retryable one, such as an unreadable usage ledger, is no verdict on the caller and stays + * a Sim failure. + */ + const refusedByRunState = + refusal.statusCode >= 400 && refusal.statusCode < 500 && !refusal.retryable + logger[refusedByRunState ? 'info' : 'error']('Resume preprocessing failed', { resumeExecutionId, workflowId: pausedExecution.workflowId, userId: effectiveUserId, error: errorMessage, }) - - throw new ResumeAdmissionError( - errorMessage, - preprocessingResult.error?.statusCode ?? 500, - preprocessingResult.error?.retryable ?? false - ) + throw refusedByRunState ? markFailureKind(refusal, 'user') : refusal } logger.info('Preprocessing checks passed for resume', {