Skip to content
Merged
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: 3 additions & 3 deletions apps/sim/app/api/mcp/tools/discover/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
15 changes: 6 additions & 9 deletions apps/sim/lib/mothership/tools/server/media/generate-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ export const generateVideoServerTool: BaseServerTool<GenerateVideoArgs, Generate

try {
const outputFile = params.outputs?.files?.[0]
if (outputFile?.path) {
await validateCopilotWorkspaceFileWriteTarget(context, {
workspaceId,
target: { path: outputFile.path, mode: outputFile.mode ?? 'create' },
})
}
const outputPath = outputFile?.path || 'files/generated-video.mp4'
const mode = outputFile?.mode ?? 'create'
await validateCopilotWorkspaceFileWriteTarget(context, {
workspaceId,
target: { path: outputPath, mode },
})

let imageDataUri: string | undefined
const refPath = params.inputs?.files?.[0]?.path
Expand Down Expand Up @@ -122,9 +122,6 @@ export const generateVideoServerTool: BaseServerTool<GenerateVideoArgs, Generate
onCost: (cost) => 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down Expand Up @@ -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 })
})
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
25 changes: 25 additions & 0 deletions apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
21 changes: 14 additions & 7 deletions apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
Loading