Skip to content

Commit 6a0dfce

Browse files
committed
test(mothership): verify hosted workbenches and task compositions
1 parent 03c303f commit 6a0dfce

2 files changed

Lines changed: 599 additions & 0 deletions

File tree

apps/sim/lib/mothership/agent-cli/saved-run-read.postgres.test.ts

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ import {
134134
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
135135
import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer'
136136
import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state'
137+
import { calculateNextRunTime, getScheduleTimeValues } from '@/lib/workflows/schedules/utils'
137138
import { fileOperations } from '@/lib/workspace-files/application/operations'
138139
import { readWorkspaceFileArtifact } from '@/lib/workspace-files/application/read-workspace-file-artifact'
139140
import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text'
@@ -155,6 +156,7 @@ import {
155156
POST as createRowsRoute,
156157
GET as tableRowsRoute,
157158
} from '@/app/api/v2/tables/[tableId]/rows/route'
159+
import { POST as deployWorkflowRoute } from '@/app/api/v2/workflows/[workflowId]/deploy/route'
158160
import { POST as executeRoute } from '@/app/api/v2/workflows/[workflowId]/execute/route'
159161
import { POST as workflowOperationsRoute } from '@/app/api/v2/workflows/[workflowId]/operations/route'
160162
import { GET as runRoute } from '@/app/api/v2/workflows/[workflowId]/runs/[runId]/route'
@@ -540,6 +542,9 @@ const identity = {
540542
const execution = request.nextUrl.pathname.match(/^\/api\/v2\/workflows\/([^/]+)\/execute$/)
541543
if (execution)
542544
return executeRoute(request, { params: Promise.resolve({ workflowId: execution[1] }) })
545+
const deploy = request.nextUrl.pathname.match(/^\/api\/v2\/workflows\/([^/]+)\/deploy$/)
546+
if (deploy)
547+
return deployWorkflowRoute(request, { params: Promise.resolve({ workflowId: deploy[1] }) })
543548
const run = request.nextUrl.pathname.match(/^\/api\/v2\/workflows\/([^/]+)\/runs\/([^/]+)$/)
544549
if (run) {
545550
return runRoute(request, {
@@ -3730,6 +3735,341 @@ describe.skipIf(!process.env.MSHIP_TEST_DATABASE_URL)(
37303735
60_000
37313736
)
37323737

3738+
it.skipIf(process.env.SIM_HELPERS_SMOKE !== '1').each(['loop', 'parallel'] as const)(
3739+
'constructs a %s through the CLI and verifies every actual iteration from saved output',
3740+
async (kind) => {
3741+
fixture.permission = 'admin'
3742+
const id = generateId()
3743+
await db.insert(workflow).values({
3744+
id,
3745+
workspaceId,
3746+
userId: 'run-reader',
3747+
name: `${kind} composition`,
3748+
lastSynced: now,
3749+
createdAt: now,
3750+
updatedAt: now,
3751+
isDeployed: false,
3752+
})
3753+
fixture.physicalWorkflows.add(id)
3754+
const initial = runnableState('return 0;')
3755+
initial.edges = []
3756+
const startId = generateId()
3757+
initial.blocks = {
3758+
[startId]: {
3759+
...initial.blocks.start,
3760+
id: startId,
3761+
outputs: { items: { type: 'array' } },
3762+
},
3763+
}
3764+
await replaceWorkflowNormalizedState({
3765+
workflowId: id,
3766+
workspaceId,
3767+
attributedUserId: 'run-reader',
3768+
state: initial,
3769+
})
3770+
const itemId = generateId()
3771+
const operations = [
3772+
{
3773+
operation_type: 'add',
3774+
block_id: 'batch',
3775+
params: {
3776+
type: kind,
3777+
name: 'batch',
3778+
inputs:
3779+
kind === 'loop'
3780+
? { loopType: 'forEach', collection: '<start.items>' }
3781+
: { parallelType: 'collection', collection: '<start.items>' },
3782+
nestedNodes: {
3783+
[itemId]: {
3784+
type: 'function',
3785+
name: 'doubleitem',
3786+
inputs: { language: 'javascript', code: `return <${kind}.currentItem> * 2;` },
3787+
},
3788+
},
3789+
connections: { [`${kind}-start-source`]: itemId, [`${kind}-end-source`]: 'report' },
3790+
},
3791+
},
3792+
{
3793+
operation_type: 'add',
3794+
block_id: 'report',
3795+
params: {
3796+
type: 'function',
3797+
name: 'report',
3798+
inputs: { language: 'javascript', code: 'return <batch.results>;' },
3799+
},
3800+
},
3801+
{
3802+
operation_type: 'edit',
3803+
block_id: startId,
3804+
params: { connections: { source: 'batch' } },
3805+
},
3806+
]
3807+
const applied = await runCli(
3808+
[
3809+
'workflows',
3810+
'operations',
3811+
'apply',
3812+
id,
3813+
'--atomic',
3814+
'--yes',
3815+
'--operations',
3816+
JSON.stringify(operations),
3817+
],
3818+
identity,
3819+
null
3820+
)
3821+
expect(applied.exitCode, applied.stderr).toBe(0)
3822+
expect(JSON.parse(applied.stdout).skipped).toEqual([])
3823+
for (const items of [[1, 3, 5], [7]]) {
3824+
const executed = await runCli(
3825+
['workflows', 'run', id, '--manual', '--input', JSON.stringify({ items })],
3826+
identity,
3827+
null
3828+
)
3829+
expect(executed.exitCode, JSON.stringify({ ...executed, errors: fixture.errors })).toBe(0)
3830+
const run = v2ExecuteWorkflowDataSchema.parse(JSON.parse(executed.stdout))
3831+
await Promise.all(postExecution.mock.calls.map(([promise]) => promise))
3832+
clearLargeValueCacheForTests()
3833+
const saved = await runCli(
3834+
['workflows', 'runs', 'get', run.runId, '--workflow', id, '--include-output'],
3835+
identity,
3836+
null
3837+
)
3838+
expect(saved.exitCode, saved.stderr).toBe(0)
3839+
const output = v2WorkflowRunStatusSchema.parse(JSON.parse(saved.stdout))
3840+
expect(output.status).toBe('completed')
3841+
expect(output.output).toEqual(run.output)
3842+
const numbers: number[] = []
3843+
const pending: unknown[] = [output.output]
3844+
while (pending.length) {
3845+
const value = pending.pop()
3846+
if (typeof value === 'number') numbers.push(value)
3847+
else if (Array.isArray(value)) pending.push(...value)
3848+
else if (value && typeof value === 'object') pending.push(...Object.values(value))
3849+
}
3850+
expect(
3851+
numbers.sort((a, b) => a - b),
3852+
JSON.stringify(output.output)
3853+
).toEqual(items.map((item) => item * 2))
3854+
}
3855+
expect(executeInSandbox).not.toHaveBeenCalled()
3856+
expect(executeShellInSandbox).not.toHaveBeenCalled()
3857+
},
3858+
60_000
3859+
)
3860+
3861+
it.skipIf(process.env.SIM_HELPERS_SMOKE !== '1')(
3862+
'constructs condition routing through the CLI and executes both sides of each threshold',
3863+
async () => {
3864+
fixture.permission = 'admin'
3865+
const id = generateId()
3866+
await db.insert(workflow).values({
3867+
id,
3868+
workspaceId,
3869+
userId: 'run-reader',
3870+
name: 'Routing composition',
3871+
lastSynced: now,
3872+
createdAt: now,
3873+
updatedAt: now,
3874+
isDeployed: false,
3875+
})
3876+
fixture.physicalWorkflows.add(id)
3877+
const initial = runnableState('return 0;')
3878+
initial.edges = []
3879+
const startId = generateId()
3880+
initial.blocks = { [startId]: { ...initial.blocks.start, id: startId } }
3881+
await replaceWorkflowNormalizedState({
3882+
workflowId: id,
3883+
workspaceId,
3884+
attributedUserId: 'run-reader',
3885+
state: initial,
3886+
})
3887+
const operations = [
3888+
{
3889+
operation_type: 'add',
3890+
block_id: 'gate',
3891+
params: {
3892+
type: 'condition',
3893+
name: 'gate',
3894+
inputs: {
3895+
conditions: [
3896+
{ title: 'If', value: '<start.amount> < 100' },
3897+
{ title: 'Else If', value: '<start.amount> < 500' },
3898+
{ title: 'Else', value: '' },
3899+
],
3900+
},
3901+
connections: { if: 'small', 'else-if-0': 'medium', else: 'large' },
3902+
},
3903+
},
3904+
...['small', 'medium', 'large'].map((label) => ({
3905+
operation_type: 'add',
3906+
block_id: label,
3907+
params: {
3908+
type: 'function',
3909+
name: label,
3910+
inputs: { language: 'javascript', code: `return '${label}:' + <start.amount>;` },
3911+
},
3912+
})),
3913+
{
3914+
operation_type: 'edit',
3915+
block_id: startId,
3916+
params: { connections: { source: 'gate' } },
3917+
},
3918+
]
3919+
const applied = await runCli(
3920+
[
3921+
'workflows',
3922+
'operations',
3923+
'apply',
3924+
id,
3925+
'--atomic',
3926+
'--yes',
3927+
'--operations',
3928+
JSON.stringify(operations),
3929+
],
3930+
identity,
3931+
null
3932+
)
3933+
expect(applied.exitCode, applied.stderr).toBe(0)
3934+
expect(JSON.parse(applied.stdout).skipped).toEqual([])
3935+
for (const [amount, label] of [
3936+
[42, 'small'],
3937+
[99, 'small'],
3938+
[100, 'medium'],
3939+
[499, 'medium'],
3940+
[500, 'large'],
3941+
[750, 'large'],
3942+
] as const) {
3943+
const executed = await runCli(
3944+
['workflows', 'run', id, '--manual', '--input', JSON.stringify({ amount })],
3945+
identity,
3946+
null
3947+
)
3948+
expect(executed.exitCode, JSON.stringify({ ...executed, errors: fixture.errors })).toBe(0)
3949+
const run = v2ExecuteWorkflowDataSchema.parse(JSON.parse(executed.stdout))
3950+
expect(run.status).toBe('completed')
3951+
expect(run.output).toMatchObject({ result: `${label}:${amount}` })
3952+
}
3953+
await Promise.all(postExecution.mock.calls.map(([promise]) => promise))
3954+
expect(executeInSandbox).not.toHaveBeenCalled()
3955+
expect(executeShellInSandbox).not.toHaveBeenCalled()
3956+
},
3957+
60_000
3958+
)
3959+
3960+
it.skipIf(process.env.SIM_HELPERS_SMOKE !== '1')(
3961+
'constructs a weekday schedule, executes its error route, and enforces deployment permissions',
3962+
async () => {
3963+
fixture.permission = 'admin'
3964+
const id = generateId()
3965+
await db.insert(workflow).values({
3966+
id,
3967+
workspaceId,
3968+
userId: 'run-reader',
3969+
name: 'Schedule composition',
3970+
lastSynced: now,
3971+
createdAt: now,
3972+
updatedAt: now,
3973+
isDeployed: false,
3974+
})
3975+
fixture.physicalWorkflows.add(id)
3976+
await replaceWorkflowNormalizedState({
3977+
workflowId: id,
3978+
workspaceId,
3979+
attributedUserId: 'run-reader',
3980+
state: { blocks: {}, edges: [] },
3981+
})
3982+
const operations = [
3983+
{
3984+
operation_type: 'add',
3985+
block_id: 'schedule',
3986+
params: {
3987+
type: 'schedule',
3988+
name: 'weekday',
3989+
inputs: { scheduleType: 'custom', cronExpression: '30 6 * * 1-5', timezone: 'UTC' },
3990+
connections: { source: 'fail' },
3991+
},
3992+
},
3993+
{
3994+
operation_type: 'add',
3995+
block_id: 'fail',
3996+
params: {
3997+
type: 'function',
3998+
name: 'fail',
3999+
inputs: {
4000+
language: 'javascript',
4001+
code: 'throw new Error("synthetic schedule failure");',
4002+
},
4003+
connections: { error: 'report' },
4004+
},
4005+
},
4006+
{
4007+
operation_type: 'add',
4008+
block_id: 'report',
4009+
params: {
4010+
type: 'function',
4011+
name: 'report',
4012+
inputs: { language: 'javascript', code: 'return { handled: <fail.error> };' },
4013+
},
4014+
},
4015+
]
4016+
const applied = await runCli(
4017+
[
4018+
'workflows',
4019+
'operations',
4020+
'apply',
4021+
id,
4022+
'--atomic',
4023+
'--yes',
4024+
'--operations',
4025+
JSON.stringify(operations),
4026+
],
4027+
identity,
4028+
null
4029+
)
4030+
expect(applied.exitCode, applied.stderr).toBe(0)
4031+
expect(JSON.parse(applied.stdout).skipped).toEqual([])
4032+
const stateRead = await runCli(['workflows', 'state', 'get', id], identity, null)
4033+
expect(stateRead.exitCode, stateRead.stderr).toBe(0)
4034+
const state: WorkflowState = JSON.parse(stateRead.stdout)
4035+
const scheduled = Object.values(state.blocks).find((block) => block.type === 'schedule')
4036+
if (!scheduled) throw new Error('Schedule was not persisted')
4037+
vi.useFakeTimers({ toFake: ['Date'] })
4038+
try {
4039+
vi.setSystemTime(new Date('2026-09-04T06:31:00Z'))
4040+
expect(
4041+
calculateNextRunTime('custom', getScheduleTimeValues(scheduled)).toISOString()
4042+
).toBe('2026-09-07T06:30:00.000Z')
4043+
} finally {
4044+
vi.useRealTimers()
4045+
}
4046+
const executed = await runCli(
4047+
['workflows', 'run', id, '--manual', '--trigger', scheduled.id],
4048+
identity,
4049+
null
4050+
)
4051+
expect(executed.exitCode, JSON.stringify({ ...executed, errors: fixture.errors })).toBe(0)
4052+
const run = v2ExecuteWorkflowDataSchema.parse(JSON.parse(executed.stdout))
4053+
expect(run.output).toMatchObject({
4054+
result: { handled: expect.stringContaining('synthetic schedule failure') },
4055+
})
4056+
await Promise.all(postExecution.mock.calls.map(([promise]) => promise))
4057+
fixture.permission = 'read'
4058+
const deploy = await runCli(['workflows', 'deploy', id], identity, null)
4059+
expect(deploy.exitCode).not.toBe(0)
4060+
expect(deploy.stderr).toMatch(/FORBIDDEN|NOT_FOUND|permission|access/i)
4061+
const [stored] = await db.select().from(workflow).where(eq(workflow.id, id))
4062+
expect(stored.isDeployed).toBe(false)
4063+
expect(
4064+
await db
4065+
.select()
4066+
.from(workflowDeploymentVersion)
4067+
.where(eq(workflowDeploymentVersion.workflowId, id))
4068+
).toEqual([])
4069+
},
4070+
60_000
4071+
)
4072+
37334073
it.skipIf(process.env.SIM_HELPERS_SMOKE !== '1').each(['completed', 'failed'] as const)(
37344074
'executes a saved workflow and reads its %s result from externalized log bytes',
37354075
async (expectedStatus) => {

0 commit comments

Comments
 (0)