From 0bc7538d4ac0c6fd6b96e02085f80ca615465bb9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 12:38:16 -0800 Subject: [PATCH 1/3] improvement(workflows): lint the manual-run entry check and surface lint findings in apply warnings --- apps/docs/openapi-v2-workflows.json | 74 ++++++++++++++- apps/sim/app/api/v2/lib/workflow-lint.ts | 9 ++ .../[workflowId]/deploy/route.test.ts | 1 + .../[workflowId]/operations/route.test.ts | 1 + .../[workflowId]/state/route.test.ts | 1 + .../hooks/use-workflow-execution.ts | 60 ++---------- .../lib/api/contracts/v2/openapi/workflows.ts | 6 +- .../api/contracts/v2/workflow-graph.test.ts | 8 ++ apps/sim/lib/api/contracts/v2/workflows.ts | 22 +++++ .../agent-cli/engines/lint-scope.test.ts | 1 + .../apply-workflow-operations.test.ts | 31 ++++++- .../application/apply-workflow-operations.ts | 10 +- .../workflows/application/deployments.test.ts | 1 + .../lib/workflows/editing/lint-report.test.ts | 91 +++++++++++++++++++ apps/sim/lib/workflows/editing/lint-report.ts | 27 ++++++ apps/sim/lib/workflows/editing/lint.ts | 15 ++- apps/sim/lib/workflows/triggers/manual-run.ts | 78 ++++++++++++++++ apps/sim/lib/workflows/triggers/utils.ts | 4 +- packages/sim-cli/src/generated/v2-api.ts | 21 +++++ 19 files changed, 402 insertions(+), 59 deletions(-) create mode 100644 apps/sim/lib/workflows/triggers/manual-run.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index aea9b3fa5c8..648eb7c7b97 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -549,6 +549,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } @@ -722,9 +723,12 @@ ], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] }, - "warnings": [], + "warnings": [ + "Workflow lint found issues. Fix these before continuing: Blocks missing required fields: \"Triage\" (systemPrompt)" + ], "needsRedeployment": true, "dryRun": false } @@ -2655,6 +2659,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } @@ -8813,6 +8818,63 @@ }, "description": "Table block `filter` and `order` fields checked against the bound table's live schema that name no column (nor the implicit `id`, `createdAt`, `updatedAt`). Such a run fails inside the block's error edge. A filter holding a `` reference, or one that is not JSON, is not checked." }, + "manualRunIssue": { + "anyOf": [ + { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": ["no-trigger", "multiple-api-triggers", "trigger-not-connected"], + "description": "Why the run is refused." + }, + "message": { + "type": "string", + "description": "The error the manual run raises." + }, + "blockId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Trigger the run would start from, when the problem is that trigger." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of that trigger." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of that trigger." + } + }, + "required": ["reason", "message", "blockId", "blockName", "blockType"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Why a manual run from the editor's Run button would be refused before it starts: no enabled trigger it can start from, more than one API trigger, or a selected trigger with no outgoing connection. `null` when a manual run can begin. Not raised for a workflow only a chat trigger can start." + }, "notes": { "type": "array", "items": { @@ -8831,6 +8893,7 @@ "fieldIssues", "unresolvedReferences", "tableFieldIssues", + "manualRunIssue", "notes" ], "additionalProperties": false, @@ -8897,6 +8960,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } @@ -9461,7 +9525,7 @@ "items": { "type": "string" }, - "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence and, when `lint` has findings, one summary of them, so the findings are not lost to a caller that reads only `warnings`. Empty when there is nothing to report." }, "needsRedeployment": { "type": "boolean", @@ -9581,9 +9645,12 @@ ], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] }, - "warnings": [], + "warnings": [ + "Workflow lint found issues. Fix these before continuing: Blocks missing required fields: \"Triage\" (systemPrompt)" + ], "needsRedeployment": true, "dryRun": false } @@ -12554,6 +12621,7 @@ "fieldIssues": [], "unresolvedReferences": [], "tableFieldIssues": [], + "manualRunIssue": null, "notes": [] } } diff --git a/apps/sim/app/api/v2/lib/workflow-lint.ts b/apps/sim/app/api/v2/lib/workflow-lint.ts index 4a5567a158f..eaba56d271e 100644 --- a/apps/sim/app/api/v2/lib/workflow-lint.ts +++ b/apps/sim/app/api/v2/lib/workflow-lint.ts @@ -62,6 +62,15 @@ export function presentWorkflowLint(lint: WorkflowLintReport) { field: issue.field, tableName: issue.tableName, })), + manualRunIssue: lint.manualRunIssue + ? { + reason: lint.manualRunIssue.reason, + message: lint.manualRunIssue.message, + blockId: lint.manualRunIssue.blockId ?? null, + blockName: lint.manualRunIssue.blockName ?? null, + blockType: lint.manualRunIssue.blockType ?? null, + } + : null, notes: lint.notes, } } diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts index 0a52ffa8ab7..2f1491338e7 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts @@ -61,6 +61,7 @@ const unquotedRowJson = { }, ], tableFieldIssues: [], + manualRunIssue: null, notes: [], } diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts index 87fbe9a36dc..c406e0b6a96 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts @@ -52,6 +52,7 @@ const LINT = { fieldIssues: [], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: [], } diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts index 5828a6e47b0..b47cf8a1f32 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts @@ -73,6 +73,7 @@ const EMPTY_LINT = { fieldIssues: [], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: [], } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index ab83c2f062d..963806756d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -29,16 +29,13 @@ import type { StreamToolData, } from '@/lib/workflows/executor/execution-events' import { collectInputFormatFiles, isFileFieldType } from '@/lib/workflows/input-format' +import { resolveManualRunEntry } from '@/lib/workflows/triggers/manual-run' import { resolveStartCandidates, StartBlockPath, TriggerUtils, } from '@/lib/workflows/triggers/triggers' -import { - extractTriggerMockPayload, - selectBestTrigger, - triggerNeedsMockPayload, -} from '@/lib/workflows/triggers/utils' +import { extractTriggerMockPayload, triggerNeedsMockPayload } from '@/lib/workflows/triggers/utils' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' import { getRunFromBlockDependencyState } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/run-from-block' import { @@ -1096,63 +1093,26 @@ export function useWorkflowExecution() { startBlockId = startBlock.blockId } else { - const candidates = resolveStartCandidates(enabledStates, { - execution: 'manual', - }) - - if (candidates.length === 0) { - const error = new WorkflowValidationError( - 'Workflow requires at least one trigger block to execute', - 'validation', - 'validation', - 'Workflow Validation' - ) - logger.error('No trigger blocks found for manual run', { + const entry = resolveManualRunEntry(enabledStates, workflowEdges) + if (!entry.ok) { + logger.error('Manual run has no valid entry', { + reason: entry.issue.reason, + startBlockId: entry.issue.blockId, allBlockTypes: Object.values(enabledStates).map((b) => b.type), }) if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) - throw error - } - - // Check for multiple API triggers (still not allowed) - const apiCandidates = candidates.filter( - (candidate) => candidate.path === StartBlockPath.SPLIT_API - ) - if (apiCandidates.length > 1) { - const error = new WorkflowValidationError( - 'Multiple API Trigger blocks found. Keep only one.', + throw new WorkflowValidationError( + entry.issue.message, 'validation', 'validation', 'Workflow Validation' ) - logger.error('Multiple API triggers found') - if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) - throw error } - // Priority: Start Block > Schedules > External Triggers > Legacy - const selectedTriggers = selectBestTrigger(candidates, workflowEdges) - - const selectedCandidate = selectedTriggers[0] + const selectedCandidate = entry.candidate startBlockId = selectedCandidate.blockId const selectedTrigger = selectedCandidate.block - if (selectedCandidate.path !== StartBlockPath.LEGACY_STARTER) { - const outgoingConnections = workflowEdges.filter((edge) => edge.source === startBlockId) - if (outgoingConnections.length === 0) { - const triggerName = selectedTrigger.name || selectedTrigger.type - const error = new WorkflowValidationError( - `${triggerName} must be connected to other blocks to execute`, - 'validation', - 'validation', - 'Workflow Validation' - ) - logger.error('Trigger has no outgoing connections', { triggerName, startBlockId }) - if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) - throw error - } - } - if (triggerNeedsMockPayload(selectedCandidate)) { const mockPayload = extractTriggerMockPayload(selectedCandidate) finalWorkflowInput = mockPayload diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index b991553fb4b..32736ff772e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -116,6 +116,7 @@ const EMPTY_LINT_EXAMPLE = { fieldIssues: [], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: [], } as const @@ -419,9 +420,12 @@ const declaredRoutes = [ ], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: [], }, - warnings: [], + warnings: [ + 'Workflow lint found issues. Fix these before continuing: Blocks missing required fields: "Triage" (systemPrompt)', + ], needsRedeployment: true, dryRun: false, }, diff --git a/apps/sim/lib/api/contracts/v2/workflow-graph.test.ts b/apps/sim/lib/api/contracts/v2/workflow-graph.test.ts index 6b1de7058e4..19cb2abef67 100644 --- a/apps/sim/lib/api/contracts/v2/workflow-graph.test.ts +++ b/apps/sim/lib/api/contracts/v2/workflow-graph.test.ts @@ -103,6 +103,7 @@ const EMPTY_LINT = { fieldIssues: [], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: [], } @@ -174,6 +175,13 @@ const FULL_LINT = { tableName: 'Leads', }, ], + manualRunIssue: { + reason: 'trigger-not-connected', + message: 'Start must be connected to other blocks to execute', + blockId: 'block-1', + blockName: 'Start', + blockType: 'start_trigger', + }, notes: ['lint note'], } diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index a441518b467..84f6515a8da 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -439,6 +439,23 @@ const v2WorkflowLintSchema = z .describe( "Table block `filter` and `order` fields checked against the bound table's live schema that name no column (nor the implicit `id`, `createdAt`, `updatedAt`). Such a run fails inside the block's error edge. A filter holding a `` reference, or one that is not JSON, is not checked." ), + manualRunIssue: z + .object({ + reason: z + .enum(['no-trigger', 'multiple-api-triggers', 'trigger-not-connected']) + .describe('Why the run is refused.'), + message: z.string().describe('The error the manual run raises.'), + blockId: z + .string() + .nullable() + .describe('Trigger the run would start from, when the problem is that trigger.'), + blockName: z.string().nullable().describe('Display name of that trigger.'), + blockType: z.string().nullable().describe('Registered type of that trigger.'), + }) + .nullable() + .describe( + "Why a manual run from the editor's Run button would be refused before it starts: no enabled trigger it can start from, more than one API trigger, or a selected trigger with no outgoing connection. `null` when a manual run can begin. Not raised for a workflow only a chat trigger can start." + ), notes: z.array(z.string()).describe('Advisory notes about the report itself.'), }) .meta({ @@ -3626,6 +3643,11 @@ export const v2ApplyWorkflowOperationsDataSchema = v2WorkflowGraphWriteResultSch 'Dry run only: the provisional id the evaluation assigned to each block whose `block_id` was not already a UUID, keyed by the `block_id` you asked for. These are not the ids a committed apply produces — the real apply mints new ones — so never wire a later request against them. Wire edges by `block_id` within one batch, or by the `mintedBlockIds` the real apply returns.' ), lint: v2WorkflowLintSchema, + warnings: z + .array(z.string()) + .describe( + 'Non-fatal notes about blocks and edges that were normalized or dropped before persistence and, when `lint` has findings, one summary of them, so the findings are not lost to a caller that reads only `warnings`. Empty when there is nothing to report.' + ), dryRun: z .boolean() .describe( diff --git a/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts b/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts index 9affedd2e0b..1635d443774 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts @@ -92,6 +92,7 @@ describe('Mothership workflow lint scope', () => { fieldIssues: [], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: [], }) mocks.secrets.mockResolvedValue({ secrets: [{ envKey: 'WORKFLOW_KEY' }], nextCursorKeys: null }) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 490cf941a9a..cf733649712 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -77,7 +77,8 @@ vi.mock('@/lib/workflows/editing/validation', () => ({ preValidateCredentialInputs: hoisted.preValidate, UNRESOLVABLE_AT_LINT_NOTE: 'lint note', })) -vi.mock('@/lib/workflows/editing/lint', () => ({ +vi.mock('@/lib/workflows/editing/lint', async (importOriginal) => ({ + ...(await importOriginal()), collectWorkflowFieldIssues: () => [], collectDanglingBlockOutputReferences: () => [], collectUnquotedJsonStringReferences: () => [], @@ -257,10 +258,38 @@ describe('applyWorkflowOperations', () => { fieldIssues: [], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: ['No entry block: nothing can start this workflow.'], }) }) + /** + * An agent piping the apply response through a projection of `warnings` + * never saw lint findings, which lived only under `lint`. + */ + it('summarizes lint findings in warnings, on commit and on dry run', async () => { + const orphan = { blockId: 'block-1', blockName: 'Start', blockType: 'starter' } + mocks.lintGraph.mockReturnValue({ ...EMPTY_GRAPH_LINT, orphanBlocks: [orphan] }) + + for (const dryRun of [false, true]) { + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun }, + }) + expect(result.warnings).toEqual([ + 'Workflow lint found issues. Fix these before continuing: Blocks with no incoming edge: "Start" (starter)', + ]) + } + }) + + it('adds no lint warning when lint finds nothing', async () => { + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + expect(result.warnings).toEqual([]) + }) + describe('dry run', () => { /** * The engine mints a UUID for every non-UUID `block_id` on each call, so a diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index c3f9e161e51..44c04a6690d 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -39,6 +39,7 @@ import { } from '@/lib/workflows/editing/block-enablement' import { applyOperationsToWorkflowState } from '@/lib/workflows/editing/engine' import type { WorkflowLintReport } from '@/lib/workflows/editing/lint' +import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint' import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' import { operationsReferenceSimSandbox } from '@/lib/workflows/editing/sandbox-projection' import { @@ -378,6 +379,12 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ input.checkAborted?.() + /** + * The findings also ride in `warnings`, where a caller that projects the + * response down to its warnings still sees them. + */ + const lintWarnings = hasWorkflowLintIssues(lint) ? [formatWorkflowLintMessage(lint)] : [] + /** * A dry run still runs the whole engine — operations are applied, refusals * collected, the result validated and linted — and stops at the write. An @@ -433,6 +440,7 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ ...validation.warnings, ...prepared.warnings, ...(Object.keys(mintedBlockIds).length > 0 ? [DRY_RUN_PREVIEW_BLOCK_IDS_WARNING] : []), + ...lintWarnings, ], needsRedeployment: await checkNeedsRedeployment(context.workflowId), dryRun: true, @@ -473,7 +481,7 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ inputValidationErrors: validationErrors, mintedBlockIds, lint, - warnings: [...validation.warnings, ...persisted.warnings], + warnings: [...validation.warnings, ...persisted.warnings, ...lintWarnings], needsRedeployment: await checkNeedsRedeployment(context.workflowId), dryRun: false, } diff --git a/apps/sim/lib/workflows/application/deployments.test.ts b/apps/sim/lib/workflows/application/deployments.test.ts index 706b80eb242..b1c46c44bfc 100644 --- a/apps/sim/lib/workflows/application/deployments.test.ts +++ b/apps/sim/lib/workflows/application/deployments.test.ts @@ -93,6 +93,7 @@ const cleanLint = { fieldIssues: [], unresolvedReferences: [], tableFieldIssues: [], + manualRunIssue: null, notes: [], } diff --git a/apps/sim/lib/workflows/editing/lint-report.test.ts b/apps/sim/lib/workflows/editing/lint-report.test.ts index db87e259cfa..e090204634e 100644 --- a/apps/sim/lib/workflows/editing/lint-report.test.ts +++ b/apps/sim/lib/workflows/editing/lint-report.test.ts @@ -143,6 +143,97 @@ describe('buildWorkflowLintReport notes', () => { }) }) +/** + * The editor's Run button refuses some graphs before they start. Lint said + * nothing about them, so an agent-built workflow looked clean until a user + * pressed Run. + */ +describe('buildWorkflowLintReport manual run', () => { + async function manualRunIssue(blocks: Record, edges: unknown[]) { + return (await buildWorkflowLintReport({ blocks, edges } as never, scope)).manualRunIssue + } + + it('reports a trigger with no outgoing connection, naming it', async () => { + expect( + await manualRunIssue( + { start: block('start', 'start_trigger'), agent: block('agent', 'agent') }, + [] + ) + ).toEqual({ + reason: 'trigger-not-connected', + message: 'start must be connected to other blocks to execute', + blockId: 'start', + blockName: 'start', + blockType: 'start_trigger', + }) + }) + + it('reports a graph with no trigger a manual run can start from', async () => { + expect( + await manualRunIssue({ a: block('a', 'function'), b: block('b', 'function') }, [ + edge('a', 'b'), + ]) + ).toMatchObject({ + reason: 'no-trigger', + message: 'Workflow requires at least one trigger block to execute', + }) + }) + + it('reports a disabled trigger as no trigger', async () => { + expect( + await manualRunIssue( + { + start: { ...block('start', 'start_trigger'), enabled: false }, + fn: block('fn', 'function'), + }, + [edge('start', 'fn')] + ) + ).toMatchObject({ reason: 'no-trigger' }) + }) + + it('reports more than one API trigger', async () => { + expect( + await manualRunIssue( + { + api1: block('api1', 'api_trigger'), + api2: block('api2', 'api_trigger'), + fn: block('fn', 'function'), + }, + [edge('api1', 'fn'), edge('api2', 'fn')] + ) + ).toMatchObject({ + reason: 'multiple-api-triggers', + message: 'Multiple API Trigger blocks found. Keep only one.', + }) + }) + + it.each([ + ['a connected Start block', 'start_trigger'], + ['a connected webhook trigger', 'webhook'], + ['a connected schedule trigger', 'schedule'], + ])('passes %s', async (_label, type) => { + expect( + await manualRunIssue({ t: block('t', type), fn: block('fn', 'function') }, [edge('t', 'fn')]) + ).toBeNull() + }) + + it('passes a legacy starter without connections, which a manual run accepts', async () => { + expect(await manualRunIssue({ start: block('start', 'starter') }, [])).toBeNull() + }) + + it('passes a chat-only workflow, which runs from chat rather than the Run button', async () => { + expect( + await manualRunIssue({ chat: block('chat', 'chat_trigger'), fn: block('fn', 'function') }, [ + edge('chat', 'fn'), + ]) + ).toBeNull() + }) + + it('leaves an empty graph to its own note', async () => { + expect(await manualRunIssue({}, [])).toBeNull() + }) +}) + /** * The report builder is the one place with database access, so it resolves * each Table block's filter and sort fields against the bound table's live diff --git a/apps/sim/lib/workflows/editing/lint-report.ts b/apps/sim/lib/workflows/editing/lint-report.ts index 5963ae669b4..c35fc1a3dfc 100644 --- a/apps/sim/lib/workflows/editing/lint-report.ts +++ b/apps/sim/lib/workflows/editing/lint-report.ts @@ -21,6 +21,11 @@ import { collectUnresolvedReferences, UNRESOLVABLE_AT_LINT_NOTE, } from '@/lib/workflows/editing/validation' +import { + type ManualRunEntryIssue, + resolveManualRunEntry, +} from '@/lib/workflows/triggers/manual-run' +import { resolveStartCandidates } from '@/lib/workflows/triggers/triggers' const logger = createLogger('WorkflowLintReport') @@ -52,6 +57,27 @@ export const EMPTY_GRAPH_NOTE = */ export const NO_ENTRY_BLOCK_NOTE = 'No entry block: nothing can start this workflow.' +/** + * What the editor's Run button would refuse. A graph with no manual entry that + * a chat can still start is a chat workflow, valid for its trigger, so it is + * not reported; an empty graph has its own note. + */ +function collectManualRunIssue( + graph: Pick +): ManualRunEntryIssue | null { + const blocks = graph.blocks ?? {} + if (Object.keys(blocks).length === 0) return null + const entry = resolveManualRunEntry(blocks, graph.edges ?? []) + if (entry.ok) return null + if ( + entry.issue.reason === 'no-trigger' && + resolveStartCandidates(blocks, { execution: 'chat' }).length > 0 + ) { + return null + } + return entry.issue +} + export interface WorkflowLintScope { workflowId: string workspaceId: string @@ -206,6 +232,7 @@ export async function buildWorkflowLintReport( fieldIssues: collectWorkflowFieldIssues(graph.blocks), unresolvedReferences, tableFieldIssues, + manualRunIssue: collectManualRunIssue(graph), notes, } } diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index abd5acfcf11..67bf2d37522 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -12,6 +12,7 @@ import { getResponseFormatOutputs, } from '@/lib/workflows/blocks/outputs' import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import type { ManualRunEntryIssue } from '@/lib/workflows/triggers/manual-run' import { getBlock } from '@/blocks' import { isTriggerBlockType, @@ -116,6 +117,12 @@ export interface WorkflowLintReport extends WorkflowLintResult { fieldIssues: WorkflowLintFieldIssue[] unresolvedReferences: WorkflowLintUnresolvedReference[] tableFieldIssues: WorkflowLintTableFieldIssue[] + /** + * Why the editor's Run button would refuse this graph before it starts, or + * `null` when a manual run can begin. Not raised for a graph a manual run + * cannot start but a chat can (a chat-trigger-only workflow). + */ + manualRunIssue: ManualRunEntryIssue | null notes: string[] } @@ -420,6 +427,7 @@ type WorkflowLintIssueView = WorkflowLintResult & { fieldIssues?: WorkflowLintFieldIssue[] unresolvedReferences?: WorkflowLintUnresolvedReference[] tableFieldIssues?: WorkflowLintTableFieldIssue[] + manualRunIssue?: ManualRunEntryIssue | null } export function hasWorkflowLintIssues(lint: WorkflowLintIssueView) { @@ -430,7 +438,8 @@ export function hasWorkflowLintIssues(lint: WorkflowLintIssueView) { lint.invalidConnectionTargets.length > 0 || (lint.fieldIssues?.length ?? 0) > 0 || (lint.unresolvedReferences?.length ?? 0) > 0 || - (lint.tableFieldIssues?.length ?? 0) > 0 + (lint.tableFieldIssues?.length ?? 0) > 0 || + lint.manualRunIssue != null ) } @@ -548,6 +557,10 @@ export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) { ) } + if (lint.manualRunIssue) { + parts.push(`A manual run would fail: ${lint.manualRunIssue.message}`) + } + return `Workflow lint found issues. Fix these before continuing: ${parts.join('; ')}` } diff --git a/apps/sim/lib/workflows/triggers/manual-run.ts b/apps/sim/lib/workflows/triggers/manual-run.ts new file mode 100644 index 00000000000..97f4035cb1d --- /dev/null +++ b/apps/sim/lib/workflows/triggers/manual-run.ts @@ -0,0 +1,78 @@ +import { + resolveStartCandidates, + type StartBlockCandidate, + StartBlockPath, +} from '@/lib/workflows/triggers/triggers' +import { selectBestTrigger } from '@/lib/workflows/triggers/utils' + +export interface ManualRunEntryIssue { + reason: 'no-trigger' | 'multiple-api-triggers' | 'trigger-not-connected' + /** The error a manual run raises, word for word. */ + message: string + /** The trigger the run would start from, when the problem is that trigger. */ + blockId?: string + blockName?: string + blockType?: string +} + +type ManualRunBlock = { + type: string + name?: string + enabled?: boolean + subBlocks?: Record +} + +type ManualRunEntry = + | { ok: true; candidate: StartBlockCandidate } + | { ok: false; issue: ManualRunEntryIssue } + +/** + * Picks the block a manual (editor Run button) execution starts from, or the + * reason that run is refused before it begins. Disabled blocks never qualify. + * Shared by the run hook and the workflow lint so the two cannot disagree on + * what a manual run accepts. + */ +export function resolveManualRunEntry( + blocks: Record, + edges: Array<{ source: string; target: string }> +): ManualRunEntry { + const candidates = resolveStartCandidates(blocks, { execution: 'manual' }) + if (candidates.length === 0) { + return { + ok: false, + issue: { + reason: 'no-trigger', + message: 'Workflow requires at least one trigger block to execute', + }, + } + } + + if (candidates.filter((c) => c.path === StartBlockPath.SPLIT_API).length > 1) { + return { + ok: false, + issue: { + reason: 'multiple-api-triggers', + message: 'Multiple API Trigger blocks found. Keep only one.', + }, + } + } + + const candidate = selectBestTrigger(candidates, edges)[0] + if ( + candidate.path !== StartBlockPath.LEGACY_STARTER && + !edges.some((edge) => edge.source === candidate.blockId) + ) { + return { + ok: false, + issue: { + reason: 'trigger-not-connected', + message: `${candidate.block.name || candidate.block.type} must be connected to other blocks to execute`, + blockId: candidate.blockId, + blockName: candidate.block.name, + blockType: candidate.block.type, + }, + } + } + + return { ok: true, candidate } +} diff --git a/apps/sim/lib/workflows/triggers/utils.ts b/apps/sim/lib/workflows/triggers/utils.ts index b333ea4b12a..8dbcec30373 100644 --- a/apps/sim/lib/workflows/triggers/utils.ts +++ b/apps/sim/lib/workflows/triggers/utils.ts @@ -105,7 +105,7 @@ export function groupTriggersByPath< groups.push(group) } - logger.info('Grouped triggers by path', { + logger.debug('Grouped triggers by path', { groupCount: groups.length, groups: groups.map((g) => ({ count: g.length, @@ -175,7 +175,7 @@ function selectBestFromGroup + manualRunIssue: { + reason: 'no-trigger' | 'multiple-api-triggers' | 'trigger-not-connected' + message: string + blockId: string | null + blockName: string | null + blockType: string | null + } | null notes: Array } @@ -5079,6 +5086,13 @@ type DeployWorkflowResponseRef4 = { field: string tableName: string }> + manualRunIssue: { + reason: 'no-trigger' | 'multiple-api-triggers' | 'trigger-not-connected' + message: string + blockId: string | null + blockName: string | null + blockType: string | null + } | null notes: Array } @@ -12920,6 +12934,13 @@ type ReplaceWorkflowStateResponseRef0 = { field: string tableName: string }> + manualRunIssue: { + reason: 'no-trigger' | 'multiple-api-triggers' | 'trigger-not-connected' + message: string + blockId: string | null + blockName: string | null + blockType: string | null + } | null notes: Array } From 8020723f0d6acca19ce5a7c72f4e342648fb6e7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 13:12:14 -0800 Subject: [PATCH 2/3] improvement(workflows): describe every case where manualRunIssue is null --- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/lib/api/contracts/v2/workflows.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 648eb7c7b97..6c2e0063513 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -8873,7 +8873,7 @@ "type": "null" } ], - "description": "Why a manual run from the editor's Run button would be refused before it starts: no enabled trigger it can start from, more than one API trigger, or a selected trigger with no outgoing connection. `null` when a manual run can begin. Not raised for a workflow only a chat trigger can start." + "description": "Why a manual run from the editor's Run button would be refused before it starts: no enabled trigger it can start from, more than one API trigger, or a selected trigger with no outgoing connection. `null` when no manual-run issue is reported: the run can begin, only a chat trigger can start the workflow, or the graph is empty (reported in `notes` instead)." }, "notes": { "type": "array", diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 84f6515a8da..b79f8a63fde 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -454,7 +454,7 @@ const v2WorkflowLintSchema = z }) .nullable() .describe( - "Why a manual run from the editor's Run button would be refused before it starts: no enabled trigger it can start from, more than one API trigger, or a selected trigger with no outgoing connection. `null` when a manual run can begin. Not raised for a workflow only a chat trigger can start." + "Why a manual run from the editor's Run button would be refused before it starts: no enabled trigger it can start from, more than one API trigger, or a selected trigger with no outgoing connection. `null` when no manual-run issue is reported: the run can begin, only a chat trigger can start the workflow, or the graph is empty (reported in `notes` instead)." ), notes: z.array(z.string()).describe('Advisory notes about the report itself.'), }) From a942c669cefdf816234b76f3bc87515a14ddb861 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 13:43:57 -0800 Subject: [PATCH 3/3] docs(workflows): make the apply-operations response example a wired graph --- apps/docs/openapi-v2-workflows.json | 32 ++++++++++++++++--- .../lib/api/contracts/v2/openapi/workflows.ts | 16 ++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 6c2e0063513..44fed572f91 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -706,8 +706,20 @@ "agent-1": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" }, "lint": { - "sources": [], - "sinks": [], + "sources": [ + { + "blockId": "start-1", + "blockName": "Start", + "blockType": "start_trigger" + } + ], + "sinks": [ + { + "blockId": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77", + "blockName": "Triage", + "blockType": "agent" + } + ], "orphanBlocks": [], "emptyOutgoingPorts": [], "invalidBranchPorts": [], @@ -9628,8 +9640,20 @@ "agent-1": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" }, "lint": { - "sources": [], - "sinks": [], + "sources": [ + { + "blockId": "start-1", + "blockName": "Start", + "blockType": "start_trigger" + } + ], + "sinks": [ + { + "blockId": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77", + "blockName": "Triage", + "blockType": "agent" + } + ], "orphanBlocks": [], "emptyOutgoingPorts": [], "invalidBranchPorts": [], diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 32736ff772e..d71fd982169 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -403,8 +403,20 @@ const declaredRoutes = [ inputValidationErrors: [], mintedBlockIds: { 'agent-1': 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' }, lint: { - sources: [], - sinks: [], + sources: [ + { + blockId: 'start-1', + blockName: 'Start', + blockType: 'start_trigger', + }, + ], + sinks: [ + { + blockId: 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77', + blockName: 'Triage', + blockType: 'agent', + }, + ], orphanBlocks: [], emptyOutgoingPorts: [], invalidBranchPorts: [],