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
3 changes: 2 additions & 1 deletion apps/docs/content/docs/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7911,7 +7911,7 @@ sim workflows run <workflowId> [options]
| --- | --- | --- |
| `--input <json\|@file>` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). |
| `--async` | No | Queue the run and return immediately. Defaults to false. |
| `--execution-timeout-seconds <value>` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. |
| `--execution-timeout-seconds <value>` | No | Maximum duration of the run in seconds, capped by the plan's execution timeout. Requires --async or --wait-timeout. |
| `--select-output <value...>` | No | Return blockName.path values (e.g. agent_1.content), or childWorkflowId.blockName.path for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing paths are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
| `--include-file-base64` | No | Inline eligible output files as base64 content. Defaults to true for the installed CLI; Chat defaults to file references and admits requested bytes only for synchronous runs. Rejected when async is true. |
| `--no-include-file-base64` | No | Send --include-file-base64 as false. |
Expand All @@ -7923,6 +7923,7 @@ sim workflows run <workflowId> [options]
| `--from-block <blockId>` | No | Run manually from this saved workflow block. |
| `--source-run <runId>` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). |
| `--stop-after <blockId>` | No | Stop the run after this saved block, failing it if the run takes a path that skips the block; with --from-block on the same block, re-runs only that block (implies --manual). |
| `--wait-timeout <seconds>` | No | Wait at most this many seconds for a synchronous run (0 waits until it settles). A run still going then continues in the background under async limits: the command prints its run record and exits 4, and `sim workflows runs wait` keeps waiting. Not with --async or --follow. |
| `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. |
| `--include-thinking` | No | Show model reasoning while following (requires --follow). |
| `--include-tool-calls` | No | Show tool calls while following (requires --follow). |
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/content/docs/cli/workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ sim workflows run <workflowId> [options]
| --- | --- | --- |
| `--input <json\|@file>` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). |
| `--async` | No | Queue the run and return immediately. Defaults to false. |
| `--execution-timeout-seconds <value>` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. |
| `--execution-timeout-seconds <value>` | No | Maximum duration of the run in seconds, capped by the plan's execution timeout. Requires --async or --wait-timeout. |
| `--select-output <value...>` | No | Return blockName.path values (e.g. agent_1.content), or childWorkflowId.blockName.path for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing paths are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
| `--include-file-base64` | No | Inline eligible output files as base64 content. Defaults to true for the installed CLI; Chat defaults to file references and admits requested bytes only for synchronous runs. Rejected when async is true. |
| `--no-include-file-base64` | No | Send --include-file-base64 as false. |
Expand All @@ -707,6 +707,7 @@ sim workflows run <workflowId> [options]
| `--from-block <blockId>` | No | Run manually from this saved workflow block. |
| `--source-run <runId>` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). |
| `--stop-after <blockId>` | No | Stop the run after this saved block, failing it if the run takes a path that skips the block; with --from-block on the same block, re-runs only that block (implies --manual). |
| `--wait-timeout <seconds>` | No | Wait at most this many seconds for a synchronous run (0 waits until it settles). A run still going then continues in the background under async limits: the command prints its run record and exits 4, and `sim workflows runs wait` keeps waiting. Not with --async or --follow. |
| `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. |
| `--include-thinking` | No | Show model reasoning while following (requires --follow). |
| `--include-tool-calls` | No | Show tool calls while following (requires --follow). |
Expand Down
31 changes: 26 additions & 5 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -3797,6 +3797,15 @@
},
"example5": {
"summary": "Request example 5",
"value": {
"run": {
"source": "manual"
},
"waitTimeoutSeconds": 240
}
},
"example6": {
"summary": "Request example 6",
"value": {
"run": {
"source": "manual",
Expand All @@ -3808,8 +3817,8 @@
}
}
},
"example6": {
"summary": "Request example 6",
"example7": {
"summary": "Request example 7",
"value": {
"run": {
"source": "manual",
Expand Down Expand Up @@ -14245,7 +14254,7 @@
"required": ["runId", "statusUrl"],
"additionalProperties": false,
"title": "Queued workflow run",
"description": "Receipt returned when a workflow run is queued."
"description": "Receipt returned when a workflow run is queued, or is still running when `waitTimeoutSeconds` elapsed."
},
"ExecuteWorkflowQueuedResponse": {
"type": "object",
Expand Down Expand Up @@ -14282,7 +14291,7 @@
}
},
"run": {
"description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.",
"description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only; `waitTimeoutSeconds` lets a synchronous one continue in the background.",
"oneOf": [
{
"type": "object",
Expand Down Expand Up @@ -14369,11 +14378,17 @@
"type": "boolean"
},
"executionTimeoutSeconds": {
"description": "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`.",
"description": "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true` or `waitTimeoutSeconds`; otherwise returns `400`.",
"type": "integer",
"minimum": 1,
"maximum": 604800
},
"waitTimeoutSeconds": {
"description": "Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires an API key or OAuth access token. Cannot be combined with `async` or `stream`.",
"type": "integer",
"minimum": 0,
"maximum": 604800
},
"stream": {
"default": false,
"description": "Return Server-Sent Events instead of JSON when true. Cannot be combined with `async`.",
Expand Down Expand Up @@ -14435,6 +14450,12 @@
"source": "manual"
}
},
{
"run": {
"source": "manual"
},
"waitTimeoutSeconds": 240
},
{
"run": {
"source": "manual",
Expand Down
242 changes: 242 additions & 0 deletions apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
executionPreprocessingMock,
executionPreprocessingMockFns,
loggingSessionMock,
loggingSessionMockFns,
resetDbChainMock,
setEnv,
workflowAuthzMockFns,
Expand Down Expand Up @@ -35,8 +36,11 @@ import {
v2RouteMocks,
} from '@sim/testing/mocks/v2-route.mock'
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkspaceApiKeyAuthorizationError } from '@/lib/core/application'
import { getRemainingExecutionMs } from '@/lib/core/execution-limits'
import { abortManualExecution } from '@/lib/execution/manual-cancellation'

const {
mockClaimExecutionId,
Expand Down Expand Up @@ -414,6 +418,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
ok: true,
executionId: 'execution-123',
pending,
logged: Promise.resolve(),
cancel: vi.fn(),
})

Expand Down Expand Up @@ -1120,4 +1125,241 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
})
expect(mockPreprocessExecution).not.toHaveBeenCalled()
})
describe('waitTimeoutSeconds', () => {
const settledCore = {
success: true,
output: { result: 'done' },
metadata: {
duration: 42,
startTime: '2026-07-31T00:00:00.000Z',
endTime: '2026-07-31T00:00:01.000Z',
},
}
const receipt = {
runId: 'execution-123',
statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/runs/execution-123',
}

/** A run the test settles by hand, recording the signal the executor was handed. */
function holdRun() {
const held: { signal?: AbortSignal; finish: (result: unknown) => void } = {
finish: () => {},
}
mockExecuteWorkflowCore.mockImplementationOnce(
({ abortSignal }: { abortSignal: AbortSignal }) =>
new Promise((resolve) => {
held.signal = abortSignal
held.finish = resolve
abortSignal.addEventListener('abort', () =>
resolve({ success: false, status: 'cancelled', output: {}, metadata: {} })
)
})
)
return held
}

function readLines(response: Response) {
if (!response.body) throw new Error('Expected NDJSON response body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
return {
reader,
next: async () => {
const chunk = await reader.read()
return chunk.done ? null : JSON.parse(decoder.decode(chunk.value))
},
}
}

it('answers with the ordinary result when the run settles within the wait', async () => {
const response = await callExecute(
{ input: {}, waitTimeoutSeconds: 30 },
{ Accept: 'application/x-ndjson' }
)
const lines = readLines(response)

expect(await lines.next()).toMatchObject({ type: 'heartbeat' })
expect(await lines.next()).toMatchObject({
type: 'final',
data: { runId: 'execution-123', status: 'completed', output: { result: 'done' } },
})
expect(await lines.next()).toBeNull()
})

it('admits a detachable run as an async run with the async timeout', async () => {
const response = await callExecute({ input: {}, waitTimeoutSeconds: 30 })

expect(response.status).toBe(200)
expect((await response.json()).data).toMatchObject({
runId: 'execution-123',
status: 'completed',
})
expect(mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({ rateLimitCounter: 'async', executionType: 'async' })
)
const { abortSignal } = mockExecuteWorkflowCore.mock.calls[0][0]
expect(getRemainingExecutionMs(abortSignal)).toBeGreaterThan(60_000)
})

it('detaches a run still going at the deadline and lets it finish in the background', async () => {
vi.useFakeTimers()
try {
const held = holdRun()
const response = await callExecute(
{ input: {}, waitTimeoutSeconds: 20 },
{ Accept: 'application/x-ndjson' }
)
const lines = readLines(response)
expect(await lines.next()).toMatchObject({ type: 'heartbeat' })

await vi.advanceTimersByTimeAsync(20_000)
const frames = []
for (let frame = await lines.next(); frame; frame = await lines.next()) frames.push(frame)
expect(frames.filter((frame) => frame.type !== 'heartbeat')).toEqual([
{ type: 'detached', data: receipt },
])

expect(held.signal?.aborted).toBe(false)
expect(mockAdmissionRelease).not.toHaveBeenCalled()
expect(mockReleaseExecutionIdClaim).not.toHaveBeenCalled()

held.finish(settledCore)
await vi.advanceTimersByTimeAsync(0)
expect(mockAdmissionRelease).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})

it('withholds the receipt until the run can be found by its id', async () => {
vi.useFakeTimers()
try {
let logStarted!: () => void
loggingSessionMockFns.mockWhenLogStarted.mockReturnValueOnce(
new Promise<void>((resolve) => {
logStarted = resolve
})
)
const held = holdRun()
const pending = Promise.resolve(callExecute({ input: {}, waitTimeoutSeconds: 1 }))
await vi.advanceTimersByTimeAsync(5_000)
let answered = false
void pending.then(() => {
answered = true
})
await vi.advanceTimersByTimeAsync(0)
expect(answered).toBe(false)

logStarted()
const response = await pending
expect(response.status).toBe(202)
expect((await response.json()).data).toEqual(receipt)
held.finish(settledCore)
} finally {
vi.useRealTimers()
}
})

it('answers 202 with the run receipt on the JSON transport', async () => {
vi.useFakeTimers()
try {
const held = holdRun()
const pending = callExecute({ input: {}, waitTimeoutSeconds: 20 })
await vi.advanceTimersByTimeAsync(20_000)
const response = await pending

expect(response.status).toBe(202)
expect(response.headers.get('X-Run-Id')).toBe('execution-123')
expect((await response.json()).data).toEqual(receipt)
expect(held.signal?.aborted).toBe(false)
expect(mockAdmissionRelease).not.toHaveBeenCalled()

held.finish(settledCore)
await vi.advanceTimersByTimeAsync(0)
expect(mockAdmissionRelease).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})

it('keeps a detachable run going when the caller disconnects', async () => {
const held = holdRun()
const disconnect = new AbortController()
const request = new NextRequest(new URL('http://localhost:3000/api/test'), {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
'X-API-Key': 'test-key',
Accept: 'application/x-ndjson',
}),
body: JSON.stringify({ input: {}, waitTimeoutSeconds: 600 }),
signal: disconnect.signal,
})
const response = await POST(request, createRouteContext({ workflowId: 'workflow-1' }))
const lines = readLines(response)
await lines.next()

disconnect.abort()
await lines.reader.cancel()

expect(held.signal?.aborted).toBe(false)
held.finish(settledCore)
})

it('stops a detached run through runs cancel in the process that holds it', async () => {
const held = holdRun()
const response = await callExecute(
{ input: {}, waitTimeoutSeconds: 600 },
{ Accept: 'application/x-ndjson' }
)
const lines = readLines(response)
await lines.next()

expect(abortManualExecution('execution-123')).toBe(true)
expect(await lines.next()).toMatchObject({
type: 'final',
data: { runId: 'execution-123', status: 'cancelled' },
})
expect(held.signal?.aborted).toBe(true)
expect(abortManualExecution('execution-123')).toBe(false)
})

it('refuses a wait on runs that never hold the response', async () => {
expect((await callExecute({ waitTimeoutSeconds: 30, async: true })).status).toBe(400)
expect((await callExecute({ waitTimeoutSeconds: 30, stream: true })).status).toBe(400)

dbChainMockFns.limit.mockReset()
queuePublicWorkflowReads()
expect((await callPublicExecute({ waitTimeoutSeconds: 30 })).status).toBe(400)
expect(mockPreprocessExecution).not.toHaveBeenCalled()
})

it('accepts an execution timeout only on a run that may outlive the response', async () => {
expect((await callExecute({ executionTimeoutSeconds: 60 })).status).toBe(400)

const response = await callExecute({ executionTimeoutSeconds: 60, waitTimeoutSeconds: 30 })
expect(response.status).toBe(200)
expect(mockPreprocessExecution).toHaveBeenCalledWith(
expect.objectContaining({ requestedTimeoutSeconds: 60 })
)
})

it('dispatches a detachable manual draft run without tying it to the request', async () => {
authenticatePersonalKey()

const response = await callExecute({
run: { source: 'manual', stopAfterBlockId: 'agent-1' },
waitTimeoutSeconds: 30,
})

expect(response.status).toBe(200)
const call = mockExecuteManualTrigger.mock.calls[0][0]
expect(call.input).toMatchObject({
mode: 'sync-result-stream',
detachable: true,
stopAfterBlockId: 'agent-1',
})
expect(call.input.abortSignal).toBeUndefined()
})
})
})
Loading
Loading