Skip to content

Commit c68d930

Browse files
committed
fix(workflows): preserve file inputs in workflow testing
1 parent d61006a commit c68d930

7 files changed

Lines changed: 346 additions & 183 deletions

File tree

apps/sim/executor/variables/resolver.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ import {
1010
LARGE_ARRAY_MANIFEST_VERSION,
1111
type LargeArrayManifest,
1212
} from '@/lib/execution/payloads/large-array-manifest-metadata'
13+
import {
14+
collectSandboxFileMountRefs,
15+
replaceSandboxFileMountRefs,
16+
} from '@/lib/execution/payloads/sandbox-file-mount-ref'
17+
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
1318
import { BlockType } from '@/executor/constants'
1419
import { ExecutionState } from '@/executor/execution/state'
1520
import type { ExecutionContext } from '@/executor/types'
1621
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
22+
import { buildStartBlockOutput } from '@/executor/utils/start-block'
1723
import { VariableResolver } from '@/executor/variables/resolver'
1824
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
1925
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
@@ -127,6 +133,79 @@ function createResolver(
127133
}
128134
}
129135

136+
describe('Start file path references', () => {
137+
const workspaceId = '11111111-1111-4111-8111-111111111111'
138+
const key = `workspace/${workspaceId}/photo.png`
139+
const uploadedFile = {
140+
id: 'file-1',
141+
name: 'photo.png',
142+
size: 128,
143+
type: 'image/png',
144+
}
145+
146+
it.each([
147+
{
148+
language: 'shell',
149+
file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' },
150+
},
151+
{
152+
language: 'shell',
153+
file: {
154+
...uploadedFile,
155+
url: `/api/files/serve/s3/${encodeURIComponent(key)}?context=workspace`,
156+
},
157+
},
158+
{
159+
language: 'python',
160+
file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' },
161+
},
162+
{
163+
language: 'javascript',
164+
file: { ...uploadedFile, key, url: 'https://storage.example.com/photo.png' },
165+
},
166+
])('mounts a Start upload referenced from $language code', async ({ language, file }) => {
167+
const start = createBlock('start', 'Start', 'start_trigger', {
168+
inputFormat: [{ name: 'files', type: 'file[]', value: '' }],
169+
})
170+
const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, { language })
171+
const output = buildStartBlockOutput({
172+
resolution: { blockId: start.id, block: start, path: StartBlockPath.UNIFIED },
173+
workspaceId,
174+
workflowInput: { input: 'Edit the image', files: [file] },
175+
})
176+
const { ctx } = createResolver(language)
177+
const state = new ExecutionState()
178+
state.setBlockOutput(start.id, output)
179+
ctx.blockStates = state.getBlockStates()
180+
const workflow: SerializedWorkflow = {
181+
version: '1',
182+
blocks: [start, functionBlock],
183+
connections: [],
184+
loops: {},
185+
parallels: {},
186+
}
187+
const resolver = new VariableResolver(workflow, {}, state, { navigatePathAsync })
188+
const result = await resolver.resolveInputsForFunctionBlock(
189+
ctx,
190+
functionBlock.id,
191+
{ code: 'IN="<start.files[0].path>"' },
192+
functionBlock
193+
)
194+
195+
expect(collectSandboxFileMountRefs(result.contextVariables)).toEqual([
196+
{ ...file, key, context: 'workspace' },
197+
])
198+
expect(
199+
replaceSandboxFileMountRefs(result.contextVariables, () => '/tmp/sim/inputs/photo.png')
200+
).toEqual({ __blockRef_0: '/tmp/sim/inputs/photo.png' })
201+
expect(result.resolvedInputs.code).not.toContain('<start.files[0].path>')
202+
expect(result.resolvedInputs.code).not.toContain('null')
203+
if (language === 'shell') {
204+
expect(result.resolvedInputs.code).toBe(`IN="\${__blockRef_0}"`)
205+
}
206+
})
207+
})
208+
130209
/** Runs one condition expression through the resolver and returns the value the handler receives. */
131210
async function resolveConditionExpression(
132211
value: string,

apps/sim/lib/mcp/workflow-tool-schema.ts

Lines changed: 12 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,9 @@
11
import { z } from 'zod'
2+
import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types'
23
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
4+
import { generateWorkflowInputShape } from '@/lib/workflows/input-schema'
35
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
46
import type { InputFormatField } from '@/lib/workflows/types'
5-
import type { McpToolSchema } from './types'
6-
7-
/**
8-
* Extended property definition for workflow tool schemas.
9-
* More specific than the generic McpToolSchema properties.
10-
*/
11-
export interface McpToolProperty {
12-
[key: string]: unknown
13-
type: string
14-
description?: string
15-
items?: McpToolProperty
16-
properties?: Record<string, McpToolProperty>
17-
}
18-
19-
/**
20-
* Extended MCP tool schema with typed properties (for workflow tool generation).
21-
* Extends the base McpToolSchema with more specific property types.
22-
*/
23-
export interface McpToolInputSchema extends McpToolSchema {
24-
properties: Record<string, McpToolProperty>
25-
}
26-
27-
export interface McpToolDefinition {
28-
name: string
29-
description: string
30-
inputSchema: McpToolInputSchema
31-
}
32-
33-
/**
34-
* File item Zod schema for MCP file inputs.
35-
* This is the single source of truth for file structure.
36-
*/
37-
export const fileItemZodSchema = z.object({
38-
name: z.string().describe('File name'),
39-
data: z.string().describe('Base64 encoded file content'),
40-
mimeType: z.string().describe('MIME type of the file'),
41-
})
42-
43-
/**
44-
* Convert InputFormatField type to Zod schema
45-
*/
46-
function fieldTypeToZod(fieldType: string | undefined, isRequired: boolean): z.ZodTypeAny {
47-
let zodType: z.ZodTypeAny
48-
49-
switch (fieldType) {
50-
case 'string':
51-
zodType = z.string()
52-
break
53-
case 'number':
54-
zodType = z.number()
55-
break
56-
case 'boolean':
57-
zodType = z.boolean()
58-
break
59-
case 'object':
60-
zodType = z.record(z.string(), z.any())
61-
break
62-
case 'array':
63-
zodType = z.array(z.any())
64-
break
65-
case 'files':
66-
zodType = z.array(fileItemZodSchema)
67-
break
68-
default:
69-
zodType = z.string()
70-
}
71-
72-
return isRequired ? zodType : zodType.optional()
73-
}
74-
75-
/**
76-
* Generate Zod schema shape from InputFormatField array.
77-
* This is used directly by the MCP server for tool registration.
78-
*/
79-
export function generateToolZodSchema(inputFormat: InputFormatField[]): z.ZodRawShape | undefined {
80-
if (!inputFormat || inputFormat.length === 0) {
81-
return undefined
82-
}
83-
84-
const shape: Record<string, z.ZodTypeAny> = {}
85-
86-
for (const field of inputFormat) {
87-
if (!field.name) continue
88-
89-
const zodType = fieldTypeToZod(field.type, true)
90-
shape[field.name] = field.name ? zodType.describe(field.name) : zodType
91-
}
92-
93-
return Object.keys(shape).length > 0 ? shape : undefined
94-
}
95-
96-
/**
97-
* Map InputFormatField type to JSON Schema type (for database storage)
98-
*/
99-
function mapFieldTypeToJsonSchemaType(fieldType: string | undefined): string {
100-
switch (fieldType) {
101-
case 'string':
102-
return 'string'
103-
case 'number':
104-
return 'number'
105-
case 'boolean':
106-
return 'boolean'
107-
case 'object':
108-
return 'object'
109-
case 'array':
110-
return 'array'
111-
case 'files':
112-
return 'array'
113-
default:
114-
return 'string'
115-
}
116-
}
1177

1188
/**
1199
* Sanitize a workflow name to be a valid MCP tool name.
@@ -136,54 +26,15 @@ export function sanitizeToolName(name: string): string {
13626
* This converts the workflow's input format definition to JSON Schema format
13727
* that MCP clients can use to understand tool parameters.
13828
*/
139-
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolInputSchema {
140-
const properties: Record<string, McpToolProperty> = {}
141-
const required: string[] = []
142-
143-
for (const field of inputFormat) {
144-
if (!field.name) continue
145-
146-
const fieldName = field.name
147-
const fieldType = mapFieldTypeToJsonSchemaType(field.type)
148-
149-
const property: McpToolProperty = {
150-
type: fieldType,
151-
// Use custom description if provided, otherwise use field name
152-
description: field.description?.trim() || fieldName,
153-
}
154-
155-
// Handle array types
156-
if (fieldType === 'array') {
157-
if (field.type === 'file[]') {
158-
property.items = {
159-
type: 'object',
160-
properties: {
161-
name: { type: 'string', description: 'File name' },
162-
url: { type: 'string', description: 'File URL' },
163-
type: { type: 'string', description: 'MIME type' },
164-
size: { type: 'number', description: 'File size in bytes' },
165-
},
166-
}
167-
// Use custom description if provided, otherwise use default
168-
if (!field.description?.trim()) {
169-
property.description = 'Array of file objects'
170-
}
171-
} else {
172-
property.items = { type: 'string' }
173-
}
174-
}
175-
176-
properties[fieldName] = property
177-
178-
// All fields are considered required by default
179-
// (in the future, we could add an optional flag to InputFormatField)
180-
required.push(fieldName)
181-
}
182-
29+
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolSchema {
30+
const schema = z.toJSONSchema(z.object(generateWorkflowInputShape(inputFormat)), {
31+
target: 'draft-07',
32+
io: 'input',
33+
})
18334
return {
18435
type: 'object',
185-
properties,
186-
required: required.length > 0 ? required : undefined,
36+
properties: schema.properties as Record<string, McpToolSchemaProperty>,
37+
...(schema.required?.length ? { required: schema.required } : {}),
18738
}
18839
}
18940

@@ -198,10 +49,10 @@ export function applyDescriptionOverrides(
19849
overrides: Record<string, string> | null | undefined
19950
): Record<string, unknown> {
20051
if (!overrides || Object.keys(overrides).length === 0) return baseSchema
201-
const baseProperties = baseSchema.properties as Record<string, McpToolProperty> | undefined
52+
const baseProperties = baseSchema.properties as Record<string, McpToolSchemaProperty> | undefined
20253
if (!baseProperties) return baseSchema
20354

204-
const properties: Record<string, McpToolProperty> = {}
55+
const properties: Record<string, McpToolSchemaProperty> = {}
20556
for (const [name, property] of Object.entries(baseProperties)) {
20657
const override = overrides[name]
20758
properties[name] =
@@ -244,7 +95,7 @@ export function extractDescriptionOverrides(
24495
| Record<string, { description?: unknown }>
24596
| undefined
24697
if (!schemaProperties) return overrides
247-
const baseProperties = (baseSchema.properties ?? {}) as Record<string, McpToolProperty>
98+
const baseProperties = (baseSchema.properties ?? {}) as Record<string, McpToolSchemaProperty>
24899

249100
for (const [name, property] of Object.entries(schemaProperties)) {
250101
if (!(name in baseProperties)) continue

apps/sim/lib/workflows/input-format.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export type InputFormatFile = Pick<UserFile, 'id' | 'name' | 'url' | 'size' | 't
8484
* mirrors `normalizeStartFile` exactly (including the parse, so a malformed
8585
* internal URL is rejected rather than accepted on the prefix alone).
8686
*/
87-
function hasRecoverableFileKey(file: InputFormatFile): boolean {
87+
export function hasRecoverableFileKey(file: InputFormatFile): boolean {
8888
if (typeof file.key === 'string' && file.key.length > 0) return true
8989
if (typeof file.url !== 'string' || !isInternalFileUrl(file.url)) return false
9090
try {

0 commit comments

Comments
 (0)