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/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -3701,7 +3701,7 @@
"post": {
"operationId": "executeWorkflowV2",
"summary": "Execute workflow",
"description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.",
"description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.",
"x-sim-operation": "workflows.execute",
"x-oauth-scope": "api:write",
"tags": ["Workflows"],
Expand Down Expand Up @@ -3891,7 +3891,7 @@
}
},
"202": {
"description": "The asynchronous run was queued.",
"description": "The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.",
"headers": {
"X-Run-Id": {
"$ref": "#/components/headers/X-Run-Id"
Expand Down Expand Up @@ -14267,7 +14267,7 @@
"required": ["data"],
"additionalProperties": false,
"title": "Queued workflow execution response",
"description": "Receipt returned for an asynchronous workflow run.",
"description": "Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.",
"examples": [
{
"data": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { createFieldFromOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields'

describe('createFieldFromOutput', () => {
it.each([
['untyped elements', { type: 'any' }],
['primitive elements', { type: 'string' }],
])('offers no child fields for an array of %s', (_, items) => {
const field = createFieldFromOutput('rows', { type: 'array', items })
expect(field.children).toBeUndefined()
})

it('offers the declared fields of object elements', () => {
const field = createFieldFromOutput('rows', {
type: 'array',
items: { type: 'object', properties: { id: { type: 'string' } } },
})
expect(field.children?.map((child) => child.name)).toEqual(['id'])
})

it('keeps the fields of an untyped object that has a field named items', () => {
const field = createFieldFromOutput('page', {
items: { type: 'string' },
total: { type: 'number' },
})
expect(field.children?.map((child) => child.name)).toEqual(['items', 'total'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const RESERVED_KEYS = new Set(['type', 'description'])
/**
* Checks if a property is an object type
*/
const isObject = (prop: any): boolean => prop && typeof prop === 'object'
const isObject = (prop: unknown): prop is Record<string, any> =>
prop !== null && typeof prop === 'object'

/**
* Gets a subblock value from the store
Expand All @@ -35,6 +36,11 @@ const extractChildFields = (prop: any): SchemaField[] | undefined => {
return extractNestedFields(prop.items.properties)
}

/** Typed elements without `properties` (strings, `any`) have no fields to reference. */
if (prop.type === 'array' && isObject(prop.items) && typeof prop.items.type === 'string') {
return undefined
}

if (!('type' in prop)) {
return extractNestedFields(prop)
}
Expand Down Expand Up @@ -71,7 +77,7 @@ const extractNestedFields = (properties: Record<string, any>): SchemaField[] =>
/**
* Creates a schema field from an output definition
*/
const createFieldFromOutput = (name: string, output: any): SchemaField => {
export const createFieldFromOutput = (name: string, output: unknown): SchemaField => {
const hasExplicitType = isObject(output) && typeof output.type === 'string'
const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string'

Expand Down
27 changes: 27 additions & 0 deletions apps/sim/executor/utils/block-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,33 @@ describe('resolveBlockReference', () => {
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
})

it('rejects an undeclared field under a json field that declares its properties', () => {
const ctx = createContext({
blockData: { 'block-1': { result: { summary: 'hello' } } },
blockOutputSchemas: {
'block-1': { result: { type: 'json', properties: { summary: { type: 'string' } } } },
},
})

expect(resolveBlockReference('start', ['result', 'summary'], ctx)?.value).toBe('hello')
expect(() => resolveBlockReference('start', ['result', 'typo'], ctx)).toThrow(
InvalidFieldError
)
})

it('accepts an undeclared field under an open json field that declares its properties', () => {
const ctx = createContext({
blockData: { 'block-1': { result: { summary: 'hello' } } },
blockOutputSchemas: {
'block-1': {
result: { type: 'json', properties: { summary: { type: 'string' } }, open: true },
},
},
})

expect(resolveBlockReference('start', ['result', 'extra'], ctx)?.value).toBeUndefined()
})

it('should resolve values nested under json field on executed block', () => {
const ctx = createContext({
blockData: { 'block-1': { result: { summary: 'hello' } } },
Expand Down
19 changes: 16 additions & 3 deletions apps/sim/executor/utils/block-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface SchemaNode {
type?: string
description?: string
properties?: unknown
/** Fields beyond `properties` are allowed, so a path past them is not checked. */
open?: boolean
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
items?: unknown
}

Expand Down Expand Up @@ -72,15 +74,25 @@ function getProperties(schema: unknown): Record<string, unknown> | undefined {
: undefined
}

/** A node declaring its fields but allowing others, so a field it does not name is not an error. */
function isOpenSchemaNode(value: unknown): boolean {
return asSchemaNode(value)?.open === true
}

function lookupField(schema: unknown, fieldName: string): unknown | undefined {
if (typeof schema !== 'object' || schema === null) return undefined
const typed = schema as Record<string, unknown>
const props = getProperties(schema)

/** An open node's fields are its `properties`; its own keys are schema metadata, never fields. */
if (isOpenSchemaNode(schema)) {
return props && fieldName in props ? props[fieldName] : undefined
}

if (fieldName in typed) {
return typed[fieldName]
}

const props = getProperties(schema)
if (props && fieldName in props) {
return props[fieldName]
}
Expand Down Expand Up @@ -140,7 +152,7 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri
if (arrayMatch) {
const [, prop] = arrayMatch
const fieldDef = lookupField(current, prop)
if (!fieldDef) return false
if (!fieldDef) return isOpenSchemaNode(current)

if (isFileType(fieldDef)) {
const nextPart = pathParts[i + 1]
Expand Down Expand Up @@ -194,9 +206,10 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri
current = itemField
continue
}
return isOpenSchemaNode(items)
}

return false
return isOpenSchemaNode(current)
}

return true
Expand Down
7 changes: 4 additions & 3 deletions apps/sim/lib/api/contracts/v2/openapi/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ const executeQueuedResponseSchema = documentedSchema(
v2ExecuteWorkflowQueuedResponseSchema,
'ExecuteWorkflowQueuedResponse',
'Queued workflow execution response',
'Receipt returned for an asynchronous workflow run.',
'Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.',
[QUEUED_RUN_EXAMPLE]
)

Expand Down Expand Up @@ -1194,7 +1194,7 @@ const declaredRoutes = [
applicationOperation: workflowOperations.execute,
operationId: 'executeWorkflowV2',
summary: 'Execute workflow',
description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`,
description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`,
errors: [
'BadRequest',
'Unauthorized',
Expand All @@ -1218,7 +1218,8 @@ const declaredRoutes = [
additionalContentTypes: ['application/x-ndjson', 'text/event-stream'],
},
202: {
description: 'The asynchronous run was queued.',
description:
'The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.',
headers: ['X-Run-Id', ...RATE_LIMIT_HEADERS],
},
},
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/mcp/generated/v2-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,7 +1246,7 @@ export const V2_MCP_OPERATIONS = {
contract: v2ExecuteWorkflowContract,
summary: 'Execute workflow',
description:
'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.',
'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.',
handler: () =>
import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST),
},
Expand Down
46 changes: 29 additions & 17 deletions apps/sim/lib/mcp/output-schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isRecordLike } from '@sim/utils/object'
import { isRecordLike, toRecord } from '@sim/utils/object'
import type { McpToolSchema } from '@/lib/mcp/types'

/**
Expand All @@ -15,15 +15,21 @@ const MAX_SHAPE_DEPTH = 8
const MAX_SHAPE_NODES = 500

/**
* The structure of a declared output schema: types, properties and array items
* only. Descriptions, enums and examples are dropped because the snapshot is a
* block input, and the executor and lint read every string in a block's inputs
* as a possible `<block.path>` reference.
* The structure of a declared output schema: types, properties, openness and
* array items only. Descriptions, enums and examples are dropped because the
* snapshot is a block input, and the executor and lint read every string in a
* block's inputs as a possible `<block.path>` reference.
*/
export interface McpToolOutputShape {
type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'any'
/** Declared fields of a closed object; absent when the object is open or undeclared. */
/** Declared fields of an object; absent when it declares none, which leaves every field allowed. */
properties?: Record<string, McpToolOutputShape>
/**
* The object allows fields beyond `properties`, as JSON Schema reads any object
* not declaring `additionalProperties: false`. A snapshot saved before this was
* recorded lacks it and stays closed until the operation is picked again.
*/
open?: true
items?: McpToolOutputShape
}

Expand Down Expand Up @@ -67,23 +73,28 @@ function reduceNode(node: unknown, depth: number, budget: { nodes: number }): Mc

switch (declaredType(node)) {
case 'object': {
const open =
(node.additionalProperties !== undefined && node.additionalProperties !== false) ||
node.patternProperties !== undefined
if (open || !isRecordLike(node.properties)) return { type: 'object' }
const closed = node.additionalProperties === false && node.patternProperties === undefined
if (!closed && !isRecordLike(node.properties)) return { type: 'object' }
const entries: [string, McpToolOutputShape][] = []
for (const [name, child] of Object.entries(node.properties)) {
/** Past the budget the object stays open: any field resolves, none is saved. */
for (const [name, child] of Object.entries(toRecord(node.properties))) {
/** Past the budget the object is saved untyped: any field resolves, none is declared. */
if (budget.nodes <= 0) return { type: 'object' }
entries.push([name, reduceNode(child, depth + 1, budget)])
}
/** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */
return { type: 'object', properties: Object.fromEntries(entries) }
return {
type: 'object',
/** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */
properties: Object.fromEntries(entries),
...(!closed && { open: true }),
}
}
case 'array':
return isRecordLike(node.items)
? { type: 'array', items: reduceNode(node.items, depth + 1, budget) }
: { type: 'array' }
return {
type: 'array',
items: isRecordLike(node.items)
? reduceNode(node.items, depth + 1, budget)
: { type: 'any' },
}
case 'string':
return { type: 'string' }
case 'number':
Expand Down Expand Up @@ -139,6 +150,7 @@ function isShape(value: unknown, depth = 0): value is McpToolOutputShape {
return false
}
if (depth > MAX_SHAPE_DEPTH + 1) return false
if (value.open !== undefined && value.open !== true) return false
if (value.items !== undefined && !isShape(value.items, depth + 1)) return false
if (value.properties === undefined) return true
return (
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/workflows/blocks/outputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@ export function getEvaluatorMetricOutputs(
interface DeclaredOutputNode {
type: PrimitiveValueType
properties?: Record<string, DeclaredOutputNode>
/** Fields beyond `properties` are allowed: a reference to one checks like any `json` path. */
open?: true
items?: DeclaredOutputNode
}

Expand All @@ -449,6 +451,7 @@ function toOutputNode(shape: McpToolOutputShape): DeclaredOutputNode {
properties: Object.fromEntries(
Object.entries(shape.properties).map(([name, child]) => [name, toOutputNode(child)])
),
...(shape.open && { open: true }),
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
}
: { type: 'json' }
case 'array':
Expand Down
Loading
Loading