-
-
-
-
-
-
-
-
- {copied.async ? 'Copied' : 'Copy'}
-
-
-
setAsyncExampleType(value as AsyncExampleType)}
- align='end'
- dropdownWidth={160}
- />
+ {!info.isPublicApi && (
+
+
+
+
+
+
+
+
+
+ {copied.async ? 'Copied' : 'Copy'}
+
+
+ setAsyncExampleType(value as AsyncExampleType)}
+ align='end'
+ dropdownWidth={160}
+ />
+
+
-
-
+ )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
index d46e57a420c..9e5e643733d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
@@ -226,7 +226,21 @@ export function DeployModal({
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
const getInputFormatExample = (includeStreaming = false) => {
- return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
+ const inputFormatExample = getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
+ if (!inputFormatExample) return ''
+
+ const match = inputFormatExample.match(/-d\s*'([\s\S]*)'/)
+ if (!match) {
+ throw new Error(`Invalid workflow input example: ${inputFormatExample}`)
+ }
+
+ const legacyBody = JSON.parse(match[1]) as Record
+ const { stream, selectedOutputs, ...input } = legacyBody
+ return ` -d '${JSON.stringify({
+ input,
+ ...(stream === true ? { stream: true } : {}),
+ ...(Array.isArray(selectedOutputs) ? { selectedOutputs } : {}),
+ })}'`
}
const deploymentInfo: WorkflowDeploymentInfoUI | null = (() => {
@@ -234,7 +248,7 @@ export function DeployModal({
return null
}
- const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
+ const endpoint = `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts
index 10e0faa7c45..8fcae222c9a 100644
--- a/apps/sim/lib/api/contracts/v2/workflows.ts
+++ b/apps/sim/lib/api/contracts/v2/workflows.ts
@@ -437,6 +437,31 @@ export const v2ExecuteWorkflowContract = defineRouteContract({
},
})
+/** Resume input is scoped to one pause context on the parent execution. */
+export const v2ResumeWorkflowBodySchema = z
+ .object({
+ contextId: z.string().min(1, 'contextId cannot be empty'),
+ input: z.unknown().optional(),
+ })
+ .strict()
+export type V2ResumeWorkflowBody = z.input
+
+export const v2ResumeWorkflowQueuedSchema = v2ExecuteWorkflowQueuedSchema.extend({
+ queuePosition: z.number().int().positive().optional(),
+})
+export type V2ResumeWorkflowQueued = z.output
+
+export const v2ResumeWorkflowContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/v2/workflows/[id]/executions/[executionId]/resume',
+ params: workflowExecutionParamsSchema,
+ body: v2ResumeWorkflowBodySchema,
+ response: {
+ mode: 'json',
+ schema: v2DataResponse(v2ExecuteWorkflowDataSchema),
+ },
+})
+
/**
* The polled execution resource. `queued` is backfilled from the async job
* queue before the worker writes the durable log row — v1's jobs endpoint 404
diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts
index 8847adc6630..a4e5743211d 100644
--- a/apps/sim/lib/api/contracts/workflows.ts
+++ b/apps/sim/lib/api/contracts/workflows.ts
@@ -546,6 +546,7 @@ const pausedWorkflowExecutionDetailSchema = pausedWorkflowExecutionSummarySchema
})
const workflowExecutionStatusEnum = z.enum([
+ 'queued',
'pending',
'running',
'paused',
diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts
index b2a46f0b893..b70b87bbc8c 100644
--- a/apps/sim/lib/compare/data/sim.ts
+++ b/apps/sim/lib/compare/data/sim.ts
@@ -1086,10 +1086,10 @@ export const simProfile: CompetitorProfile = {
},
asyncExecution: {
value:
- 'Yes: a workflow can be triggered in fire-and-forget async mode, returning HTTP 202 with a job ID immediately, then polled via a dedicated jobs endpoint through queued/processing/completed/failed states',
+ 'Yes: a workflow can be triggered in fire-and-forget async mode, returning HTTP 202 with an execution ID immediately, then polled through the canonical execution resource across queued/running/terminal states',
detail:
- 'Async jobs are tracked via polling the job endpoint rather than a completion webhook/callback option.',
- shortValue: 'Async mode: job ID returned immediately, poll for result',
+ 'Async runs are tracked by execution ID through the same execution status endpoint used for durable logs rather than a separate queue-job resource.',
+ shortValue: 'Async mode: execution ID returned immediately, poll for result',
confidence: 'verified',
sources: [
{
@@ -1098,8 +1098,8 @@ export const simProfile: CompetitorProfile = {
asOf: '2026-07-02',
},
{
- url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/app/api/jobs/[jobId]/route.ts',
- label: 'Sim codebase: async job status endpoint',
+ url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts',
+ label: 'Sim codebase: execution status endpoint',
asOf: '2026-07-02',
},
],
diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
index a1db42f3e4c..cb97bedf8e8 100644
--- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
@@ -31,7 +31,21 @@ import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../para
import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context'
function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string {
- return `${baseUrl}/api/workflows/${workflowId}/execute`
+ return `${baseUrl}/api/v2/workflows/${workflowId}/execute`
+}
+
+function buildWorkflowExecutionStatusEndpoint(
+ baseUrl: string,
+ apiEndpoint: string,
+ executionId: string
+): string {
+ if (
+ !apiEndpoint.startsWith(`${baseUrl}/api/v2/workflows/`) ||
+ !apiEndpoint.endsWith('/execute')
+ ) {
+ throw new Error(`Invalid workflow execution endpoint: ${apiEndpoint}`)
+ }
+ return `${apiEndpoint.slice(0, -'/execute'.length)}/executions/${executionId}`
}
function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) {
@@ -58,9 +72,12 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) {
method: 'POST',
transport: 'json',
stream: false,
- headers: { 'X-Execution-Mode': 'async' },
- body: { input: { key: 'value' } },
- jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`,
+ body: { async: true, input: { key: 'value' } },
+ executionStatusEndpointTemplate: buildWorkflowExecutionStatusEndpoint(
+ baseUrl,
+ apiEndpoint,
+ '{executionId}'
+ ),
},
},
}
@@ -79,9 +96,8 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) {
async: `curl -X POST "${apiEndpoint}" \\
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
- -H "X-Execution-Mode: async" \\
- -d '{"input":{"key":"value"}}'`,
- poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\
+ -d '{"async":true,"input":{"key":"value"}}'`,
+ poll: `curl "${buildWorkflowExecutionStatusEndpoint(baseUrl, apiEndpoint, 'EXECUTION_ID')}" \\
-H "X-API-Key: YOUR_API_KEY"`,
}
}
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts
index 00642c49321..7e3577dab0a 100644
--- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts
@@ -3,22 +3,25 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { MockApiError, mockResolveTriggerRegion, mockTrigger } = vi.hoisted(() => {
- class MockApiError extends Error {
- constructor(
- readonly status: number | undefined,
- message: string
- ) {
- super(message)
+const { MockApiError, mockListRuns, mockResolveTriggerRegion, mockRetrieveRun, mockTrigger } =
+ vi.hoisted(() => {
+ class MockApiError extends Error {
+ constructor(
+ readonly status: number | undefined,
+ message: string
+ ) {
+ super(message)
+ }
}
- }
- return {
- MockApiError,
- mockResolveTriggerRegion: vi.fn(),
- mockTrigger: vi.fn(),
- }
-})
+ return {
+ MockApiError,
+ mockListRuns: vi.fn(),
+ mockResolveTriggerRegion: vi.fn(),
+ mockRetrieveRun: vi.fn(),
+ mockTrigger: vi.fn(),
+ }
+ })
vi.mock('@trigger.dev/core/v3', () => ({
taskContext: { isInsideTask: false },
@@ -28,7 +31,8 @@ vi.mock('@trigger.dev/sdk', () => ({
ApiError: MockApiError,
runs: {
cancel: vi.fn(),
- retrieve: vi.fn(),
+ list: mockListRuns,
+ retrieve: mockRetrieveRun,
},
tasks: {
batchTriggerAndWait: vi.fn(),
@@ -63,6 +67,7 @@ describe('TriggerDevJobQueue enqueue', () => {
expect.objectContaining({
idempotencyKey: 'workflow:1',
idempotencyKeyTTL: '14d',
+ tags: ['jobId:workflow:1'],
})
)
})
@@ -113,3 +118,44 @@ describe('TriggerDevJobQueue enqueue', () => {
expect(mockTrigger).not.toHaveBeenCalled()
})
})
+
+describe('TriggerDevJobQueue getJob', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('resolves a deterministic job ID through its Trigger.dev tag', async () => {
+ mockRetrieveRun
+ .mockRejectedValueOnce(new MockApiError(404, 'run not found'))
+ .mockResolvedValueOnce({
+ id: 'run-1',
+ taskIdentifier: 'workflow-execution',
+ payload: { workflowId: 'workflow-1' },
+ status: 'COMPLETED',
+ createdAt: '2026-08-05T12:00:00.000Z',
+ finishedAt: '2026-08-05T12:00:05.000Z',
+ attemptCount: 1,
+ output: { output: { answer: 42 } },
+ })
+ mockListRuns.mockReturnValueOnce(
+ (async function* () {
+ yield { id: 'run-1' }
+ })()
+ )
+ const queue = new TriggerDevJobQueue()
+
+ const job = await queue.getJob('workflow-execution:execution-1')
+
+ expect(mockListRuns).toHaveBeenCalledWith({
+ tag: 'jobId:workflow-execution:execution-1',
+ limit: 1,
+ })
+ expect(mockRetrieveRun).toHaveBeenNthCalledWith(2, 'run-1')
+ expect(job).toMatchObject({
+ id: 'workflow-execution:execution-1',
+ status: 'completed',
+ output: { output: { answer: 42 } },
+ metadata: { workflowId: 'workflow-1' },
+ })
+ })
+})
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
index 12f9f15bc88..4059f3066dc 100644
--- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
@@ -189,7 +189,26 @@ export class TriggerDevJobQueue implements JobQueueBackend {
async getJob(jobId: string): Promise {
try {
- const run = await runs.retrieve(jobId)
+ let run: Awaited>
+ try {
+ run = await runs.retrieve(jobId)
+ } catch (error) {
+ const isNotFound =
+ (error instanceof Error && error.message.toLowerCase().includes('not found')) ||
+ (error && typeof error === 'object' && 'status' in error && error.status === 404)
+ if (!isNotFound) throw error
+
+ let runId: string | undefined
+ for await (const candidate of runs.list({ tag: `jobId:${jobId}`, limit: 1 })) {
+ runId = candidate.id
+ break
+ }
+ if (!runId) {
+ logger.debug('Job not found in trigger.dev', { jobId })
+ return null
+ }
+ run = await runs.retrieve(runId)
+ }
const payload = run.payload as Record
const metadata: JobMetadata = {
@@ -270,6 +289,7 @@ function buildTags(options?: EnqueueOptions): string[] {
const tags: string[] = []
const meta = options?.metadata
+ if (options?.jobId) tags.push(`jobId:${options.jobId}`)
if (meta?.workspaceId) tags.push(`workspaceId:${meta.workspaceId}`)
if (meta?.workflowId) tags.push(`workflowId:${meta.workflowId}`)
if (meta?.userId) tags.push(`userId:${meta.userId}`)
diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts
index 949c98be1dc..4e8992bafd4 100644
--- a/apps/sim/lib/workflows/executor/enqueue-execution.ts
+++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts
@@ -11,6 +11,7 @@ const logger = createLogger('WorkflowEnqueueExecution')
const ASYNC_ENQUEUE_ATTEMPTS = 2
export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:'
+export const RESUME_EXECUTION_JOB_ID_PREFIX = 'resume-execution:'
export interface EnqueueWorkflowExecutionParams {
requestId: string
diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts
new file mode 100644
index 00000000000..dfbc0719224
--- /dev/null
+++ b/apps/sim/lib/workflows/executor/execution-status.test.ts
@@ -0,0 +1,222 @@
+/**
+ * @vitest-environment node
+ */
+import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetJob } = vi.hoisted(() => ({
+ mockGetJob: vi.fn(),
+}))
+
+vi.mock('@/lib/core/async-jobs', () => ({
+ getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }),
+}))
+
+vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({
+ RESUME_EXECUTION_JOB_ID_PREFIX: 'resume-execution:',
+ WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:',
+}))
+
+import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status'
+
+const input = {
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ includeOutput: false,
+ selectedOutputs: [],
+}
+
+describe('getWorkflowExecutionStatus queue projection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ })
+
+ it('projects a queued workflow job as an execution resource', async () => {
+ mockGetJob.mockResolvedValue({
+ status: 'pending',
+ createdAt: new Date('2026-08-05T12:00:00.000Z'),
+ metadata: {
+ workflowId: 'workflow-1',
+ correlation: { triggerType: 'api' },
+ },
+ })
+
+ const status = await getWorkflowExecutionStatus(input)
+
+ expect(status).toMatchObject({
+ executionId: 'execution-1',
+ workflowId: 'workflow-1',
+ status: 'queued',
+ trigger: 'api',
+ startedAt: '2026-08-05T12:00:00.000Z',
+ endedAt: null,
+ error: null,
+ })
+ expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1')
+ })
+
+ it('uses the resume entry ID when the queued work is a resume attempt', async () => {
+ queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }])
+ mockGetJob.mockResolvedValueOnce({
+ status: 'processing',
+ createdAt: new Date('2026-08-05T12:00:00.000Z'),
+ startedAt: new Date('2026-08-05T12:00:01.000Z'),
+ metadata: { workflowId: 'workflow-1' },
+ })
+
+ const status = await getWorkflowExecutionStatus(input)
+
+ expect(status).toMatchObject({
+ executionId: 'execution-1',
+ workflowId: 'workflow-1',
+ status: 'running',
+ startedAt: '2026-08-05T12:00:01.000Z',
+ })
+ expect(mockGetJob).toHaveBeenCalledWith('resume-execution:resume-entry-1')
+ })
+
+ it('projects an active resume ahead of the existing paused log', async () => {
+ queueTableRows(schemaMock.workflowExecutionLogs, [
+ {
+ executionId: 'execution-1',
+ workflowId: 'workflow-1',
+ status: 'paused',
+ },
+ ])
+ queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }])
+ mockGetJob.mockResolvedValueOnce({
+ status: 'pending',
+ createdAt: new Date('2026-08-05T12:00:00.000Z'),
+ metadata: { workflowId: 'workflow-1' },
+ })
+
+ const status = await getWorkflowExecutionStatus(input)
+
+ expect(status).toMatchObject({
+ executionId: 'execution-1',
+ status: 'queued',
+ paused: null,
+ })
+ })
+
+ it('keeps an active resume queued while its background job is not yet visible', async () => {
+ queueTableRows(schemaMock.workflowExecutionLogs, [
+ {
+ executionId: 'execution-1',
+ workflowId: 'workflow-1',
+ status: 'paused',
+ trigger: 'api',
+ },
+ ])
+ queueTableRows(schemaMock.resumeQueue, [
+ {
+ id: 'resume-entry-1',
+ status: 'claimed',
+ queuedAt: new Date('2026-08-05T12:00:00.000Z'),
+ claimedAt: new Date('2026-08-05T12:00:01.000Z'),
+ },
+ ])
+ mockGetJob.mockResolvedValueOnce(null)
+
+ const status = await getWorkflowExecutionStatus(input)
+
+ expect(status).toMatchObject({
+ executionId: 'execution-1',
+ status: 'queued',
+ trigger: 'api',
+ startedAt: '2026-08-05T12:00:01.000Z',
+ paused: null,
+ })
+ })
+
+ it('projects a pending serialized resume as queued', async () => {
+ queueTableRows(schemaMock.workflowExecutionLogs, [
+ {
+ executionId: 'execution-1',
+ workflowId: 'workflow-1',
+ status: 'paused',
+ trigger: 'api',
+ },
+ ])
+ queueTableRows(schemaMock.resumeQueue, [
+ {
+ id: 'resume-entry-2',
+ status: 'pending',
+ queuedAt: new Date('2026-08-05T12:00:02.000Z'),
+ claimedAt: null,
+ },
+ ])
+
+ const status = await getWorkflowExecutionStatus(input)
+
+ expect(status).toMatchObject({
+ executionId: 'execution-1',
+ status: 'queued',
+ startedAt: '2026-08-05T12:00:02.000Z',
+ paused: null,
+ })
+ expect(mockGetJob).not.toHaveBeenCalled()
+ })
+
+ it('does not let an orphaned pending resume mask a terminal log', async () => {
+ queueTableRows(schemaMock.workflowExecutionLogs, [
+ {
+ executionId: 'execution-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ status: 'completed',
+ level: 'info',
+ trigger: 'api',
+ startedAt: new Date('2026-08-05T12:00:00.000Z'),
+ endedAt: new Date('2026-08-05T12:00:01.000Z'),
+ totalDurationMs: 1000,
+ executionData: null,
+ costTotal: null,
+ },
+ ])
+ queueTableRows(schemaMock.resumeQueue, [
+ {
+ id: 'resume-entry-2',
+ status: 'pending',
+ queuedAt: new Date('2026-08-05T12:00:02.000Z'),
+ claimedAt: null,
+ },
+ ])
+
+ const status = await getWorkflowExecutionStatus(input)
+
+ expect(status).toMatchObject({
+ executionId: 'execution-1',
+ status: 'completed',
+ })
+ expect(mockGetJob).not.toHaveBeenCalled()
+ })
+
+ it('returns completed queue output when requested', async () => {
+ mockGetJob.mockResolvedValueOnce({
+ status: 'completed',
+ createdAt: new Date('2026-08-05T12:00:00.000Z'),
+ completedAt: new Date('2026-08-05T12:00:05.000Z'),
+ output: { output: { answer: 42 } },
+ metadata: { workflowId: 'workflow-1' },
+ })
+
+ const status = await getWorkflowExecutionStatus({ ...input, includeOutput: true })
+
+ expect(status).toMatchObject({
+ status: 'completed',
+ finalOutput: { answer: 42 },
+ })
+ })
+
+ it('does not expose a queue record belonging to another workflow', async () => {
+ mockGetJob.mockResolvedValueOnce({
+ status: 'pending',
+ createdAt: new Date('2026-08-05T12:00:00.000Z'),
+ metadata: { workflowId: 'workflow-2' },
+ })
+
+ await expect(getWorkflowExecutionStatus(input)).resolves.toBeNull()
+ })
+})
diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts
index d608dd3d825..f90ef18e1c6 100644
--- a/apps/sim/lib/workflows/executor/execution-status.ts
+++ b/apps/sim/lib/workflows/executor/execution-status.ts
@@ -1,12 +1,18 @@
import { db } from '@sim/db'
-import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema'
-import { and, eq } from 'drizzle-orm'
+import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema'
+import { and, eq, inArray, sql } from 'drizzle-orm'
import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows'
+import { getJobQueue } from '@/lib/core/async-jobs'
+import type { Job } from '@/lib/core/async-jobs/types'
import {
collectFunctionalBlockOutputs,
type FunctionalExecutionDataSource,
} from '@/lib/logs/execution/functional-outputs'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
+import {
+ RESUME_EXECUTION_JOB_ID_PREFIX,
+ WORKFLOW_EXECUTION_JOB_ID_PREFIX,
+} from '@/lib/workflows/executor/enqueue-execution'
import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata'
import type { PausePoint } from '@/executor/types'
@@ -14,7 +20,9 @@ import type { PausePoint } from '@/executor/types'
* Reads a single execution's status resource — the log row, the paused-state
* overlay, and (when requested) materialized outputs. Extracted so the v1 and
* v2 status routes render the identical resource from one read path.
- * Auth is the caller's responsibility. Returns `null` when no log row exists.
+ * Auth is the caller's responsibility. Before a worker writes the durable log
+ * row, the deterministic queue record is projected as the same execution
+ * resource so callers never need a separate job identifier or endpoint.
*/
type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
@@ -79,6 +87,38 @@ function extractError(executionData: unknown): string | null {
return null
}
+function extractJobFinalOutput(output: unknown): unknown | null {
+ if (!output || typeof output !== 'object' || !('output' in output)) return null
+ return (output as Record).output ?? null
+}
+
+function projectQueueJob(
+ job: Job,
+ input: Pick
+): WorkflowExecutionStatusResponse {
+ const status: WorkflowExecutionStatusResponse['status'] =
+ job.status === 'pending' ? 'queued' : job.status === 'processing' ? 'running' : job.status
+ const startedAt = job.startedAt ?? job.createdAt
+ const endedAt = job.completedAt ?? null
+
+ return {
+ executionId: input.executionId,
+ workflowId: input.workflowId,
+ status,
+ trigger: job.metadata.correlation?.triggerType ?? 'api',
+ level: status === 'failed' ? 'error' : 'info',
+ startedAt: startedAt.toISOString(),
+ endedAt: endedAt?.toISOString() ?? null,
+ totalDurationMs: endedAt ? endedAt.getTime() - startedAt.getTime() : null,
+ paused: null,
+ cost: null,
+ error: status === 'failed' ? (job.error ?? 'Execution failed') : null,
+ finalOutput:
+ input.includeOutput && status === 'completed' ? extractJobFinalOutput(job.output) : null,
+ blockOutputs: null,
+ }
+}
+
export interface GetWorkflowExecutionStatusInput {
workflowId: string
executionId: string
@@ -114,6 +154,63 @@ export async function getWorkflowExecutionStatus(
)
.limit(1)
+ const [activeResume] = await db
+ .select({
+ id: resumeQueue.id,
+ status: resumeQueue.status,
+ queuedAt: resumeQueue.queuedAt,
+ claimedAt: resumeQueue.claimedAt,
+ })
+ .from(resumeQueue)
+ .where(
+ and(
+ eq(resumeQueue.parentExecutionId, executionId),
+ eq(resumeQueue.newExecutionId, executionId),
+ inArray(resumeQueue.status, ['pending', 'claimed'] as const)
+ )
+ )
+ .orderBy(sql`case when ${resumeQueue.status} = 'claimed' then 0 else 1 end`)
+ .limit(1)
+
+ const hasTerminalLog =
+ logRow?.status === 'completed' || logRow?.status === 'failed' || logRow?.status === 'cancelled'
+ const projectedResume = hasTerminalLog ? undefined : activeResume
+
+ const queueJobIds = [
+ ...(projectedResume?.status === 'claimed'
+ ? [`${RESUME_EXECUTION_JOB_ID_PREFIX}${projectedResume.id}`]
+ : []),
+ ...(!logRow ? [`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`] : []),
+ ]
+
+ if (queueJobIds.length > 0) {
+ const jobQueue = await getJobQueue()
+ for (const jobId of queueJobIds) {
+ const job = await jobQueue.getJob(jobId)
+ if (!job || job.metadata.workflowId !== workflowId) continue
+ return projectQueueJob(job, { executionId, includeOutput, workflowId })
+ }
+ }
+
+ if (projectedResume) {
+ const startedAt = projectedResume.claimedAt ?? projectedResume.queuedAt
+ return {
+ executionId,
+ workflowId,
+ status: 'queued',
+ trigger: logRow?.trigger ?? 'api',
+ level: 'info',
+ startedAt: startedAt.toISOString(),
+ endedAt: null,
+ totalDurationMs: null,
+ paused: null,
+ cost: null,
+ error: null,
+ finalOutput: null,
+ blockOutputs: null,
+ }
+ }
+
if (!logRow) return null
const [pausedRow] = await db
diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md
index 2690f635a17..390649be1cf 100644
--- a/packages/python-sdk/README.md
+++ b/packages/python-sdk/README.md
@@ -48,10 +48,10 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
Execute a workflow with optional input data.
```python
-# With dict input (spread at root level of request body)
+# With dict input (sent as the v2 input object)
result = client.execute_workflow("workflow-id", {"message": "Hello, world!"})
-# With primitive input (wrapped as { input: value })
+# With primitive input (sent as { input: { input: value } })
result = client.execute_workflow("workflow-id", "NVDA")
# With options (keyword-only arguments)
@@ -60,7 +60,7 @@ result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60
**Parameters:**
- `workflow_id` (str): The ID of the workflow to execute
-- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64.
+- `input` (any, optional): Input data to pass to the workflow. Dicts become the v2 `input` object; primitives and lists become `{ input: value }` inside it. File objects are automatically converted to base64.
- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0)
- `stream` (bool, keyword-only): Enable streaming responses
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
@@ -115,17 +115,35 @@ result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, tim
**Returns:** `WorkflowExecutionResult`
-##### get_job_status(job_id)
+##### get_workflow_execution(workflow_id, execution_id, *, include_output=None, selected_outputs=None)
-Get the status of an async job.
+Get the status and optional outputs of a workflow execution. Use the execution ID returned by async execution.
```python
-status = client.get_job_status("job-id-from-async-execution")
-print("Job status:", status)
+status = client.get_workflow_execution(
+ "workflow-id",
+ "execution-id",
+ include_output=True,
+ selected_outputs=["agent.content"]
+)
+print("Execution status:", status["status"])
```
**Parameters:**
-- `job_id` (str): The job ID returned from async execution
+- `workflow_id` (str): The workflow ID
+- `execution_id` (str): The execution ID returned from async execution
+- `include_output` (bool, keyword-only): Include the final output for completed executions
+- `selected_outputs` (list, keyword-only): Block output selectors to include
+
+**Returns:** `dict`
+
+##### get_job_status(job_id)
+
+Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_execution()` with an execution ID.
+
+```python
+status = client.get_job_status("legacy-job-id")
+```
**Returns:** `dict`
@@ -248,9 +266,8 @@ class SimStudioError(Exception):
@dataclass
class AsyncExecutionResult:
success: bool
- job_id: str
+ execution_id: str
status_url: str
- execution_id: Optional[str] = None
message: str = ""
async_execution: bool = True
```
@@ -527,4 +544,4 @@ isort simstudio/
## License
-Apache-2.0
\ No newline at end of file
+Apache-2.0
diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py
index 0e2609e2f26..e930e2467ba 100644
--- a/packages/python-sdk/simstudio/__init__.py
+++ b/packages/python-sdk/simstudio/__init__.py
@@ -49,9 +49,8 @@ class WorkflowStatus:
class AsyncExecutionResult:
"""Result of an async workflow execution."""
success: bool
- job_id: str
+ execution_id: str
status_url: str
- execution_id: Optional[str] = None
message: str = ""
async_execution: bool = True
@@ -159,7 +158,7 @@ def execute_workflow(
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
"""
Execute a workflow with optional input data.
- If async_execution is True, returns immediately with a task ID.
+ If async_execution is True, returns immediately with an execution ID.
File objects in input will be automatically detected and converted to base64.
@@ -179,31 +178,26 @@ def execute_workflow(
Raises:
SimStudioError: If the workflow execution fails
"""
- url = f"{self.base_url}/api/workflows/{workflow_id}/execute"
-
- # Build headers - async execution uses X-Execution-Mode header
+ url = f"{self.base_url}/api/v2/workflows/{workflow_id}/execute"
headers = self._session.headers.copy()
- if async_execution:
- headers['X-Execution-Mode'] = 'async'
try:
- # Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field
- body = {}
+ workflow_input = {}
if input is not None:
if isinstance(input, dict):
- # Dict input: spread at root level (matches curl/API behavior)
- body = input.copy()
+ workflow_input = input.copy()
else:
- # Primitive or list input: wrap in 'input' field
- body = {'input': input}
+ workflow_input = {'input': input}
- # Convert any file objects in the input to base64 format
- body = self._convert_files_to_base64(body)
+ workflow_input = self._convert_files_to_base64(workflow_input)
+ body = {'input': workflow_input}
if stream is not None:
body['stream'] = stream
if selected_outputs is not None:
body['selectedOutputs'] = selected_outputs
+ if async_execution is not None:
+ body['async'] = async_execution
response = self._session.post(
url,
@@ -227,35 +221,41 @@ def execute_workflow(
if not response.ok:
try:
error_data = response.json()
- error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}')
- error_code = error_data.get('code')
+ error = error_data.get('error', {})
+ error_message = error.get('message', f'HTTP {response.status_code}: {response.reason}')
+ error_code = error.get('code')
except (ValueError, KeyError):
error_message = f'HTTP {response.status_code}: {response.reason}'
error_code = None
raise SimStudioError(error_message, error_code, response.status_code)
- result_data = response.json()
+ result = response.json()
+ if 'data' not in result:
+ raise SimStudioError('Invalid v2 workflow execution response', 'EXECUTION_ERROR')
+ result_data = result['data']
- # Check if this is an async execution response (202 status)
- if response.status_code == 202 and 'jobId' in result_data:
+ if response.status_code == 202:
+ if 'executionId' not in result_data or 'statusUrl' not in result_data:
+ raise SimStudioError('Invalid v2 async execution response', 'EXECUTION_ERROR')
return AsyncExecutionResult(
- success=result_data.get('success', True),
- job_id=result_data['jobId'],
+ success=True,
+ execution_id=result_data['executionId'],
status_url=result_data['statusUrl'],
- execution_id=result_data.get('executionId'),
- message=result_data.get('message', ''),
- async_execution=result_data.get('async', True)
+ message='Workflow execution queued',
+ async_execution=True
)
+ execution_error = result_data.get('error')
return WorkflowExecutionResult(
- success=result_data['success'],
+ success=result_data.get('status') != 'failed',
output=result_data.get('output'),
- error=result_data.get('error'),
- logs=result_data.get('logs'),
- metadata=result_data.get('metadata'),
- trace_spans=result_data.get('traceSpans'),
- total_duration=result_data.get('totalDuration')
+ error=execution_error.get('message') if execution_error else None,
+ metadata={
+ 'duration': result_data.get('durationMs'),
+ 'executionId': result_data['executionId']
+ },
+ total_duration=result_data.get('durationMs')
)
except requests.Timeout:
@@ -378,10 +378,10 @@ def close(self) -> None:
def get_job_status(self, job_id: str) -> Dict[str, Any]:
"""
- Get the status of an async job.
+ Get the status of a legacy async job.
Args:
- job_id: The job ID returned from async execution
+ job_id: The job ID returned from legacy async execution
Returns:
Dictionary containing the job status
@@ -412,6 +412,61 @@ def get_job_status(self, job_id: str) -> Dict[str, Any]:
except requests.RequestException as e:
raise SimStudioError(f'Failed to get job status: {str(e)}', 'STATUS_ERROR')
+ def get_workflow_execution(
+ self,
+ workflow_id: str,
+ execution_id: str,
+ *,
+ include_output: Optional[bool] = None,
+ selected_outputs: Optional[list] = None
+ ) -> Dict[str, Any]:
+ """
+ Get a workflow execution's current status and optional outputs from the v2 API.
+
+ Args:
+ workflow_id: The workflow ID
+ execution_id: The execution ID returned from async execution
+ include_output: Include the final output for completed executions
+ selected_outputs: Block output selectors to include
+
+ Returns:
+ Dictionary containing the execution status
+
+ Raises:
+ SimStudioError: If getting the status fails
+ """
+ url = f"{self.base_url}/api/v2/workflows/{workflow_id}/executions/{execution_id}"
+ params = {}
+ if include_output is not None:
+ params['includeOutput'] = str(include_output).lower()
+ if selected_outputs:
+ params['selectedOutputs'] = ','.join(selected_outputs)
+
+ try:
+ response = self._session.get(url, params=params or None)
+
+ self._update_rate_limit_info(response)
+
+ if not response.ok:
+ try:
+ error_data = response.json()
+ error = error_data.get('error', {})
+ error_message = error.get('message', f'HTTP {response.status_code}: {response.reason}')
+ error_code = error.get('code')
+ except (ValueError, KeyError):
+ error_message = f'HTTP {response.status_code}: {response.reason}'
+ error_code = None
+
+ raise SimStudioError(error_message, error_code, response.status_code)
+
+ result = response.json()
+ if 'data' not in result:
+ raise SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR')
+ return result['data']
+
+ except requests.RequestException as e:
+ raise SimStudioError(f'Failed to get workflow execution: {str(e)}', 'STATUS_ERROR')
+
def execute_with_retry(
self,
workflow_id: str,
@@ -565,4 +620,4 @@ def __exit__(self, exc_type, exc_val, exc_tb):
# For backward compatibility
-Client = SimStudioClient
\ No newline at end of file
+Client = SimStudioClient
diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py
index 814ad7610ef..8473758198b 100644
--- a/packages/python-sdk/tests/test_client.py
+++ b/packages/python-sdk/tests/test_client.py
@@ -7,6 +7,19 @@
from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus
+def v2_execution_response(output=None):
+ return {
+ "data": {
+ "executionId": "execution-123",
+ "workflowId": "workflow-id",
+ "status": "completed",
+ "output": {} if output is None else output,
+ "error": None,
+ "durationMs": 10
+ }
+ }
+
+
def test_simstudio_client_initialization():
"""Test SimStudioClient initialization."""
client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
@@ -95,18 +108,16 @@ def test_context_manager(mock_close):
@patch('simstudio.requests.Session.post')
-def test_async_execution_returns_job_id(mock_post):
+def test_async_execution_returns_execution_id(mock_post):
"""Test async execution returns AsyncExecutionResult."""
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 202
mock_response.json.return_value = {
- "success": True,
- "jobId": "job-123",
- "statusUrl": "https://test.sim.ai/api/jobs/job-123",
- "executionId": "execution-123",
- "message": "Workflow execution started",
- "async": True
+ "data": {
+ "executionId": "execution-123",
+ "statusUrl": "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123"
+ }
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -119,13 +130,17 @@ def test_async_execution_returns_job_id(mock_post):
)
assert result.success is True
- assert result.job_id == "job-123"
- assert result.status_url == "https://test.sim.ai/api/jobs/job-123"
assert result.execution_id == "execution-123"
+ assert result.status_url == "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123"
assert result.async_execution is True
call_args = mock_post.call_args
- assert call_args[1]["headers"]["X-Execution-Mode"] == "async"
+ assert call_args.args[0] == "https://sim.ai/api/v2/workflows/workflow-id/execute"
+ assert "X-Execution-Mode" not in call_args.kwargs["headers"]
+ assert call_args.kwargs["json"] == {
+ "input": {"message": "Hello"},
+ "async": True
+ }
@patch('simstudio.requests.Session.post')
@@ -134,11 +149,7 @@ def test_sync_execution_returns_result(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {
- "success": True,
- "output": {"result": "completed"},
- "logs": []
- }
+ mock_response.json.return_value = v2_execution_response({"result": "completed"})
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -160,7 +171,7 @@ def test_async_header_not_set_when_false(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.json.return_value = v2_execution_response()
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -173,18 +184,14 @@ def test_async_header_not_set_when_false(mock_post):
@patch('simstudio.requests.Session.get')
def test_get_job_status_success(mock_get):
- """Test getting job status."""
+ """Test getting legacy job status."""
mock_response = Mock()
mock_response.ok = True
mock_response.json.return_value = {
"success": True,
"taskId": "task-123",
"status": "completed",
- "metadata": {
- "startedAt": "2024-01-01T00:00:00Z",
- "completedAt": "2024-01-01T00:01:00Z",
- "duration": 60000
- },
+ "metadata": {"duration": 60000},
"output": {"result": "done"}
}
mock_response.headers.get.return_value = None
@@ -201,7 +208,7 @@ def test_get_job_status_success(mock_get):
@patch('simstudio.requests.Session.get')
def test_get_job_status_not_found(mock_get):
- """Test job not found error."""
+ """Test legacy job not found error."""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 404
@@ -220,6 +227,60 @@ def test_get_job_status_not_found(mock_get):
assert "Job not found" in str(exc_info.value)
+@patch('simstudio.requests.Session.get')
+def test_get_workflow_execution_success(mock_get):
+ mock_response = Mock()
+ mock_response.ok = True
+ mock_response.json.return_value = {
+ "data": {
+ "executionId": "execution-123",
+ "workflowId": "workflow-123",
+ "status": "completed",
+ "output": {"result": "done"}
+ }
+ }
+ mock_response.headers.get.return_value = None
+ mock_get.return_value = mock_response
+
+ client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai")
+ result = client.get_workflow_execution(
+ "workflow-123",
+ "execution-123",
+ include_output=True,
+ selected_outputs=["agent.content"]
+ )
+
+ assert result["executionId"] == "execution-123"
+ assert result["status"] == "completed"
+ assert result["output"]["result"] == "done"
+ mock_get.assert_called_once_with(
+ "https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123",
+ params={"includeOutput": "true", "selectedOutputs": "agent.content"}
+ )
+
+
+@patch('simstudio.requests.Session.get')
+def test_get_workflow_execution_not_found(mock_get):
+ mock_response = Mock()
+ mock_response.ok = False
+ mock_response.status_code = 404
+ mock_response.reason = "Not Found"
+ mock_response.json.return_value = {
+ "error": {
+ "code": "NOT_FOUND",
+ "message": "Execution not found"
+ }
+ }
+ mock_response.headers.get.return_value = None
+ mock_get.return_value = mock_response
+
+ client = SimStudioClient(api_key="test-api-key")
+
+ with pytest.raises(SimStudioError) as exc_info:
+ client.get_workflow_execution("workflow-123", "invalid-execution")
+ assert "Execution not found" in str(exc_info.value)
+
+
@patch('simstudio.requests.Session.post')
@patch('simstudio.time.sleep')
def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
@@ -227,10 +288,7 @@ def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {
- "success": True,
- "output": {"result": "success"}
- }
+ mock_response.json.return_value = v2_execution_response({"result": "success"})
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -264,10 +322,7 @@ def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post):
success_response = Mock()
success_response.ok = True
success_response.status_code = 200
- success_response.json.return_value = {
- "success": True,
- "output": {"result": "success"}
- }
+ success_response.json.return_value = v2_execution_response({"result": "success"})
success_response.headers.get.return_value = None
mock_post.side_effect = [rate_limit_response, success_response]
@@ -321,8 +376,10 @@ def test_execute_with_retry_no_retry_on_other_errors(mock_post):
mock_response.status_code = 500
mock_response.reason = "Internal Server Error"
mock_response.json.return_value = {
- "error": "Server error",
- "code": "INTERNAL_ERROR"
+ "error": {
+ "code": "INTERNAL_ERROR",
+ "message": "Server error"
+ }
}
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -349,7 +406,7 @@ def test_get_rate_limit_info_after_api_call(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.json.return_value = v2_execution_response()
mock_response.headers.get.side_effect = lambda h: {
'x-ratelimit-limit': '100',
'x-ratelimit-remaining': '95',
@@ -436,7 +493,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.json.return_value = v2_execution_response()
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -451,7 +508,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post):
call_args = mock_post.call_args
request_body = call_args[1]["json"]
- assert request_body["message"] == "test"
+ assert request_body["input"] == {"message": "test"}
assert request_body["stream"] is True
assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"]
@@ -463,7 +520,7 @@ def test_execute_workflow_with_string_input(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.json.return_value = v2_execution_response()
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -473,7 +530,7 @@ def test_execute_workflow_with_string_input(mock_post):
call_args = mock_post.call_args
request_body = call_args[1]["json"]
- assert request_body["input"] == "NVDA"
+ assert request_body["input"] == {"input": "NVDA"}
assert "0" not in request_body # Should not spread string characters
@@ -483,7 +540,7 @@ def test_execute_workflow_with_number_input(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.json.return_value = v2_execution_response()
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -493,7 +550,7 @@ def test_execute_workflow_with_number_input(mock_post):
call_args = mock_post.call_args
request_body = call_args[1]["json"]
- assert request_body["input"] == 42
+ assert request_body["input"] == {"input": 42}
@patch('simstudio.requests.Session.post')
@@ -502,7 +559,7 @@ def test_execute_workflow_with_list_input(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.json.return_value = v2_execution_response()
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -512,17 +569,16 @@ def test_execute_workflow_with_list_input(mock_post):
call_args = mock_post.call_args
request_body = call_args[1]["json"]
- assert request_body["input"] == ["NVDA", "AAPL", "GOOG"]
+ assert request_body["input"] == {"input": ["NVDA", "AAPL", "GOOG"]}
assert "0" not in request_body # Should not spread list
@patch('simstudio.requests.Session.post')
-def test_execute_workflow_with_dict_input_spreads_at_root(mock_post):
- """Test execution with dict input spreads at root level."""
+def test_execute_workflow_with_dict_input_uses_v2_input_field(mock_post):
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
- mock_response.json.return_value = {"success": True, "output": {}}
+ mock_response.json.return_value = v2_execution_response()
mock_response.headers.get.return_value = None
mock_post.return_value = mock_response
@@ -532,6 +588,4 @@ def test_execute_workflow_with_dict_input_spreads_at_root(mock_post):
call_args = mock_post.call_args
request_body = call_args[1]["json"]
- assert request_body["ticker"] == "NVDA"
- assert request_body["quantity"] == 100
- assert "input" not in request_body # Should not wrap in input field
\ No newline at end of file
+ assert request_body["input"] == {"ticker": "NVDA", "quantity": 100}
diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md
index 0ce547f6e51..2e2831ea93b 100644
--- a/packages/ts-sdk/README.md
+++ b/packages/ts-sdk/README.md
@@ -52,12 +52,12 @@ new SimStudioClient(config: SimStudioConfig)
Execute a workflow with optional input data.
```typescript
-// With object input (spread at root level of request body)
+// With object input (sent as the v2 input object)
const result = await client.executeWorkflow('workflow-id', {
message: 'Hello, world!'
});
-// With primitive input (wrapped as { input: value })
+// With primitive input (sent as { input: { input: value } })
const result = await client.executeWorkflow('workflow-id', 'NVDA');
// With options
@@ -68,7 +68,7 @@ const result = await client.executeWorkflow('workflow-id', { message: 'Hello' },
**Parameters:**
- `workflowId` (string): The ID of the workflow to execute
-- `input` (any, optional): Input data to pass to the workflow. Objects are spread at the root level, primitives/arrays are wrapped in `{ input: value }`. File objects are automatically converted to base64.
+- `input` (any, optional): Input data to pass to the workflow. Objects become the v2 `input` object; primitives and arrays become `{ input: value }` inside it. File objects are automatically converted to base64.
- `options` (ExecutionOptions, optional):
- `timeout` (number): Timeout in milliseconds (default: 30000)
- `stream` (boolean): Enable streaming responses
@@ -125,19 +125,35 @@ const result = await client.executeWorkflowSync('workflow-id', { data: 'some inp
**Returns:** `Promise`
-##### getJobStatus(jobId)
+##### getWorkflowExecution(workflowId, executionId, options?)
-Get the status of an async job.
+Get the status and optional outputs of a workflow execution. Use the `executionId` returned by async execution.
```typescript
-const status = await client.getJobStatus('job-id-from-async-execution');
-console.log('Job status:', status);
+const status = await client.getWorkflowExecution('workflow-id', 'execution-id', {
+ includeOutput: true,
+ selectedOutputs: ['agent.content']
+});
+console.log('Execution status:', status.status);
```
**Parameters:**
-- `jobId` (string): The job ID returned from async execution
+- `workflowId` (string): The workflow ID
+- `executionId` (string): The execution ID returned from async execution
+- `options.includeOutput` (boolean, optional): Include the final output for completed executions
+- `options.selectedOutputs` (string[], optional): Block output selectors to include
+
+**Returns:** `Promise`
+
+##### getJobStatus(jobId)
-**Returns:** `Promise`
+Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowExecution()` with an execution ID.
+
+```typescript
+const status = await client.getJobStatus('legacy-job-id');
+```
+
+**Returns:** `Promise`
##### executeWithRetry(workflowId, input?, options?, retryOptions?)
@@ -228,7 +244,7 @@ interface WorkflowExecutionResult {
### LargeValueRef
-Oversized execution values may be returned as a versioned reference inside `output`, `logs`, streaming events, or async job status responses.
+Oversized execution values may be returned as a versioned reference inside `output`, `logs`, streaming events, or execution status responses.
The `key` field is an opaque execution-scoped server storage pointer, not a client-readable download URL.
```typescript
@@ -268,9 +284,8 @@ class SimStudioError extends Error {
```typescript
interface AsyncExecutionResult {
success: boolean;
- jobId: string;
+ executionId: string;
statusUrl: string;
- executionId?: string;
message: string;
async: true;
}
@@ -533,4 +548,4 @@ bun run dev
## License
-Apache-2.0
\ No newline at end of file
+Apache-2.0
diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts
index c5066442f99..95137c9c23e 100644
--- a/packages/ts-sdk/src/index.test.ts
+++ b/packages/ts-sdk/src/index.test.ts
@@ -4,6 +4,19 @@ import { SimStudioClient, SimStudioError } from './index'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
+function v2ExecutionResponse(output: unknown = {}) {
+ return {
+ data: {
+ executionId: 'execution-123',
+ workflowId: 'workflow-id',
+ status: 'completed',
+ output,
+ error: null,
+ durationMs: 10,
+ },
+ }
+}
+
describe('SimStudioClient', () => {
let client: SimStudioClient
@@ -100,11 +113,10 @@ describe('SimStudioClient', () => {
ok: true,
status: 202,
json: vi.fn().mockResolvedValue({
- success: true,
- jobId: 'job-123',
- statusUrl: 'https://test.sim.ai/api/jobs/job-123',
- message: 'Workflow execution queued',
- async: true,
+ data: {
+ executionId: 'execution-123',
+ statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123',
+ },
}),
headers: {
get: vi.fn().mockReturnValue(null),
@@ -118,14 +130,19 @@ describe('SimStudioClient', () => {
{ async: true }
)
- expect(result).toHaveProperty('jobId', 'job-123')
- expect(result).toHaveProperty('statusUrl', 'https://test.sim.ai/api/jobs/job-123')
+ expect(result).toHaveProperty('executionId', 'execution-123')
+ expect(result).toHaveProperty(
+ 'statusUrl',
+ 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123'
+ )
expect(result).toHaveProperty('async', true)
- // Verify headers were set correctly
const calls = vi.mocked(mockFetch).mock.calls
- expect(calls[0][1]?.headers).toMatchObject({
- 'X-Execution-Mode': 'async',
+ expect(calls[0][0]).toBe('https://test.sim.ai/api/v2/workflows/workflow-id/execute')
+ expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode')
+ expect(JSON.parse(calls[0][1]?.body as string)).toEqual({
+ input: { message: 'Hello' },
+ async: true,
})
})
@@ -133,11 +150,7 @@ describe('SimStudioClient', () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: { result: 'completed' },
- logs: [],
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'completed' })),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -159,10 +172,7 @@ describe('SimStudioClient', () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: {},
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -177,18 +187,14 @@ describe('SimStudioClient', () => {
})
describe('getJobStatus', () => {
- it('should fetch job status with correct endpoint', async () => {
+ it('should fetch legacy job status with the correct endpoint', async () => {
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
success: true,
taskId: 'task-123',
status: 'completed',
- metadata: {
- startedAt: '2024-01-01T00:00:00Z',
- completedAt: '2024-01-01T00:01:00Z',
- duration: 60000,
- },
+ metadata: { duration: 60000 },
output: { result: 'done' },
}),
headers: {
@@ -202,13 +208,10 @@ describe('SimStudioClient', () => {
expect(result).toHaveProperty('taskId', 'task-123')
expect(result).toHaveProperty('status', 'completed')
expect(result).toHaveProperty('output')
-
- // Verify correct endpoint was called
- const calls = vi.mocked(mockFetch).mock.calls
- expect(calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123')
+ expect(vi.mocked(mockFetch).mock.calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123')
})
- it('should handle job not found error', async () => {
+ it('should handle legacy job not found errors', async () => {
const mockResponse = {
ok: false,
status: 404,
@@ -223,20 +226,75 @@ describe('SimStudioClient', () => {
}
vi.mocked(mockFetch).mockResolvedValue(mockResponse as any)
- await expect(client.getJobStatus('invalid-task')).rejects.toThrow(SimStudioError)
await expect(client.getJobStatus('invalid-task')).rejects.toThrow('Job not found')
})
})
+ describe('getWorkflowExecution', () => {
+ it('should fetch execution status and outputs from the v2 execution resource', async () => {
+ const mockResponse = {
+ ok: true,
+ json: vi.fn().mockResolvedValue({
+ data: {
+ executionId: 'execution-123',
+ workflowId: 'workflow-123',
+ status: 'completed',
+ output: { result: 'done' },
+ },
+ }),
+ headers: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ }
+ vi.mocked(mockFetch).mockResolvedValue(mockResponse as any)
+
+ const result = await client.getWorkflowExecution('workflow-123', 'execution-123', {
+ includeOutput: true,
+ selectedOutputs: ['agent.content'],
+ })
+
+ expect(result).toHaveProperty('executionId', 'execution-123')
+ expect(result).toHaveProperty('status', 'completed')
+ expect(result).toHaveProperty('output')
+
+ const calls = vi.mocked(mockFetch).mock.calls
+ expect(calls[0][0]).toBe(
+ 'https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123?includeOutput=true&selectedOutputs=agent.content'
+ )
+ })
+
+ it('should handle execution not found errors', async () => {
+ const mockResponse = {
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ json: vi.fn().mockResolvedValue({
+ error: {
+ code: 'NOT_FOUND',
+ message: 'Execution not found',
+ },
+ }),
+ headers: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ }
+ vi.mocked(mockFetch).mockResolvedValue(mockResponse as any)
+
+ await expect(
+ client.getWorkflowExecution('workflow-123', 'invalid-execution')
+ ).rejects.toThrow(SimStudioError)
+ await expect(
+ client.getWorkflowExecution('workflow-123', 'invalid-execution')
+ ).rejects.toThrow('Execution not found')
+ })
+ })
+
describe('executeWithRetry', () => {
it('should succeed on first attempt when no rate limit', async () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: { result: 'success' },
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'success' })),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -273,10 +331,7 @@ describe('SimStudioClient', () => {
const successResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: { result: 'success' },
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'success' })),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -334,8 +389,10 @@ describe('SimStudioClient', () => {
status: 500,
statusText: 'Internal Server Error',
json: vi.fn().mockResolvedValue({
- error: 'Server error',
- code: 'INTERNAL_ERROR',
+ error: {
+ code: 'INTERNAL_ERROR',
+ message: 'Server error',
+ },
}),
headers: {
get: vi.fn().mockReturnValue(null),
@@ -362,7 +419,7 @@ describe('SimStudioClient', () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({ success: true, output: {} }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn((header: string) => {
if (header === 'x-ratelimit-limit') return '100'
@@ -468,10 +525,7 @@ describe('SimStudioClient', () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: {},
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -488,7 +542,7 @@ describe('SimStudioClient', () => {
const calls = vi.mocked(mockFetch).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
- expect(requestBody).toHaveProperty('message', 'test')
+ expect(requestBody.input).toEqual({ message: 'test' })
expect(requestBody).toHaveProperty('stream', true)
expect(requestBody).toHaveProperty('selectedOutputs')
expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content'])
@@ -500,10 +554,7 @@ describe('SimStudioClient', () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: {},
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -516,7 +567,7 @@ describe('SimStudioClient', () => {
const calls = vi.mocked(mockFetch).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
- expect(requestBody).toHaveProperty('input', 'NVDA')
+ expect(requestBody.input).toEqual({ input: 'NVDA' })
expect(requestBody).not.toHaveProperty('0') // Should not spread string characters
})
@@ -524,10 +575,7 @@ describe('SimStudioClient', () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: {},
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -540,17 +588,14 @@ describe('SimStudioClient', () => {
const calls = vi.mocked(mockFetch).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
- expect(requestBody).toHaveProperty('input', 42)
+ expect(requestBody.input).toEqual({ input: 42 })
})
it('should wrap array input in input field', async () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: {},
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -563,8 +608,7 @@ describe('SimStudioClient', () => {
const calls = vi.mocked(mockFetch).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
- expect(requestBody).toHaveProperty('input')
- expect(requestBody.input).toEqual(['NVDA', 'AAPL', 'GOOG'])
+ expect(requestBody.input).toEqual({ input: ['NVDA', 'AAPL', 'GOOG'] })
expect(requestBody).not.toHaveProperty('0') // Should not spread array
})
@@ -572,10 +616,7 @@ describe('SimStudioClient', () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: {},
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -588,19 +629,14 @@ describe('SimStudioClient', () => {
const calls = vi.mocked(mockFetch).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
- expect(requestBody).toHaveProperty('ticker', 'NVDA')
- expect(requestBody).toHaveProperty('quantity', 100)
- expect(requestBody).not.toHaveProperty('input') // Should not wrap in input field
+ expect(requestBody.input).toEqual({ ticker: 'NVDA', quantity: 100 })
})
it('should handle null input as no input (empty body)', async () => {
const mockResponse = {
ok: true,
status: 200,
- json: vi.fn().mockResolvedValue({
- success: true,
- output: {},
- }),
+ json: vi.fn().mockResolvedValue(v2ExecutionResponse()),
headers: {
get: vi.fn().mockReturnValue(null),
},
@@ -613,8 +649,7 @@ describe('SimStudioClient', () => {
const calls = vi.mocked(mockFetch).mock.calls
const requestBody = JSON.parse(calls[0][1]?.body as string)
- // null treated as "no input" - sends empty body (consistent with Python SDK)
- expect(requestBody).toEqual({})
+ expect(requestBody).toEqual({ input: {} })
})
})
})
diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts
index d1538ff5e84..4d8777867f5 100644
--- a/packages/ts-sdk/src/index.ts
+++ b/packages/ts-sdk/src/index.ts
@@ -45,9 +45,8 @@ export interface ExecutionOptions {
export interface AsyncExecutionResult {
success: boolean
- jobId: string
+ executionId: string
statusUrl: string
- executionId?: string
message: string
async: true
}
@@ -60,6 +59,32 @@ export interface JobStatusResult {
error?: string
}
+export interface WorkflowExecutionError {
+ code: string
+ message: string
+ details?: unknown
+}
+
+export interface WorkflowExecutionStatus {
+ executionId: string
+ workflowId: string
+ status: 'queued' | 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'
+ trigger: string | null
+ startedAt: string | null
+ endedAt: string | null
+ durationMs: number | null
+ paused: Record | null
+ cost: { total: number } | null
+ error: WorkflowExecutionError | null
+ output: unknown | null
+ blockOutputs: Record | null
+}
+
+export interface GetWorkflowExecutionOptions {
+ includeOutput?: boolean
+ selectedOutputs?: string[]
+}
+
export interface RateLimitInfo {
limit: number
remaining: number
@@ -215,7 +240,7 @@ export class SimStudioClient {
input?: any,
options: ExecutionOptions = {}
): Promise {
- const url = `${this.baseUrl}/api/workflows/${workflowId}/execute`
+ const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/execute`
const { timeout = 30000, stream, selectedOutputs, async } = options
try {
@@ -227,20 +252,18 @@ export class SimStudioClient {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
}
- if (async) {
- headers['X-Execution-Mode'] = 'async'
- }
- let jsonBody: any = {}
+ let workflowInput: any = {}
if (input !== undefined && input !== null) {
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
- jsonBody = { ...input }
+ workflowInput = { ...input }
} else {
- jsonBody = { input }
+ workflowInput = { input }
}
}
- jsonBody = await this.convertFilesToBase64(jsonBody)
+ workflowInput = await this.convertFilesToBase64(workflowInput)
+ const jsonBody: Record = { input: workflowInput }
if (stream !== undefined) {
jsonBody.stream = stream
@@ -248,6 +271,9 @@ export class SimStudioClient {
if (selectedOutputs !== undefined) {
jsonBody.selectedOutputs = selectedOutputs
}
+ if (async !== undefined) {
+ jsonBody.async = async
+ }
const fetchPromise = fetch(url, {
method: 'POST',
@@ -269,16 +295,53 @@ export class SimStudioClient {
}
if (!response.ok) {
- const errorData = (await response.json().catch(() => ({}))) as unknown as any
+ const errorData = (await response.json().catch(() => ({}))) as {
+ error?: { code?: string; message?: string }
+ }
throw new SimStudioError(
- errorData.error || `HTTP ${response.status}: ${response.statusText}`,
- errorData.code,
+ errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`,
+ errorData.error?.code,
response.status
)
}
- const result = await response.json()
- return result as WorkflowExecutionResult | AsyncExecutionResult
+ const result = (await response.json()) as {
+ data?: {
+ executionId: string
+ statusUrl?: string
+ status?: 'completed' | 'failed' | 'paused' | 'cancelled'
+ output?: unknown
+ error?: WorkflowExecutionError | null
+ durationMs?: number
+ }
+ }
+ if (!result.data) {
+ throw new SimStudioError('Invalid v2 workflow execution response', 'EXECUTION_ERROR')
+ }
+
+ if (response.status === 202) {
+ if (!result.data.statusUrl) {
+ throw new SimStudioError('Invalid v2 async execution response', 'EXECUTION_ERROR')
+ }
+ return {
+ success: true,
+ executionId: result.data.executionId,
+ statusUrl: result.data.statusUrl,
+ message: 'Workflow execution queued',
+ async: true,
+ }
+ }
+
+ return {
+ success: result.data.status !== 'failed',
+ output: result.data.output,
+ error: result.data.error?.message,
+ metadata: {
+ duration: result.data.durationMs,
+ executionId: result.data.executionId,
+ },
+ totalDuration: result.data.durationMs,
+ }
} catch (error: any) {
if (error instanceof SimStudioError) {
throw error
@@ -310,7 +373,10 @@ export class SimStudioClient {
})
if (!response.ok) {
- const errorData = (await response.json().catch(() => ({}))) as unknown as any
+ const errorData = (await response.json().catch(() => ({}))) as {
+ error?: string
+ code?: string
+ }
throw new SimStudioError(
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
errorData.code,
@@ -374,8 +440,8 @@ export class SimStudioClient {
}
/**
- * Get the status of an async job
- * @param taskId The job ID returned from async execution
+ * Get the status of a legacy async job.
+ * @param taskId The job ID returned from legacy async execution
*/
async getJobStatus(taskId: string): Promise {
const url = `${this.baseUrl}/api/jobs/${taskId}`
@@ -410,6 +476,62 @@ export class SimStudioClient {
}
}
+ /**
+ * Get a workflow execution's current status and optional outputs from the v2 API.
+ */
+ async getWorkflowExecution(
+ workflowId: string,
+ executionId: string,
+ options: GetWorkflowExecutionOptions = {}
+ ): Promise {
+ const query = new URLSearchParams()
+ if (options.includeOutput !== undefined) {
+ query.set('includeOutput', String(options.includeOutput))
+ }
+ if (options.selectedOutputs?.length) {
+ query.set('selectedOutputs', options.selectedOutputs.join(','))
+ }
+ const queryString = query.toString()
+ const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/executions/${executionId}${queryString ? `?${queryString}` : ''}`
+
+ try {
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'X-API-Key': this.apiKey,
+ },
+ })
+
+ this.updateRateLimitInfo(response)
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as {
+ error?: { code?: string; message?: string }
+ }
+ throw new SimStudioError(
+ errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`,
+ errorData.error?.code,
+ response.status
+ )
+ }
+
+ const result = (await response.json()) as { data?: WorkflowExecutionStatus }
+ if (!result.data) {
+ throw new SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR')
+ }
+ return result.data
+ } catch (error: any) {
+ if (error instanceof SimStudioError) {
+ throw error
+ }
+
+ throw new SimStudioError(
+ describeError(error) || 'Failed to get workflow execution',
+ 'STATUS_ERROR'
+ )
+ }
+ }
+
/**
* Execute workflow with automatic retry on rate limit
* @param workflowId - The ID of the workflow to execute