Skip to content

Commit 72b41f7

Browse files
committed
Expose active workflow API details to Mothership
1 parent cddaff7 commit 72b41f7

4 files changed

Lines changed: 285 additions & 0 deletions

File tree

‎apps/sim/lib/mothership/agent-cli/engines/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { fileViewCommand } from '@/lib/mothership/agent-cli/engines/file-view'
55
import { workflowLintCommand } from '@/lib/mothership/agent-cli/engines/lint'
66
import { logsQueryCommand } from '@/lib/mothership/agent-cli/engines/query'
77
import { universalGrepCommand } from '@/lib/mothership/agent-cli/engines/universal-grep'
8+
import { workflowApiCommand } from '@/lib/mothership/agent-cli/engines/workflow-api'
89
import {
910
type AgentCliEngine,
1011
type AgentCliFlags,
@@ -26,6 +27,7 @@ export const AUGMENTATION_ENGINES: Readonly<Record<string, AgentCliEngine>> = {
2627
'logs query': logsQueryCommand,
2728
'workflows deps': workflowDepsCommand,
2829
'workflows lint': workflowLintCommand,
30+
'workflows api': workflowApiCommand,
2931
}
3032

3133
/** Runs one engine by the worker's name; an engine that throws yields a failed result, never a throw. */
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/** @vitest-environment node */
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { v2ExecuteWorkflowBodySchema } from '@/lib/api/contracts/v2/workflows'
4+
import { workflowApiCommand } from '@/lib/mothership/agent-cli/engines/workflow-api'
5+
import type { AgentCliRuntime } from '@/lib/mothership/agent-cli/types'
6+
7+
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://self-hosted.example' }))
8+
9+
function fixture(
10+
options: { public?: boolean; deployed?: boolean; changed?: boolean; api?: boolean } = {}
11+
) {
12+
const deployed = options.deployed !== false
13+
const requests: string[] = []
14+
const deployment = {
15+
id: 'wf-1',
16+
isDeployed: deployed,
17+
deployedAt: '2026-09-08T00:00:00Z',
18+
warnings: [],
19+
activeDeployment: deployed
20+
? { deploymentVersionId: 'version-2', version: 2, deployedAt: '2026-09-08T00:00:00Z' }
21+
: null,
22+
latestDeploymentAttempt: null,
23+
needsRedeployment: true,
24+
isPublicApi: options.public === true,
25+
webhooks: [],
26+
}
27+
const version = {
28+
id: 'version-2',
29+
version: 2,
30+
name: null,
31+
description: null,
32+
isActive: !options.changed,
33+
createdAt: '2026-09-08T00:00:00Z',
34+
state: {
35+
blocks: {
36+
start: {
37+
id: 'start',
38+
type: options.api === false ? 'schedule' : 'start_trigger',
39+
name: 'Start',
40+
enabled: true,
41+
subBlocks: {
42+
inputFormat: {
43+
value: [
44+
{
45+
name: 'query',
46+
type: 'string',
47+
value: "today's news",
48+
description: 'Search query',
49+
},
50+
{ name: 'limit', type: 'number', value: '5' },
51+
],
52+
},
53+
},
54+
},
55+
},
56+
},
57+
}
58+
const runtime: AgentCliRuntime = {
59+
workspaceId: 'ws-1',
60+
userId: 'user-1',
61+
client: {
62+
request: async <T>(path: string): Promise<T> => {
63+
requests.push(path)
64+
if (path === '/api/v2/workflows/wf-1/deployment') return { data: deployment } as T
65+
if (path === '/api/v2/workflows/wf-1/versions/2') return { data: version } as T
66+
throw new Error(`Unexpected request: ${path}`)
67+
},
68+
},
69+
}
70+
return { runtime, requests }
71+
}
72+
73+
describe('Mothership workflow API details', () => {
74+
it('reads the pinned live schema, not the changed draft, and describes the configured external API', async () => {
75+
const { runtime, requests } = fixture()
76+
const result = await workflowApiCommand.execute(['wf-1'], runtime, {})
77+
expect(result.exitCode).toBe(0)
78+
const api = JSON.parse(result.stdout)
79+
expect(requests).toEqual([
80+
'/api/v2/workflows/wf-1/deployment',
81+
'/api/v2/workflows/wf-1/versions/2',
82+
])
83+
expect(api).toMatchObject({
84+
activeVersion: 2,
85+
needsRedeployment: true,
86+
method: 'POST',
87+
endpoint: 'https://self-hosted.example/api/v2/workflows/wf-1/execute',
88+
})
89+
expect(api.input.fields).toEqual([
90+
{ name: 'query', type: 'string', default: "today's news", description: 'Search query' },
91+
{ name: 'limit', type: 'number', default: '5' },
92+
])
93+
expect(api.responses.errors).toContain('HTTP 200 can contain data.status=failed')
94+
expect(api.examples.poll).toContain('/runs/<runId>?includeOutput=true')
95+
expect(result.stdout.length).toBeLessThan(7000)
96+
// The example must survive shell quoting, including apostrophes in saved inputs,
97+
// and parse as the actual public execute contract rather than a native tool body.
98+
for (const [mode, command] of Object.entries<string>(api.examples)) {
99+
if (mode === 'poll') continue
100+
const quoted = command.split(' --data ')[1]
101+
expect(quoted.startsWith("'") && quoted.endsWith("'")).toBe(true)
102+
const body = JSON.parse(quoted.slice(1, -1).replaceAll("'\\''", "'"))
103+
expect(v2ExecuteWorkflowBodySchema.safeParse(body).success).toBe(true)
104+
expect(body.input).toEqual({ query: "today's news", limit: 5 })
105+
expect(body.async === true).toBe(mode === 'async')
106+
expect(body.stream === true).toBe(mode === 'stream')
107+
}
108+
})
109+
110+
it('keeps API-key auth on queued execution and polling even for a public workflow', async () => {
111+
const { runtime } = fixture({ public: true })
112+
const api = JSON.parse((await workflowApiCommand.execute(['wf-1'], runtime, {})).stdout)
113+
expect(api.authentication.type).toBe('public')
114+
expect(api.examples.sync).not.toContain('X-API-Key')
115+
expect(api.examples.stream).not.toContain('X-API-Key')
116+
expect(api.examples.async).toContain('X-API-Key')
117+
expect(api.examples.poll).toContain('X-API-Key')
118+
})
119+
120+
it('does not advertise a live endpoint when undeployed or lacking an API entry block', async () => {
121+
const { runtime, requests } = fixture({ deployed: false })
122+
const undeployed = JSON.parse((await workflowApiCommand.execute(['wf-1'], runtime, {})).stdout)
123+
expect(undeployed.isDeployed).toBe(false)
124+
expect(undeployed.endpoint).toBeUndefined()
125+
expect(requests).toHaveLength(1)
126+
const noApi = JSON.parse(
127+
(await workflowApiCommand.execute(['wf-1'], fixture({ api: false }).runtime, {})).stdout
128+
)
129+
expect(noApi.apiRunnable).toBe(false)
130+
expect(noApi.endpoint).toBeUndefined()
131+
})
132+
133+
it('refuses mismatched active-version observations and propagates authorization failures', async () => {
134+
const changed = await workflowApiCommand.execute(
135+
['wf-1'],
136+
fixture({ changed: true }).runtime,
137+
{}
138+
)
139+
expect(changed.exitCode).toBe(1)
140+
expect(changed.stderr).toContain('active deployment changed')
141+
const { runtime } = fixture()
142+
runtime.client.request = vi.fn().mockRejectedValue(new Error('Forbidden'))
143+
await expect(workflowApiCommand.execute(['wf-1'], runtime, {})).rejects.toThrow('Forbidden')
144+
expect(runtime.client.request).toHaveBeenCalledTimes(1)
145+
})
146+
})
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {
2+
v2ExecuteWorkflowBodySchema,
3+
v2ExecuteWorkflowContract,
4+
v2ExecuteWorkflowDataSchema,
5+
v2ExecuteWorkflowQueuedSchema,
6+
v2GetWorkflowDeploymentContract,
7+
v2GetWorkflowRunContract,
8+
v2GetWorkflowVersionContract,
9+
} from '@/lib/api/contracts/v2/workflows'
10+
import { getBaseUrl } from '@/lib/core/utils/urls'
11+
import { type AgentCliEngine, agentCliFail, agentCliOk } from '@/lib/mothership/agent-cli/types'
12+
import { resolveTriggerRunOptions } from '@/lib/workflows/triggers/run-options'
13+
import { resolveStartCandidates } from '@/lib/workflows/triggers/triggers'
14+
15+
function shellQuote(value: string): string {
16+
return `'${value.replaceAll("'", "'\\''")}'`
17+
}
18+
19+
/** Reads authorized deployment snapshots, never the editable draft or credential values. */
20+
export const workflowApiCommand: AgentCliEngine = {
21+
async execute(positionals, runtime) {
22+
const [workflowId] = positionals
23+
if (!workflowId) return agentCliFail('Usage: sim workflows api <workflowId>')
24+
const encodedId = encodeURIComponent(workflowId)
25+
const deploymentPath = v2GetWorkflowDeploymentContract.path.replace('[workflowId]', encodedId)
26+
const deployment = v2GetWorkflowDeploymentContract.response.schema.parse(
27+
await runtime.client.request(deploymentPath)
28+
).data
29+
const active = deployment.activeDeployment
30+
if (!deployment.isDeployed || !active) {
31+
return agentCliOk(
32+
JSON.stringify({
33+
workflowId,
34+
isDeployed: false,
35+
deployment,
36+
note: 'No active deployment is serving this workflow. Deploy it before handing callers an API.',
37+
})
38+
)
39+
}
40+
const versionPath = v2GetWorkflowVersionContract.path
41+
.replace('[workflowId]', encodedId)
42+
.replace('[version]', String(active.version))
43+
const version = v2GetWorkflowVersionContract.response.schema.parse(
44+
await runtime.client.request(versionPath)
45+
).data
46+
if (!version.isActive || version.id !== active.deploymentVersionId) {
47+
return agentCliFail(
48+
'The active deployment changed while reading its API. Run workflows api again.'
49+
)
50+
}
51+
52+
const [entry] = resolveStartCandidates(version.state.blocks, { execution: 'api' })
53+
if (!entry) {
54+
return agentCliOk(
55+
JSON.stringify({
56+
workflowId,
57+
activeVersion: active.version,
58+
apiRunnable: false,
59+
webhooks: deployment.webhooks,
60+
note: 'The active version has no API-compatible Start block. Use its configured trigger surface.',
61+
})
62+
)
63+
}
64+
const option = resolveTriggerRunOptions(version.state.blocks).find(
65+
(candidate) => candidate.triggerBlockId === entry.blockId
66+
)
67+
const endpoint =
68+
getBaseUrl() + v2ExecuteWorkflowContract.path.replace('[workflowId]', encodedId)
69+
const pollUrl =
70+
getBaseUrl() +
71+
v2GetWorkflowRunContract.path.replace('[workflowId]', encodedId).replace('[runId]', '<runId>')
72+
const sample = { input: option?.mockPayload ?? {} }
73+
const authHeader = deployment.isPublicApi ? '' : ' -H "X-API-Key: $SIM_API_KEY"'
74+
const curl = (body: object, streaming = false, requireKey = false): string =>
75+
`curl${streaming ? ' -N' : ''} -X POST ${shellQuote(endpoint)} -H 'Content-Type: application/json'${requireKey ? ' -H "X-API-Key: $SIM_API_KEY"' : authHeader} --data ${shellQuote(JSON.stringify(body))}`
76+
const describeFields = (
77+
fields: Record<string, { description?: string }>
78+
): Record<string, string> =>
79+
Object.fromEntries(
80+
Object.entries(fields).map(([name, field]) => [name, field.description ?? name])
81+
)
82+
83+
return agentCliOk(
84+
JSON.stringify(
85+
{
86+
workflowId,
87+
activeVersion: active.version,
88+
needsRedeployment: deployment.needsRedeployment,
89+
endpoint,
90+
method: v2ExecuteWorkflowContract.method,
91+
authentication: {
92+
type: deployment.isPublicApi ? 'public' : 'sim_api_key',
93+
header: 'X-API-Key',
94+
note: deployment.isPublicApi
95+
? 'Anonymous sync/stream execution is enabled. Async execution and reading saved runs require a Sim API key.'
96+
: 'Use a Sim API key with access to this workflow. Vendor integration credentials are resolved by Sim, not sent in this header.',
97+
},
98+
input: {
99+
blockId: entry.blockId,
100+
blockName: entry.block.name,
101+
fields:
102+
option?.inputFormat.map(({ name, type, description, value }) => ({
103+
name,
104+
type,
105+
...(description ? { description } : {}),
106+
default: value,
107+
})) ?? [],
108+
note: 'Put these fields inside the request body input object. Defaults come from the active version; downstream blocks may require nonempty values. Examples are illustrative, not executed tests.',
109+
},
110+
request: describeFields(
111+
v2ExecuteWorkflowBodySchema.pick({ input: true, async: true, stream: true }).shape
112+
),
113+
examples: {
114+
sync: curl(sample),
115+
stream: curl({ ...sample, stream: true }, true),
116+
async: curl({ ...sample, async: true }, false, true),
117+
poll: `curl ${shellQuote(`${pollUrl}?includeOutput=true`)} -H "X-API-Key: $SIM_API_KEY"`,
118+
},
119+
responses: {
120+
envelope:
121+
'Sync and async JSON responses wrap all result fields in data: read data.output or data.statusUrl, not output or statusUrl at the top level.',
122+
sync: { httpStatus: 200, data: describeFields(v2ExecuteWorkflowDataSchema.shape) },
123+
async: { httpStatus: 202, data: describeFields(v2ExecuteWorkflowQueuedSchema.shape) },
124+
stream:
125+
'Server-Sent Events instead of the JSON response. Cannot be combined with async.',
126+
errors:
127+
'Check HTTP status and data.status. HTTP 200 can contain data.status=failed and data.error; request/authentication errors use the top-level error object.',
128+
poll: 'Follow data.statusUrl from the queue receipt with the same key; add includeOutput=true to retrieve the saved output. Queued or running is not completion.',
129+
},
130+
},
131+
null,
132+
2
133+
)
134+
)
135+
},
136+
}

‎apps/sim/lib/mothership/tools/cli-tool-display.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export const CLI_TOOL_TITLES: Record<string, string> = {
241241
cli_workflow_trace: 'Analyzing run trace',
242242
cli_workflows_grep: 'Searching workflows',
243243
cli_workflows_deps: 'Tracing workflow inputs',
244+
cli_workflows_api: 'Reading workflow API details',
244245
cli_workflows_lint: 'Validating workflow',
245246
// Non-CLI copilot worker tools
246247
cli_help: 'Checking CLI reference',

0 commit comments

Comments
 (0)